From 09359540d91e3ba69520beae42e6eb6c6086818f Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 9 Sep 2026 11:22:06 -0400 Subject: [PATCH 1/2] refactor(collections): pf-4402 pf api quality scores --- .../collection.patternFlyApi.test.ts | 18 +++ .../collection.patternFlyApiHelpers.test.ts | 134 ++++++++++------- src/collection.patternFlyApi.ts | 17 ++- src/collection.patternFlyApiHelpers.ts | 138 ++++++++++++++---- src/resource.helpers.ts | 59 ++++++++ 5 files changed, 282 insertions(+), 84 deletions(-) diff --git a/src/__tests__/collection.patternFlyApi.test.ts b/src/__tests__/collection.patternFlyApi.test.ts index b4683ff0..fa584500 100644 --- a/src/__tests__/collection.patternFlyApi.test.ts +++ b/src/__tests__/collection.patternFlyApi.test.ts @@ -179,6 +179,24 @@ describe('parsePayload', () => { it('parses numeric payloads as non-empty', () => { expect(parsePayload('42').isEmpty).toBe(false); }); + + it.each([ + { + description: 'length', + payload: 'A'.repeat(200), + expected: 1 + }, + { + description: 'code fence', + payload: '```ts file="./ButtonBasic.tsx"\n```', + expected: 0.95 + } + ])('should calculate qualityScore during payload parsing, $description', ({ payload, expected }) => { + // See collection.patternFlyApi.test.ts for quality scoring tests + const parsed = parsePayload(payload); + + expect(parsed.qualityScore).toBeLessThanOrEqual(expected); + }); }); describe('crawler', () => { diff --git a/src/__tests__/collection.patternFlyApiHelpers.test.ts b/src/__tests__/collection.patternFlyApiHelpers.test.ts index 0a6ae48f..b5737ad6 100644 --- a/src/__tests__/collection.patternFlyApiHelpers.test.ts +++ b/src/__tests__/collection.patternFlyApiHelpers.test.ts @@ -7,7 +7,6 @@ import { getApiFallbackDescription, getLiveExampleCount, hasEmptyFileCodeFence, - hasLiveExample, isRawImport, normalizeSlug } from '../collection.patternFlyApiHelpers'; @@ -79,53 +78,6 @@ describe('isRawImport', () => { }); }); -describe('hasLiveExample', () => { - it.each([ - { - description: 'self-closing LiveExample tag', - input: '', - expected: true - }, - { - description: 'opening LiveExample tag with attributes', - input: '', - expected: true - }, - { - description: 'case-insensitive liveexample tag', - input: '', - expected: true - }, - { - description: 'LiveExample tag with multiline attributes', - input: '', - expected: true - }, - { - description: 'LiveExample tag without attributes', - input: '', - expected: true - }, - { - description: 'text without LiveExample tag', - input: '
Regular HTML component
', - expected: false - }, - { - description: 'extended component name without word boundary match', - input: '', - expected: false - }, - { - description: 'empty string', - input: '', - expected: false - } - ])('should detect LiveExample tags, $description', ({ input, expected }) => { - expect(hasLiveExample(input)).toBe(expected); - }); -}); - describe('getLiveExampleCount', () => { it.each([ { @@ -152,6 +104,31 @@ describe('getLiveExampleCount', () => { description: 'empty string', input: '', expected: 0 + }, + { + description: 'opening LiveExample tag with attributes', + input: '', + expected: 1 + }, + { + description: 'LiveExample tag with multiline attributes', + input: '', + expected: 1 + }, + { + description: 'LiveExample tag without attributes', + input: '', + expected: 1 + }, + { + description: 'text without LiveExample tag', + input: '
Regular HTML component
', + expected: 0 + }, + { + description: 'extended component name without word boundary match', + input: '', + expected: 0 } ])('should count LiveExample occurrences, $description', ({ input, expected }) => { expect(getLiveExampleCount(input)).toBe(expected); @@ -335,6 +312,57 @@ describe('calculateContentQualityScore', () => { ])('should calculate quality score, $description', ({ content, options, expected }: any) => { expect(calculateContentQualityScore(content, options)).toBe(expected); }); + + it.each([ + { + description: 'multiple paragraphs multiple live examples', + content: [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '\n', + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '\n', + '', + '' + ].join('\n'), + expected: 0.94 + }, + { + description: 'single paragraph', + content: [ + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + '\n', + '', + '' + ].join('\n'), + expected: 0.94 + }, + { + description: 'no paragraphs', + content: [ + 'Lorem ipsum dolor sit amet.', + '', + '' + ].join('\n'), + expected: 0.91 + }, + { + description: 'no paragraphs single example', + content: [ + 'Lorem ipsum dolor sit amet.', + '' + ].join('\n'), + expected: 0.91 + }, + { + description: 'single example', + content: [ + '' + ].join('\n'), + expected: 0.91 + } + ])('should cap "LiveExample" penalities for wordy documents, $description', ({ content, expected }) => { + expect(calculateContentQualityScore(content)).toBe(expected); + }); }); describe('normalizeSlug', () => { @@ -662,7 +690,7 @@ describe('getApiFallbackDescription', () => { description: 'examples category', displayName: 'Button', category: 'examples', - expected: 'PatternFly Button examples and demos.' + expected: 'PatternFly examples and demos for Button.' }, { description: 'doc category', @@ -680,7 +708,7 @@ describe('getApiFallbackDescription', () => { description: 'default arguments without parameters', displayName: undefined, category: undefined, - expected: 'PatternFly documentation and guidelines for .' + expected: 'PatternFly documentation and guidelines.' } ])('should provide fallback description, $description', ({ displayName, category, expected }: any) => { expect(getApiFallbackDescription(displayName, category)).toBe(expected); @@ -705,7 +733,7 @@ describe('extractApiDescription', () => { description: 'detailType equals examples returns fallback', content: '# Title\nValid paragraph line exceeding twenty characters in length.', context: { displayName: 'Button', detailType: 'examples' }, - expected: 'PatternFly Button examples and demos.' + expected: 'PatternFly examples and demos for Button.' }, { description: 'markdown content uses first valid paragraph', @@ -759,13 +787,13 @@ describe('extractApiDescription', () => { description: 'undefined content and undefined context returns fallback', content: undefined, context: undefined, - expected: 'PatternFly documentation and guidelines for .' + expected: 'PatternFly documentation and guidelines.' }, { description: 'null context explicitly passed returns fallback', content: undefined, context: null as any, - expected: 'PatternFly documentation and guidelines for .' + expected: 'PatternFly documentation and guidelines.' } ])('should extract API description, $description', ({ content, context, expected }: any) => { expect(extractApiDescription(content, context)).toBe(expected); diff --git a/src/collection.patternFlyApi.ts b/src/collection.patternFlyApi.ts index fb194209..a2b279ea 100644 --- a/src/collection.patternFlyApi.ts +++ b/src/collection.patternFlyApi.ts @@ -66,11 +66,13 @@ interface ApiContent { * @property content - Content retrieved from the API. * @property path - Initial or relative path used to fetch the content. * @property resolvedPath - Absolute or resolved path after processing the initial path. + * @property qualityScore - Content quality score. */ interface ApiCrawler { content: string; path: string; resolvedPath: string; + qualityScore: number; } /** @@ -85,10 +87,12 @@ type ParsePayloadApi = string | number | boolean | null | string[] | Record { updatedPayload = payload; } + const qualityScore = calculateContentQualityScore(updatedPayload); let isEmpty: boolean; let parsedPayload: ParsePayloadApi; @@ -158,7 +163,7 @@ const parsePayload = (payload: unknown): ParsePayload => { isEmpty = updatedPayload.length === 0; } - return { isEmpty, payload: parsedPayload }; + return { isEmpty, payload: parsedPayload, qualityScore }; }; /** @@ -243,13 +248,13 @@ const crawler = async ( continue; } - const { isEmpty, payload } = parsePayload.memo(res.content); + const { isEmpty, payload, qualityScore } = parsePayload.memo(res.content); if (Array.isArray(payload)) { // Terminal Data Arrays (props, css, etc) if (componentPaths.some(componentPath => res?.path?.endsWith(`/${componentPath}`))) { if (!isEmpty) { - content.push({ ...res }); + content.push({ ...res, qualityScore }); } continue; } @@ -285,7 +290,7 @@ const crawler = async ( // String Payloads (Markdown, HTML, .tsx source code) if (!isEmpty) { - content.push({ ...res }); + content.push({ ...res, qualityScore }); } // Probe Traversal Paths on Facet Endpoints (e.g. /react -> /react/examples) @@ -389,7 +394,7 @@ const apiSpider = async (options = getOptions()): Promise => { * @returns The process metadata entry. */ const contentMetadata = (crawlerResponse: ApiCrawler, options = getOptions()): ApiContent => { - const { content, resolvedPath } = crawlerResponse; + const { content, resolvedPath, qualityScore } = crawlerResponse; const { base } = options.patternflyOptions.api; // Relative path after '/api/' @@ -424,7 +429,7 @@ const contentMetadata = (crawlerResponse: ApiCrawler, options = getOptions()): A const displayName = extractApiDisplayName(content, { slug: normalizedItem, category: normalizedCategory, section: normalizedSection }); const description = extractApiDescription(content, { displayName, category: normalizedCategory, detailType: normalizedDetailType }); - const isLowQuality = calculateContentQualityScore(content, { category: normalizedCategory }) < MIN_API_QUALITY_THRESHOLD; + const isLowQuality = qualityScore < MIN_API_QUALITY_THRESHOLD; const isDeferred = DEFERRED_API_CATEGORIES.has(normalizedCategory); return { diff --git a/src/collection.patternFlyApiHelpers.ts b/src/collection.patternFlyApiHelpers.ts index 53052cb3..aa906f26 100644 --- a/src/collection.patternFlyApiHelpers.ts +++ b/src/collection.patternFlyApiHelpers.ts @@ -1,4 +1,10 @@ -import { isJson, isJsonLike } from './resource.helpers'; +import { + breakdownProse, + contentType, + getInlinedCodeBlockCount, + isJson, + isJsonLike +} from './resource.helpers'; /** * Detect imports that use the `?raw` query param. @@ -9,11 +15,13 @@ const isRawImport = (str: string) => /import\s+[\w*\s{},]+\s+from\s+['"][^'"]+\?raw['"]/i.test(str); /** - * Detect a `` tag. + * Count the number of raw imports in a given string. * - * @param str + * @param str - Input string. + * @returns Number of raw imports found in the input string. */ -const hasLiveExample = (str: string) => /]*\/?>/i.test(str); +const getRawImportCount = (str: string): number => + (str.match(/import\s+[\w*\s{},]+\s+from\s+['"][^'"]+\?raw['"]/gi) || []).length; /** * Count the number of `` tags in a given string. @@ -39,6 +47,79 @@ const hasEmptyFileCodeFence = (str: string) => /```[\w-]*\s+file="[^"]+"\s*\n\s*```/i.test(str) || /```[\w-]*\s*\n\s*```/.test(str); +/** + * Calculate template example counts. + * + * - When `?raw` import and `` are paired (1:1), count as 1 unit. + * - When `?raw` import and `` appear without the other (orphaned/unpaired), each adds 1 independently to the count. + * + * @param content - Input content.ß + * @returns Total effective template reference count. + */ +const getTemplateCount = (content: string): { pairedCount: number; orphanCount: number; totalUnits: number } => { + const liveCount = getLiveExampleCount(content); + const rawCount = getRawImportCount(content); + + const pairedCount = Math.min(liveCount, rawCount); + const orphanCount = Math.abs(liveCount - rawCount); + const totalUnits = pairedCount + orphanCount; // Equivalent to Math.max(liveCount, rawCount) + + return { pairedCount, orphanCount, totalUnits }; +}; + +/** + * Is the content a content aggregator? + * + * @param content - Content to eval. + * @returns Returns `true` if the content aggregates other content. + */ +const isContentAggregator = (content: string): boolean => { + const type = contentType(content); + + if (type !== '' && type !== 'markdown' && type !== 'html') { + return false; + } + + const { totalUnits } = getTemplateCount(content); + const inlinedCodeCount = getInlinedCodeBlockCount(content); + + // Check: Multiple external example tags without inlined blocks + if (totalUnits >= 2 && inlinedCodeCount === 0) { + return true; + } + + // Check: High template density + if (totalUnits > inlinedCodeCount * 2 && totalUnits >= 3) { + return true; + } + + // Check: Template tags present without paragraphs + const { paragraphs } = breakdownProse(content); + + return totalUnits >= 1 && paragraphs < 2 && inlinedCodeCount === 0; +}; + +/** + * Determine if the content has anything to offer, weight. + * + * Counts: + * - if the content type must be empty or 'markdown' + * - if the content contains at least 2 paragraphs, or has a word count of at + * least 100, or has at least one inline code block + * - if the live example, template count, is greater than zero + * + * @param content - Content to eval + * @returns `true` has weight. + */ +const isSubstantialGuide = (content: string) => { + const { totalUnits } = getTemplateCount(content); + const inlinedCount = getInlinedCodeBlockCount(content); + const { type, paragraphs, wordCount } = breakdownProse(content); + const hasSubstance = paragraphs >= 2 || wordCount >= 100 || inlinedCount >= 1; + + return (type === '' || type === 'markdown') && hasSubstance && totalUnits > 0; +}; + /** * Calculate a quality score for a PatternFly API response. * @@ -53,7 +134,7 @@ const hasEmptyFileCodeFence = (str: string) => const calculateContentQualityScore = ( content: unknown, { - baseScore = 1, category, qualityReduction = 0.03, minCharacters = 150 + baseScore = 1, category, qualityReduction = 0.03, minCharacters = 100 }: { baseScore?: number; category?: undefined | string; qualityReduction?: number; minCharacters?: number } = {} ): number => { if (content === undefined || content === null) { @@ -78,20 +159,21 @@ const calculateContentQualityScore = ( let score = baseScore; - if (isJsonLike(trimmed)) { - const jsonValid = isJson(trimmed); - - if (!jsonValid) { - score -= qualityReduction; - } - } - - if (isRawImport(trimmed)) { + if (isJsonLike(trimmed) && !isJson(trimmed)) { score -= qualityReduction; } - if (hasLiveExample(trimmed)) { - score -= qualityReduction * getLiveExampleCount(trimmed); + const { totalUnits } = getTemplateCount(trimmed); + + if (isContentAggregator(trimmed)) { + // Aggregator overview pages take linear deductions, falling below 0.95 + score -= qualityReduction * Math.max(2, totalUnits); + } else if (isSubstantialGuide(trimmed)) { + // Developer guides, cap isolated demo tag/raw imports + score -= qualityReduction; + } else { + // Non-guide stubs + score -= qualityReduction * totalUnits; } if (trimmed.length < minCharacters && !trimmed.includes('```') && !hasEmptyFileCodeFence(trimmed)) { @@ -231,25 +313,28 @@ const extractApiDisplayName = (content?: string, context: { slug?: string; categ * Provide a fallback description based on kind/category when no prose is available. * * @param displayName - Display name - * @param category - Category / facet kind + * @param [category] - Category / facet kind */ -const getApiFallbackDescription = (displayName = '', category = 'doc'): string => { +const getApiFallbackDescription = (displayName: string, category?: string): string => { + const trimmed = typeof displayName === 'string' ? displayName.trim() : ''; + const updatedDisplayName = trimmed ? ` for ${trimmed}` : ''; + switch (category) { case 'props': - return `PatternFly React component props and TypeScript interfaces for ${displayName}.`; + return `PatternFly React component props and TypeScript interfaces${updatedDisplayName}.`; case 'css': return `PatternFly ${ - displayName.toLowerCase().includes('css') ? '' : 'CSS '}variables and tokens for ${displayName}.`; + displayName.toLowerCase().includes('css') ? '' : 'CSS '}variables and tokens${updatedDisplayName}.`; case 'html': case 'html-demos': - return `PatternFly HTML examples and markup structure for ${displayName}.`; + return `PatternFly HTML examples and markup structure${updatedDisplayName}.`; case 'react': case 'react-demos': - return `PatternFly React component examples and demos for ${displayName}.`; + return `PatternFly React component examples and demos${updatedDisplayName}.`; case 'examples': - return `PatternFly ${displayName} examples and demos.`; + return `PatternFly examples and demos${updatedDisplayName}.`; default: - return `PatternFly documentation and guidelines for ${displayName}.`; + return `PatternFly documentation and guidelines${updatedDisplayName}.`; } }; @@ -380,10 +465,13 @@ export { extractApiDisplayName, extractApiName, formatSlugToTitle, + isContentAggregator, + isSubstantialGuide, getApiFallbackDescription, getLiveExampleCount, + getRawImportCount, + getTemplateCount, hasEmptyFileCodeFence, - hasLiveExample, isRawImport, normalizeSlug }; diff --git a/src/resource.helpers.ts b/src/resource.helpers.ts index 67f2ddff..defa1e37 100644 --- a/src/resource.helpers.ts +++ b/src/resource.helpers.ts @@ -2,6 +2,23 @@ import { filterPatternFly, type FilterPatternFlyFilters } from './patternFly.sea import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; import { isPlainObject } from './server.helpers'; +/** + * Count the number of inlined code blocks in a given string. + * + * @param str - Input string. + * @param minBlockLength - Minimum length of code block content to consider it valid. + * @returns Number of inlined code blocks found in the input string. + */ +const getInlinedCodeBlockCount = (str: string, minBlockLength = 20): number => { + const codeBlocks = str.match(/```[a-zA-Z0-9_-]*\n([\s\S]*?)```/g) || []; + + return codeBlocks.filter(block => { + const inner = block.replace(/^```[^\n]*\n/, '').replace(/```$/, '').trim(); + + return inner.length >= minBlockLength && !inner.startsWith('file='); + }).length; +}; + /** * Is content CSS-like? * @@ -406,6 +423,46 @@ const contentType = (content: unknown): '' | 'sh' | 'python' | 'markdown' | 'jav return updatedLanguage; }; +/** + * Break down prose content for quality evaluation. + * + * @note This function is intended to only evaluate prose content, not code. + * The internal content guard will return empty counts if the content is not + * prose. + * + * @param content - The content to be broken down. + * @returns An object containing the content type, original content, paragraphs, and word count of the content. + */ +const breakdownProse = (content: string): { type: string; content: string; paragraphs: number; wordCount: number } => { + const type = contentType(content); + + if (type !== '' && type !== 'markdown') { + return { + type, + content, + paragraphs: 0, + wordCount: 0 + }; + } + + const cleaned = content + .replace(/```[\s\S]*?```/g, '') // Remove fenced blocks (```...```) + .replace(/<[^>]+>/g, '') // Remove inline HTML/JSX + .replace(/^\s*(import|export)\s+.*?;?\s*$/gm, '') // Remove import / export statements + .trim(); + + // Split paragraphs, use min-length + const paragraphs = cleaned.split(/\n\s*\n/).filter(paragraph => paragraph.trim().length > 30); + const words = cleaned.split(/\s+/).filter(Boolean); + + return { + type, + content, + paragraphs: paragraphs.length, + wordCount: words.length + }; +}; + /** * Format content as a code block for Markdown rendering. * @@ -487,8 +544,10 @@ const paramCompletion = async (filters: FilterPatternFlyFilters) => { }; export { + breakdownProse, contentType, formatContentForMarkdown, + getInlinedCodeBlockCount, isJavaLike, isJsLike, isJson, From efec348f4cb6cf0d52097a56a3d9114d0229958d Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 9 Sep 2026 11:37:47 -0400 Subject: [PATCH 2/2] fix: review update --- .../collection.patternFlyApiHelpers.test.ts | 160 ++++++++++++++++++ src/__tests__/resource.helpers.test.ts | 93 ++++++++++ src/collection.patternFlyApiHelpers.ts | 67 +++++--- 3 files changed, 293 insertions(+), 27 deletions(-) diff --git a/src/__tests__/collection.patternFlyApiHelpers.test.ts b/src/__tests__/collection.patternFlyApiHelpers.test.ts index b5737ad6..1671cd00 100644 --- a/src/__tests__/collection.patternFlyApiHelpers.test.ts +++ b/src/__tests__/collection.patternFlyApiHelpers.test.ts @@ -6,6 +6,10 @@ import { formatSlugToTitle, getApiFallbackDescription, getLiveExampleCount, + getRawImportCount, + getTemplateCount, + isContentAggregator, + isSubstantialGuide, hasEmptyFileCodeFence, isRawImport, normalizeSlug @@ -78,6 +82,144 @@ describe('isRawImport', () => { }); }); +describe('getRawImportCount', () => { + it.each([ + { + description: 'plain text with no import', + input: 'const a = "./Button.tsx";', + expected: 0 + }, + { + description: 'single raw import', + input: "import ButtonRaw from './Button.tsx?raw';", + expected: 1 + }, + { + description: 'multiple raw imports across multiple lines', + input: "import Btn1 from './Btn1.tsx?raw';\nimport Btn2 from './Btn2.tsx?raw';\nimport Btn3 from './Btn3.tsx?raw';", + expected: 3 + }, + { + description: 'multiline raw import statement', + input: "import {\n Button,\n Card\n} from './components?raw';", + expected: 1 + }, + { + description: 'mixed raw imports and standard imports', + input: "import React from 'react';\nimport ExampleRaw from './Example?raw';\nimport { Card } from './Card';", + expected: 1 + } + ])('should count raw imports, $description', ({ input, expected }) => { + expect(getRawImportCount(input)).toBe(expected); + }); +}); + +describe('getTemplateCount', () => { + it.each([ + { + description: 'content with no templates, raw imports', + input: 'Pure documentation content without any examples.', + expected: { pairedCount: 0, orphanCount: 0, totalUnits: 0 } + }, + { + description: 'live example, raw import', + input: "import Example from './Example?raw';\n", + expected: { pairedCount: 1, orphanCount: 0, totalUnits: 1 } + }, + { + description: 'orphaned raw import, no live examples', + input: "import Example1 from './Example1?raw';\nimport Example2 from './Example2?raw';", + expected: { pairedCount: 0, orphanCount: 2, totalUnits: 2 } + }, + { + description: 'orphaned live examples, no raw imports', + input: '\n', + expected: { pairedCount: 0, orphanCount: 2, totalUnits: 2 } + }, + { + description: 'multiple live examples and a single raw import', + input: "import Ex1 from './Ex1?raw';\n\n\n", + expected: { pairedCount: 1, orphanCount: 2, totalUnits: 3 } + } + ])('should calculate template counts and pairings, $description', ({ input, expected }) => { + expect(getTemplateCount(input)).toEqual(expected); + }); +}); + +describe('isContentAggregator', () => { + it.each([ + { + description: 'multiple templates without inlined code blocks', + input: '\n', + expected: true + }, + { + description: 'multiple templates where total units is greater than inlinedCodeCount', + input: '\n\n\n```tsx\nconst a = \'single block that exceeds 20 characters\';\n```', + expected: true + }, + { + description: 'template tag present, lacks prose paragraphs and no inlined code', + input: '\nShort line', + expected: true + }, + { + description: 'multiple paragraphs and inlined code, content is not an aggregator', + input: '\n\nThis is the first substantive paragraph detailing how to implement this pattern.\n\nThis is the second substantive paragraph detailing accessibility guidelines and props.\n\n```tsx\nimport React from \'react\';\nexport const Sample = () => ;\n```', + expected: false + }, + { + description: 'non-markdown/html, content is not an aggregator', + input: '{"template": "", "raw": "import x from \'x?raw\'"}', + expected: false + }, + { + description: 'no templates, content is not an aggregator', + input: '# Documentation\n\nThis is a standard document with no template references.', + expected: false + } + ])('should determine if content is an aggregator, $description', ({ input, expected }) => { + expect(isContentAggregator(input)).toBe(expected); + }); +}); + +describe('isSubstantialGuide', () => { + it.each([ + { + description: 'markdown with templates and multiple paragraphs', + input: 'This is paragraph one explaining how to configure the component in depth.\n\nThis is paragraph two explaining proper event handling and keyboard navigation.\n\n', + expected: true + }, + { + description: 'markdown with templates and an inlined code block', + input: '```tsx\nimport React from \'react\';\nexport const App = () => Content;\n```\n', + expected: true + }, + { + description: 'markdown with templates and minimum number of words', + input: `${'word '.repeat(105)}\n\n`, + expected: true + }, + { + description: 'aggregator stub with templates but no paragraphs, words < 100, and no inlined code', + input: "import Ex1 from './Ex1?raw';\nimport Ex2 from './Ex2?raw';\n\n", + expected: false + }, + { + description: 'substantial document without template units', + input: "This is paragraph one explaining how to configure the component in depth.\n\nThis is paragraph two explaining proper event handling and keyboard navigation.\n\n```tsx\nimport React from 'react';\nexport const App = () => Content;\n```", + expected: false + }, + { + description: 'non-markdown content type', + input: '{"paragraphs": 5, "template": ""}', + expected: false + } + ])('should determine if content is a substantial guide, $description', ({ input, expected }) => { + expect(isSubstantialGuide(input)).toBe(expected); + }); +}); + describe('getLiveExampleCount', () => { it.each([ { @@ -777,6 +919,24 @@ describe('extractApiDescription', () => { context: { displayName: 'Card', category: 'doc' }, expected: 'PatternFly documentation and guidelines for Card.' }, + { + description: 'content with purposeful script injections', + content: "import React from 'react';\n# Heading\nShort line <", + context: { displayName: 'Card', category: 'doc' }, + expected: 'PatternFly documentation and guidelines for Card.' + }, + { + description: 'content with purposeful nested script injections', + content: "import React from 'react';\n# Heading\nShort line ipt> console.warn('lorem ipsum')", + context: { displayName: 'Card', category: 'doc' }, + expected: 'PatternFly documentation and guidelines for Card.' + }, + { + description: 'content with purposeful unclosed script injections', + content: "import React from 'react';\n# Heading\nShort line