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/src/sanitize.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // Stage 1: clone the page into an inert document, inline open shadow roots
2
2
  // within limits, and strip non-content. The live page is never modified.
3
+ import { CONCEALED_MARK, createConcealmentWalk, isText, isElement, DOM_LOCAL_NAME, DOM_GET_ROOT_NODE, DOM_CHILD_NODES, DOM_NODE_VALUE, DOM_SHADOW_ROOT, DOM_CONTENT, DOM_APPEND_CHILD, DOM_NODE_TYPE} from "./concealed.js";
3
4
 
4
5
  /** @type {Readonly<Required<import("./types.js").SanitizeOptions>>} */
5
6
  export const DEFAULT_SANITIZE_OPTIONS = Object.freeze({
@@ -9,6 +10,7 @@ export const DEFAULT_SANITIZE_OPTIONS = Object.freeze({
9
10
  stripMeta: true,
10
11
  stripLinkTags: true,
11
12
  stripAds: true,
13
+ detectConcealedText: false,
12
14
  // App-specific overlays or widgets to drop, e.g. "[data-app-overlay]".
13
15
  extraStripSelectors: [],
14
16
  shadowNodeLimit: 5000,
@@ -36,10 +38,10 @@ const AD_ELEMENT_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*-(?:placement|advert|adve
36
38
 
37
39
  /** @param {any} node */
38
40
  export function isAdvertisementElement(node) {
39
- if (node?.nodeType !== 1) return false;
41
+ if (node?.[DOM_NODE_TYPE] !== 1) return false;
40
42
  // Sites put page-wide ad settings on <body> (heise.de's data-ad-unit).
41
- if (node.localName === "html" || node.localName === "body") return false;
42
- if (AD_ELEMENT_NAME.test(node.localName || "")) return true;
43
+ if (node[DOM_LOCAL_NAME] === "html" || node[DOM_LOCAL_NAME] === "body") return false;
44
+ if (AD_ELEMENT_NAME.test(node[DOM_LOCAL_NAME] || "")) return true;
43
45
  try {
44
46
  return Boolean(node.matches?.(AD_SELECTOR));
45
47
  } catch {
@@ -59,7 +61,7 @@ function stripSelectors(options) {
59
61
  }
60
62
 
61
63
  function isStripped(node, selectors, options) {
62
- if (node.nodeType !== 1) return false;
64
+ if (!isElement(node)) return false;
63
65
  if (selectors.some((selector) => node.matches?.(selector))) return true;
64
66
  return Boolean(options.stripAds && isAdvertisementElement(node));
65
67
  }
@@ -96,27 +98,31 @@ function positiveLimit(value, name) {
96
98
  *
97
99
  * @param {Document|Element} root
98
100
  * @param {import("./types.js").SanitizeOptions} [options]
99
- * @returns {{ document: Document, omissions: { shadowHosts: number } }}
100
101
  */
101
102
  export function sanitizeToInertDocument(root, options = {}) {
102
103
  const opts = { ...DEFAULT_SANITIZE_OPTIONS, ...options };
103
- const limits = {
104
- nodes: positiveLimit(opts.shadowNodeLimit, "shadowNodeLimit"),
105
- chars: positiveLimit(opts.shadowCharLimit, "shadowCharLimit"),
106
- depth: positiveLimit(opts.shadowDepthLimit, "shadowDepthLimit"),
107
- };
104
+ const limits = [
105
+ positiveLimit(opts.shadowNodeLimit, "shadowNodeLimit"),
106
+ positiveLimit(opts.shadowCharLimit, "shadowCharLimit"),
107
+ positiveLimit(opts.shadowDepthLimit, "shadowDepthLimit"),
108
+ ];
108
109
  const selectors = stripSelectors(opts);
109
- const sourceDoc = /** @type {Document} */ (root.nodeType === 9 ? root : root.ownerDocument);
110
- const source = root.nodeType === 9 ? /** @type {Document} */ (root).documentElement : root;
110
+ const sourceDoc = /** @type {Document} */ (root[DOM_NODE_TYPE] === 9 ? root : root.ownerDocument);
111
+ const source = root[DOM_NODE_TYPE] === 9 ? /** @type {Document} */ (root).documentElement : root;
111
112
  const inert = createInertDocument(sourceDoc);
113
+ const concealment = createConcealmentWalk(sourceDoc, opts.detectConcealedText);
112
114
 
113
115
  function shallow(node) {
114
- if (node.nodeType !== 1) return inert.importNode(node, false);
115
- const name = node.prefix ? `${node.prefix}:${node.localName}` : node.localName;
116
+ if (!isElement(node)) {
117
+ const copy = inert.importNode(node, false);
118
+ return copy;
119
+ }
120
+ const name = node.prefix ? `${node.prefix}:${node[DOM_LOCAL_NAME]}` : node[DOM_LOCAL_NAME];
116
121
  const copy = node.namespaceURI
117
122
  ? inert.createElementNS(node.namespaceURI, name)
118
123
  : inert.createElement(name);
119
124
  for (const attr of Array.from(node.attributes || [])) {
125
+ if (attr.name.toLowerCase() === CONCEALED_MARK) continue;
120
126
  // HTML allows colon-bearing names without a namespace (framework
121
127
  // directives); setAttributeNS rejects those.
122
128
  if (attr.namespaceURI) copy.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
@@ -129,89 +135,111 @@ export function sanitizeToInertDocument(root, options = {}) {
129
135
  let chars = 0;
130
136
  let overLimit = false;
131
137
  let shadowHosts = 0;
132
-
133
- function isAssigningSlot(node) {
134
- return node.nodeType === 1 && node.localName === "slot"
135
- && typeof node.assignedNodes === "function" && Boolean(node.getRootNode?.().host);
138
+ function carry(work, pair) {
139
+ for (let i = 2; i <= 8; i++) pair[i] = work[i];
140
+ return pair;
136
141
  }
137
142
 
138
- function copyShadowRoot(host, target, depth) {
139
- const outer = [nodes, chars];
140
- nodes = 0;
141
- chars = 0;
142
- for (const child of Array.from(host.shadowRoot.childNodes)) {
143
- const childCopy = shadowNode(child, depth);
144
- if (childCopy) target.appendChild(childCopy);
145
- if (overLimit) break;
146
- }
147
- if (overLimit) shadowHosts += 1;
148
- overLimit = false;
149
- [nodes, chars] = outer;
143
+ function isAssigningSlot(node) {
144
+ return isElement(node) && node[DOM_LOCAL_NAME] === "slot"
145
+ && typeof node.assignedNodes === "function" && !!node[DOM_GET_ROOT_NODE]?.().host;
150
146
  }
151
147
 
152
- function shadowNode(node, depth) {
153
- const inPage = node.getRootNode?.().nodeType === 9;
154
- if (depth > limits.depth || (!inPage && ++nodes > limits.nodes)) {
148
+ function shadowNode(work, depth, lightHost) {
149
+ const [node, allocated] = work;
150
+ const inPage = lightHost || node[DOM_GET_ROOT_NODE]?.()[DOM_NODE_TYPE] === 9;
151
+ if (depth > limits[2] || (!inPage && ++nodes > limits[0])) {
155
152
  overLimit = true;
156
153
  return null;
157
154
  }
158
- if (isStripped(node, selectors, opts)) return inert.createDocumentFragment();
159
- const copy = shallow(node);
160
- if (inPage && node.shadowRoot) {
161
- copyShadowRoot(node, copy, depth + 1);
162
- return copy;
155
+ if (!lightHost && isStripped(node, selectors, opts)) return inert.createDocumentFragment();
156
+ const copy = allocated || shallow(node);
157
+ work = concealment.inherit(work, node, copy);
158
+ const host = inPage && node[DOM_SHADOW_ROOT];
159
+ const outer = host && [nodes, chars];
160
+ let elementChars = 0;
161
+ if (host) nodes = chars = 0;
162
+ else {
163
+ if (!inPage) chars += isElement(node) ? (elementChars = (copy.outerHTML || "").length) : (node[DOM_NODE_VALUE] || "").length * 6;
164
+ if (chars > limits[1]) { overLimit = true; return null; }
163
165
  }
164
- if (!inPage) chars += node.nodeType === 1 ? (copy.outerHTML || "").length : (node.nodeValue || "").length * 6;
165
- if (chars > limits.chars) {
166
- overLimit = true;
167
- return null;
168
- }
169
- let children = node.childNodes;
166
+ let children = node[DOM_CHILD_NODES];
170
167
  let target = copy;
171
- if (isAssigningSlot(node)) {
168
+ if (host) children = host[DOM_CHILD_NODES];
169
+ else if (isAssigningSlot(node)) {
172
170
  const assigned = node.assignedNodes({ flatten: true });
173
- children = assigned.length ? assigned : node.childNodes;
171
+ children = assigned.length ? assigned : node[DOM_CHILD_NODES];
174
172
  target = inert.createDocumentFragment();
175
- } else if (node.shadowRoot) {
176
- children = node.shadowRoot.childNodes;
177
- } else if (node.nodeType === 1 && node.localName === "template" && node.content) {
178
- children = node.content.childNodes;
179
- target = copy.content || copy;
173
+ } else if (node[DOM_SHADOW_ROOT]) {
174
+ children = node[DOM_SHADOW_ROOT][DOM_CHILD_NODES];
175
+ } else if (isElement(node) && node[DOM_LOCAL_NAME] === "template" && node[DOM_CONTENT]) {
176
+ children = node[DOM_CONTENT][DOM_CHILD_NODES];
177
+ target = copy[DOM_CONTENT] || copy;
180
178
  }
179
+ const pairs = [];
180
+ let textAllocations = 0;
181
181
  for (const child of Array.from(children || [])) {
182
- const childCopy = shadowNode(child, depth + 1);
183
- if (childCopy) target.appendChild(childCopy);
182
+ // Stripped children still pass through the recursive node-budget gate.
183
+ // Only direct text must exist while this element's declaration is
184
+ // local. Element allocation stays behind the recursive budget gate.
185
+ const pageText = child[DOM_GET_ROOT_NODE]?.()[DOM_NODE_TYPE] === 9;
186
+ const allocateText = isText(child) && (pageText || ++textAllocations <= limits[0] - nodes);
187
+ pairs.push([child, allocateText ? shallow(child) : null]);
188
+ }
189
+ concealment.process(node, copy, work, host ? null : pairs);
190
+ if (!inPage && isElement(node)) {
191
+ // Detection may add the compatibility hidden attribute. Charge it before
192
+ // accepting children, just as when detection ran during shallow cloning.
193
+ chars += (copy.outerHTML || "").length - elementChars;
194
+ if (chars > limits[1]) { overLimit = true; return null; }
195
+ }
196
+ for (const pair of pairs) {
197
+ const childCopy = shadowNode(carry(work, pair), depth + 1);
198
+ if (childCopy) target[DOM_APPEND_CHILD](childCopy);
184
199
  if (overLimit) break;
185
200
  }
201
+ if (host) {
202
+ if (overLimit) shadowHosts++;
203
+ overLimit = false;
204
+ [nodes, chars] = outer;
205
+ return copy;
206
+ }
186
207
  return isAssigningSlot(node) ? target : copy;
187
208
  }
188
209
 
189
210
  const clone = shallow(source);
211
+ const initial = concealment.initial(source);
190
212
  // Iterative light-DOM walk: ordinary pages get no depth limit.
191
- const pending = [[source, clone]];
213
+ const pending = [carry(initial, [source, clone])];
192
214
  while (pending.length) {
193
- const [live, copy] = /** @type {any[]} */ (pending.pop());
194
- if (live.shadowRoot) {
195
- copyShadowRoot(live, copy, 1);
215
+ const work = /** @type {any[]} */ (pending.pop());
216
+ const [live, copy] = work;
217
+ if (live[DOM_SHADOW_ROOT]) {
218
+ shadowNode(work, 0, true);
196
219
  continue;
197
220
  }
198
- const children = live.content?.childNodes || live.childNodes;
199
- const target = copy.content || copy;
221
+ const children = live[DOM_CONTENT]?.[DOM_CHILD_NODES] || live[DOM_CHILD_NODES];
222
+ const target = copy[DOM_CONTENT] || copy;
200
223
  const pairs = [];
201
224
  for (const child of Array.from(children || [])) {
202
225
  if (isStripped(child, selectors, opts)) continue;
203
226
  const childCopy = shallow(child);
204
- target.appendChild(childCopy);
227
+ target[DOM_APPEND_CHILD](childCopy);
205
228
  pairs.push([child, childCopy]);
206
229
  }
207
- for (let i = pairs.length - 1; i >= 0; i -= 1) pending.push(pairs[i]);
230
+ concealment.process(live, copy, work, pairs);
231
+ for (let i = pairs.length - 1; i >= 0; i -= 1) {
232
+ const pair = pairs[i];
233
+ if (!isElement(pair[0])) continue;
234
+ pending.push(carry(concealment.inherit(work, pair[0]), pair));
235
+ }
208
236
  }
209
237
 
210
- if (clone.localName === "html") {
238
+ if (clone[DOM_LOCAL_NAME] === "html") {
211
239
  inert.removeChild(inert.documentElement);
212
- inert.appendChild(clone);
240
+ inert[DOM_APPEND_CHILD](clone);
213
241
  } else {
214
- inert.body.appendChild(clone);
242
+ inert.body[DOM_APPEND_CHILD](clone);
215
243
  }
216
- return { document: inert, omissions: { shadowHosts } };
244
+ return { document: inert, omissions: { shadowHosts }, concealment };
217
245
  }
@@ -68,6 +68,21 @@ function hasMeaningfulContent(el) {
68
68
  * data: placeholder. Then drop images that are still inline data.
69
69
  */
70
70
  function standardizeImages(doc, base, stats) {
71
+ const squash = (text) => (text || "").replace(/\s+/g, " ").trim();
72
+ for (const element of doc.querySelectorAll("figcaption, .caption")) {
73
+ const caption = squash(element.textContent);
74
+ if (!caption || element.closest(".pswp-caption-content") || isHiddenElement(element)) continue;
75
+ const figure = element.closest("figure");
76
+ // PhotoSwipe's copy is hidden by CSS; galleries may have only that copy.
77
+ for (const copy of (figure || element.parentElement?.parentElement)?.querySelectorAll(".pswp-caption-content") || []) {
78
+ if (squash(copy.textContent) === caption) copy.remove();
79
+ }
80
+ for (const img of figure?.querySelectorAll("img") || []) {
81
+ for (const attr of ["alt", "title"]) {
82
+ if (squash(img.getAttribute(attr)) === caption) img.removeAttribute(attr);
83
+ }
84
+ }
85
+ }
71
86
  for (const img of Array.from(doc.querySelectorAll("img"))) {
72
87
  const src = img.getAttribute("src") || "";
73
88
  if (!src || src.startsWith("data:")) {
@@ -100,6 +115,19 @@ function standardizeLinks(doc, base, stats) {
100
115
  stats.emptyLinksRemoved += 1;
101
116
  continue;
102
117
  }
118
+ if (base && href) {
119
+ try {
120
+ const target = new URL(href, base);
121
+ // Some browser URL parsers accept percent-encoded spaces in a host.
122
+ // Special-scheme hosts cannot contain ASCII whitespace or controls.
123
+ if (/^(?:https?|ftp|wss?):$/.test(target.protocol)
124
+ && /[\x00-\x20\x7f]/.test(decodeURIComponent(target.hostname))) throw new TypeError("Invalid link host");
125
+ } catch {
126
+ a.replaceWith(...Array.from(a.childNodes));
127
+ stats.invalidLinksUnwrapped += 1;
128
+ continue;
129
+ }
130
+ }
103
131
  if (href) a.setAttribute("href", resolveUrl(href, base));
104
132
  }
105
133
  }
@@ -170,6 +198,10 @@ function removeOwnPermalinks(heading, stats) {
170
198
  * remaining element is the heading with the heading itself.
171
199
  */
172
200
  function standardizeHeadings(doc, stats) {
201
+ // An upper-deck kicker belongs with the title, not ordinary body prose.
202
+ for (const label of doc.querySelectorAll(".upper-deck__text")) {
203
+ label.innerHTML = `<strong>${label.innerHTML}</strong>`;
204
+ }
173
205
  for (const widget of Array.from(doc.querySelectorAll(EDIT_WIDGETS))) {
174
206
  if (!(widget.textContent || "").trim() || widget.matches?.(ALWAYS_REMOVED_WIDGETS)) {
175
207
  widget.remove();
@@ -220,7 +252,7 @@ function tabOf(panel, tabs) {
220
252
  * name, in place of a tab strip that points nowhere. A long inactive panel is
221
253
  * left out, and named in stats.omittedTabs.
222
254
  */
223
- function standardizeTabPanels(doc, stats) {
255
+ export function prepareConcealment(doc, concealment) {
224
256
  // Docker's docs write aria-role, and call a panel a tab: only the strip's is right.
225
257
  for (const list of Array.from(doc.querySelectorAll("[aria-role='tablist']"))) {
226
258
  list.setAttribute("role", "tablist");
@@ -228,13 +260,29 @@ function standardizeTabPanels(doc, stats) {
228
260
  for (const panel of Array.from(list.nextElementSibling?.children || [])) panel.setAttribute("role", "tabpanel");
229
261
  }
230
262
  const panels = Array.from(doc.querySelectorAll(TAB_PANELS));
263
+ const hasProvenance = concealment && ["computed", "incomplete"].includes(concealment.check);
264
+ const inactive = new Map(panels.map((panel) => [panel, isInactivePanel(panel)]));
265
+ const panelLengths = new Map(panels.map((panel) => [panel, (panel.textContent || "").replace(/\s+/g, " ").trim().length]));
266
+ // Resolve short-panel exceptions before lifting, but copy labels only after
267
+ // lifting: concealed label text must never become a new visible heading.
268
+ for (const panel of panels) {
269
+ if (hasProvenance && inactive.get(panel) && panelLengths.get(panel) <= MAX_INACTIVE_PANEL_CHARS) {
270
+ concealment?.exempt(panel);
271
+ }
272
+ }
273
+ concealment?.lift(doc);
274
+ return { panels, hasProvenance, inactive, panelLengths };
275
+ }
276
+
277
+ function standardizeTabPanels(doc, stats, concealment) {
278
+ const { panels, hasProvenance, inactive, panelLengths } = prepareConcealment(doc, concealment);
231
279
  if (!panels.length) return;
232
280
  const tabs = Array.from(doc.querySelectorAll(TABS));
233
281
  for (const panel of panels) {
234
282
  const label = (tabOf(panel, tabs)?.textContent || "").replace(/\s+/g, " ").trim().slice(0, MAX_TAB_LABEL_CHARS);
235
283
  const text = (panel.textContent || "").replace(/\s+/g, " ").trim();
236
- if (isInactivePanel(panel)) {
237
- if (text.length > MAX_INACTIVE_PANEL_CHARS) {
284
+ if (hasProvenance ? inactive.get(panel) : isInactivePanel(panel)) {
285
+ if ((hasProvenance ? panelLengths.get(panel) : text.length) > MAX_INACTIVE_PANEL_CHARS) {
238
286
  stats.omittedTabs.push(label);
239
287
  panel.remove();
240
288
  continue;
@@ -259,11 +307,19 @@ function standardizeAsides(doc) {
259
307
  quote.append(...Array.from(aside.childNodes));
260
308
  aside.replaceWith(quote);
261
309
  }
310
+ // Readability treats "header" as clutter, even when it labels a warning.
311
+ for (const header of Array.from(doc.querySelectorAll("blockquote .admonition-header"))) {
312
+ const p = doc.createElement("p");
313
+ const strong = doc.createElement("strong");
314
+ strong.append(...Array.from(header.childNodes));
315
+ p.append(strong);
316
+ header.replaceWith(p);
317
+ }
262
318
  }
263
319
 
264
320
  /**
265
321
  * @param {Document} doc the inert clone (mutated in place)
266
- * @param {{ url?: string }} [options]
322
+ * @param {{ url?: string, concealment?: ReturnType<typeof import('./concealed.js').createConcealmentWalk> }} [options]
267
323
  */
268
324
  export function standardizeDocument(doc, options = {}) {
269
325
  const base = options.url || "";
@@ -271,13 +327,14 @@ export function standardizeDocument(doc, options = {}) {
271
327
  lazyImagesResolved: 0,
272
328
  inlineImagesDropped: 0,
273
329
  scriptLinksUnwrapped: 0,
330
+ invalidLinksUnwrapped: 0,
274
331
  emptyLinksRemoved: 0,
275
332
  preWrapped: 0,
276
333
  headingWidgetsRemoved: 0,
277
334
  headingsUnwrapped: 0,
278
335
  omittedTabs: /** @type {string[]} */ ([]),
279
336
  };
280
- standardizeTabPanels(doc, stats);
337
+ standardizeTabPanels(doc, stats, options.concealment);
281
338
  standardizeAsides(doc);
282
339
  standardizeHeadings(doc, stats);
283
340
  standardizeCode(doc, stats);
package/src/types.js CHANGED
@@ -14,6 +14,7 @@ export {};
14
14
  * @property {boolean} [stripMeta]
15
15
  * @property {boolean} [stripLinkTags]
16
16
  * @property {boolean} [stripAds]
17
+ * @property {boolean} [detectConcealedText] opt-in computed-style check, default false
17
18
  * @property {readonly string[]} [extraStripSelectors] app-specific overlays or widgets to drop, e.g. "[data-app-overlay]"
18
19
  * @property {number} [shadowNodeLimit]
19
20
  * @property {number} [shadowCharLimit]
@@ -69,11 +70,13 @@ export {};
69
70
  */
70
71
 
71
72
  /**
72
- * Check `kind` before reading `chars`: only hidden-text omissions have it.
73
+ * Check `kind` before reading `chars`: hidden Unicode omissions use code points;
74
+ * concealed_text uses UTF-16 units. Other kinds do not have it.
73
75
  * @typedef {{ kind: "shadow_content", count: number, note: string }
74
76
  * | { kind: "json_line", count: number, note: string }
75
77
  * | { kind: "inactive_tabs", count: number, note: string }
76
78
  * | { kind: "comments", count: number, note: string }
79
+ * | { kind: "concealed_text", count: number, chars: number, note: string }
77
80
  * | HiddenTextOmission} PageMarkdownOmission
78
81
  */
79
82
 
@@ -93,6 +96,9 @@ export {};
93
96
  /**
94
97
  * @typedef {object} PageMarkdownResult
95
98
  * @property {string} version the package version that made this result
99
+ * @property {ConcealedText[]} concealedText capped captured blocks, raw text in UTF-16 units
100
+ * @property {ConcealmentCheck} concealmentCheck source computed-style capability
101
+ * @property {boolean} concealedTextTruncated true if any block, text or entry cap omitted report text
96
102
  * @property {string} markdown
97
103
  * @property {PageMarkdownMetadata} metadata
98
104
  * @property {PageMarkdownExtraction} extraction
@@ -104,6 +110,17 @@ export {};
104
110
 
105
111
  /** @typedef {{ label: string, value: string }} PageFact */
106
112
 
113
+ /**
114
+ * The one-time probe establishes that the document's view can return every
115
+ * required field and exposed optional field. Afterwards, incomplete means a
116
+ * CONSULTED value was empty or threw; unread irrelevant values are not checked.
117
+ * Computed means no such failure; static-only means no computed-style API;
118
+ * disabled means detection was turned off.
119
+ * @typedef {"computed"|"static-only"|"disabled"|"incomplete"} ConcealmentCheck
120
+ */
121
+ /** @typedef {"display_none"|"visibility_hidden"|"opacity_zero"|"font_size_zero"|"clipped"|"transparent_text"|"same_color"} ConcealmentReason */
122
+ /** @typedef {{ reason: ConcealmentReason, chars: number, text: string }} ConcealedText */
123
+
107
124
  /**
108
125
  * @typedef {string | string[] | ((node: HTMLElement, options: Record<string, unknown>) => boolean)} TurndownFilter
109
126
  */