page-markdown 0.2.0 → 0.3.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,418 @@
1
+ // Stage-1 style provenance; consumed before standardization copies any text.
2
+ // No geometry reads; getComputedStyle can itself trigger style/layout work.
3
+ import { isCollapsedAnswer } from "./extract.js";
4
+ export const CONCEALED_MARK = "data-page-markdown-concealed";
5
+ export const CONCEALED_BLOCK_LIMIT = 2048;
6
+ export const CONCEALED_TOTAL_LIMIT = 16384;
7
+ export const CONCEALED_ENTRY_LIMIT = 100;
8
+ // UTF-16 units, without whitespace normalization. A block is a consecutive
9
+ // run of concealed text with one reason in the nearest BLOCK ancestor; visible
10
+ // text, another block or another reason ends it. Each captured text node is
11
+ // visited once, so nested signals never double-count. Precedence is below.
12
+ /** @type {import('./types.js').ConcealmentReason[]} */
13
+ const REASONS = ["display_none", "visibility_hidden", "opacity_zero", "font_size_zero", "clipped", "transparent_text", "same_color"];
14
+ const BLOCK = /^(?:address|article|aside|blockquote|body|dd|div|dl|dt|figcaption|figure|h[1-6]|li|main|ol|p|pre|section|table|td|th|tr|ul)$/;
15
+ // Fixed read/probe indices: 0..15 required, 16..18 optional prefixed fields.
16
+ const ALL_FIELDS = 'display visibility opacity fontSize position clip clipPath transitionProperty transitionDuration animationName animationDuration color backgroundColor backgroundImage backgroundClip textShadow webkitBackgroundClip webkitTextFillColor webkitTextStrokeWidth'.split(' ');
17
+ const [NONE, VISIBLE, HIDDEN, AUTO, INCOMPLETE] = /** @type {['none', 'visible', 'hidden', 'auto', 'incomplete']} */ ('none visible hidden auto incomplete'.split(' '));
18
+ const SPACE = /\s/;
19
+ const NORMALIZE = /[A-Z]|^[^\x21-\x7e]|[^\x21-\x7e]$/;
20
+ const ZERO_PX = /^0(?:\.0+)?px$/;
21
+
22
+ export function isText(node) { return node[DOM_NODE_TYPE] === 3; }
23
+ export function isElement(node) { return node[DOM_NODE_TYPE] === 1; }
24
+
25
+ // Shared DOM property names keep the browser bundle within its size ceiling.
26
+ // This dictionary is allocated once, never per captured element.
27
+ export const [DOM_PARENT_ELEMENT, DOM_LOCAL_NAME, DOM_GET_ROOT_NODE, DOM_CHILD_NODES, DOM_FIRST_CHILD, DOM_NEXT_SIBLING, DOM_NODE_VALUE, DOM_SHADOW_ROOT, DOM_CONTENT, DOM_APPEND_CHILD, DOM_NODE_TYPE] = 'parentElement localName getRootNode childNodes firstChild nextSibling nodeValue shadowRoot content appendChild nodeType'.split(' ');
28
+
29
+ // Provisional scroll-reveal policy. A named animation may affect opacity; its
30
+ // name is not evidence of its keyframes or that content will ever appear.
31
+ export function keepPendingOpacity(style) {
32
+ return pendingOpacity(style.transitionProperty, style.transitionDuration, style.animationName, style.animationDuration);
33
+ }
34
+ function pendingOpacity(properties, transitions, names, animations) {
35
+ const applies = (names, durations, transition = false) => {
36
+ const times = (durations || "").split(",");
37
+ const entries = (names || "").split(",");
38
+ // Transitions use the last matching property; animations may overlap.
39
+ for (let i = entries.length - 1; i >= 0; i--) {
40
+ const name = entries[i].trim();
41
+ if (transition ? name === 'all' || name === 'opacity' : name && name !== NONE) {
42
+ const time = times[i % times.length].trim();
43
+ if (/^(?:\d*\.)?\d+(?:ms|s)$/.test(time) && parseFloat(time) > 0) return true;
44
+ if (transition) return false;
45
+ }
46
+ }
47
+ return false;
48
+ };
49
+ return applies(properties, transitions, true) || applies(names, animations);
50
+ }
51
+
52
+ function parent(el) {
53
+ return el.assignedSlot || el[DOM_PARENT_ELEMENT] || el[DOM_GET_ROOT_NODE]?.().host || null;
54
+ }
55
+
56
+ // Supported clips only: zero-area legacy rect (unitless zero or px,
57
+ // comma/space separated), inset(50%) and circle(0 [at ...]), zero px or
58
+ // percent radius. No geometry, polygons, masks, overflow or off-screen tests.
59
+ /** @param {string} clipPath @param {string} [clip] @param {string} [position] */
60
+ function clipped(clipPath, clip = AUTO, position) {
61
+ const positioned = position === 'absolute' || position === 'fixed';
62
+ const rect = positioned && /^rect\(\s*((?:-?(?:\d*\.)?\d+px|0)(?:[\s,]+(?:-?(?:\d*\.)?\d+px|0)){3})\s*\)$/.exec(clip);
63
+ const edges = rect?.[1]?.split(/[\s,]+/).map(parseFloat);
64
+ return (edges && (edges[0] === edges[2] || edges[1] === edges[3]))
65
+ || /^inset\(\s*50%\s*\)$/.test(clipPath)
66
+ || /^circle\(\s*0(?:px|%)?(?:\s+at\s+[^)]+)?\s*\)$/.test(clipPath);
67
+ }
68
+
69
+ // Work items belong to the clone walk. Slots 2..8 carry primitives or a
70
+ // shared exceptional provenance link: persistent, background, provider, head,
71
+ // visibility origin, cleanup chain, and the source element represented.
72
+ /** @param {Document} doc @param {boolean} enabled */
73
+ export function createConcealmentWalk(doc, enabled) {
74
+ const view = doc.defaultView;
75
+ const active = enabled && typeof view?.getComputedStyle === "function";
76
+ /** @type {import('./types.js').ConcealmentCheck} */
77
+ let check = !enabled ? "disabled" : active ? "computed" : "static-only";
78
+ const exemptions = new Set(), computedHidden = new Set(), records = new Map();
79
+ const cleanups = new Map(), planted = new Set(), images = new Map(), colours = new Map();
80
+ const pending = new Set();
81
+ const counts = [0, 0, 0, 0, 0, 0, 0, 0, 0];
82
+ // Counter indices follow the names exposed by the counters getter below.
83
+ const optional = [];
84
+ let probed = false;
85
+ let outside = false;
86
+ /** @type {import('./types.js').ConcealedText[]} */
87
+ const entries = [];
88
+ const summary = { count: 0, chars: 0, truncated: false };
89
+
90
+ function slow() { counts[3]++; }
91
+ function declaration(el) {
92
+ counts[0]++;
93
+ if (outside) counts[6]++;
94
+ try { return view?.getComputedStyle(el); } catch { check = INCOMPLETE; return null; }
95
+ }
96
+ function read(raw, field) {
97
+ counts[1]++;
98
+ if (outside) counts[7]++;
99
+ let value;
100
+ try { value = raw?.[ALL_FIELDS[field]]; } catch { /* consulted failure */ }
101
+ if (typeof value !== "string" || !value) { check = INCOMPLETE; return ""; }
102
+ // Canonical computed strings need no normalization or replacement string.
103
+ if (NORMALIZE.test(value)) { slow(); value = value.trim().toLowerCase(); }
104
+ if (!value) check = INCOMPLETE;
105
+ return value;
106
+ }
107
+ function probe(el) {
108
+ if (probed) return;
109
+ probed = true;
110
+ const raw = declaration(el);
111
+ for (let i = 0; i < ALL_FIELDS.length; i++) {
112
+ if (i < 16 || (optional[i] = !!raw && ALL_FIELDS[i] in raw)) read(raw, i);
113
+ }
114
+ counts[4] = 1;
115
+ counts[5] = counts[1];
116
+ }
117
+ function maybe(raw, field) {
118
+ return optional[field] || raw && ALL_FIELDS[field] in raw ? read(raw, field) : undefined;
119
+ }
120
+
121
+ // Parse canonical computed rgb/rgba without arrays, matches, or cache writes.
122
+ // Fractional channels remain doubles, never rounded to eight-bit channels.
123
+ let red = 0, green = 0, blue = 0, alpha = 0;
124
+ function rgb(value) {
125
+ if (value === "rgba(0, 0, 0, 0)") { red = green = blue = alpha = 0; return true; }
126
+ if (value === "rgb(0, 0, 0)") { red = green = blue = 0; alpha = 1; return true; }
127
+ const rgba = value.startsWith('rgba(');
128
+ let i = rgba ? 5 : value.startsWith('rgb(') ? 4 : 0;
129
+ if (!i) return false;
130
+ let comma;
131
+ alpha = 1;
132
+ for (let channel = 0; channel < 4; channel++) {
133
+ while (SPACE.test(value[i])) i++;
134
+ const begin = i;
135
+ let scale = 0, n = 0;
136
+ for (; i < value.length; i++) {
137
+ const code = value.charCodeAt(i);
138
+ if (code === 46 && !scale) scale = 1;
139
+ else if (code >= 48 && code <= 57) {
140
+ n = n * 10 + code - 48;
141
+ if (scale) scale *= 10;
142
+ } else break;
143
+ }
144
+ if (i === begin || value[i - 1] === '.') return false;
145
+ // Division matches decimal conversion while numerator and denominator
146
+ // are exact integers. Unusually long tokens use exact JS conversion.
147
+ if (n < 9e15 && scale <= 1e22) n /= scale || 1;
148
+ else { slow(); n = Number(value.slice(begin, i)); }
149
+ if (channel === 3 && value[i] === '%') { n /= 100; i++; }
150
+ if (n > (channel === 3 ? 1 : 255)) return false;
151
+ if (channel === 0) red = n;
152
+ else if (channel === 1) green = n;
153
+ else if (channel === 2) blue = n;
154
+ else alpha = n;
155
+ const end = i;
156
+ while (SPACE.test(value[i])) i++;
157
+ if (channel === 0) comma = value[i] === ',';
158
+ if (channel < 2) {
159
+ if (comma ? value[i++] !== ',' : end === i || rgba) return false;
160
+ } else {
161
+ if (value[i] === ')') return (!rgba || channel === 3) && i + 1 === value.length;
162
+ if (channel === 3 || (comma ? !rgba || value[i++] !== ',' : rgba || value[i++] !== '/')) return false;
163
+ }
164
+ }
165
+ }
166
+ function parsed(value) {
167
+ let result = colours.get(value);
168
+ if (!result) { rgb(value); colours.set(value, result = [red, green, blue, alpha]); }
169
+ return result;
170
+ }
171
+ function same(a, b) {
172
+ if (a === b) return true;
173
+ const c = parsed(a), d = parsed(b);
174
+ return c.every((n, i) => n === d[i]);
175
+ }
176
+ function backgroundSafe(el, provider) {
177
+ if (!el) return false;
178
+ const cached = images.get(el);
179
+ if (cached?.[0] === provider) return cached[1];
180
+ counts[8]++;
181
+ const safe = read(declaration(el), 13) === NONE
182
+ && (el === provider || backgroundSafe(parent(el), provider));
183
+ images.set(el, [provider, safe]);
184
+ return safe;
185
+ }
186
+ function initial(source) {
187
+ if (active) probe(source);
188
+ outside = true;
189
+ const work = inherit([,, 0, 0, 0, false, 0, 0, null], source);
190
+ outside = false;
191
+ return work;
192
+ }
193
+ function inherit(work, child, clone) {
194
+ // Flattened assignments can omit slot carriers; templates break the source
195
+ // parent relation. Only those exceptional edges reconstruct a source path.
196
+ const actual = active && (isText(child) ? child.assignedSlot || child[DOM_PARENT_ELEMENT] : parent(child));
197
+ if (!active || actual === work[8]) return work;
198
+ slow();
199
+ const adjusted = [,, 0, 0, 0, false, 0, 0, null];
200
+ const path = [];
201
+ for (let el = actual; el && el !== work[8] && !path.includes(el); el = parent(el)) path.push(el);
202
+ if (path.length && parent(path[path.length - 1]) === work[8]) {
203
+ for (let i = 2; i <= 8; i++) adjusted[i] = work[i];
204
+ }
205
+ for (let i = path.length - 1; i >= 0; i--) process(path[i], null, adjusted,
206
+ i === 0 && isText(child) && clone ? [[child, clone]] : null);
207
+ return adjusted;
208
+ }
209
+ function textSignal(el, raw, work) {
210
+ const color = read(raw, 11);
211
+ if (!rgb(color)) return 0;
212
+ const fill = maybe(raw, 17) ?? color;
213
+ if (!rgb(fill)) { slow(); return 0; }
214
+ if (fill !== color) slow();
215
+ let candidate = alpha === 0 ? 32 : 0;
216
+ if (!candidate) {
217
+ if (!same(color, fill) || !work[3]) return 0;
218
+ if (color !== work[3]) {
219
+ // A successful same() leaves the channels equal to color and fill.
220
+ const r = red, g = green, b = blue, a = alpha;
221
+ if (!rgb(work[3]) || r !== red || g !== green || b !== blue || a !== alpha) return 0;
222
+ slow();
223
+ }
224
+ candidate = 64;
225
+ }
226
+ slow();
227
+ if (candidate === 64 && !backgroundSafe(el, work[4])) return 0;
228
+ const clip = read(raw, 14), webkit = maybe(raw, 16);
229
+ const shadow = read(raw, 15), stroke = maybe(raw, 18);
230
+ return clip && !clip.split(/\s*,\s*/).includes('text')
231
+ && webkit !== '' && !webkit?.split(/\s*,\s*/).includes('text')
232
+ && shadow === NONE && (stroke === undefined || ZERO_PX.test(stroke)) ? candidate : 0;
233
+ }
234
+ function process(el, copy, work, pairs) {
235
+ if (!active || !isElement(el)) return;
236
+ const raw = declaration(el);
237
+ const display = read(raw, 0), visibility = read(raw, 1);
238
+ const opacity = read(raw, 2), clipPath = read(raw, 6);
239
+ let mask = display === NONE ? 1 : 0;
240
+ const hidden = visibility === HIDDEN || visibility === 'collapse';
241
+ if (hidden) mask |= 2;
242
+ if (opacity && +opacity === 0) {
243
+ slow();
244
+ if (pendingOpacity(read(raw, 7), read(raw, 8), read(raw, 9), read(raw, 10))) pending.add(el);
245
+ else mask |= 4;
246
+ }
247
+ if (clipPath !== NONE) {
248
+ slow();
249
+ if (clipped(clipPath)) mask |= 16;
250
+ }
251
+ if (!(mask & 16)) {
252
+ const clip = read(raw, 5);
253
+ if (clip !== AUTO) {
254
+ slow();
255
+ if (clipped(clipPath, clip, read(raw, 4))) mask |= 16;
256
+ }
257
+ }
258
+ const bg = read(raw, 12);
259
+ if (rgb(bg)) {
260
+ if (alpha === 1) { work[3] = bg; work[4] = el; }
261
+ else if (alpha !== 0) { work[3] = work[4] = 0; }
262
+ } else { slow(); work[3] = work[4] = 0; }
263
+ work[5] = work[5] || el[DOM_LOCAL_NAME] === 'head';
264
+ let hasText = false, actualText = false;
265
+ for (let child = el[DOM_FIRST_CHILD]; child; child = child[DOM_NEXT_SIBLING]) {
266
+ if (isText(child) && child[DOM_NODE_VALUE]) { hasText = actualText = true; break; }
267
+ }
268
+ if (!hasText && pairs) for (let i = 0; i < pairs.length; i++) {
269
+ if (pairs[i][0][DOM_NODE_TYPE] === 3 && pairs[i][0][DOM_NODE_VALUE]) { hasText = true; break; }
270
+ }
271
+ if (hasText) {
272
+ const fontSize = read(raw, 3);
273
+ if (ZERO_PX.test(fontSize)) mask |= 8;
274
+ if (actualText && !(mask & (4 | 8 | 16))) {
275
+ mask |= textSignal(el, raw, work);
276
+ }
277
+ }
278
+ if (copy?.hasAttribute(CONCEALED_MARK)) {
279
+ slow();
280
+ const marks = (copy.getAttribute(CONCEALED_MARK) || '').split(' ');
281
+ planted.add(copy);
282
+ for (let i = 0; i < REASONS.length; i++) if (marks.includes(REASONS[i])) mask |= 1 << i;
283
+ }
284
+ if (mask & (1 | 2 | 4 | 16)) {
285
+ slow();
286
+ // Carrier: copy, mask, persistent predecessor, visibility cleanup predecessor.
287
+ const carrier = [copy, mask, work[2], work[7]];
288
+ if (mask & (1 | 4 | 16)) work[2] = carrier;
289
+ if (hidden) { work[6] ||= carrier; work[7] = carrier; }
290
+ if (copy && mask & 3 && !copy.hasAttribute(HIDDEN)) { copy.setAttribute(HIDDEN, ''); computedHidden.add(copy); }
291
+ }
292
+ if (!hidden) work[6] = 0;
293
+ if (pairs && !work[5]) for (let i = 0; i < pairs.length; i++) {
294
+ const live = pairs[i][0], clone = pairs[i][1];
295
+ if (!clone || (live.assignedSlot || live[DOM_PARENT_ELEMENT]) !== el) continue;
296
+ if (!isText(live)) continue;
297
+ if (mask || work[2]) {
298
+ slow();
299
+ // Text record: chain, mask, copy, visibility origin, cleanup, visible.
300
+ records.set(clone, [work[2], mask, copy, work[6], work[7], visibility === VISIBLE]);
301
+ counts[2]++;
302
+ } else if (work[7] && visibility === VISIBLE) {
303
+ slow();
304
+ cleanups.set(clone, work[7]);
305
+ }
306
+ }
307
+ work[8] = el;
308
+ }
309
+ function exempt(el) { exemptions.add(el); }
310
+ function lift(inert) {
311
+ if (!active || !records.size && !computedHidden.size && !cleanups.size && !planted.size) return;
312
+ for (const el of inert.querySelectorAll('[hidden], [style]')) {
313
+ if (el.querySelector('pre') || isCollapsedAnswer(el)) exemptions.add(el);
314
+ }
315
+ function reason(record) {
316
+ let mask = record[1] & (4 | 8 | 16 | 32 | 64);
317
+ for (let link = record[0]; link; link = link[2]) {
318
+ mask |= link[1] & (4 | 16);
319
+ if (link[1] & 1 && !exemptions.has(link[0])) mask |= 1;
320
+ }
321
+ if (record[1] & 2 && !exemptions.has(record[2]) && !exemptions.has(record[3]?.[0])) mask |= 2;
322
+ return REASONS.find((_, i) => mask & 1 << i);
323
+ }
324
+ const cleared = new Set();
325
+ function clear(link) {
326
+ for (; link && !cleared.has(link); link = link[3]) {
327
+ cleared.add(link);
328
+ if (!exemptions.has(link[0])) link[0]?.style?.removeProperty('visibility');
329
+ }
330
+ }
331
+ /** @type {0 | [any, import('./types.js').ConcealmentReason, string, Text[]]} */
332
+ let previous = 0;
333
+ const groups = [];
334
+ // Ordered, allocation-free traversal of ordinary inert nodes. The stack is
335
+ // only needed at template fragments, whose parent is not the template.
336
+ const resumes = [];
337
+ let node = inert.documentElement;
338
+ while (node) {
339
+ if (isText(node)) {
340
+ const record = records.get(node);
341
+ const selected = record && reason(record);
342
+ if (!selected) {
343
+ if (record?.[5]) clear(record[4]);
344
+ clear(cleanups.get(node));
345
+ previous = 0;
346
+ } else {
347
+ let block = node[DOM_PARENT_ELEMENT];
348
+ while (block?.[DOM_PARENT_ELEMENT] && !BLOCK.test(block[DOM_LOCAL_NAME])) block = block[DOM_PARENT_ELEMENT];
349
+ if (!previous || previous[0] !== block || previous[1] !== selected) {
350
+ previous = [block, selected, '', /** @type {Text[]} */ ([])];
351
+ groups.push(previous);
352
+ }
353
+ previous[2] += node[DOM_NODE_VALUE] || '';
354
+ previous[3].push(node);
355
+ }
356
+ }
357
+ if (node[DOM_CONTENT]?.[DOM_FIRST_CHILD]) { resumes.push(node); node = node[DOM_CONTENT][DOM_FIRST_CHILD]; continue; }
358
+ if (node[DOM_FIRST_CHILD] && node[DOM_LOCAL_NAME] !== 'head') { node = node[DOM_FIRST_CHILD]; continue; }
359
+ while (node && !node[DOM_NEXT_SIBLING]) {
360
+ node = node.parentNode;
361
+ if (node?.[DOM_NODE_TYPE] === 11) node = resumes.pop();
362
+ }
363
+ node = node?.[DOM_NEXT_SIBLING];
364
+ }
365
+ let stored = 0;
366
+ for (const [, reason, text, nodes] of groups) {
367
+ const chars = text.length;
368
+ // Use the selected reason and raw run boundaries before report capping.
369
+ // A removes all source whitespace; B preserves whitespace-only runs and
370
+ // replaces substantive runs with one space only at a source boundary.
371
+ const removesWhitespace = reason === REASONS[0] || reason === REASONS[3];
372
+ const substantive = !!text.trim();
373
+ if (!removesWhitespace && !substantive) continue;
374
+ if (!removesWhitespace && /^\s|\s$/.test(text)) nodes[0].before(inert.createTextNode(" "));
375
+ for (const node of nodes) node.remove();
376
+ if (!substantive) continue;
377
+ summary.count += 1;
378
+ summary.chars += chars;
379
+ const captured = text.slice(0, Math.min(CONCEALED_BLOCK_LIMIT, CONCEALED_TOTAL_LIMIT - stored));
380
+ if (entries.length < CONCEALED_ENTRY_LIMIT) {
381
+ entries.push({ reason, chars, text: captured });
382
+ stored += captured.length;
383
+ } else summary.truncated = true;
384
+ if (captured.length < chars) summary.truncated = true;
385
+ }
386
+ for (const el of computedHidden) el.removeAttribute(HIDDEN);
387
+ // Includes template contents, which Document.querySelectorAll omits.
388
+ for (const el of planted) el.removeAttribute(CONCEALED_MARK);
389
+ }
390
+ return { process, initial, inherit, exempt, lift, entries, summary,
391
+ get counters() { return Object.fromEntries('styleCalls propertyReads recordsCreated slowPathEntries probeStyleCalls probeReads outsideStyleCalls outsideReads rereads'.split(' ').map((name, i) => [name, counts[i]])); },
392
+ get check() { return check; }, get pendingOpacity() { return pending.size; } };
393
+ }
394
+
395
+ // Adapter for internal callers that copy one node at a time. Keeping this
396
+ // separate lets browser bundles omit the deferred-copy compatibility walk.
397
+ /** @param {Document} doc @param {boolean} enabled */
398
+ export function createConcealment(doc, enabled) {
399
+ const capture = createConcealmentWalk(doc, enabled);
400
+ const deferred = new Map();
401
+ const lift = capture.lift;
402
+ return Object.assign(capture, {
403
+ copy(live, clone) { if (capture.check !== 'disabled' && capture.check !== 'static-only') deferred.set(live, clone); },
404
+ lift(inert) {
405
+ const states = new Map();
406
+ for (const [live, clone] of deferred) {
407
+ if (!isElement(live)) continue;
408
+ const work = [...(states.get(parent(live)) || capture.initial(live))];
409
+ const pairs = [];
410
+ for (let child = live[DOM_FIRST_CHILD]; child; child = child[DOM_NEXT_SIBLING]) pairs.push([child, deferred.get(child)]);
411
+ capture.process(live, clone, work, pairs);
412
+ states.set(live, work);
413
+ }
414
+ deferred.clear();
415
+ lift(inert);
416
+ },
417
+ });
418
+ }
package/src/convert.js CHANGED
@@ -13,14 +13,29 @@ export const TURNDOWN_OPTIONS = Object.freeze({
13
13
  hr: "---",
14
14
  });
