diff --git a/packages/@d-zero/anatomist/src/capture-layout-tree.spec.ts b/packages/@d-zero/anatomist/src/capture-layout-tree.spec.ts index 60e1d0a5..d48abb36 100644 --- a/packages/@d-zero/anatomist/src/capture-layout-tree.spec.ts +++ b/packages/@d-zero/anatomist/src/capture-layout-tree.spec.ts @@ -60,6 +60,58 @@ describe('captureLayoutTree', () => { expect(result.root?.children[0]?.tagName).toBe('P'); }); + it('prefers a higher-priority selector even when it matches later in DOM order', () => { + const doc = createDocument( + '
Wrong
' + + '
Right
', + ); + + const result = captureLayoutTree(null, SELECTORS, FALLBACK_SELECTORS, 10, doc); + + expect(result.mainSelector).toBe('main#page'); + }); + + it('prefers a nested higher-priority match over its lower-priority ancestor wrapper', () => { + const doc = createDocument( + '
' + + '

breadcrumb

' + + '
Real main
' + + '
Sidebar
' + + '
', + ); + + const result = captureLayoutTree(null, SELECTORS, FALLBACK_SELECTORS, 10, doc); + + expect(result.mainSelector).toBe('main#page'); + }); + + it('skips an invalid selector in the priority list and keeps trying the rest', () => { + const doc = createDocument( + '
Right
', + ); + + const result = captureLayoutTree( + null, + ['[[[invalid', ...SELECTORS], + FALLBACK_SELECTORS, + 10, + doc, + ); + + expect(result.mainSelector).toBe('main#page'); + }); + + it('prefers the earlier-listed selector when two selectors both match different elements', () => { + const doc = createDocument( + '
Wrong
' + + '
Right
', + ); + + const result = captureLayoutTree(null, SELECTORS, FALLBACK_SELECTORS, 10, doc); + + expect(result.mainSelector).toBe('div#main'); + }); + it('falls back to the fallback selector list when the priority list has no match', () => { const doc = createDocument( '
Fallback
', @@ -70,6 +122,26 @@ describe('captureLayoutTree', () => { expect(result.mainSelector).toBe('div#primaryMain'); }); + it('still returns DOM-order-first match among fallback selectors (fallback list is not priority-ordered per element)', () => { + const doc = createDocument( + '
' + + '

breadcrumb

' + + '
Inner
' + + '
', + ); + + // Both #wrapperMain and #innerMain match the same fallback selector + // (`[id*="main" i]`), so — unlike the priority list above — there is + // no higher/lower priority to arbitrate between them. `querySelector` + // returns whichever matches first in document order, which is the + // outer wrapper here. This is accepted, existing behavior: the + // priority-list fix only orders *between* selectors in the array, + // not among multiple elements matching the *same* selector. + const result = captureLayoutTree(null, SELECTORS, FALLBACK_SELECTORS, 10, doc); + + expect(result.mainSelector).toBe('div#wrapperMain'); + }); + it('tries the explicit mainContentSelector before the priority list', () => { const doc = createDocument( '
Wrong
Right
', diff --git a/packages/@d-zero/anatomist/src/capture-layout-tree.ts b/packages/@d-zero/anatomist/src/capture-layout-tree.ts index 98bbe741..48fb76b5 100644 --- a/packages/@d-zero/anatomist/src/capture-layout-tree.ts +++ b/packages/@d-zero/anatomist/src/capture-layout-tree.ts @@ -30,6 +30,20 @@ * can match a different element than the one actually found, so this * module resolves the element itself directly instead of round-tripping * through that string. + * + * WHY the priority-list resolution logic in `resolveMainElement` is + * duplicated rather than shared: same closure-free constraint as above. + * `extractMainContentsFromDocument` (`@d-zero/beholder/get-main-contents.ts`) + * independently re-implements the identical "try each selector in array + * order, first match wins" strategy for the same reason. The two copies + * have drifted out of sync before (one queried the array with a single + * `querySelector(selectors.join(','))` — which resolves by DOM document + * order, not array priority — while the other still tried selectors one at + * a time); `capture-layout-tree.spec.ts` and `get-main-contents.spec.ts` + * both carry matching regression cases (searchable by the test name + * "prefers a higher-priority selector...") specifically so that fixing one + * side's resolution logic without the other shows up as a spec gap, not a + * silent divergence. * @module */ @@ -117,12 +131,27 @@ export function captureLayoutTree( } } + // Tried one at a time, in priority order, rather than joined into a + // single `querySelector(selectors.join(','))` call: a CSS group + // selector matches whichever selector is first in *document order*, + // not whichever is first in this array — an ancestor wrapper matching + // a low-priority selector (e.g. `#contents`) would win over a + // descendant matching a higher-priority one (e.g. `#main`) just + // because it appears earlier in the DOM. Querying one selector at a + // time makes this array's order the actual priority. let element: Element | null = null; - try { - element = doc.querySelector(selectors.join(',')); - } catch { - // The built-in selector list is a fixed, known-valid constant, so - // this should be unreachable — but fail closed rather than throw. + for (const sel of selectors) { + try { + element = doc.querySelector(sel); + } catch { + // The built-in selector list is a fixed, known-valid constant, + // so this should be unreachable — but fail closed rather than + // aborting the whole priority list over one bad entry. + continue; + } + if (element) { + break; + } } if (element) { diff --git a/packages/@d-zero/beholder/src/get-main-contents.spec.ts b/packages/@d-zero/beholder/src/get-main-contents.spec.ts index a3bcb3bc..46c75224 100644 --- a/packages/@d-zero/beholder/src/get-main-contents.spec.ts +++ b/packages/@d-zero/beholder/src/get-main-contents.spec.ts @@ -203,11 +203,11 @@ describe('extractMainContentsFromDocument', () => { expect(result.wordCount).toBe(15); }); - it('returns first matching element in DOM order when multiple Phase-1 selectors match', () => { + it('prefers a higher-priority selector even when it matches later in DOM order', () => { const result = extract(` -
Semantic main
ID content
+
Semantic main
`); @@ -215,6 +215,51 @@ describe('extractMainContentsFromDocument', () => { expect(result.wordCount).toBe(12); }); + it('prefers a nested higher-priority match over its lower-priority ancestor wrapper', () => { + const result = extract(` + +
+

breadcrumb

+
Real main
+
Sidebar
+
+ + `); + + expect(result.main?.id).toBe('main'); + }); + + it('prefers the earlier-listed selector when two selectors both match different elements', () => { + const result = extract(` + +
Wrong
+
Right
+ + `); + + expect(result.main?.id).toBe('main'); + }); + + it('still returns DOM-order-first match among fallback selectors (fallback list is not priority-ordered per element)', () => { + // Both #wrapperMain and #innerMain match the same fallback selector + // (`[id*="main" i]`), so — unlike the priority list above — there is + // no higher/lower priority to arbitrate between them. `querySelector` + // returns whichever matches first in document order, which is the + // outer wrapper here. This is accepted, existing behavior: the + // priority-list fix only orders *between* selectors in the array, + // not among multiple elements matching the *same* selector. + const result = extract(` + +
+

breadcrumb

+
Inner
+
+ + `); + + expect(result.main?.id).toBe('wrapperMain'); + }); + it('uses Phase-2 class*=main when Phase-1 finds nothing', () => { const result = extract( '
Phase two
', diff --git a/packages/@d-zero/beholder/src/get-main-contents.ts b/packages/@d-zero/beholder/src/get-main-contents.ts index 5f7e57bd..2a0d8314 100644 --- a/packages/@d-zero/beholder/src/get-main-contents.ts +++ b/packages/@d-zero/beholder/src/get-main-contents.ts @@ -7,6 +7,19 @@ * * WHY no `@medv/finder`: selector strings are diagnostic only; a tag+id+class // cspell:disable-line * path is enough and avoids a Node-only dependency inside the page realm. + * + * WHY the priority-list resolution logic below is duplicated rather than + * shared: same closure-free constraint as above. `@d-zero/anatomist`'s + * `capture-layout-tree.ts` independently re-implements the identical "try + * each selector in array order, first match wins" strategy for the same + * reason. The two copies have drifted out of sync before (one queried the + * array with a single `querySelector(selectors.join(','))` — which resolves + * by DOM document order, not array priority — while the other still tried + * selectors one at a time); this file's spec and + * `anatomist/capture-layout-tree.spec.ts` both carry matching regression + * cases (searchable by the test name "prefers a higher-priority + * selector...") specifically so that fixing one side's resolution logic + * without the other shows up as a spec gap, not a silent divergence. * @module */ @@ -118,14 +131,27 @@ export function extractMainContentsFromDocument( selectors.unshift(mainContentSelector); } + // Tried one at a time, in priority order, rather than joined into a + // single `querySelector(selectors.join(','))` call: a CSS group selector + // matches whichever selector is first in *document order*, not whichever + // is first in this array — an ancestor wrapper matching a low-priority + // selector (e.g. `#contents`) would win over a descendant matching a + // higher-priority one (e.g. `#main`) just because it appears earlier in + // the DOM. Querying one selector at a time makes this array's order the + // actual priority. let $main: Element | null = null; - try { - $main = doc.querySelector(selectors.join(',')); - } catch { - // Invalid custom selector: retry without it so built-in selectors still run. - if (mainContentSelector) { - selectors.shift(); - $main = doc.querySelector(selectors.join(',')); + for (const sel of selectors) { + try { + $main = doc.querySelector(sel); + } catch { + // Invalid selector — only reachable for the caller-supplied + // mainContentSelector unshifted above, since the built-in list is + // a fixed, known-valid constant. Skip it and keep trying the + // remaining selectors in priority order. + continue; + } + if ($main) { + break; } }