Skip to content

Commit 768187d

Browse files
authored
Merge pull request #935 from d-zero-dev/fix/main-content-selector-priority
fix(beholder,anatomist): resolve main-content selectors by priority, not DOM order
2 parents 3e1fbd9 + 70f62fe commit 768187d

4 files changed

Lines changed: 186 additions & 14 deletions

File tree

‎packages/@d-zero/anatomist/src/capture-layout-tree.spec.ts‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,58 @@ describe('captureLayoutTree', () => {
6060
expect(result.root?.children[0]?.tagName).toBe('P');
6161
});
6262

63+
it('prefers a higher-priority selector even when it matches later in DOM order', () => {
64+
const doc = createDocument(
65+
'<body><div class="main" data-rect="0,0,1,1">Wrong</div>' +
66+
'<main id="page" data-rect="0,0,800,600">Right</main></body>',
67+
);
68+
69+
const result = captureLayoutTree(null, SELECTORS, FALLBACK_SELECTORS, 10, doc);
70+
71+
expect(result.mainSelector).toBe('main#page');
72+
});
73+
74+
it('prefers a nested higher-priority match over its lower-priority ancestor wrapper', () => {
75+
const doc = createDocument(
76+
'<body><div class="main" data-rect="0,0,800,600">' +
77+
'<p data-rect="0,0,100,20">breadcrumb</p>' +
78+
'<main id="page" data-rect="0,20,400,580">Real main</main>' +
79+
'<div id="aside" data-rect="400,20,400,580">Sidebar</div>' +
80+
'</div></body>',
81+
);
82+
83+
const result = captureLayoutTree(null, SELECTORS, FALLBACK_SELECTORS, 10, doc);
84+
85+
expect(result.mainSelector).toBe('main#page');
86+
});
87+
88+
it('skips an invalid selector in the priority list and keeps trying the rest', () => {
89+
const doc = createDocument(
90+
'<body><main id="page" data-rect="0,0,800,600">Right</main></body>',
91+
);
92+
93+
const result = captureLayoutTree(
94+
null,
95+
['[[[invalid', ...SELECTORS],
96+
FALLBACK_SELECTORS,
97+
10,
98+
doc,
99+
);
100+
101+
expect(result.mainSelector).toBe('main#page');
102+
});
103+
104+
it('prefers the earlier-listed selector when two selectors both match different elements', () => {
105+
const doc = createDocument(
106+
'<body><div class="main" data-rect="0,0,1,1">Wrong</div>' +
107+
'<div id="main" data-rect="0,0,800,600">Right</div></body>',
108+
);
109+
110+
const result = captureLayoutTree(null, SELECTORS, FALLBACK_SELECTORS, 10, doc);
111+
112+
expect(result.mainSelector).toBe('div#main');
113+
});
114+
63115
it('falls back to the fallback selector list when the priority list has no match', () => {
64116
const doc = createDocument(
65117
'<body><div id="primaryMain" data-rect="0,0,800,600">Fallback</div></body>',
@@ -70,6 +122,26 @@ describe('captureLayoutTree', () => {
70122
expect(result.mainSelector).toBe('div#primaryMain');
71123
});
72124

125+
it('still returns DOM-order-first match among fallback selectors (fallback list is not priority-ordered per element)', () => {
126+
const doc = createDocument(
127+
'<body><div id="wrapperMain" data-rect="0,0,800,600">' +
128+
'<p data-rect="0,0,100,20">breadcrumb</p>' +
129+
'<div id="innerMain" data-rect="0,20,800,580">Inner</div>' +
130+
'</div></body>',
131+
);
132+
133+
// Both #wrapperMain and #innerMain match the same fallback selector
134+
// (`[id*="main" i]`), so — unlike the priority list above — there is
135+
// no higher/lower priority to arbitrate between them. `querySelector`
136+
// returns whichever matches first in document order, which is the
137+
// outer wrapper here. This is accepted, existing behavior: the
138+
// priority-list fix only orders *between* selectors in the array,
139+
// not among multiple elements matching the *same* selector.
140+
const result = captureLayoutTree(null, SELECTORS, FALLBACK_SELECTORS, 10, doc);
141+
142+
expect(result.mainSelector).toBe('div#wrapperMain');
143+
});
144+
73145
it('tries the explicit mainContentSelector before the priority list', () => {
74146
const doc = createDocument(
75147
'<body><main data-rect="0,0,1,1">Wrong</main><section id="custom" data-rect="0,0,800,600">Right</section></body>',

‎packages/@d-zero/anatomist/src/capture-layout-tree.ts‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@
3030
* can match a different element than the one actually found, so this
3131
* module resolves the element itself directly instead of round-tripping
3232
* through that string.
33+
*
34+
* WHY the priority-list resolution logic in `resolveMainElement` is
35+
* duplicated rather than shared: same closure-free constraint as above.
36+
* `extractMainContentsFromDocument` (`@d-zero/beholder/get-main-contents.ts`)
37+
* independently re-implements the identical "try each selector in array
38+
* order, first match wins" strategy for the same reason. The two copies
39+
* have drifted out of sync before (one queried the array with a single
40+
* `querySelector(selectors.join(','))` — which resolves by DOM document
41+
* order, not array priority — while the other still tried selectors one at
42+
* a time); `capture-layout-tree.spec.ts` and `get-main-contents.spec.ts`
43+
* both carry matching regression cases (searchable by the test name
44+
* "prefers a higher-priority selector...") specifically so that fixing one
45+
* side's resolution logic without the other shows up as a spec gap, not a
46+
* silent divergence.
3347
* @module
3448
*/
3549

@@ -117,12 +131,27 @@ export function captureLayoutTree(
117131
}
118132
}
119133

134+
// Tried one at a time, in priority order, rather than joined into a
135+
// single `querySelector(selectors.join(','))` call: a CSS group
136+
// selector matches whichever selector is first in *document order*,
137+
// not whichever is first in this array — an ancestor wrapper matching
138+
// a low-priority selector (e.g. `#contents`) would win over a
139+
// descendant matching a higher-priority one (e.g. `#main`) just
140+
// because it appears earlier in the DOM. Querying one selector at a
141+
// time makes this array's order the actual priority.
120142
let element: Element | null = null;
121-
try {
122-
element = doc.querySelector(selectors.join(','));
123-
} catch {
124-
// The built-in selector list is a fixed, known-valid constant, so
125-
// this should be unreachable — but fail closed rather than throw.
143+
for (const sel of selectors) {
144+
try {
145+
element = doc.querySelector(sel);
146+
} catch {
147+
// The built-in selector list is a fixed, known-valid constant,
148+
// so this should be unreachable — but fail closed rather than
149+
// aborting the whole priority list over one bad entry.
150+
continue;
151+
}
152+
if (element) {
153+
break;
154+
}
126155
}
127156

128157
if (element) {

‎packages/@d-zero/beholder/src/get-main-contents.spec.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,18 +203,63 @@ describe('extractMainContentsFromDocument', () => {
203203
expect(result.wordCount).toBe(15);
204204
});
205205

206-
it('returns first matching element in DOM order when multiple Phase-1 selectors match', () => {
206+
it('prefers a higher-priority selector even when it matches later in DOM order', () => {
207207
const result = extract(`
208208
<html><body>
209-
<main>Semantic main</main>
210209
<div id="content">ID content</div>
210+
<main>Semantic main</main>
211211
</body></html>
212212
`);
213213

214214
expect(result.main?.nodeName).toBe('MAIN');
215215
expect(result.wordCount).toBe(12);
216216
});
217217

218+
it('prefers a nested higher-priority match over its lower-priority ancestor wrapper', () => {
219+
const result = extract(`
220+
<html><body>
221+
<div id="contents">
222+
<p>breadcrumb</p>
223+
<div id="main">Real main</div>
224+
<div id="aside">Sidebar</div>
225+
</div>
226+
</body></html>
227+
`);
228+
229+
expect(result.main?.id).toBe('main');
230+
});
231+
232+
it('prefers the earlier-listed selector when two selectors both match different elements', () => {
233+
const result = extract(`
234+
<html><body>
235+
<div class="main">Wrong</div>
236+
<div id="main">Right</div>
237+
</body></html>
238+
`);
239+
240+
expect(result.main?.id).toBe('main');
241+
});
242+
243+
it('still returns DOM-order-first match among fallback selectors (fallback list is not priority-ordered per element)', () => {
244+
// Both #wrapperMain and #innerMain match the same fallback selector
245+
// (`[id*="main" i]`), so — unlike the priority list above — there is
246+
// no higher/lower priority to arbitrate between them. `querySelector`
247+
// returns whichever matches first in document order, which is the
248+
// outer wrapper here. This is accepted, existing behavior: the
249+
// priority-list fix only orders *between* selectors in the array,
250+
// not among multiple elements matching the *same* selector.
251+
const result = extract(`
252+
<html><body>
253+
<div id="wrapperMain">
254+
<p>breadcrumb</p>
255+
<div id="innerMain">Inner</div>
256+
</div>
257+
</body></html>
258+
`);
259+
260+
expect(result.main?.id).toBe('wrapperMain');
261+
});
262+
218263
it('uses Phase-2 class*=main when Phase-1 finds nothing', () => {
219264
const result = extract(
220265
'<html><body><div class="page-main-area">Phase two</div></body></html>',

‎packages/@d-zero/beholder/src/get-main-contents.ts‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@
77
*
88
* WHY no `@medv/finder`: selector strings are diagnostic only; a tag+id+class // cspell:disable-line
99
* path is enough and avoids a Node-only dependency inside the page realm.
10+
*
11+
* WHY the priority-list resolution logic below is duplicated rather than
12+
* shared: same closure-free constraint as above. `@d-zero/anatomist`'s
13+
* `capture-layout-tree.ts` independently re-implements the identical "try
14+
* each selector in array order, first match wins" strategy for the same
15+
* reason. The two copies have drifted out of sync before (one queried the
16+
* array with a single `querySelector(selectors.join(','))` — which resolves
17+
* by DOM document order, not array priority — while the other still tried
18+
* selectors one at a time); this file's spec and
19+
* `anatomist/capture-layout-tree.spec.ts` both carry matching regression
20+
* cases (searchable by the test name "prefers a higher-priority
21+
* selector...") specifically so that fixing one side's resolution logic
22+
* without the other shows up as a spec gap, not a silent divergence.
1023
* @module
1124
*/
1225

@@ -118,14 +131,27 @@ export function extractMainContentsFromDocument(
118131
selectors.unshift(mainContentSelector);
119132
}
120133

134+
// Tried one at a time, in priority order, rather than joined into a
135+
// single `querySelector(selectors.join(','))` call: a CSS group selector
136+
// matches whichever selector is first in *document order*, not whichever
137+
// is first in this array — an ancestor wrapper matching a low-priority
138+
// selector (e.g. `#contents`) would win over a descendant matching a
139+
// higher-priority one (e.g. `#main`) just because it appears earlier in
140+
// the DOM. Querying one selector at a time makes this array's order the
141+
// actual priority.
121142
let $main: Element | null = null;
122-
try {
123-
$main = doc.querySelector(selectors.join(','));
124-
} catch {
125-
// Invalid custom selector: retry without it so built-in selectors still run.
126-
if (mainContentSelector) {
127-
selectors.shift();
128-
$main = doc.querySelector(selectors.join(','));
143+
for (const sel of selectors) {
144+
try {
145+
$main = doc.querySelector(sel);
146+
} catch {
147+
// Invalid selector — only reachable for the caller-supplied
148+
// mainContentSelector unshifted above, since the built-in list is
149+
// a fixed, known-valid constant. Skip it and keep trying the
150+
// remaining selectors in priority order.
151+
continue;
152+
}
153+
if ($main) {
154+
break;
129155
}
130156
}
131157

0 commit comments

Comments
 (0)