Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/checks/content-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
66 changes: 59 additions & 7 deletions src/checks/content-structure/section-header-quality.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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']);

Expand Down Expand Up @@ -57,28 +59,78 @@ 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
* are supplementary labels rather than structural section headers.
*/
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 });
Comment thread
dacharyc marked this conversation as resolved.

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;
const text = h.textContent.trim();
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;
Expand Down
186 changes: 185 additions & 1 deletion test/unit/checks/section-header-quality.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -35,6 +36,189 @@ describe('section-header-quality', () => {
return ctx;
}

function makePanelCtx(...groups: Array<Array<{ label: string | null; html: string }>>) {
return makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: groups.map((panels) => ({
framework: 'mdx',
tabCount: panels.length,
htmlSlice: '<Tabs></Tabs>',
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 &amp; configure &#x53;DK', '## Install & configure SDK'],
['single entity decoding', '## &amp;copy;', '## `&copy;`'],
['image labels', '## ![Installation](/install.svg)', '## Installation'],
['inline HTML', '## <em>Installation</em>', '<h2>Installation</h2>'],
])('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<h2>Installation</h2>\n```'],
['HTML in indented code', ' <h2>Installation</h2>'],
['multiline inline code', '`<h2>Installation</h2>\nConfiguration`'],
['HTML comments', '<!--\n## Installation\n<h2>Configuration</h2>\n-->'],
['indented comments', ' <!--\n## Installation\n<h2>Configuration</h2>\n -->'],
['quoted comments', '> <!--\n> ## Installation\n> <h2>Configuration</h2>\n> -->'],
['nested list fences', '- example\n\n ```md\n ## Installation\n ```'],
['nested indented code', '> ## Installation\n> <h2>Configuration</h2>'],
['quoted fences', '> ```html\n> ## Installation\n> <h2>Configuration</h2>\n> ```'],
])('ignores headings inside %s', async (_kind, example) => {
const result = await check.run(
makePanelCtx(
['Python', 'Node'].map((label) => ({
label,
html: `<Tab name="${label}">\n\n## ${label} Setup\n\n${example}\n\n</Tab>`.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', '<aside>', '</aside>'],
['note role', '<div role="note">', '</div>'],
['alert role', '<div role="alert">', '</div>'],
['status role', '<div role="status">', '</div>'],
['complementary role', '<div role="complementary">', '</div>'],
['admonition class', '<div class="theme-admonition warning">', '</div>'],
['callout data attribute', '<Box data-paste-element="CALLOUT">', '</Box>'],
])('excludes mixed Markdown/HTML headings inside %s ancestors', async (_name, open, close) => {
const panels = ['Python', 'Node'].map((label) => ({
label,
html: `<Tab name="${label}">\n\n## ${label} Setup\n\n${open}\n<div>\n\n## Warning\n\nNote\n----\n\n<h3>Important</h3>\n\n</div>\n${close}\n\n## ${label} Usage\n\n</Tab>`,
}));
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: '<h2>\n\n## Python Setup\n\n</h2>' },
{ label: 'Node', html: '<h2>\n\n## Node Setup\n\n</h2>' },
]),
);
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: `<div><h2>${title(label)}</h2></div>` })),
);
const markdownGroups = panelLabels.map((labels, index) =>
labels.map((label) => ({
label,
html: `<Tab name="${label}">\n\n${index === 0 ? `## **${title(label)}** ##` : `${title(label)}\n------------`}\n\n</Tab>`,
})),
);
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 = `<Tabs>\n<${tag} name="Python">${separator}Installation\n------------${separator}</${tag}>\n<${tag} name="Node">${separator}## **Installation** ##${separator}</${tag}>\n</Tabs>`;
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);
Expand Down
Loading