diff --git a/docs/checks/content-structure.md b/docs/checks/content-structure.md index 4c9e34f..3f114e7 100644 --- a/docs/checks/content-structure.md +++ b/docs/checks/content-structure.md @@ -48,6 +48,12 @@ Whether headers in tabbed sections include enough context to be meaningful witho When agents see serialized tabbed content, headers are the only way to tell which section applies to which context. Generic headers like "Step 1" repeated across Python, Node, and Go variants are indistinguishable in the serialized output. Headers like "Step 1 (Python/PyMongo)" preserve the filtering context agents need. +### What is measured + +The check reads HTML and Markdown headings inside tab panels. Markdown headings can use hash prefixes (`## Installation`) or an underline of `=` or `-` characters. Labels are compared by their text, so bold, links, inline code, escaped punctuation, and character references such as `&` do not hide repeated headings. + +Headings shown inside fenced or indented Markdown code examples and HTML comments are ignored. Headings inside callouts or admonitions are also excluded: a repeated "Warning" label is supplementary content, not a structural section header. + ### Results | Result | Condition | diff --git a/src/checks/content-structure/section-header-quality.ts b/src/checks/content-structure/section-header-quality.ts index 816b431..6b04bd4 100644 --- a/src/checks/content-structure/section-header-quality.ts +++ b/src/checks/content-structure/section-header-quality.ts @@ -1,8 +1,10 @@ import type { HTMLElement } from 'node-html-parser'; import { parse } from 'node-html-parser'; +import type { Nodes } from 'mdast'; import { registerCheck } from '../registry.js'; import type { CheckContext, CheckResult, CheckStatus } from '../../types.js'; import type { DetectedTabGroup } from '../../helpers/detect-tabs.js'; +import { parseMarkdown } from '../../helpers/parse-markdown.js'; interface TabbedPageResult { url: string; @@ -22,7 +24,7 @@ interface GroupHeaderAnalysis { hasCrossGroupGeneric: boolean; } -const MD_HEADING_RE = /^#{1,6}\s+(.+)$/gm; +const HTML_HEADING_TAGS = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']); const CALLOUT_ROLES = new Set(['alert', 'note', 'status', 'complementary']); @@ -57,6 +59,13 @@ function isCalloutHeading(h: HTMLElement): boolean { return false; } +function headingText(node: Nodes): string { + if (node.type === 'text' || node.type === 'inlineCode') return node.value; + if (node.type === 'image' || node.type === 'imageReference') return node.alt ?? ''; + if (node.type === 'break') return '\n'; + return 'children' in node ? node.children.map(headingText).join('') : ''; +} + /** * Extract section header text from content that may be HTML, markdown, or a * mix (MDX). Excludes headings inside callout/admonition containers, which @@ -64,9 +73,47 @@ function isCalloutHeading(h: HTMLElement): boolean { */ function extractHeaders(content: string): string[] { const headers: string[] = []; + const markdownHeaders: Array<{ text: string; offset: number }> = []; + const htmlSource = content.split(''); + const panel = parse(content).querySelector('tab, tabitem'); + const start = panel?.firstChild?.range[0]; + const end = panel?.lastChild?.range[1]; + let markdownSource = content; + if ( + panel && + start !== undefined && + end !== undefined && + content.slice(0, panel.range[0]).trim() === '' && + content.slice(panel.range[1]).trim() === '' + ) { + markdownSource = + content.slice(0, start).replace(/[^\r\n]/g, ' ') + + content.slice(start, end) + + content.slice(end).replace(/[^\r\n]/g, ' '); + } + const tree = parseMarkdown(markdownSource, { normalizeVendorFences: false }); + + const visit = (node: Nodes): void => { + const offset = node.position?.start.offset; + const end = node.position?.end.offset; + if (offset === undefined || end === undefined) return; + + if (node.type === 'heading') { + const text = headingText(node).trim(); + if (text.length > 0) markdownHeaders.push({ text, offset }); + } + if (node.type === 'code' || node.type === 'inlineCode') { + for (let index = offset; index < end; index++) { + if (htmlSource[index] !== '\n' && htmlSource[index] !== '\r') htmlSource[index] = ' '; + } + return; + } + if ('children' in node) node.children.forEach(visit); + }; + visit(tree); // HTML headers — skip callout/admonition headings - const root = parse(content); + const root = parse(htmlSource.join('')); const htmlHeaders = root.querySelectorAll('h1, h2, h3, h4, h5, h6'); for (const h of htmlHeaders) { if (isCalloutHeading(h)) continue; @@ -74,11 +121,16 @@ function extractHeaders(content: string): string[] { if (text.length > 0) headers.push(text); } - // Markdown headers (## Heading) - let match; - while ((match = MD_HEADING_RE.exec(content)) !== null) { - const text = match[1].trim(); - if (text.length > 0) headers.push(text); + const excludedContainers = root + .querySelectorAll('*') + .filter((element) => HTML_HEADING_TAGS.has(element.rawTagName) || isCalloutHeading(element)); + for (const { text, offset } of markdownHeaders) { + if ( + excludedContainers.some((element) => offset >= element.range[0] && offset < element.range[1]) + ) { + continue; + } + headers.push(text); } return headers; diff --git a/test/unit/checks/section-header-quality.test.ts b/test/unit/checks/section-header-quality.test.ts index 8f546b9..10e61e0 100644 --- a/test/unit/checks/section-header-quality.test.ts +++ b/test/unit/checks/section-header-quality.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { createContext } from '../../../src/runner.js'; import { getCheck } from '../../../src/checks/registry.js'; +import { detectTabGroups } from '../../../src/helpers/detect-tabs.js'; import '../../../src/checks/index.js'; describe('section-header-quality', () => { @@ -35,6 +36,189 @@ describe('section-header-quality', () => { return ctx; } + function makePanelCtx(...groups: Array>) { + return makeCtx({ + status: 'pass', + tabbedPages: [ + { + url: 'http://test.local/page', + tabGroups: groups.map((panels) => ({ + framework: 'mdx', + tabCount: panels.length, + htmlSlice: '', + panels, + })), + totalTabbedChars: 200, + status: 'pass', + }, + ], + }); + } + + it.each([ + ['setext headings', 'Installation\n============', 'Installation\n------------'], + ['closing ATX hashes', '## Installation ##', '## Installation'], + ['formatted labels', '## **Installation**', '## [Installation](/install)'], + ['emphasis and inline code', '## *Installation*', '## `Installation`'], + ['reference links', '## [Installation][setup]\n\n[setup]: /install', '## Installation'], + ['escaped punctuation', '## Install \\*SDK\\*', '## Install `*SDK*`'], + ['entities', '## Install & configure SDK', '## Install & configure SDK'], + ['single entity decoding', '## &copy;', '## `©`'], + ['image labels', '## ![Installation](/install.svg)', '## Installation'], + ['inline HTML', '## Installation', '

