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.
- package/ADDITIONAL_TERMS.md +122 -122
- package/CHANGELOG.md +80 -0
- package/LICENSE +694 -694
- package/README.md +55 -10
- package/THIRD_PARTY_NOTICES.md +264 -264
- package/dist/types/concealed.d.ts +59 -0
- package/dist/types/extract.d.ts +1 -0
- package/dist/types/index.d.ts +8 -3
- package/dist/types/sanitize.d.ts +18 -1
- package/dist/types/standardize.d.ts +14 -1
- package/dist/types/types.d.ts +37 -1
- 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 +34 -15
- package/src/extract.js +37 -2
- package/src/index.js +23 -4
- package/src/sanitize.js +93 -65
- package/src/standardize.js +62 -5
- package/src/types.js +18 -1
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,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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
95
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
267
|
-
|
|
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.
|
|
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,
|
|
98
|
-
|
|
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
|
}
|