page-markdown 0.1.5 → 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,7 +13,31 @@ 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) {
23
+ // Link text is one line: a link around blocks would otherwise split across lines.
24
+ turndown.addRule("pmInlineLink", {
25
+ filter: (node) => node.nodeName === "A" && Boolean(node.getAttribute("href")),
26
+ replacement(content, node) {
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`;
39
+ },
40
+ });
17
41
  turndown.addRule("pmStrikethrough", {
18
42
  filter: ["del", "s", "strike"],
19
43
  replacement: (content) => `~~${content}~~`,
@@ -66,24 +90,25 @@ function spanOf(cell) {
66
90
  }
67
91
 
68
92
  // Each cell's column, and the row-spanned cells that fill the slots before it
69
- // (or at the row's end): null for the rest of a cell's column span. A span
70
- // stops at its row group.
93
+ // (or at the row's end). A span stops at its row group.
71
94
  function tableGrid(rows) {
72
95
  const columns = new Map();
73
96
  const before = new Map();
74
97
  const after = new Map();
75
98
  let carry = [];
76
- let group = null;
99
+ let group;
77
100
  for (const row of rows) {
78
101
  if (row.parentNode !== group) carry = [];
79
102
  group = row.parentNode;
80
103
  let column = 0;
81
- let slots = [];
104
+ let slots;
82
105
  const take = (gaps) => {
83
106
  while (carry[column]?.left > 0 || (gaps && column < carry.length)) {
84
107
  const entry = carry[column];
85
- slots.push(entry?.left > 0 ? entry.cell : null);
86
- 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);
87
112
  column += 1;
88
113
  }
89
114
  };
@@ -92,10 +117,11 @@ function tableGrid(rows) {
92
117
  take();
93
118
  before.set(cell, slots);
94
119
  columns.set(cell, column);
95
- 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;
96
122
  const span = spanOf(cell);
97
123
  for (let i = 0; i < span; i += 1) {
98
- if (left > 0) carry[column + i] = { cell: i ? null : cell, left };
124
+ if (left > 0) carry[column + i] = { cell, left };
99
125
  }
100
126
  column += span;
101
127
  }
@@ -187,10 +213,64 @@ function cellText(content) {
187
213
  return text.replace(/\|/g, "\\|");
188
214
  }
189
215
 
216
+ // An icon's straight strokes, as lists of points; null if any stroke curves.
217
+ function iconStrokes(svg) {
218
+ const strokes = [];
219
+ for (const shape of Array.from(svg.querySelectorAll?.("path, polyline, line") || [])) {
220
+ const at = (name) => shape.getAttribute(name);
221
+ const d = at("d") ?? `M${at("points") ?? `${at("x1")} ${at("y1")} ${at("x2")} ${at("y2")}`}`;
222
+ const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+/g) || [];
223
+ let x = 0;
224
+ let y = 0;
225
+ let command = "";
226
+ let points = [];
227
+ for (let i = 0; i < tokens.length;) {
228
+ if (/[a-zA-Z]/.test(tokens[i])) command = tokens[i++];
229
+ const relative = command > "Z";
230
+ const type = command.toUpperCase();
231
+ if (!"MLHV".includes(type)) return null;
232
+ if (type !== "V") x = Number(tokens[i++]) + (relative ? x : 0);
233
+ if (type !== "H") y = Number(tokens[i++]) + (relative ? y : 0);
234
+ if (type === "M") {
235
+ points = [];
236
+ strokes.push(points);
237
+ command = relative ? "l" : "L";
238
+ }
239
+ points.push([x, y]);
240
+ }
241
+ }
242
+ return strokes;
243
+ }
244
+
245
+ // What an icon's shape says, for the tick, dash and cross of comparison tables.
246
+ function iconShape(strokes) {
247
+ const [first, second] = strokes || [];
248
+ if (strokes?.length === 1 && first.length === 2 && first[0][1] === first[1][1]) return "\u2013";
249
+ if (strokes?.length === 1 && first.length === 3) {
250
+ const [a, b, c] = first;
251
+ const arm = (p) => Math.hypot(p[0] - b[0], p[1] - b[1]);
252
+ const ratio = Math.max(arm(a), arm(c)) / Math.min(arm(a), arm(c));
253
+ if (b[1] > a[1] && b[1] > c[1] && (a[0] - b[0]) * (c[0] - b[0]) < 0 && ratio > 1.3) return "\u2713";
254
+ }
255
+ const slope = ([p, q]) => (q[0] - p[0]) * (q[1] - p[1]);
256
+ if (strokes?.length === 2 && first.length === 2 && second.length === 2 && slope(first) * slope(second) < 0) return "\u2717";
257
+ return "";
258
+ }
259
+
260
+ // A cell holding only an icon would be empty, and the table couldn't say which
261
+ // plan has what: its accessible name, else its shape, else that an icon is there.
262
+ function cellIcon(cell) {
263
+ const svg = cell.querySelector?.("svg");
264
+ if (!svg) return "";
265
+ return svg.getAttribute("aria-label")?.trim() || svg.querySelector("title")?.textContent?.trim()
266
+ || iconShape(iconStrokes(svg)) || "[icon]";
267
+ }
268
+
190
269
  function tableRules(turndown) {
191
270
  turndown.addRule("pmTableCell", {
192
271
  filter: ["th", "td"],
193
- replacement(content, node) {
272
+ replacement(rawContent, node) {
273
+ const content = rawContent.trim() ? rawContent : cellIcon(node);
194
274
  if (isBlockCell(node)) {
195
275
  // Header cells become the labels on the other cells.
196
276
  const label = cellLabel(node);
@@ -200,8 +280,10 @@ function tableRules(turndown) {
200
280
  const info = tableInfo(closest(node, "TABLE"));
201
281
  const text = cellText(content);
202
282
  info.text.set(node, text);
203
- const pad = " | ".repeat(spanOf(node) - 1);
204
- 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)}`;
205
287
  },
206
288
  });
207
289
  turndown.addRule("pmTableRow", {
@@ -238,8 +320,8 @@ function tableRules(turndown) {
238
320
  });
239
321
  turndown.addRule("pmTableCaption", {
240
322
  filter: "caption",
241
- replacement() {
242
- return "";
323
+ replacement(content, node) {
324
+ return isBlockCell(node) ? `\n${content}\n` : "";
243
325
  },
244
326
  });
245
327
  }