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
42 changes: 39 additions & 3 deletions src/checks/content-structure/section-header-quality.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { HTMLElement } from 'node-html-parser';
import { parse } from 'node-html-parser';
import { registerCheck } from '../registry.js';
import type { CheckContext, CheckResult, CheckStatus } from '../../types.js';
Expand All @@ -23,17 +24,52 @@ interface GroupHeaderAnalysis {

const MD_HEADING_RE = /^#{1,6}\s+(.+)$/gm;

const CALLOUT_ROLES = new Set(['alert', 'note', 'status', 'complementary']);

/**
* Check whether a heading is inside a callout/admonition container rather than
* being a structural section header. Walks up the ancestor chain looking for
* signals: semantic HTML (<aside>), ARIA roles, class names containing
* "callout"/"admonition", or data-* attribute values containing those keywords.
*/
function isCalloutHeading(h: HTMLElement): boolean {
let el: HTMLElement | null = h;
while (el) {
// Semantic HTML
if (el.rawTagName === 'aside') return true;

// ARIA roles
const role = el.getAttribute('role');
if (role && CALLOUT_ROLES.has(role)) return true;

// Class and data-* attribute values
const attrs = el.attributes;
for (const [key, value] of Object.entries(attrs)) {
if (key === 'role') continue; // already checked
if (key === 'class' || key.startsWith('data-')) {
const lower = value.toLowerCase();
if (lower.includes('callout') || lower.includes('admonition')) return true;
}
}

el = el.parentNode as HTMLElement | null;
}
return false;
}

/**
* Extract header text from content that may be HTML, markdown, or a mix (MDX).
* Tries HTML parsing first, then falls back to markdown heading regex.
* 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[] = [];

// HTML headers
// HTML headers — skip callout/admonition headings
const root = parse(content);
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);
}
Expand Down
285 changes: 285 additions & 0 deletions test/unit/checks/section-header-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,291 @@ describe('section-header-quality', () => {
);
});

// Callout/admonition heading exclusion tests (issue #51)
it('excludes callout headings inside <aside> elements', async () => {
const result = await check.run(
makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: [
{
framework: 'generic-aria',
tabCount: 2,
htmlSlice: '<div></div>',
panels: [
{
label: 'Python',
html: '<div><aside><h2>Warning</h2><p>Be careful</p></aside></div>',
},
{
label: 'Node',
html: '<div><aside><h2>Warning</h2><p>Be careful</p></aside></div>',
},
],
},
],
totalTabbedChars: 100,
status: 'pass',
},
],
}),
);
// No section headers remain after excluding callout headings
expect(result.status).toBe('skip');
expect(result.message).toContain('no section headers inside tab panels');
});

it('excludes callout headings inside elements with ARIA role="note"', async () => {
const result = await check.run(
makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: [
{
framework: 'generic-aria',
tabCount: 2,
htmlSlice: '<div></div>',
panels: [
{
label: 'Python',
html: '<div><div role="note"><h3>Note</h3><p>Info here</p></div></div>',
},
{
label: 'Node',
html: '<div><div role="note"><h3>Note</h3><p>Info here</p></div></div>',
},
],
},
],
totalTabbedChars: 100,
status: 'pass',
},
],
}),
);
expect(result.status).toBe('skip');
expect(result.message).toContain('no section headers inside tab panels');
});

it('excludes callout headings inside elements with admonition class', async () => {
const result = await check.run(
makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: [
{
framework: 'mkdocs',
tabCount: 2,
htmlSlice: '<div></div>',
panels: [
{
label: 'Python',
html: '<div><div class="admonition warning"><h3>Warning</h3><p>Careful</p></div></div>',
},
{
label: 'Node',
html: '<div><div class="admonition warning"><h3>Warning</h3><p>Careful</p></div></div>',
},
],
},
],
totalTabbedChars: 100,
status: 'pass',
},
],
}),
);
expect(result.status).toBe('skip');
expect(result.message).toContain('no section headers inside tab panels');
});

it('excludes callout headings inside elements with data-* callout attributes', async () => {
// Twilio Paste pattern: data-paste-element="CALLOUT" on ancestor
const result = await check.run(
makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: [
{
framework: 'generic-aria',
tabCount: 2,
htmlSlice: '<div></div>',
panels: [
{
label: 'Python',
html: '<div><div data-paste-element="CALLOUT"><div><h2 data-paste-element="CALLOUT_HEADING">Warning</h2></div></div></div>',
},
{
label: 'Node',
html: '<div><div data-paste-element="CALLOUT"><div><h2 data-paste-element="CALLOUT_HEADING">Warning</h2></div></div></div>',
},
],
},
],
totalTabbedChars: 100,
status: 'pass',
},
],
}),
);
expect(result.status).toBe('skip');
expect(result.message).toContain('no section headers inside tab panels');
});

it('counts section headers but ignores callout headings in the same panel', async () => {
// Panels have both a real section header and a callout heading.
// Only the section header should be counted; the callout should be ignored.
const result = await check.run(
makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: [
{
framework: 'generic-aria',
tabCount: 2,
htmlSlice: '<div></div>',
panels: [
{
label: 'Python',
html: '<div><h2>Python Setup</h2><aside><h3>Warning</h3><p>Be careful</p></aside></div>',
},
{
label: 'Node',
html: '<div><h2>Node Setup</h2><aside><h3>Warning</h3><p>Be careful</p></aside></div>',
},
],
},
],
totalTabbedChars: 100,
status: 'pass',
},
],
}),
);
// "Python Setup" and "Node Setup" include variant context → pass
// "Warning" in <aside> is excluded from analysis entirely
expect(result.status).toBe('pass');
expect(result.details?.groupsWithGenericMajority).toBe(0);
});

// Framework-specific callout pattern tests
it('excludes Bootstrap alert headings (role="alert" + alert-heading)', async () => {
const result = await check.run(
makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: [
{
framework: 'generic-aria',
tabCount: 2,
htmlSlice: '<div></div>',
panels: [
{
label: 'Python',
html: '<div><div class="alert alert-warning" role="alert"><h4 class="alert-heading">Warning!</h4><p>Check your configuration.</p></div></div>',
},
{
label: 'Node',
html: '<div><div class="alert alert-warning" role="alert"><h4 class="alert-heading">Warning!</h4><p>Check your configuration.</p></div></div>',
},
],
},
],
totalTabbedChars: 100,
status: 'pass',
},
],
}),
);
expect(result.status).toBe('skip');
expect(result.message).toContain('no section headers inside tab panels');
});

it('excludes headings inside Docusaurus admonition containers', async () => {
// Docusaurus uses class names containing "admonition" and "alert"
const result = await check.run(
makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: [
{
framework: 'docusaurus',
tabCount: 2,
htmlSlice: '<div></div>',
panels: [
{
label: 'Python',
html: '<div><div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><h5>Deprecation Notice</h5><p>This API will be removed.</p></div></div>',
},
{
label: 'Node',
html: '<div><div class="theme-admonition theme-admonition-warning admonition_xJq3 alert alert--warning"><h5>Deprecation Notice</h5><p>This API will be removed.</p></div></div>',
},
],
},
],
totalTabbedChars: 100,
status: 'pass',
},
],
}),
);
expect(result.status).toBe('skip');
expect(result.message).toContain('no section headers inside tab panels');
});

it('excludes headings inside Sphinx/MkDocs admonition with nested content', async () => {
// Sphinx/MkDocs admonition titles use <p>, but user content inside
// the admonition could contain headings (e.g., a long note with sections)
const result = await check.run(
makeCtx({
status: 'pass',
tabbedPages: [
{
url: 'http://test.local/page',
tabGroups: [
{
framework: 'sphinx',
tabCount: 2,
htmlSlice: '<div></div>',
panels: [
{
label: 'Python',
html: '<div><h2>Python Setup</h2><div class="admonition note"><p class="admonition-title">Note</p><h4>Prerequisites</h4><p>You need Python 3.8+</p></div></div>',
},
{
label: 'Node',
html: '<div><h2>Node Setup</h2><div class="admonition note"><p class="admonition-title">Note</p><h4>Prerequisites</h4><p>You need Node 18+</p></div></div>',
},
],
},
],
totalTabbedChars: 200,
status: 'pass',
},
],
}),
);
// "Python Setup" / "Node Setup" are section headers → counted, contextual → pass
// "Prerequisites" inside .admonition → excluded
expect(result.status).toBe('pass');
expect(result.details?.groupsWithGenericMajority).toBe(0);
});

it('detects contextual markdown headers in MDX panels', async () => {
const result = await check.run(
makeCtx({
Expand Down