Skip to content

Commit 3e4fd29

Browse files
authored
Merge pull request #52 from agent-ecosystem/fix/callouts-detected-as-section-headers
Fix: callouts should not trigger duplicate section header warnings
2 parents cf48997 + ebdb7cb commit 3e4fd29

2 files changed

Lines changed: 324 additions & 3 deletions

File tree

src/checks/content-structure/section-header-quality.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { HTMLElement } from 'node-html-parser';
12
import { parse } from 'node-html-parser';
23
import { registerCheck } from '../registry.js';
34
import type { CheckContext, CheckResult, CheckStatus } from '../../types.js';
@@ -23,17 +24,52 @@ interface GroupHeaderAnalysis {
2324

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

27+
const CALLOUT_ROLES = new Set(['alert', 'note', 'status', 'complementary']);
28+
29+
/**
30+
* Check whether a heading is inside a callout/admonition container rather than
31+
* being a structural section header. Walks up the ancestor chain looking for
32+
* signals: semantic HTML (<aside>), ARIA roles, class names containing
33+
* "callout"/"admonition", or data-* attribute values containing those keywords.
34+
*/
35+
function isCalloutHeading(h: HTMLElement): boolean {
36+
let el: HTMLElement | null = h;
37+
while (el) {
38+
// Semantic HTML
39+
if (el.rawTagName === 'aside') return true;
40+
41+
// ARIA roles
42+
const role = el.getAttribute('role');
43+
if (role && CALLOUT_ROLES.has(role)) return true;
44+
45+
// Class and data-* attribute values
46+
const attrs = el.attributes;
47+
for (const [key, value] of Object.entries(attrs)) {
48+
if (key === 'role') continue; // already checked
49+
if (key === 'class' || key.startsWith('data-')) {
50+
const lower = value.toLowerCase();
51+
if (lower.includes('callout') || lower.includes('admonition')) return true;
52+
}
53+
}
54+
55+
el = el.parentNode as HTMLElement | null;
56+
}
57+
return false;
58+
}
59+
2660
/**
27-
* Extract header text from content that may be HTML, markdown, or a mix (MDX).
28-
* Tries HTML parsing first, then falls back to markdown heading regex.
61+
* Extract section header text from content that may be HTML, markdown, or a
62+
* mix (MDX). Excludes headings inside callout/admonition containers, which
63+
* are supplementary labels rather than structural section headers.
2964
*/
3065
function extractHeaders(content: string): string[] {
3166
const headers: string[] = [];
3267

33-
// HTML headers
68+
// HTML headers — skip callout/admonition headings
3469
const root = parse(content);
3570
const htmlHeaders = root.querySelectorAll('h1, h2, h3, h4, h5, h6');
3671
for (const h of htmlHeaders) {
72+
if (isCalloutHeading(h)) continue;
3773
const text = h.textContent.trim();
3874
if (text.length > 0) headers.push(text);
3975
}

test/unit/checks/section-header-quality.test.ts

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -558,6 +558,291 @@ describe('section-header-quality', () => {
558558
);
559559
});
560560

561+
// Callout/admonition heading exclusion tests (issue #51)
562+
it('excludes callout headings inside <aside> elements', async () => {
563+
const result = await check.run(
564+
makeCtx({
565+
status: 'pass',
566+
tabbedPages: [
567+
{
568+
url: 'http://test.local/page',
569+
tabGroups: [
570+
{
571+
framework: 'generic-aria',
572+
tabCount: 2,
573+
htmlSlice: '<div></div>',
574+
panels: [
575+
{
576+
label: 'Python',
577+
html: '<div><aside><h2>Warning</h2><p>Be careful</p></aside></div>',
578+
},
579+
{
580+
label: 'Node',
581+
html: '<div><aside><h2>Warning</h2><p>Be careful</p></aside></div>',
582+
},
583+
],
584+
},
585+
],
586+
totalTabbedChars: 100,
587+
status: 'pass',
588+
},
589+
],
590+
}),
591+
);
592+
// No section headers remain after excluding callout headings
593+
expect(result.status).toBe('skip');
594+
expect(result.message).toContain('no section headers inside tab panels');
595+
});
596+
597+
it('excludes callout headings inside elements with ARIA role="note"', async () => {
598+
const result = await check.run(
599+
makeCtx({
600+
status: 'pass',
601+
tabbedPages: [
602+
{
603+
url: 'http://test.local/page',
604+
tabGroups: [
605+
{
606+
framework: 'generic-aria',
607+
tabCount: 2,
608+
htmlSlice: '<div></div>',
609+
panels: [
610+
{
611+
label: 'Python',
612+
html: '<div><div role="note"><h3>Note</h3><p>Info here</p></div></div>',
613+
},
614+
{
615+
label: 'Node',
616+
html: '<div><div role="note"><h3>Note</h3><p>Info here</p></div></div>',
617+
},
618+
],
619+
},
620+
],
621+
totalTabbedChars: 100,
622+
status: 'pass',
623+
},
624+
],
625+
}),
626+
);
627+
expect(result.status).toBe('skip');
628+
expect(result.message).toContain('no section headers inside tab panels');
629+
});
630+
631+
it('excludes callout headings inside elements with admonition class', async () => {
632+
const result = await check.run(
633+
makeCtx({
634+
status: 'pass',
635+
tabbedPages: [
636+
{
637+
url: 'http://test.local/page',
638+
tabGroups: [
639+
{
640+
framework: 'mkdocs',
641+
tabCount: 2,
642+
htmlSlice: '<div></div>',
643+
panels: [
644+
{
645+
label: 'Python',
646+
html: '<div><div class="admonition warning"><h3>Warning</h3><p>Careful</p></div></div>',
647+
},
648+
{
649+
label: 'Node',
650+
html: '<div><div class="admonition warning"><h3>Warning</h3><p>Careful</p></div></div>',
651+
},
652+
],
653+
},
654+
],
655+
totalTabbedChars: 100,
656+
status: 'pass',
657+
},
658+
],
659+
}),
660+
);
661+
expect(result.status).toBe('skip');
662+
expect(result.message).toContain('no section headers inside tab panels');
663+
});
664+
665+
it('excludes callout headings inside elements with data-* callout attributes', async () => {
666+
// Twilio Paste pattern: data-paste-element="CALLOUT" on ancestor
667+
const result = await check.run(
668+
makeCtx({
669+
status: 'pass',
670+
tabbedPages: [
671+
{
672+
url: 'http://test.local/page',
673+
tabGroups: [
674+
{
675+
framework: 'generic-aria',
676+
tabCount: 2,
677+
htmlSlice: '<div></div>',
678+
panels: [
679+
{
680+
label: 'Python',
681+
html: '<div><div data-paste-element="CALLOUT"><div><h2 data-paste-element="CALLOUT_HEADING">Warning</h2></div></div></div>',
682+
},
683+
{
684+
label: 'Node',
685+
html: '<div><div data-paste-element="CALLOUT"><div><h2 data-paste-element="CALLOUT_HEADING">Warning</h2></div></div></div>',
686+
},
687+
],
688+
},
689+
],
690+
totalTabbedChars: 100,
691+
status: 'pass',
692+
},
693+
],
694+
}),
695+
);
696+
expect(result.status).toBe('skip');
697+
expect(result.message).toContain('no section headers inside tab panels');
698+
});
699+
700+
it('counts section headers but ignores callout headings in the same panel', async () => {
701+
// Panels have both a real section header and a callout heading.
702+
// Only the section header should be counted; the callout should be ignored.
703+
const result = await check.run(
704+
makeCtx({
705+
status: 'pass',
706+
tabbedPages: [
707+
{
708+
url: 'http://test.local/page',
709+
tabGroups: [
710+
{
711+
framework: 'generic-aria',
712+
tabCount: 2,
713+
htmlSlice: '<div></div>',
714+
panels: [
715+
{
716+
label: 'Python',
717+
html: '<div><h2>Python Setup</h2><aside><h3>Warning</h3><p>Be careful</p></aside></div>',
718+
},
719+
{
720+
label: 'Node',
721+
html: '<div><h2>Node Setup</h2><aside><h3>Warning</h3><p>Be careful</p></aside></div>',
722+
},
723+
],
724+
},
725+
],
726+
totalTabbedChars: 100,
727+
status: 'pass',
728+
},
729+
],
730+
}),
731+
);
732+
// "Python Setup" and "Node Setup" include variant context → pass
733+
// "Warning" in <aside> is excluded from analysis entirely
734+
expect(result.status).toBe('pass');
735+
expect(result.details?.groupsWithGenericMajority).toBe(0);
736+
});
737+
738+
// Framework-specific callout pattern tests
739+
it('excludes Bootstrap alert headings (role="alert" + alert-heading)', async () => {
740+
const result = await check.run(
741+
makeCtx({
742+
status: 'pass',
743+
tabbedPages: [
744+
{
745+
url: 'http://test.local/page',
746+
tabGroups: [
747+
{
748+
framework: 'generic-aria',
749+
tabCount: 2,
750+
htmlSlice: '<div></div>',
751+
panels: [
752+
{
753+
label: 'Python',
754+
html: '<div><div class="alert alert-warning" role="alert"><h4 class="alert-heading">Warning!</h4><p>Check your configuration.</p></div></div>',
755+
},
756+
{
757+
label: 'Node',
758+
html: '<div><div class="alert alert-warning" role="alert"><h4 class="alert-heading">Warning!</h4><p>Check your configuration.</p></div></div>',
759+
},
760+
],
761+
},
762+
],
763+
totalTabbedChars: 100,
764+
status: 'pass',
765+
},
766+
],
767+
}),
768+
);
769+
expect(result.status).toBe('skip');
770+
expect(result.message).toContain('no section headers inside tab panels');
771+
});
772+
773+
it('excludes headings inside Docusaurus admonition containers', async () => {
774+
// Docusaurus uses class names containing "admonition" and "alert"
775+
const result = await check.run(
776+
makeCtx({
777+
status: 'pass',
778+
tabbedPages: [
779+
{
780+
url: 'http://test.local/page',
781+
tabGroups: [
782+
{
783+
framework: 'docusaurus',
784+
tabCount: 2,
785+
htmlSlice: '<div></div>',
786+
panels: [
787+
{
788+
label: 'Python',
789+
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>',
790+
},
791+
{
792+
label: 'Node',
793+
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>',
794+
},
795+
],
796+
},
797+
],
798+
totalTabbedChars: 100,
799+
status: 'pass',
800+
},
801+
],
802+
}),
803+
);
804+
expect(result.status).toBe('skip');
805+
expect(result.message).toContain('no section headers inside tab panels');
806+
});
807+
808+
it('excludes headings inside Sphinx/MkDocs admonition with nested content', async () => {
809+
// Sphinx/MkDocs admonition titles use <p>, but user content inside
810+
// the admonition could contain headings (e.g., a long note with sections)
811+
const result = await check.run(
812+
makeCtx({
813+
status: 'pass',
814+
tabbedPages: [
815+
{
816+
url: 'http://test.local/page',
817+
tabGroups: [
818+
{
819+
framework: 'sphinx',
820+
tabCount: 2,
821+
htmlSlice: '<div></div>',
822+
panels: [
823+
{
824+
label: 'Python',
825+
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>',
826+
},
827+
{
828+
label: 'Node',
829+
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>',
830+
},
831+
],
832+
},
833+
],
834+
totalTabbedChars: 200,
835+
status: 'pass',
836+
},
837+
],
838+
}),
839+
);
840+
// "Python Setup" / "Node Setup" are section headers → counted, contextual → pass
841+
// "Prerequisites" inside .admonition → excluded
842+
expect(result.status).toBe('pass');
843+
expect(result.details?.groupsWithGenericMajority).toBe(0);
844+
});
845+
561846
it('detects contextual markdown headers in MDX panels', async () => {
562847
const result = await check.run(
563848
makeCtx({

0 commit comments

Comments
 (0)