crawldna 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1208 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (C) 2026 Bogdan Marian Vasaiu
3
+ // HTML -> clean Markdown. Isolate the main content node, drop site chrome,
4
+ // convert with Turndown (fenced code + GFM tables).
5
+
6
+ import { createHash } from 'node:crypto';
7
+ import { parse } from 'node-html-parser';
8
+ import TurndownService from 'turndown';
9
+ import { gfm } from 'turndown-plugin-gfm';
10
+
11
+ // Elements that are site chrome, never content. Removed from within the chosen
12
+ // content node before conversion.
13
+ //
14
+ // The list holds ONLY unambiguous, never-in-content signals (rule: never lose
15
+ // content). Deliberately NOT here, because each one names real content on some
16
+ // site and the link-density pruner below already removes the navigational case:
17
+ // - `.menu` → a restaurant's FOOD menu ("extract the pizzas" — the #1 task);
18
+ // a link-dense site menu is caught by pruneNavByLinkDensity.
19
+ // - `.banner` / `.announcement` → real announcements (schools, status pages, docs
20
+ // deprecation notices).
21
+ // - `form` → booking calendars, order-able menus, configurators and search
22
+ // result filters LIVE inside forms; inputs render to almost no
23
+ // Markdown anyway, so keeping forms costs ~nothing.
24
+ // - `header` → an <article>'s own <header> is its title/byline (content);
25
+ // only the site masthead is chrome — handled separately below.
26
+ const CHROME_SELECTORS = [
27
+ 'script', 'style', 'noscript', 'svg', 'template', 'iframe',
28
+ 'nav', 'aside', 'footer',
29
+ '[role=navigation]', '[role=banner]', '[role=contentinfo]', '[role=search]',
30
+ '.sidebar', '.navbar', '.nav', '.toc', '.table-of-contents',
31
+ '.breadcrumb', '.breadcrumbs', '.pagination', '.pager',
32
+ '.cookie', '.cookies', '.cookie-banner',
33
+ '.edit-this-page', '.theme-doc-toc', '.theme-doc-footer', '.pagination-nav',
34
+ '.skip-link', '.skip-to-content',
35
+ // heading permalink anchors (Docusaurus/VitePress/MkDocs/devsite/…)
36
+ '.header-anchor', '.headerlink', 'a.anchor', '[aria-label*=permalink i]', '[aria-label*=Permalink]',
37
+ // common per-page action chrome
38
+ '.edit-page', '.edit-link', '.feedback', '.devsite-page-rating', '.page-actions',
39
+ '[aria-label*="Copy" i][role=button]',
40
+ // advertisements (carbon ads etc.) — never content
41
+ '#carbonads', '.carbonads', '[class*=carbonads]', '[class*=carbon-ads]',
42
+ '.advertisement', '.ad-container', '.ad-banner', '[data-ad]', '[id*=carbonads]',
43
+ // ARIA tab STRIPS — the row of tab labels is control chrome, never content: each
44
+ // captured state re-serialises the active/inactive label combination as junk text
45
+ // ("pnpmyarnnpmbun"). The PANELS (role=tabpanel) are the content and stay; the
46
+ // clicked tab's label survives as the reveal's visible variant marker.
47
+ '[role=tablist]', '[role=tab]',
48
+ ];
49
+
50
+ // Candidate containers for "the main content", best-first, used when no
51
+ // framework-specific selector is supplied.
52
+ const MAIN_CANDIDATES = [
53
+ 'main article', 'article', 'main', '[role=main]',
54
+ '.markdown', '.markdown-body', '.content', '.main-content', '.doc-content',
55
+ '#content', '#main', '.post', '.entry-content',
56
+ ];
57
+
58
+ function buildTurndown() {
59
+ const td = new TurndownService({
60
+ codeBlockStyle: 'fenced',
61
+ headingStyle: 'atx',
62
+ bulletListMarker: '-',
63
+ emDelimiter: '_',
64
+ hr: '---',
65
+ linkStyle: 'inlined',
66
+ });
67
+ td.use(gfm);
68
+
69
+ // ARIA lists (`role=list` / `role=listitem`) are real lists whatever tag carries
70
+ // them. App frameworks build every repeating surface this way (transaction
71
+ // feeds, stat cards, contact lists) out of <div>s — without this rule each FIELD
72
+ // of an item shatters into its own paragraph ("JL" / "John Leider" / "21 Mar" /
73
+ // "+$36.11") and the reader can no longer tell what belongs to what. One item =
74
+ // one bullet line, exactly like the native <li> treatment.
75
+ // Flatten an item's content to one line; nested item rules may already have
76
+ // emitted a bullet (a shaped row wrapping a role=listitem) — never stack two.
77
+ const inlineItem = (content) =>
78
+ String(content || '')
79
+ .replace(/\s*\n+\s*/g, ' ')
80
+ .trim()
81
+ .replace(/^(?:-\s+)+/, '');
82
+ td.addRule('ariaListItem', {
83
+ filter: (node) => (node.getAttribute && node.getAttribute('role')) === 'listitem',
84
+ replacement: (content) => '\n- ' + inlineItem(content) + '\n',
85
+ });
86
+ td.addRule('ariaList', {
87
+ filter: (node) => (node.getAttribute && node.getAttribute('role')) === 'list',
88
+ replacement: (content) => '\n\n' + String(content || '').replace(/\n{2,}/g, '\n').trim() + '\n\n',
89
+ });
90
+
91
+ // Role-LESS repeated rows: the same fix for app lists built from bare <div>s
92
+ // (transaction feeds, comment threads). Shared with markVisualHeadings below —
93
+ // see isShapedRow at module scope — so "a row that flattens to a bullet" has a
94
+ // SINGLE definition, and #26 never plants a heading marker inside one.
95
+ td.addRule('shapedRowItem', {
96
+ filter: isShapedRow,
97
+ replacement: (content) => '\n- ' + inlineItem(content) + '\n',
98
+ });
99
+
100
+ // A GFM table cell must be SINGLE-LINE. Real-world cells wrap their content in
101
+ // block markup (sort-button headers, status chips, rating widgets) and the gfm
102
+ // plugin passes the resulting newlines straight through — one multi-line cell
103
+ // shatters the entire table into garbage. Flatten each cell to one line and
104
+ // escape stray pipes; the row/border layout stays the plugin's. (Added after
105
+ // use(gfm), so this rule takes precedence over the plugin's tableCell.)
106
+ td.addRule('tableCellSingleLine', {
107
+ filter: ['th', 'td'],
108
+ replacement: (content, node) => {
109
+ const flat = String(content || '')
110
+ .replace(/\s*\n+\s*/g, ' ')
111
+ .replace(/[ \t]{2,}/g, ' ')
112
+ .replace(/\|/g, '\\|')
113
+ .trim();
114
+ const index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
115
+ return (index === 0 ? '| ' : ' ') + flat + ' |';
116
+ },
117
+ });
118
+
119
+ // Fenced code blocks that keep the language hint (language-js, lang-js,
120
+ // hljs language-js, highlight-source-js, ...).
121
+ td.addRule('fencedCodeWithLang', {
122
+ filter: (node) => node.nodeName === 'PRE',
123
+ replacement: (_content, node) => {
124
+ const codeEl =
125
+ (node.querySelector && node.querySelector('code')) || node;
126
+ const cls =
127
+ (codeEl.getAttribute && codeEl.getAttribute('class')) ||
128
+ (node.getAttribute && node.getAttribute('class')) ||
129
+ '';
130
+ const lang = (cls.match(/(?:language|lang|highlight|brush|source)[-:](\w+)/i) || [])[1] || '';
131
+ const text = (codeEl.textContent || node.textContent || '').replace(/\n$/, '');
132
+ const fence = '```';
133
+ return `\n\n${fence}${lang}\n${text}\n${fence}\n\n`;
134
+ },
135
+ });
136
+
137
+ // #26 — visual headings: data-crawldna-heading="2|3|4" carries the level the
138
+ // page PAINTED (a big/bold short line) without using <h*>. Stamped in-browser
139
+ // from computed styles at capture (markVisualHeadings in engine/perceive.mjs)
140
+ // or from inline styles in the static path (markVisualHeadings below). Emit a
141
+ // real ATX heading so the .md keeps the page's skeleton: the text is verbatim,
142
+ // only the #'s are added. Added LAST so it wins over shapedRowItem for a
143
+ // marked element (a title is never a data row).
144
+ td.addRule('visualHeading', {
145
+ filter: (node) =>
146
+ /^[2-6]$/.test((node.getAttribute && node.getAttribute('data-crawldna-heading')) || ''),
147
+ replacement: (content, node) => {
148
+ // Strip a LEADING heading marker the flattened content may carry from a
149
+ // marked child (defensive: the twins already refuse to mark an element that
150
+ // contains a marked descendant, so nesting shouldn't reach here) — never a
151
+ // doubled `#### #### …`.
152
+ const text = String(content || '').replace(/\s*\n+\s*/g, ' ').trim().replace(/^#{1,6}\s+/, '');
153
+ if (!text) return '';
154
+ const level = parseInt(node.getAttribute('data-crawldna-heading'), 10);
155
+ return '\n\n' + '#'.repeat(level) + ' ' + text + '\n\n';
156
+ },
157
+ });
158
+
159
+ // A hyperlink whose content is BLOCK-level (a card/badge wrapped in <a>) makes
160
+ // Turndown's default rule emit `[\n\ntext\n\n](url)`, which splits across blank
161
+ // lines into an orphaned `](url)[` fragment (the anchor text stranded, the URL
162
+ // dangling) — 585 of them in a live Vuetify run. Flatten the link text to ONE
163
+ // line so a block-wrapping link stays a valid, whole `[text](url)`; the URL is
164
+ // never lost. Normal inline links are unchanged (their content has no newlines).
165
+ td.addRule('inlineLinkFlat', {
166
+ filter: (node) => node.nodeName === 'A' && !!(node.getAttribute && node.getAttribute('href')),
167
+ replacement: (content, node) => {
168
+ const text = String(content || '').replace(/\s*\n+\s*/g, ' ').replace(/[ \t]{2,}/g, ' ').trim();
169
+ if (!text) return ''; // empty/permalink anchors drop (matches the later cleanup)
170
+ const href = node.getAttribute('href');
171
+ const title = node.title ? ` "${node.title.replace(/"/g, '')}"` : '';
172
+ return `[${text}](${href}${title})`;
173
+ },
174
+ });
175
+
176
+ // Key-value tables (each row is `<th>label</th><td>value</td>`, no heading row)
177
+ // are left as RAW HTML by the gfm plugin (it only converts tables with a heading
178
+ // row), leaking `<table>…</table>` into the .md. Convert a header-less table to a
179
+ // GFM table (synthesised blank header) so the data stays readable and consistent;
180
+ // PROPER tables (first row all-<th>) fall through to the gfm plugin untouched.
181
+ const isHeadingRow = (row) =>
182
+ row && row.childNodes && Array.prototype.some.call(row.childNodes, (c) => c.nodeName === 'TH') &&
183
+ Array.prototype.every.call(row.childNodes, (c) => c.nodeType !== 1 || c.nodeName === 'TH');
184
+ td.addRule('headerlessTable', {
185
+ filter: (node) => node.nodeName === 'TABLE' && !(node.rows && node.rows[0] && isHeadingRow(node.rows[0])),
186
+ replacement: (_content, node) => {
187
+ const rows = Array.prototype.slice.call(node.rows || []);
188
+ if (!rows.length) return '';
189
+ const cellText = (r) =>
190
+ Array.prototype.slice.call(r.cells || []).map((c) =>
191
+ (c.textContent || '').replace(/\s+/g, ' ').replace(/\|/g, '\\|').trim());
192
+ const rowCells = rows.map(cellText);
193
+ const ncol = Math.max(...rowCells.map((c) => c.length), 1);
194
+ // Degenerate SINGLE-COLUMN table — a responsive stack (the inline-API Slots/
195
+ // Events panels), not tabular data. An empty-header 1-col GFM table is just
196
+ // `| |` noise (2966 in a live run), so render the non-empty cells as a bullet
197
+ // list instead: no data lost, no invented pairing, no empty rows.
198
+ if (ncol <= 1) {
199
+ const items = rowCells.map((c) => (c[0] || '').trim()).filter(Boolean);
200
+ return items.length ? '\n\n' + items.map((t) => `- ${t}`).join('\n') + '\n\n' : '';
201
+ }
202
+ // Real multi-column key-value table with no heading row → GFM (blank header),
203
+ // dropping any fully-empty rows.
204
+ const line = (arr) => '| ' + arr.concat(Array(Math.max(0, ncol - arr.length)).fill('')).join(' | ') + ' |';
205
+ const out = [line(Array(ncol).fill('')), line(Array(ncol).fill('---'))];
206
+ for (const c of rowCells) if (c.some((x) => x)) out.push(line(c));
207
+ return '\n\n' + out.join('\n') + '\n\n';
208
+ },
209
+ });
210
+
211
+ return td;
212
+ }
213
+
214
+ function textOf(node) {
215
+ return (node && node.text ? node.text : '').replace(/\s+/g, ' ').trim();
216
+ }
217
+
218
+ // A row that gets FLATTENED to a single bullet (shapedRowItem): ≥3 sibling divs
219
+ // sharing the same base class token, each a SHORT flat item (no headings/tables/
220
+ // fences/lists inside, not a table-cell fragment). Structural, so prose never
221
+ // matches. Shared by the shapedRowItem rule AND by markVisualHeadings, which must
222
+ // never plant a heading marker inside such a row (#26): the marker would collapse
223
+ // into the bullet as a stray `- #### …` / mid-line `###`.
224
+ // Tag name that works on BOTH DOMs this file touches: Turndown's node (domino,
225
+ // nodeName) inside the conversion rules, and node-html-parser (tagName, no
226
+ // nodeName) inside markVisualHeadings. Both return an uppercase tag.
227
+ const tagOf = (n) => (n && (n.tagName || n.nodeName)) || '';
228
+ function shapeToken(n) {
229
+ const cls = (n.getAttribute && n.getAttribute('class')) || '';
230
+ return `${tagOf(n)}|${cls.split(/\s+/)[0] || ''}`;
231
+ }
232
+ function isShapedRow(node) {
233
+ if (!node || tagOf(node) !== 'DIV') return false;
234
+ const cls = (node.getAttribute && node.getAttribute('class')) || '';
235
+ if (!cls.trim()) return false;
236
+ const text = (node.textContent || '').replace(/\s+/g, ' ').trim();
237
+ if (!text || text.length > 200) return false;
238
+ if (node.querySelector && node.querySelector('h1,h2,h3,h4,h5,h6,table,pre,ul,ol')) return false;
239
+ for (let p = node.parentNode; p; p = p.parentNode) {
240
+ if (tagOf(p) === 'TD' || tagOf(p) === 'TH') return false;
241
+ }
242
+ const want = shapeToken(node);
243
+ let alike = 0;
244
+ for (const sib of node.parentNode ? node.parentNode.childNodes : []) {
245
+ if (sib.nodeType === 1 && shapeToken(sib) === want) alike++;
246
+ }
247
+ return alike >= 3;
248
+ }
249
+
250
+ /** Rewrite relative href/src to absolute so links survive extraction. */
251
+ function absolutize(root, baseUrl) {
252
+ if (!baseUrl) return;
253
+ for (const a of root.querySelectorAll('a[href]')) {
254
+ const href = a.getAttribute('href');
255
+ if (!href || /^(#|mailto:|tel:|javascript:)/i.test(href)) continue;
256
+ try {
257
+ a.setAttribute('href', new URL(href, baseUrl).toString());
258
+ } catch {
259
+ /* leave as-is */
260
+ }
261
+ }
262
+ for (const img of root.querySelectorAll('img[src]')) {
263
+ const src = img.getAttribute('src');
264
+ if (!src || src.startsWith('data:')) continue;
265
+ try {
266
+ img.setAttribute('src', new URL(src, baseUrl).toString());
267
+ } catch {
268
+ /* leave as-is */
269
+ }
270
+ }
271
+ }
272
+
273
+ /** Pick the densest plausible main-content node. */
274
+ function pickMainContent(root, contentSelector) {
275
+ const selectors = [].concat(contentSelector || []).filter(Boolean);
276
+ for (const sel of selectors) {
277
+ const node = root.querySelector(sel);
278
+ if (node && textOf(node).length > 40) return node;
279
+ }
280
+ let best = null;
281
+ let bestLen = 0;
282
+ for (const sel of MAIN_CANDIDATES) {
283
+ const node = root.querySelector(sel);
284
+ if (!node) continue;
285
+ const len = textOf(node).length;
286
+ if (len > bestLen) {
287
+ best = node;
288
+ bestLen = len;
289
+ }
290
+ }
291
+ return best || root.querySelector('body') || root;
292
+ }
293
+
294
+ // --- #26: visual headings (the .md's skeleton) ------------------------------
295
+ // Apps mark titles VISUALLY, not semantically: a card/section title is a short
296
+ // <div> painted bigger (or bolder) than the text around it, and Turndown only
297
+ // trusts <h1>–<h6> — so the page's skeleton flattens to anonymous lines. The
298
+ // browser path stamps data-crawldna-heading="2|3|4" from COMPUTED styles at
299
+ // capture time (markVisualHeadings in engine/perceive.mjs, inlined into
300
+ // reveal's captureHtml); this is its Node TWIN for the static path: the same
301
+ // ratio rules (rule #2 — a font ratio, never a class name) applied to INLINE
302
+ // styles, which are all a static fetch carries. Deterministic, zero model
303
+ // calls (identical under --no-ai), and it only ever ADDS a heading level —
304
+ // no text is removed or rewritten (rule #1). Keep in sync with the browser twin.
305
+
306
+ const FONT_SIZE_RE = /(?:^|;)\s*font-size\s*:\s*([0-9.]+)\s*(px|pt|rem)\b/i;
307
+ const FONT_WEIGHT_RE = /(?:^|;)\s*font-weight\s*:\s*(bolder|bold|normal|[0-9]{3})\b/i;
308
+
309
+ function inlineSizePx(el) {
310
+ const m = FONT_SIZE_RE.exec((el.getAttribute && el.getAttribute('style')) || '');
311
+ if (!m) return null;
312
+ const v = parseFloat(m[1]);
313
+ const unit = m[2].toLowerCase();
314
+ return unit === 'pt' ? v * (4 / 3) : unit === 'rem' ? v * 16 : v;
315
+ }
316
+
317
+ function inlineWeight(el) {
318
+ const m = FONT_WEIGHT_RE.exec((el.getAttribute && el.getAttribute('style')) || '');
319
+ if (m) return /^bold/i.test(m[1]) ? 700 : m[1].toLowerCase() === 'normal' ? 400 : parseInt(m[1], 10);
320
+ // NB: node-html-parser elements expose tagName (uppercase), NOT nodeName —
321
+ // nodeName only exists on turndown's own DOM inside the conversion rules.
322
+ if (el.tagName === 'B' || el.tagName === 'STRONG') return 700; // tag-implied bold
323
+ return null;
324
+ }
325
+
326
+ /** Inline styles cascade: resolve by the nearest self-or-ancestor declaration. */
327
+ function resolvedSize(el) {
328
+ for (let n = el; n && n.getAttribute; n = n.parentNode) {
329
+ const s = inlineSizePx(n);
330
+ if (s) return s;
331
+ }
332
+ return 16; // browser default
333
+ }
334
+
335
+ function resolvedWeight(el) {
336
+ for (let n = el; n && n.getAttribute; n = n.parentNode) {
337
+ const w = inlineWeight(n);
338
+ if (w) return w;
339
+ }
340
+ return 400;
341
+ }
342
+
343
+ const SKIP_TEXT_TAGS = new Set(['SCRIPT', 'STYLE', 'TEMPLATE', 'NOSCRIPT']);
344
+
345
+ /** Char-weighted font metrics of a subtree's text (optionally excluding one
346
+ * branch): histogram + extremes. Budgeted in TEXT NODES visited. */
347
+ function visualStats(root, excl, budget = 400) {
348
+ const st = { chars: 0, bySize: new Map(), maxSize: 0, minSize: Infinity, maxWeight: 0 };
349
+ const stack = [root];
350
+ let visits = 0;
351
+ while (stack.length && visits < budget) {
352
+ const n = stack.pop();
353
+ if (n === excl) continue;
354
+ if (n.nodeType === 3) {
355
+ visits++;
356
+ const t = (n.text || '').replace(/\s+/g, ' ').trim();
357
+ if (t.length < 2) continue;
358
+ const el = n.parentNode;
359
+ if (!el || SKIP_TEXT_TAGS.has(el.tagName)) continue;
360
+ const size = Math.round(resolvedSize(el) * 2) / 2;
361
+ const weight = resolvedWeight(el);
362
+ st.chars += t.length;
363
+ st.bySize.set(size, (st.bySize.get(size) || 0) + t.length);
364
+ if (size > st.maxSize) st.maxSize = size;
365
+ if (size < st.minSize) st.minSize = size;
366
+ if (weight > st.maxWeight) st.maxWeight = weight;
367
+ } else if (n.nodeType === 1 && !SKIP_TEXT_TAGS.has(n.tagName)) {
368
+ for (const c of n.childNodes) stack.push(c);
369
+ }
370
+ }
371
+ return st;
372
+ }
373
+
374
+ function dominantSize(bySize, fallback) {
375
+ let best = fallback;
376
+ let chars = 0;
377
+ for (const [size, ch] of bySize) {
378
+ if (ch > chars) {
379
+ chars = ch;
380
+ best = size;
381
+ }
382
+ }
383
+ return best;
384
+ }
385
+
386
+ // Never a heading: interactive/label surfaces, cells, list items (#25), code,
387
+ // real headings — and nothing nested under an already-marked title.
388
+ const HEADING_BANNED_TAGS = new Set([
389
+ 'A', 'BUTTON', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'TABLE', 'TH', 'TD',
390
+ 'LI', 'UL', 'OL', 'DL', 'PRE', 'CODE', 'KBD', 'SAMP', 'LABEL', 'SELECT',
391
+ 'OPTION', 'TEXTAREA', 'INPUT', 'SUMMARY', 'FIGCAPTION', 'BLOCKQUOTE',
392
+ 'NAV', 'ASIDE', 'FOOTER',
393
+ ]);
394
+ const HEADING_BANNED_ROLES = /^(heading|button|tab|list|listitem)$/i;
395
+
396
+ function headingBannedAt(el) {
397
+ for (let n = el; n && n.getAttribute; n = n.parentNode) {
398
+ if (HEADING_BANNED_TAGS.has(n.tagName)) return true;
399
+ const role = n.getAttribute('role');
400
+ if (role && HEADING_BANNED_ROLES.test(role)) return true;
401
+ if (n.getAttribute('data-crawldna-heading')) return true;
402
+ }
403
+ return false;
404
+ }
405
+
406
+ // Includes [data-crawldna-heading]: an element that already CONTAINS a marked
407
+ // title must not itself be marked, else the outer rule wraps the inner marker into
408
+ // `#### #### …` (and mashes title+subtitle onto one line). Marking runs inner-first
409
+ // on the browser twin, so this is what actually blocks the nesting.
410
+ const HEADING_STRUCTURAL = 'h1,h2,h3,h4,h5,h6,table,ul,ol,pre,blockquote,button,a,input,select,textarea,[data-crawldna-heading]';
411
+
412
+ /** Is `el` (or one of its near ancestors) a row that gets flattened to a bullet?
413
+ * If so, a heading marker planted here would collapse into the bullet. */
414
+ function insideFlattenedRow(el) {
415
+ let hops = 0;
416
+ for (let a = el; a && a.getAttribute && hops < 6; a = a.parentNode, hops++) {
417
+ if (isShapedRow(a)) return true;
418
+ }
419
+ return false;
420
+ }
421
+
422
+ /** Stamp data-crawldna-heading="2|3|4" on inline-styled visual titles. Mutates
423
+ * the tree (attributes only — content untouched). Browser-stamped markers are
424
+ * respected, never re-done. */
425
+ function markVisualHeadings(content) {
426
+ const page = visualStats(content, null, 4000);
427
+ if (!page.chars) return;
428
+ const body = dominantSize(page.bySize, 16);
429
+ for (const el of content.querySelectorAll('div, p, section, header')) {
430
+ if (el.getAttribute('data-crawldna-heading')) continue;
431
+ const text = textOf(el);
432
+ if (text.length < 2 || text.length > 60) continue; // a title is one short line
433
+ if (!/\p{L}/u.test(text)) continue; // bare numbers/prices are data, not titles
434
+ if (headingBannedAt(el)) continue;
435
+ if (el.querySelector(HEADING_STRUCTURAL)) continue;
436
+ // A title INSIDE a repeated row that #25 flattens to a bullet must not be
437
+ // marked: the marker would collapse into the bullet as a stray `- #### …`.
438
+ // The candidate is often a title-wrapper NESTED in the card, so we must test
439
+ // ANCESTORS (up to the block), not just the element's own siblings — the
440
+ // 8 gallery tiles, 4 stat cards and colour swatches all sit one level up.
441
+ // (Summary/Transactions/Recent Orders survive: their cards carry a table/list
442
+ // or have <3 same-shape siblings, so isShapedRow is false for them.)
443
+ if (insideFlattenedRow(el)) continue;
444
+ const st = visualStats(el, null);
445
+ if (!st.chars || st.maxSize < body) continue; // never smaller than the page body font
446
+ if (st.minSize < 0.75 * st.maxSize) continue; // mixed sizes = composite block, not a title
447
+ // LOCAL body font: dominant size of the surrounding text, so an all-big
448
+ // block (a hero) does not promote its own lines.
449
+ let local = body;
450
+ for (let anc = el.parentNode, hops = 0; anc && anc.getAttribute && hops < 6; hops++, anc = anc.parentNode) {
451
+ const around = visualStats(anc, el);
452
+ if (around.chars >= 40) {
453
+ local = dominantSize(around.bySize, body);
454
+ break;
455
+ }
456
+ }
457
+ const jump = st.maxSize >= 1.15 * local || (st.maxWeight >= 600 && st.maxSize >= local);
458
+ if (!jump) continue;
459
+ const ratio = st.maxSize / body;
460
+ const level = ratio >= 1.8 ? 2 : ratio >= 1.35 ? 3 : 4;
461
+ el.setAttribute('data-crawldna-heading', String(level));
462
+ }
463
+ }
464
+
465
+ // Tokens that occur ONLY inside (often URL-encoded) inline-SVG markup and never
466
+ // in real prose. When a data-URI SVG image breaks attribute parsing, its body
467
+ // spills into the text as garbage blocks; this lets us drop those blocks even
468
+ // when the <img> removal above can't catch the mis-parsed remnant.
469
+ const SVG_NOISE_RE =
470
+ /data:image\/svg|%3c\/?svg|%3csvg|feGaussianBlur|feFlood|feBlend|fegaussianblur|linearGradient|radialGradient|gradientUnits|stdDeviation|BackgroundImageFix|foregroundBlur|interpolation-filters|userSpaceOnUse/i;
471
+
472
+ /** Drop paragraph blocks that are inline-SVG / data-URI image noise. */
473
+ export function stripSvgNoise(markdown) {
474
+ return String(markdown || '')
475
+ .split(/\n{2,}/)
476
+ .filter((block) => !SVG_NOISE_RE.test(block))
477
+ .join('\n\n')
478
+ .replace(/\n{3,}/g, '\n\n')
479
+ .trim();
480
+ }
481
+
482
+ // --- #8: Trafilatura-style universal cleanup -------------------------------
483
+ // CHROME_SELECTORS above is robust but can miss IN-CONTENT navigation on unusual
484
+ // layouts (an unclassed <ul> of links, a link grid) — leaving nav boilerplate in the
485
+ // output. LINK DENSITY is the universal signal Trafilatura uses: a container that is
486
+ // almost entirely anchors, with little text of its own, is navigation — droppable
487
+ // without naming a class. It is paired with a CASCADE (Barbaresi; SIGIR-2023): keep the
488
+ // pruned "precise" extraction ONLY when it preserved the page's non-link text, else fall
489
+ // back to the un-pruned one — so pruning can NEVER lose real content (project rule #1).
490
+
491
+ /** Length of a page's NON-LINK, NON-IMAGE word text — the "content" a cascade must not
492
+ * lose. Link text and URLs are excluded ON PURPOSE: removing navigation (which is all
493
+ * links) must not look like content loss, while removing prose/code must. */
494
+ export function contentWordLen(markdown) {
495
+ return String(markdown || '')
496
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ') // images
497
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, ' ') // links: drop BOTH visible text and url
498
+ .replace(/<https?:\/\/[^>\s]+>/gi, ' ') // autolinks
499
+ .replace(/[#>|`*_~+\-]/g, ' ') // markdown structural punctuation
500
+ .replace(/\s+/g, ' ')
501
+ .trim().length;
502
+ }
503
+
504
+ /** Remove clearly-navigational, link-dense containers from within the content node.
505
+ * Universal (a ratio, never a class name) and bounded (only containers that are almost
506
+ * all links with little text of their own). NAVIGATION navigates the SITE, so a
507
+ * link-dense container is pruned only when its links stay overwhelmingly on `host`
508
+ * (relative hrefs count as internal): a list pointing mostly OFF-site is a
509
+ * reference/resource list — content the cascade below cannot protect, because
510
+ * contentWordLen ignores link text by design. Mutates `content`; returns the count
511
+ * removed. */
512
+ function pruneNavByLinkDensity(content, host = '') {
513
+ const MIN_LINKS = 4; // a couple of links is prose; navigation has many
514
+ const DENSITY = 0.8; // ≥80% of the text is anchor text → navigation
515
+ const MAX_NONLINK_CHARS = 200; // and little text of its own (bullets / separators)
516
+ const MAX_EXTERNAL = 0.2; // more than this fraction off-site → references, kept
517
+ const naked = (h) => String(h || '').toLowerCase().replace(/^www\./, '');
518
+ const site = naked(host);
519
+ const matches = [];
520
+ for (const el of content.querySelectorAll('ul, ol, nav, div, section')) {
521
+ const anchors = el.querySelectorAll('a');
522
+ if (anchors.length < MIN_LINKS) continue;
523
+ if (site) {
524
+ let external = 0;
525
+ for (const a of anchors) {
526
+ const m = (a.getAttribute('href') || '').match(/^https?:\/\/([^/:?#]+)/i);
527
+ if (m && naked(m[1]) !== site) external++;
528
+ }
529
+ if (external / anchors.length > MAX_EXTERNAL) continue;
530
+ }
531
+ const total = textOf(el).length;
532
+ if (!total) continue;
533
+ let linkLen = 0;
534
+ for (const a of anchors) linkLen += textOf(a).length;
535
+ if (linkLen / total >= DENSITY && total - linkLen <= MAX_NONLINK_CHARS) matches.push(el);
536
+ }
537
+ const set = new Set(matches);
538
+ let removed = 0;
539
+ for (const el of matches) {
540
+ // Remove only the OUTERMOST match (skip one nested inside another slated node) so we
541
+ // never touch an already-detached child.
542
+ let p = el.parentNode;
543
+ let nested = false;
544
+ while (p && p !== content) {
545
+ if (set.has(p)) {
546
+ nested = true;
547
+ break;
548
+ }
549
+ p = p.parentNode;
550
+ }
551
+ if (nested) continue;
552
+ try {
553
+ el.remove();
554
+ removed++;
555
+ } catch {
556
+ /* already detached */
557
+ }
558
+ }
559
+ return removed;
560
+ }
561
+
562
+ // Doc-toolbar chrome that frameworks render INSIDE the article (so the DOM chrome pass
563
+ // misses it): "Edit this page", "Copy Page as Markdown", feedback widgets, etc.
564
+ // Stripped generically by phrase, as links or standalone lines.
565
+ const TOOLBAR =
566
+ '(?:edit (?:this )?page|edit (?:on|in) github|edit source|copy (?:page|markdown)(?: as markdown)?|copy as markdown|view source|view (?:page )?source|open in [^\\]\\n]{0,30}|report (?:an? )?(?:issue|problem|bug)|give feedback|send feedback|provide feedback|was this (?:page )?helpful[^\\n]*)';
567
+
568
+ /** Line-level Markdown cleanups that must NEVER reach inside a fenced code block:
569
+ * drop toolbar/artifact lines, collapse whitespace runs BETWEEN words (leading
570
+ * indentation is structure — nested lists and code depend on it), trim trailing
571
+ * spaces, cap blank runs at one. The previous whole-string regexes flattened the
572
+ * indentation of every ``` fence too, wrecking each code sample's layout. */
573
+ function cleanupLines(markdown) {
574
+ const toolbarLine = new RegExp('^[ \\t]*' + TOOLBAR + '[ \\t]*$', 'i');
575
+ const out = [];
576
+ let inFence = false;
577
+ let fence = '';
578
+ let blanks = 0;
579
+ for (const line of String(markdown).split('\n')) {
580
+ const m = line.match(/^\s*(```+|~~~+)/);
581
+ if (m) {
582
+ if (!inFence) {
583
+ inFence = true;
584
+ fence = m[1];
585
+ } else if (line.trim().startsWith(fence)) {
586
+ inFence = false;
587
+ }
588
+ out.push(line);
589
+ blanks = 0;
590
+ continue;
591
+ }
592
+ if (inFence) {
593
+ out.push(line); // verbatim inside code
594
+ continue;
595
+ }
596
+ const l = line
597
+ .replace(/(\S)[ \t]{2,}/g, '$1 ')
598
+ // adjacent links `[a](u)[b](u)` render glued; separate them with a space so a
599
+ // row of buttons is readable (universal — a link-close immediately followed by
600
+ // a link-open, never inside a fence: this runs only on non-fence lines).
601
+ .replace(/\]\(([^)]*)\)\[/g, ']($1) [')
602
+ .replace(/[ \t]+$/, '');
603
+ // toolbar actions rendered as plain standalone lines
604
+ if (toolbarLine.test(l)) continue;
605
+ // orphan link-close artifacts from broken next/prev nav-card markup: a line
606
+ // that is only `](url)` (optionally with a dangling `[` from the next link) and
607
+ // no opening bracket. The inlineLinkFlat turndown rule prevents these at source;
608
+ // this stays as a net for any that arrive from other paths.
609
+ if (/^[ \t]*\]\([^)]*\)\[?[ \t]*$/.test(l)) continue;
610
+ // empty heading: a `#`..`######` with no text (an emptied <h*> / stripped title).
611
+ if (/^#{1,6}[ \t]*$/.test(l)) continue;
612
+ // orphan punctuation from a card whose image/body was removed: a line that is
613
+ // only `[`, `]` or `!` is never meaningful Markdown outside a fence.
614
+ if (/^[[\]!]$/.test(l.trim())) continue;
615
+ if (l.trim() === '') {
616
+ blanks++;
617
+ if (blanks > 1) continue;
618
+ } else blanks = 0;
619
+ out.push(l);
620
+ }
621
+ return out.join('\n').trim();
622
+ }
623
+
624
+ /** Turndown a prepared content node and run the deterministic Markdown cleanups. */
625
+ function renderMarkdown(content) {
626
+ const td = buildTurndown();
627
+ let markdown = '';
628
+ try {
629
+ markdown = td.turndown(content.innerHTML || '');
630
+ } catch {
631
+ markdown = textOf(content);
632
+ }
633
+ markdown = markdown
634
+ // permalink/anchor links whose visible text is just '#', '¶', or empty —
635
+ // but never the `[](src)` tail of an image `![](src)` (the lookbehind), or a
636
+ // lazy-loaded empty-alt avatar would leave an orphan `!` in its place,
637
+ // corrupting the block (and its dedup identity) in every later capture.
638
+ .replace(/(?<!!)\[\s*[#¶]?\s*\]\([^)]*\)/g, '')
639
+ // sponsor/ad links rendered inline ("ads via …", "sponsored by …")
640
+ .replace(/\[\s*(?:ads?\s+via|sponsored\b|advertisement)[^\]]*\]\([^)]*\)/gi, '')
641
+ // toolbar actions rendered as links (text may span lines)
642
+ .replace(new RegExp('\\[\\s*' + TOOLBAR + '\\s*\\]\\([^)]*\\)', 'gi'), '')
643
+ // trailing "next/prev page" footer navigation block (kept conservative to
644
+ // clearly-navigational lead-ins so real "next steps" content is not cut).
645
+ .replace(/\n#{1,6}[ \t]*(?:ready for more|continue your learning|keep reading)\b[\s\S]*$/i, '');
646
+ markdown = cleanupLines(markdown);
647
+ markdown = repairTables(markdown);
648
+
649
+ // Final safety net: remove any inline-SVG/data-URI image noise that leaked as text
650
+ // (broken data-URI attributes spill their SVG body into the document).
651
+ return stripSvgNoise(markdown);
652
+ }
653
+
654
+ /**
655
+ * Make every GFM table RECTANGULAR so none renders ragged. Turndown + the gfm
656
+ * plugin emit table rows verbatim from the HTML, which on real docs sites means:
657
+ * - a prop's DESCRIPTION arrives as a `<td colspan>` second row → ONE cell in a
658
+ * 3-column table (`| Enables … |`), shattering the column layout (~8k such rows
659
+ * in a live Vuetify run);
660
+ * - a value `<pre>`/`<code>` inside a cell becomes a FENCED block that the cell
661
+ * flattener collapses to ``` ``` 16px ``` ``` — triple backticks mid-cell;
662
+ * - stray fully-empty `| |` rows and colspan headers leave rows of differing width.
663
+ * For each table: split every row on UNESCAPED pipes, turn any fenced cell into
664
+ * inline `` `code` ``, pad short rows AND the header out to the widest row, drop
665
+ * empty DATA rows (never the header), and regenerate the separator. Content-safe —
666
+ * no cell text is dropped (only re-fitted), a fenced cell only loses its fence, and
667
+ * non-table lines pass through untouched (fenced code blocks are skipped so an ASCII
668
+ * table inside a ``` sample is never rewritten).
669
+ */
670
+ function repairTables(markdown) {
671
+ const lines = String(markdown || '').split('\n');
672
+ const out = [];
673
+ const isTableLine = (l) => /^\s*\|/.test(l);
674
+ const isFence = (l) => /^\s*(```+|~~~+)/.test(l);
675
+ const isSep = (l) => /-{2,}/.test(l) && /^[\s|:-]+$/.test(l.trim());
676
+ const splitCells = (l) =>
677
+ l.trim().replace(/^\|/, '').replace(/\|\s*$/, '').split(/(?<!\\)\|/).map((c) => c.trim());
678
+ // A cell whose value was a fenced block (`<pre>`/`<code>` in a `<td>`) → inline code.
679
+ const inlineFence = (c) => c.replace(/`{3,}[a-z0-9]*\s*([^`]*?)\s*`{3,}/gi, (_, code) => '`' + code.trim() + '`').trim();
680
+
681
+ let inFence = false;
682
+ let fence = '';
683
+ let i = 0;
684
+ while (i < lines.length) {
685
+ const line = lines[i];
686
+ const fm = line.match(/^\s*(```+|~~~+)/);
687
+ if (fm) {
688
+ if (!inFence) { inFence = true; fence = fm[1]; } else if (line.trim().startsWith(fence)) inFence = false;
689
+ out.push(line); i++; continue;
690
+ }
691
+ if (inFence || !isTableLine(line)) { out.push(line); i++; continue; }
692
+ // Gather the contiguous run of table lines.
693
+ let j = i;
694
+ while (j < lines.length && isTableLine(lines[j]) && !isFence(lines[j])) j++;
695
+ const run = lines.slice(i, j);
696
+ const sepIdx = run.findIndex(isSep);
697
+ if (sepIdx === -1) { for (const r of run) out.push(r); i = j; continue; } // no separator → not a table
698
+ const rows = run.map((r, idx) => ({ sep: idx === sepIdx, header: idx < sepIdx, cells: splitCells(r).map(inlineFence) }));
699
+ const ncol = Math.max(1, ...rows.filter((r) => !r.sep).map((r) => r.cells.length));
700
+ for (const r of rows) {
701
+ if (r.sep) { out.push('| ' + Array(ncol).fill('---').join(' | ') + ' |'); continue; }
702
+ if (!r.header && r.cells.every((c) => c === '')) continue; // drop empty data rows, keep the header
703
+ const cells = r.cells.concat(Array(Math.max(0, ncol - r.cells.length)).fill(''));
704
+ out.push('| ' + cells.join(' | ') + ' |');
705
+ }
706
+ i = j;
707
+ }
708
+ return out.join('\n');
709
+ }
710
+
711
+ /**
712
+ * Convert an HTML document to `{ title, markdown }`.
713
+ * @param {string} html
714
+ * @param {object} opts
715
+ * @param {string|string[]} [opts.contentSelector] framework-specific container(s)
716
+ * @param {string} [opts.baseUrl] for absolutising links
717
+ * @param {string} [opts.title] override the derived title
718
+ */
719
+ export function extractMarkdown(html, { contentSelector, baseUrl, title } = {}) {
720
+ if (!html || typeof html !== 'string') return { title: title || '', markdown: '' };
721
+
722
+ const root = parse(html, {
723
+ comment: false,
724
+ blockTextElements: { script: false, style: false, noscript: false },
725
+ });
726
+
727
+ // The reveal marks non-visible elements (hidden modals, off-state placeholders,
728
+ // on-screen keyboards) with data-crawldna-hidden so the serialized HTML doesn't
729
+ // leak them into the output. Drop them before anything else inspects the DOM, so
730
+ // even the main-content picker never lands on a hidden panel. No-op for the
731
+ // static path (no markers present).
732
+ for (const n of root.querySelectorAll('[data-crawldna-hidden]')) n.remove();
733
+
734
+ const docTitle =
735
+ title ||
736
+ textOf(root.querySelector('h1')) ||
737
+ textOf(root.querySelector('title')) ||
738
+ '';
739
+
740
+ absolutize(root, baseUrl);
741
+
742
+ const content = pickMainContent(root, contentSelector);
743
+
744
+ // Strip chrome from inside the chosen node.
745
+ for (const sel of CHROME_SELECTORS) {
746
+ for (const n of content.querySelectorAll(sel)) n.remove();
747
+ }
748
+ // <header> is chrome ONLY as a site masthead. Inside an <article> it is that
749
+ // article's own title/byline block — spec-blessed content (HTML5 sectioning) that
750
+ // a blanket removal used to silently delete from every blog/news page.
751
+ for (const n of content.querySelectorAll('header')) {
752
+ if (!n.closest('article')) n.remove();
753
+ }
754
+
755
+ // Drop data-URI / inline-SVG images. Sites embed decorative gradients, blurs
756
+ // and icons as <img src="data:image/svg+xml,…">; they carry no extractable
757
+ // text and the raw SVG markup (quotes/parens/newlines) shatters into garbage
758
+ // Markdown. Real-URL images (http/https) are kept so they can still be
759
+ // extracted, separated, or excluded by task.
760
+ for (const img of content.querySelectorAll('img')) {
761
+ const src = img.getAttribute('src') || '';
762
+ const srcset = img.getAttribute('srcset') || '';
763
+ if (/^\s*data:/i.test(src) || /data:image/i.test(srcset)) img.remove();
764
+ }
765
+ // Inline <svg> nodes are removed by CHROME_SELECTORS above, but defensively
766
+ // drop any that slipped through (e.g. namespaced) so their markup never leaks.
767
+ for (const n of content.querySelectorAll('svg')) n.remove();
768
+
769
+ // Self-served ad cards with no stable class/id (docs themes' own promos) still
770
+ // carry the Carbon-convention label "ads via <sponsor>". Remove the unit by that
771
+ // label: when the label sits inside a link, the link IS the ad's clickable card —
772
+ // drop the whole card (image + ad copy included), else just the label element.
773
+ // Bounded to short, label-only elements so prose mentioning the phrase survives.
774
+ const adCards = [];
775
+ for (const el of content.querySelectorAll('a, span, div, small, p')) {
776
+ const t = textOf(el);
777
+ if (t.length <= 30 && /^ads?\s+via\b/i.test(t)) adCards.push(el.closest('a') || el);
778
+ }
779
+ for (const el of adCards) {
780
+ try {
781
+ el.remove();
782
+ } catch {
783
+ /* already detached with its card */
784
+ }
785
+ }
786
+
787
+ // #26 — recover the page's visual skeleton: inline-styled titles get their
788
+ // data-crawldna-heading marker (browser captures arrive with markers already
789
+ // stamped from computed styles; those are respected, not re-done). Adds
790
+ // attributes only — marking can never change or lose text.
791
+ try {
792
+ markVisualHeadings(content);
793
+ } catch {
794
+ /* marking must never break extraction */
795
+ }
796
+
797
+ // #8 — extract PRECISELY (prune link-dense in-content navigation), but CASCADE: keep
798
+ // the pruned result only when it preserved the page's non-link content; otherwise fall
799
+ // back to the un-pruned extraction. So navigation boilerplate is trimmed without ever
800
+ // losing real prose/code. `full` is computed BEFORE pruning (it is the safe fallback).
801
+ const full = renderMarkdown(content);
802
+ let markdown = full;
803
+ let host = '';
804
+ try {
805
+ host = new URL(baseUrl || '').hostname;
806
+ } catch {
807
+ /* no base → no internal/external signal; density alone decides, as before */
808
+ }
809
+ if (pruneNavByLinkDensity(content, host) > 0) {
810
+ const pruned = renderMarkdown(content);
811
+ // Accept the trim only if it kept ≥98% of the non-link word content — i.e. it
812
+ // removed navigation, not content. This is the "precise → permissive" fallback.
813
+ if (contentWordLen(pruned) >= contentWordLen(full) * 0.98) markdown = pruned;
814
+ }
815
+
816
+ return { title: docTitle.trim(), markdown };
817
+ }
818
+
819
+ /**
820
+ * Split Markdown into structural blocks (paragraphs, headings, list chunks,
821
+ * tables, fenced code) without breaking fenced code blocks. Used for
822
+ * cross-state de-duplication.
823
+ */
824
+ export function splitBlocks(markdown) {
825
+ const lines = (markdown || '').split('\n');
826
+ const blocks = [];
827
+ let buf = [];
828
+ let inFence = false;
829
+ let fence = '';
830
+
831
+ const flush = () => {
832
+ const t = buf.join('\n').trim();
833
+ if (t) blocks.push(t);
834
+ buf = [];
835
+ };
836
+
837
+ for (const line of lines) {
838
+ const m = line.match(/^\s*(```+|~~~+)/);
839
+ if (m) {
840
+ if (!inFence) {
841
+ flush();
842
+ inFence = true;
843
+ fence = m[1];
844
+ buf.push(line);
845
+ } else if (line.trim().startsWith(fence)) {
846
+ buf.push(line);
847
+ inFence = false;
848
+ flush();
849
+ } else {
850
+ buf.push(line);
851
+ }
852
+ continue;
853
+ }
854
+ if (inFence) {
855
+ buf.push(line);
856
+ continue;
857
+ }
858
+ if (line.trim() === '') flush();
859
+ else buf.push(line);
860
+ }
861
+ flush();
862
+ return blocks;
863
+ }
864
+
865
+ /**
866
+ * Classify a Markdown block by structural TYPE and image-ness. Pure/deterministic
867
+ * so the layout router (and the block-metadata spine) agree on what a block is.
868
+ * `text` here is the paragraph type (kept for back-compat with existing routing).
869
+ */
870
+ export function classifyBlock(text) {
871
+ const t = String(text || '').trim();
872
+ const hasImage = /!\[[^\]]*\]\([^)]*\)/.test(t);
873
+ let type = 'text';
874
+ if (/^#{1,6}\s/.test(t)) type = 'heading';
875
+ else if (/^\s*(```|~~~)/.test(t)) type = 'code';
876
+ else if (/\|/.test(t) && /\n\s*\|?[\s:|-]*-{2,}/.test(t)) type = 'table';
877
+ else if (/^\s*([-*+]|\d+[.)])\s/m.test(t) && !hasImage) type = 'list';
878
+ else if (hasImage && t.replace(/!\[[^\]]*\]\([^)]*\)/g, '').replace(/[\s)\]]/g, '').length < 3) type = 'image';
879
+ return { type, hasImage };
880
+ }
881
+
882
+ /** Heading depth of a block (# = 1 … ###### = 6), or 0 if it is not a heading. */
883
+ function headingLevel(text) {
884
+ const m = String(text || '').match(/^(#{1,6})\s/);
885
+ return m ? m[1].length : 0;
886
+ }
887
+
888
+ /**
889
+ * Enrich raw blocks (`{ text, provenance? }` or bare strings) with the structural
890
+ * metadata every AI layer addresses blocks by:
891
+ * - `type` / `hasImage` — what the block is.
892
+ * - `sectionPath` — the heading ancestry the block falls UNDER (its parent
893
+ * headings, not itself), so a rule like "everything under Privacy Policy" or
894
+ * "only the white pizzas" is a metadata match, not a per-case rule.
895
+ * - `ord` — document-order index within the page (for ordinal tasks / stable sort).
896
+ * - `provenance` — which interaction surfaced the block (`baseline`, `tab:…`,
897
+ * `expander:…`, `dropdown:…`, `loadmore`), so "the dropdown results" is routable.
898
+ * This is the spine that lets layout stay one general AI-judged mechanism.
899
+ */
900
+ export function enrichBlocks(rawBlocks) {
901
+ const stack = []; // active heading ancestry: { level, title }
902
+ return (rawBlocks || []).map((b, ord) => {
903
+ const text = typeof b === 'string' ? b : b.text;
904
+ const provenance = (b && typeof b === 'object' && b.provenance) || 'baseline';
905
+ const { type, hasImage } = classifyBlock(text);
906
+ const lvl = headingLevel(text);
907
+ let sectionPath;
908
+ if (lvl) {
909
+ while (stack.length && stack[stack.length - 1].level >= lvl) stack.pop();
910
+ sectionPath = stack.map((s) => s.title);
911
+ stack.push({ level: lvl, title: text.replace(/^#{1,6}\s*/, '').trim().slice(0, 80) });
912
+ } else {
913
+ sectionPath = stack.map((s) => s.title);
914
+ }
915
+ return { text, type, hasImage, provenance, sectionPath, ord };
916
+ });
917
+ }
918
+
919
+ /**
920
+ * Remove every image from Markdown — verbatim-safe (it drops a media element, it
921
+ * never rewrites prose). Covers inline `![alt](src)`, reference `![alt][id]`,
922
+ * images wrapped in a link `[![alt](src)](href)`, and any raw `<img>` that
923
+ * survived conversion. Honors a task like "don't include images".
924
+ */
925
+ export function stripImages(markdown) {
926
+ return String(markdown || '')
927
+ // linked image: [![alt](src)](href) — drop the whole thing
928
+ .replace(/\[\s*!\[[^\]]*\]\([^)]*\)\s*\]\([^)]*\)/g, '')
929
+ // inline image: ![alt](src "title")
930
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, '')
931
+ // reference image: ![alt][id]
932
+ .replace(/!\[[^\]]*\]\[[^\]]*\]/g, '')
933
+ // raw <img …> tags that slipped through conversion
934
+ .replace(/<img\b[^>]*>/gi, '')
935
+ // tidy up artifacts left behind (trailing spaces, blank pile-ups)
936
+ .replace(/[ \t]+$/gm, '')
937
+ .replace(/\n{3,}/g, '\n\n')
938
+ .trim();
939
+ }
940
+
941
+ /**
942
+ * Remove hyperlinks but KEEP their visible text — `[text](url)` -> `text`,
943
+ * `<https://…>` -> dropped. Verbatim-safe for the words; only the link target is
944
+ * dropped. Honors a task like "strip the links". Run AFTER stripImages so an
945
+ * image's leftover does not get mistaken for link text.
946
+ */
947
+ export function stripLinks(markdown) {
948
+ return String(markdown || '')
949
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
950
+ .replace(/<(https?:\/\/[^>\s]+)>/gi, '')
951
+ .replace(/[ \t]+$/gm, '')
952
+ .replace(/\n{3,}/g, '\n\n')
953
+ .trim();
954
+ }
955
+
956
+ /**
957
+ * Apply the task's faithful element exclusions to a Markdown string. A no-op when
958
+ * nothing is excluded, so callers can invoke it unconditionally.
959
+ * @param {string} markdown
960
+ * @param {{ images?: boolean, links?: boolean }} [exclude]
961
+ */
962
+ export function applyExclusions(markdown, exclude = {}) {
963
+ let md = String(markdown || '');
964
+ if (exclude.images) md = stripImages(md);
965
+ if (exclude.links) md = stripLinks(md);
966
+ return md;
967
+ }
968
+
969
+ /** A block's DEDUP identity. Decorative empty-alt images (`![](src)`) are excluded
970
+ * from it: they lazy-load, so the same row serialises WITHOUT its avatar in the
971
+ * baseline capture and WITH it a click later — two keys for one block, and the
972
+ * whole table/list re-enters the document on every state. Images with real alt
973
+ * text stay in the key (two cards may differ only by their pictures); a block
974
+ * that is ONLY an image keeps its full identity. */
975
+ /** A block too generic to anchor a merge onto: a horizontal rule (`---`/`***`/
976
+ * `___`) or a tiny stub. These recur identically in app frames, so they make
977
+ * ambiguous anchors (#27). Content blocks — even short headings — are never weak. */
978
+ function isWeakAnchor(text) {
979
+ const s = String(text || '').trim();
980
+ if (s.length <= 2) return true;
981
+ return /^([-*_=])\1{2,}$/.test(s.replace(/\s+/g, ''));
982
+ }
983
+
984
+ const normalizeBlock = (s) => {
985
+ let t = s;
986
+ // A TABLE's identity is its ROWS, not their order: clicking a sortable column
987
+ // header re-serialises the same table re-sorted, and keeping every ordering
988
+ // repeats it whole. Sort the row lines for the KEY only (the stored block stays
989
+ // verbatim, first-seen ordering).
990
+ if (/^\s*\|/.test(t) && /\n\s*\|?[\s:|-]*-{2,}/.test(t)) {
991
+ t = t
992
+ .split('\n')
993
+ .map((l) => l.trim())
994
+ .sort()
995
+ .join('\n');
996
+ }
997
+ const noDecorative = t.replace(/!\[\s*\]\([^)]*\)/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
998
+ return noDecorative || t.replace(/\s+/g, ' ').trim().toLowerCase();
999
+ };
1000
+
1001
+ /**
1002
+ * Records every page STATE captured across a reveal (each tab, each expanded
1003
+ * accordion, each swapped view) as a full snapshot, and derives two views from
1004
+ * them: `toMarkdown()` — the COMPACT-STRUCTURED consolidated document (the shared
1005
+ * frame once, each state's changing blocks grouped under a `**label:**` marker,
1006
+ * never orphaned) — and `states()` — the FAITHFUL per-state record (each whole
1007
+ * snapshot verbatim). Nothing about a state is thrown away at capture time, so a
1008
+ * partial change never loses its structure (the `A,b,c → A,b,d → r,b,d` case).
1009
+ */
1010
+ export class BlockAccumulator {
1011
+ constructor() {
1012
+ // Nothing about a state is discarded at add() time: every capture's FULL
1013
+ // ordered block list is kept (`_states`), block text stored once (`store`),
1014
+ // and both the compact-structured document (toMarkdown) and the faithful
1015
+ // per-state record (states()) are DERIVED from it at read time. This is what
1016
+ // lets a partial change keep its structure — "state 3 = r,b,d" stays whole.
1017
+ this.seen = new Set(); // keys ever seen (drives add()'s new-block count)
1018
+ this.store = new Map(); // key -> verbatim text (first-seen)
1019
+ this.prov = new Map(); // key -> provenance of the state that first showed it
1020
+ this._states = []; // { label, provenance, order, keys:[…] } per capture — a full snapshot
1021
+ }
1022
+
1023
+ /**
1024
+ * Record ONE captured page state (the full visible DOM after a click). Splits it
1025
+ * into blocks, stores each block's verbatim text once (dedup by content key), and
1026
+ * pushes the state's ORDERED key list — the whole snapshot, kept so structure is
1027
+ * never lost (rule #1/#3). Returns the count of blocks NEVER SEEN before: the
1028
+ * reveal loop reads this as `added` to drive load-more/futility/sticky, so its
1029
+ * semantics are unchanged from the old accumulator.
1030
+ *
1031
+ * `label` is the state's variant marker (rendered as `**label:**`); `provenance`
1032
+ * the reveal source (`baseline`/`tab:…`/`expander:…`/`dropdown:…`/`loadmore`);
1033
+ * `order` (#27) the revealing control's vertical position, used to sort states
1034
+ * that share an anchor into page order (base first). Placement + the shared-frame
1035
+ * split are computed at READ time (toMarkdown), not here.
1036
+ */
1037
+ add(markdown, { label, provenance = 'baseline', order = 0 } = {}) {
1038
+ const keys = [];
1039
+ const inState = new Set();
1040
+ let added = 0;
1041
+ for (const text of splitBlocks(markdown)) {
1042
+ const key = createHash('sha1').update(normalizeBlock(text)).digest('hex');
1043
+ if (inState.has(key)) continue; // a block repeated within one capture counts once
1044
+ inState.add(key);
1045
+ keys.push(key);
1046
+ if (!this.seen.has(key)) {
1047
+ this.seen.add(key);
1048
+ this.store.set(key, text);
1049
+ this.prov.set(key, provenance || 'baseline');
1050
+ added++;
1051
+ }
1052
+ }
1053
+ this._states.push({ label: label || null, provenance: provenance || 'baseline', order: order || 0, keys });
1054
+ return added;
1055
+ }
1056
+
1057
+ /**
1058
+ * Compute the COMPACT-STRUCTURED document from the retained states:
1059
+ * the shared frame once, then each state's changing blocks as ONE contiguous,
1060
+ * labelled group anchored in its section. Returns [{ text, label, provenance }]
1061
+ * in reading order. Pure; toMarkdown/toBlocks render from it.
1062
+ */
1063
+ _render() {
1064
+ const states = this._states;
1065
+ if (!states.length) return [];
1066
+ const textOf = (k) => this.store.get(k);
1067
+ // Single capture (a normal no-reveal page) → verbatim, unchanged.
1068
+ if (states.length === 1) return states[0].keys.map((k) => ({ text: textOf(k), label: null, provenance: this.prov.get(k) }));
1069
+
1070
+ const base = states[0];
1071
+ // firstState[key] = index of the earliest state that showed the block.
1072
+ const firstState = new Map();
1073
+ states.forEach((s, i) => s.keys.forEach((k) => { if (!firstState.has(k)) firstState.set(k, i); }));
1074
+
1075
+ // Classify each later state vs the baseline. VARIANT = it HID a baseline block
1076
+ // (mutually-exclusive: a tab/view swap or a partial change) → its full changing
1077
+ // context is shown, repeated, labelled, so `d`/`r` stay WITH their state.
1078
+ // ACCRETIVE = it hid nothing (load-more, an accordion that only ADDS) → only its
1079
+ // genuinely-new blocks, once, unlabelled (no load-more blow-up).
1080
+ const isVariant = states.map((s, i) => {
1081
+ if (i === 0) return false;
1082
+ const set = new Set(s.keys);
1083
+ return base.keys.some((k) => !set.has(k));
1084
+ });
1085
+
1086
+ // FRAME = the shared skeleton: baseline blocks present in MORE THAN HALF of the
1087
+ // VARIANT states. A strict intersection ("in EVERY variant") made the frame hostage
1088
+ // to a single degenerate capture — one transient/partial render (a state caught
1089
+ // mid-transition holding almost no blocks) shares none of the body, so it evicted
1090
+ // the ENTIRE skeleton and every other state then re-emitted the whole page (a live
1091
+ // Vuetify run had one 3-line capture among nine turn a component page into 10
1092
+ // near-identical full copies; ~10% of pages carried this bloat). A MAJORITY vote
1093
+ // keeps a block framed when the bulk of states agree it is shared, so a few
1094
+ // deviant/partial captures no longer poison it — while a block in only HALF the
1095
+ // variants (a true mutually-exclusive pair) still falls OUT and stays WITH each
1096
+ // state (the A,b,c→A,b,d→r,b,d case: AAA is in 1 of 2 variants = 50%, not a
1097
+ // majority, so it repeats). Accretive-only states never vote (they hid nothing),
1098
+ // keeping their own unlabelled group. No content is lost: a block wrongly excluded
1099
+ // is merely repeated, a framed block is emitted once, and every full snapshot still
1100
+ // lives verbatim in states().
1101
+ const baseKeys = new Set(base.keys);
1102
+ const variantHits = new Map(); // baseline key -> # of variant states holding it
1103
+ let variantCount = 0;
1104
+ states.forEach((s, i) => {
1105
+ if (!isVariant[i]) return;
1106
+ variantCount++;
1107
+ for (const k of s.keys) if (baseKeys.has(k)) variantHits.set(k, (variantHits.get(k) || 0) + 1);
1108
+ });
1109
+ const frame = new Set(base.keys.filter((k) => variantCount === 0 || (variantHits.get(k) || 0) > variantCount / 2));
1110
+
1111
+ // A state's delta group: for a VARIANT, ALL its non-frame blocks in state order
1112
+ // (repetition intended — the changing context that gives `d` its meaning); for
1113
+ // ACCRETIVE/baseline, only the blocks FIRST seen in it (new content once).
1114
+ const deltaOf = (s, i) => s.keys.filter((k) => !frame.has(k) && (isVariant[i] || firstState.get(k) === i));
1115
+
1116
+ // Anchor a group before the first NON-WEAK frame block that follows its first
1117
+ // delta in the state's own order (a weak `---`/stub divider recurs in every
1118
+ // view, so it is skipped — the revealed view then lands AFTER the base content,
1119
+ // #27); END when nothing frame-y follows (load-more/appended content).
1120
+ const anchorOf = (s, delta) => {
1121
+ for (let j = s.keys.indexOf(delta[0]) + 1; j < s.keys.length; j++) {
1122
+ const k = s.keys[j];
1123
+ if (frame.has(k) && !isWeakAnchor(textOf(k))) return k;
1124
+ }
1125
+ return null; // END
1126
+ };
1127
+
1128
+ const groups = []; // { anchor, order, label, prov, keys }
1129
+ states.forEach((s, i) => {
1130
+ const delta = deltaOf(s, i);
1131
+ if (delta.length) groups.push({ anchor: anchorOf(s, delta), order: s.order || 0, label: s.label, prov: s.provenance, keys: delta });
1132
+ });
1133
+
1134
+ // Bucket groups by anchor (null = END) and sort each slot by `order` (base
1135
+ // first), stable on discovery order — the #27 representation ordering.
1136
+ const byAnchor = new Map();
1137
+ groups.forEach((g, gi) => {
1138
+ if (!byAnchor.has(g.anchor)) byAnchor.set(g.anchor, []);
1139
+ byAnchor.get(g.anchor).push({ ...g, gi });
1140
+ });
1141
+ for (const arr of byAnchor.values()) arr.sort((a, b) => a.order - b.order || a.gi - b.gi);
1142
+
1143
+ const doc = [];
1144
+ const emit = (g) => g.keys.forEach((k) => doc.push({ text: textOf(k), label: g.label, provenance: g.prov }));
1145
+ for (const k of base.keys) {
1146
+ if (!frame.has(k)) continue; // frame skeleton, in baseline document order
1147
+ (byAnchor.get(k) || []).forEach(emit);
1148
+ doc.push({ text: textOf(k), label: null, provenance: this.prov.get(k) });
1149
+ }
1150
+ (byAnchor.get(null) || []).forEach(emit); // END-anchored groups (appended content)
1151
+ return doc;
1152
+ }
1153
+
1154
+ size() {
1155
+ return this.store.size; // count of UNIQUE blocks held
1156
+ }
1157
+
1158
+ /** The FAITHFUL per-state record: each DISTINCT captured state reconstructed
1159
+ * VERBATIM from its own ordered blocks — the complete snapshot (A,b,c / A,b,d /
1160
+ * r,b,d). This is where full co-occurrence lives, so a partial change never
1161
+ * strands a fragment out of the state it belongs to.
1162
+ *
1163
+ * BYTE-IDENTICAL captures are collapsed to their first occurrence (the block
1164
+ * keys are content hashes, so an identical ordered key list IS an identical
1165
+ * snapshot). A chrome control whose click changed no content — theme toggle,
1166
+ * login, a nav tab that only opened a menu (#28) — captures a state equal to
1167
+ * one already held; it carries ZERO new content here, and the click itself is
1168
+ * preserved in the activity log, not this content record. Dropping the repeat
1169
+ * keeps the distinct states legible instead of burying them (a thin page had
1170
+ * ~26 identical chrome snapshots). Never drops a state whose content differs.
1171
+ * Consumed as the `states/…` artifact. */
1172
+ states() {
1173
+ const seen = new Set();
1174
+ const out = [];
1175
+ for (const s of this._states) {
1176
+ const sig = s.keys.join('|'); // hex hashes → identical sig ⇔ byte-identical snapshot
1177
+ if (seen.has(sig)) continue;
1178
+ seen.add(sig);
1179
+ out.push({
1180
+ label: s.label,
1181
+ provenance: s.provenance,
1182
+ order: s.order,
1183
+ markdown: s.keys.map((k) => this.store.get(k)).join('\n\n'),
1184
+ });
1185
+ }
1186
+ return out;
1187
+ }
1188
+
1189
+ /** Raw blocks (`{ text, provenance }`) in reading order for the layout router. */
1190
+ toBlocks() {
1191
+ return this._render().map((b) => ({ text: b.text, provenance: b.provenance || 'baseline' }));
1192
+ }
1193
+
1194
+ /** The consolidated .md: compact (the shared frame once) but STRUCTURED — each
1195
+ * reveal state's changing blocks grouped under a VISIBLE `**label:**` marker
1196
+ * (an HTML comment vanishes when rendered), never orphaned into a flat merge.
1197
+ * The complete per-state snapshots live in states(). */
1198
+ toMarkdown() {
1199
+ const out = [];
1200
+ let lastLabel = null;
1201
+ for (const blk of this._render()) {
1202
+ if (blk.label && blk.label !== lastLabel) out.push(`**${blk.label}:**`);
1203
+ lastLabel = blk.label || null;
1204
+ out.push(blk.text);
1205
+ }
1206
+ return out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
1207
+ }
1208
+ }