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.
- package/ADDITIONAL_TERMS.md +122 -122
- package/CHANGELOG.md +353 -186
- package/LICENSE +694 -694
- package/README.md +113 -41
- package/THIRD_PARTY_NOTICES.md +264 -264
- package/dist/types/concealed.d.ts +59 -0
- package/dist/types/extract.d.ts +10 -4
- package/dist/types/facts.d.ts +1 -0
- package/dist/types/index.d.ts +20 -6
- package/dist/types/sanitize.d.ts +22 -3
- package/dist/types/standardize.d.ts +15 -1
- package/dist/types/types.d.ts +51 -2
- package/package.json +69 -68
- package/robots.txt +79 -79
- package/src/clean.js +1 -1
- package/src/concealed.js +418 -0
- package/src/convert.js +95 -13
- package/src/extract.js +178 -8
- package/src/facts.js +6 -3
- package/src/index.js +219 -178
- package/src/refine.js +9 -3
- package/src/sanitize.js +106 -57
- package/src/standardize.js +167 -3
- package/src/types.js +23 -2
package/src/concealed.js
ADDED
|
@@ -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)
|
|
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
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
204
|
-
|
|
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
|
}
|