15
15
 
16
+ function linkDestination(node) {
17
+ const href = node.getAttribute("href").replace(/([()])/g, "\\$1");
18
+ const title = (node.getAttribute("title") || "").replace(/\s*\n+\s*/g, " ").replace(/"/g, '\\"');
19
+ return `${href}${title ? ` "${title}"` : ""}`;
20
+ }
21
+
16
22
  function gfmInlineRules(turndown) {
17
23
  // Link text is one line: a link around blocks would otherwise split across lines.
18
24
  turndown.addRule("pmInlineLink", {
19
25
  filter: (node) => node.nodeName === "A" && Boolean(node.getAttribute("href")),
20
26
  replacement(content, node) {
21
- const href = node.getAttribute("href").replace(/([()])/g, "\\$1");
22
- const title = (node.getAttribute("title") || "").replace(/\s*\n+\s*/g, " ").replace(/"/g, '\\"');
23
- return `[${content.trim().replace(/\s*\n+\s*/g, " ")}](${href}${title ? ` "${title}"` : ""})`;
27
+ if (node.querySelector("h1, h2, h3, h4, h5, h6")) return `\n\n${content}\n\n`;
28
+ return `[${content.trim().replace(/\s*\n+\s*/g, " ")}](${linkDestination(node)})`;
29
+ },
30
+ });
31
+ turndown.addRule("pmHeading", {
32
+ filter: ["h1", "h2", "h3", "h4", "h5", "h6"],
33
+ replacement(content, node) {
34
+ content = content.trim();
35
+ if (!content) return "";
36
+ const link = closest(node, "A");
37
+ if (link?.getAttribute("href")) content = `[${content.replace(/\s*\n+\s*/g, " ")}](${linkDestination(link)})`;
38
+ return `\n\n${"#".repeat(Number(node.nodeName[1]))} ${content}\n\n`;
24
39
  },
25
40
  });
26
41
  turndown.addRule("pmStrikethrough", {
@@ -75,24 +90,25 @@ function spanOf(cell) {
75
90
  }
76
91
 
77
92
  // Each cell's column, and the row-spanned cells that fill the slots before it
78
- // (or at the row's end): null for the rest of a cell's column span. A span
79
- // stops at its row group.
93
+ // (or at the row's end). A span stops at its row group.
80
94
  function tableGrid(rows) {
81
95
  const columns = new Map();
82
96
  const before = new Map();
83
97
  const after = new Map();
84
98
  let carry = [];
85
- let group = null;
99
+ let group;
86
100
  for (const row of rows) {
87
101
  if (row.parentNode !== group) carry = [];
88
102
  group = row.parentNode;
89
103
  let column = 0;
90
- let slots = [];
104
+ let slots;
91
105
  const take = (gaps) => {
92
106
  while (carry[column]?.left > 0 || (gaps && column < carry.length)) {
93
107
  const entry = carry[column];
94
- slots.push(entry?.left > 0 ? entry.cell : null);
95
- if (entry?.left > 0) entry.left -= 1;
108
+ if (entry?.left > 0) {
109
+ slots.push(entry.cell);
110
+ entry.left -= 1;
111
+ } else slots.push(null);
96
112
  column += 1;
97
113
  }
98
114
  };
@@ -101,10 +117,11 @@ function tableGrid(rows) {
101
117
  take();
102
118
  before.set(cell, slots);
103
119
  columns.set(cell, column);
104
- const left = Number.parseInt(cell.getAttribute?.("rowspan") || "1", 10) - 1;
120
+ const rowspan = Number.parseInt(cell.getAttribute?.("rowspan"), 10);
121
+ const left = rowspan === 0 ? Infinity : rowspan - 1;
105
122
  const span = spanOf(cell);
106
123
  for (let i = 0; i < span; i += 1) {
107
- if (left > 0) carry[column + i] = { cell: i ? null : cell, left };
124
+ if (left > 0) carry[column + i] = { cell, left };
108
125
  }
109
126
  column += span;
110
127
  }
@@ -263,8 +280,10 @@ function tableRules(turndown) {
263
280
  const info = tableInfo(closest(node, "TABLE"));
264
281
  const text = cellText(content);
265
282
  info.text.set(node, text);
266
- const pad = " | ".repeat(spanOf(node) - 1);
267
- return `${slotsMarkdown(info.before.get(node), info)} ${text}${pad} |`;
283
+ // A spanned value holds for each column it covers, unless it's the whole row (a caption).
284
+ const span = spanOf(node);
285
+ const whole = node.parentNode?.children?.length === 1;
286
+ return `${slotsMarkdown(info.before.get(node), info)} ${text} |${(whole ? " |" : ` ${text} |`).repeat(span - 1)}`;
268
287
  },
269
288
  });
270
289
  turndown.addRule("pmTableRow", {
@@ -301,8 +320,8 @@ function tableRules(turndown) {
301
320
  });
302
321
  turndown.addRule("pmTableCaption", {
303
322
  filter: "caption",
304
- replacement() {
305
- return "";
323
+ replacement(content, node) {
324
+ return isBlockCell(node) ? `\n${content}\n` : "";
306
325
  },
307
326
  });
308
327
  }
package/src/extract.js CHANGED
@@ -38,7 +38,7 @@ function bodyHtml(doc) {
38
38
  }
39
39
 
40
40
  // An accordion's answer, hidden until its button is pressed: FAQs on shop pages.
41
- function isCollapsedAnswer(el) {
41
+ export function isCollapsedAnswer(el) {
42
42
  return Boolean(el.previousElementSibling?.matches(TOGGLE)) && (el.textContent || "").trim().length >= MIN_ANSWER_CHARS;
43
43
  }
44
44
 
@@ -55,11 +55,39 @@ function mainContentRoot(doc) {
55
55
  for (const link of Array.from(root.querySelectorAll("a[href^='#']"))) {
56
56
  if (/^\s*skip\b/i.test(link.textContent || "")) link.remove();
57
57
  }
58
- return root;
58
+ return removeSkippableBoxes(root);
59
59
  }
60
60
 
61
61
  const withoutSpaces = (text) => text.replace(/\s+/g, "");
62
62
 
63
+ // Embedded posts and media sit in a skippable box too, and are part of the text.
64
+ const SKIP_KEPT = "blockquote, iframe, video, audio, pre, table, h1";
65
+ const MAX_END_MARKER_CHARS = 60;
66
+ const MAX_SKIPPED_SHARE = 0.25;
67
+
68
+ // "Skip Most read and continue reading": a box that opens with a link to the end
69
+ // marker it closes with. The page itself says it interrupts the text (BBC's
70
+ // most-read and podcast boxes), in whatever language.
71
+ function removeSkippableBoxes(root) {
72
+ const size = (el) => withoutSpaces(el.textContent || "");
73
+ const total = size(root).length;
74
+ for (const link of Array.from(root.querySelectorAll("a[href^='#']"))) {
75
+ const id = link.getAttribute("href").slice(1).replace(/["\\]/g, "\\$&");
76
+ const label = size(link);
77
+ if (!id || !label || !root.contains(link)) continue;
78
+ let box = link.parentElement;
79
+ let end = null;
80
+ while (box && box !== root && !(end = box.querySelector(`[id="${id}"]`))) box = box.parentElement;
81
+ if (!end || end.contains(link) || box.matches("main, article, body")) continue;
82
+ const marker = size(end);
83
+ const text = size(box);
84
+ if (marker && marker.length <= MAX_END_MARKER_CHARS && text.length > label.length + marker.length
85
+ && text.length <= total * MAX_SKIPPED_SHARE && text.startsWith(label) && text.endsWith(marker)
86
+ && !box.querySelector(SKIP_KEPT)) box.remove();
87
+ }
88
+ return root;
89
+ }
90
+
63
91
  // An article above its comments, where Readability kept the article: what's left
64
92
  // without them. null for a thread, where what Readability kept is itself a comment.
65
93
  function articleWithoutComments(root, articleHtml) {
@@ -103,6 +131,12 @@ const LAYOUT_CLASS = /^(?:\S+:)?(?:-?scroll-[mp][a-z]?-\S+|overflow(?:-[xy])?-(?
103
131
  // Docusaurus's heading "anchorTargetHideOnScrollNavbar" matches "scroll", and so
104
132
  // does the scroll-mt-2 on every code block of Docker's docs.
105
133
  function withoutMisleadingNames(doc) {
134
+ // Single-paragraph FAQ wrappers can fail Readability's link-density check.
135
+ for (const el of Array.from(doc.querySelectorAll("details div")).reverse()) {
136
+ const p = el.firstElementChild;
137
+ if (p?.localName === "p" && el.children.length === 1 && el.textContent === p.textContent
138
+ && !isHiddenElement(el) && el.getAttribute("aria-hidden") !== "true") el.replaceWith(...Array.from(el.childNodes));
139
+ }
106
140
  for (const heading of Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6"))) {
107
141
  heading.removeAttribute("class");
108
142
  heading.removeAttribute("id");
@@ -114,6 +148,7 @@ function withoutMisleadingNames(doc) {
114
148
  }
115
149
  // Readability removes what's aria-hidden, and icons in table cells usually are.
116
150
  for (const icon of Array.from(doc.querySelectorAll("td svg[aria-hidden], th svg[aria-hidden]"))) icon.removeAttribute("aria-hidden");
151
+ if (doc.body) removeSkippableBoxes(doc.body);
117
152
  return doc;
118
153
  }
119
154
 
package/src/index.js CHANGED
@@ -10,7 +10,7 @@ import { recoverLeadParagraph, chooseHeadline } from "./refine.js";
10
10
  import { collectPageFacts, pageFactsMarkdown, escapeMarkdown } from "./facts.js";
11
11
  import { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
12
12
 
13
- export const VERSION = "0.2.0";
13
+ export const VERSION = "0.3.0";
14
14
 
15
15
  export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
16
16
  export { standardizeDocument } from "./standardize.js";
@@ -60,13 +60,14 @@ function hasTopHeading(markdown) {
60
60
  * @property {string} [url] page URL for absolute links (default: the document's URL)
61
61
  * @property {"full"|"no_links"|"text_only"} [mode="full"]
62
62
  * @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
63
- * "readable": Readability with full-page fallback; "full": the whole body;
63
+ * "readable": Readability with main-content or full-page fallback; "full": the whole body;
64
64
  * or a custom extractor receiving a copy of the standardized document.
65
65
  * @property {number} [minExtractChars=100] below this, fall back to the page's <main>
66
66
  * if it marks one and it holds enough, else to the full page
67
67
  * @property {number} [minExtractShare=0.4] when Readability keeps less than this
68
68
  * share of the page's main content, use the main content instead; 0 turns it off
69
69
  * @property {boolean} [includeFullPage=false] also return the full-page Markdown
70
+ * @property {boolean} [detectConcealedText=false] opt-in live computed-style check, default false
70
71
  * @property {boolean} [includeTitle=true] start readable output with "# <headline>"
71
72
  * @property {boolean} [recoverLead=true] restore a dropped lead paragraph that matches the meta description
72
73
  * @property {boolean} [pageFacts=false] append "## Page facts": short label/value pairs
@@ -94,8 +95,14 @@ export function pageToMarkdown(input, options = {}) {
94
95
  /** @type {string[]} */
95
96
  const warnings = [];
96
97
 
97
- const { document: inert, omissions } = sanitizeToInertDocument(input, options.sanitize);
98
- const standardization = standardizeDocument(inert, { url: baseUrl(doc, url) });
98
+ const { document: inert, omissions, concealment } = sanitizeToInertDocument(input, {
99
+ ...options.sanitize,
100
+ detectConcealedText: options.detectConcealedText ?? options.sanitize?.detectConcealedText ?? false,
101
+ });
102
+ const standardization = standardizeDocument(inert, { url: baseUrl(doc, url), concealment });
103
+ if (concealment.pendingOpacity) warnings.push(`Pending opacity content retained in ${concealment.pendingOpacity} element(s): provisional scroll-reveal policy`);
104
+ if (concealment.check === "incomplete") warnings.push("Concealed text check incomplete: computed-style values unavailable");
105
+ if (concealment.summary.truncated) warnings.push("Concealed text report truncated at capture limits; counts include uncapped text");
99
106
  const extraction = extractContent(inert, {
100
107
  extract: options.extract,
101
108
  minExtractChars: options.minExtractChars,
@@ -129,6 +136,10 @@ export function pageToMarkdown(input, options = {}) {
129
136
  }
130
137
  /** @type {import("./types.js").PageMarkdownOmission[]} */
131
138
  const omissionList = [];
139
+ if (concealment.summary.count) {
140
+ omissionList.push({ kind: "concealed_text", count: concealment.summary.count, chars: concealment.summary.chars,
141
+ note: `Concealed text omitted: ${concealment.summary.count} block(s), ${concealment.summary.chars} UTF-16 units` });
142
+ }
132
143
  if (omissions.shadowHosts) {
133
144
  omissionList.push({ kind: "shadow_content", count: omissions.shadowHosts, note: "Open shadow content omitted: capture limit" });
134
145
  }
@@ -147,6 +158,9 @@ export function pageToMarkdown(input, options = {}) {
147
158
  /** @type {import("./types.js").PageMarkdownResult} */
148
159
  const result = {
149
160
  version: VERSION,
161
+ concealedText: concealment.entries,
162
+ concealmentCheck: concealment.check,
163
+ concealedTextTruncated: concealment.summary.truncated,
150
164
  markdown: main.markdown,
151
165
  metadata: {
152
166
  ...pageMetadata,
@@ -195,6 +209,11 @@ export function pageToMarkdown(input, options = {}) {
195
209
  ...(omissions.shadowHosts ? [`Open shadow content omitted: capture limit (${omissions.shadowHosts} host(s))`] : []),
196
210
  ].map((notice) => (options.removeHiddenText ? removeHiddenText(notice).text : notice));
197
211
  for (const notice of notices) result.markdown += `\n\n_${notice}._`;
212
+ const concealedNotice = omissionList.find(({ kind }) => kind === "concealed_text")?.note;
213
+ if (concealedNotice) {
214
+ result.markdown += `\n\n_${concealedNotice}._`;
215
+ if (result.fullPageMarkdown !== undefined) result.fullPageMarkdown += `\n\n_${concealedNotice}._`;
216
+ }
198
217
  result.stats.markdownChars = result.markdown.length;
199
218
  return result;
200
219
  }