Installation

'], + ])('compares rendered text for %s', async (_name, first, second) => { + const result = await check.run( + makePanelCtx([ + { label: 'Python', html: first }, + { label: 'Node', html: second }, + ]), + ); + expect(result.status).toBe('fail'); + expect(result.details?.analyses).toEqual([ + expect.objectContaining({ totalHeaders: 2, genericHeaders: 2, contextualHeaders: 0 }), + ]); + }); + + describe.each([ + ['LF', '\n'], + ['CRLF', '\r\n'], + ])('%s line endings', (_name, newline) => { + it.each([ + ['fenced code', '```md\n## Installation\n## Configuration\n```'], + ['tilde fences', '~~~md\n## Installation\n~~~'], + ['nested shorter fences', '````md\n```\n## Installation\n```\n````'], + ['pipe-bearing fence info', '```md|example\n## Installation\n```'], + ['indented code', ' ## Installation\n ## Configuration'], + ['HTML in fenced code', '```html\n

Installation

\n```'], + ['HTML in indented code', '

Installation

'], + ['multiline inline code', '`

Installation

\nConfiguration`'], + ['HTML comments', ''], + ['indented comments', ' '], + ['quoted comments', '> '], + ['nested list fences', '- example\n\n ```md\n ## Installation\n ```'], + ['nested indented code', '> ## Installation\n>

Configuration

'], + ['quoted fences', '> ```html\n> ## Installation\n>

Configuration

\n> ```'], + ])('ignores headings inside %s', async (_kind, example) => { + const result = await check.run( + makePanelCtx( + ['Python', 'Node'].map((label) => ({ + label, + html: `\n\n## ${label} Setup\n\n${example}\n\n`.replaceAll( + '\n', + newline, + ), + })), + ), + ); + expect(result.status).toBe('pass'); + expect(result.details?.analyses).toEqual([ + expect.objectContaining({ totalHeaders: 2, genericHeaders: 0, contextualHeaders: 2 }), + ]); + }); + + it('recognizes setext headings in nested Markdown containers', async () => { + const result = await check.run( + makePanelCtx([ + { label: 'Python', html: '> Installation\n> ------------'.replaceAll('\n', newline) }, + { label: 'Node', html: '- Installation\n ------------'.replaceAll('\n', newline) }, + ]), + ); + expect(result.status).toBe('fail'); + expect(result.details?.analyses).toEqual([ + expect.objectContaining({ totalHeaders: 2, genericHeaders: 2 }), + ]); + }); + }); + + it.each([ + ['aside', ''], + ['note role', '
', '
'], + ['alert role', '
', '
'], + ['status role', '
', '
'], + ['complementary role', '
', '
'], + ['admonition class', '
', '
'], + ['callout data attribute', '', ''], + ])('excludes mixed Markdown/HTML headings inside %s ancestors', async (_name, open, close) => { + const panels = ['Python', 'Node'].map((label) => ({ + label, + html: `\n\n## ${label} Setup\n\n${open}\n
\n\n## Warning\n\nNote\n----\n\n

Important

\n\n
\n${close}\n\n## ${label} Usage\n\n
`, + })); + const result = await check.run(makePanelCtx(panels, panels)); + expect(result.status).toBe('pass'); + expect(result.details?.crossGroupRepeatedHeaders).toEqual([]); + expect(result.details?.analyses).toEqual([ + expect.objectContaining({ totalHeaders: 4, genericHeaders: 0, contextualHeaders: 4 }), + expect.objectContaining({ totalHeaders: 4, genericHeaders: 0, contextualHeaders: 4 }), + ]); + }); + + it('does not count Markdown inside an HTML heading a second time', async () => { + const result = await check.run( + makePanelCtx([ + { label: 'Python', html: '

\n\n## Python Setup\n\n

' }, + { label: 'Node', html: '

\n\n## Node Setup\n\n

' }, + ]), + ); + expect(result.status).toBe('pass'); + expect(result.details?.analyses).toEqual([ + expect.objectContaining({ totalHeaders: 2, contextualHeaders: 2 }), + ]); + }); + + it.each([false, true])( + 'keeps equivalent HTML/Markdown results (contextual: %s)', + async (contextual) => { + const panelLabels = [ + ['Python', 'Node'], + ['Java', 'Ruby'], + ]; + const title = (label: string) => (contextual ? `${label} Installation` : 'Installation'); + const htmlGroups = panelLabels.map((labels) => + labels.map((label) => ({ label, html: `

${title(label)}

` })), + ); + const markdownGroups = panelLabels.map((labels, index) => + labels.map((label) => ({ + label, + html: `\n\n${index === 0 ? `## **${title(label)}** ##` : `${title(label)}\n------------`}\n\n`, + })), + ); + const htmlResult = await check.run(makePanelCtx(...htmlGroups)); + const markdownResult = await check.run(makePanelCtx(...markdownGroups)); + expect(markdownResult).toEqual(htmlResult); + expect(markdownResult.status).toBe(contextual ? 'pass' : 'fail'); + expect(markdownResult.details?.crossGroupRepeatedHeaders).toEqual( + contextual + ? [] + : [{ url: 'http://test.local/page', header: 'installation', groupCount: 2 }], + ); + }, + ); + + it.each( + ['Tab', 'TabItem'].flatMap((tag) => + ['\n', '\n\n', '\r\n', '\r\n\r\n'].map((separator) => ({ tag, separator })), + ), + )('uses detected MDX panels without fetching (%j)', async ({ tag, separator }) => { + const source = `\n<${tag} name="Python">${separator}Installation\n------------${separator}\n<${tag} name="Node">${separator}## **Installation** ##${separator}\n`; + const groups = detectTabGroups(source); + expect(groups).toHaveLength(1); + const ctx = makePanelCtx(...groups.map((group) => group.panels)); + const fetch = vi.spyOn(ctx.http, 'fetch').mockRejectedValue(new Error('Unexpected request')); + try { + const result = await check.run(ctx); + expect(result.status).toBe('fail'); + expect(result.id).toBe('section-header-quality'); + expect(result.category).toBe('content-structure'); + expect(result.details?.analyses).toEqual([ + expect.objectContaining({ totalHeaders: 2, genericHeaders: 2 }), + ]); + expect(check.dependsOn).toEqual([]); + expect(fetch).not.toHaveBeenCalled(); + } finally { + fetch.mockRestore(); + } + }); + it('skips when tabbed-content-serialization did not run', async () => { const ctx = createContext('http://test.local', { requestDelay: 0 }); const result = await check.run(ctx);