page-markdown 0.1.3 → 0.1.5

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,193 +1,196 @@
1
- // Stage 1: clone the page into an inert document, inline open shadow roots
2
- // within limits, and strip non-content. The live page is never modified.
3
-
4
- export const DEFAULT_SANITIZE_OPTIONS = Object.freeze({
5
- stripScripts: true,
6
- stripStyles: true,
7
- stripNoscript: true,
8
- stripMeta: true,
9
- stripLinkTags: true,
10
- stripAds: true,
11
- // App-specific overlays or widgets to drop, e.g. "[data-app-overlay]".
12
- extraStripSelectors: [],
13
- shadowNodeLimit: 5000,
14
- shadowCharLimit: 500000,
15
- shadowDepthLimit: 128,
16
- });
17
-
18
- const STRIP_RULES = {
19
- stripScripts: "script",
20
- stripStyles: "style",
21
- stripNoscript: "noscript",
22
- stripMeta: "meta",
23
- stripLinkTags: "link",
24
- };
25
-
26
- // Advertising placements: explicit ad markup, plus custom elements named for ad
27
- // slots (e.g. MDN's <mdn-placement-sidebar>).
28
- const AD_SELECTOR = [
29
- "ins.adsbygoogle", ".adsbygoogle", ".advertisement",
30
- "[data-ad-slot]", "[data-ad-client]", "[data-ad-unit]",
31
- "[aria-label='Advertisement' i]", "[aria-label='Advertisements' i]", "[aria-label='Sponsored' i]",
32
- "iframe[src*='doubleclick.net']", "iframe[src*='googlesyndication.com']", "iframe[id^='google_ads_iframe']",
33
- ].join(", ");
34
- const AD_ELEMENT_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*-(?:placement|advert|advertisement|ads?|sponsor|sponsored)(?:-[a-z0-9]+)*$/;
35
-
36
- /** @param {any} node */
37
- export function isAdvertisementElement(node) {
38
- if (node?.nodeType !== 1) return false;
39
- if (AD_ELEMENT_NAME.test(node.localName || "")) return true;
40
- try {
41
- return Boolean(node.matches?.(AD_SELECTOR));
42
- } catch {
43
- // DOM implementations without case-insensitive attribute selectors.
44
- return Boolean(node.matches?.(AD_SELECTOR.replace(/ i\]/g, "]")));
45
- }
46
- }
47
-
48
- function stripSelectors(options) {
49
- const selectors = Object.entries(STRIP_RULES)
50
- .filter(([flag]) => options[flag])
51
- .map(([, selector]) => selector);
52
- for (const extra of options.extraStripSelectors || []) {
53
- if (typeof extra === "string" && extra.trim()) selectors.push(extra.trim());
54
- }
55
- return selectors;
56
- }
57
-
58
- function isStripped(node, selectors, options) {
59
- if (node.nodeType !== 1) return false;
60
- if (selectors.some((selector) => node.matches?.(selector))) return true;
61
- return Boolean(options.stripAds && isAdvertisementElement(node));
62
- }
63
-
64
- /**
65
- * Creates an empty HTML document unconnected to the page: no scripts run and
66
- * no custom elements upgrade in it.
67
- * @param {Document} doc
68
- */
69
- export function createInertDocument(doc) {
70
- const impl = doc?.implementation;
71
- if (typeof impl?.createHTMLDocument === "function") return impl.createHTMLDocument("");
72
- const Parser = doc?.defaultView?.DOMParser || globalThis.DOMParser;
73
- if (typeof Parser === "function") {
74
- return new Parser().parseFromString("<!doctype html><html><head></head><body></body></html>", "text/html");
75
- }
76
- throw new Error("page-markdown: cannot create an inert document (no createHTMLDocument or DOMParser)");
77
- }
78
-
79
- const SHADOW_LIMIT = Symbol("shadow limit");
80
-
81
- function positiveLimit(value, name) {
82
- if (!Number.isSafeInteger(value) || value < 1) {
83
- throw new RangeError(`page-markdown: ${name} must be a positive integer`);
84
- }
85
- return value;
86
- }
87
-
88
- /**
89
- * Clones `root` (a Document or Element) into a new inert document.
90
- * Open shadow roots replace their host's light children (native slots supply
91
- * assigned children). Limits apply only to shadow content; a host over the
92
- * limit is emptied and counted as an omission.
93
- *
94
- * @param {Document|Element} root
95
- * @param {Partial<typeof DEFAULT_SANITIZE_OPTIONS>} [options]
96
- * @returns {{ document: Document, omissions: { shadowHosts: number } }}
97
- */
98
- export function sanitizeToInertDocument(root, options = {}) {
99
- const opts = { ...DEFAULT_SANITIZE_OPTIONS, ...options };
100
- const limits = {
101
- nodes: positiveLimit(opts.shadowNodeLimit, "shadowNodeLimit"),
102
- chars: positiveLimit(opts.shadowCharLimit, "shadowCharLimit"),
103
- depth: positiveLimit(opts.shadowDepthLimit, "shadowDepthLimit"),
104
- };
105
- const selectors = stripSelectors(opts);
106
- const sourceDoc = root.nodeType === 9 ? root : root.ownerDocument;
107
- const source = root.nodeType === 9 ? root.documentElement : root;
108
- const inert = createInertDocument(sourceDoc);
109
-
110
- function shallow(node) {
111
- if (node.nodeType !== 1) return inert.importNode(node, false);
112
- const name = node.prefix ? `${node.prefix}:${node.localName}` : node.localName;
113
- const copy = node.namespaceURI
114
- ? inert.createElementNS(node.namespaceURI, name)
115
- : inert.createElement(name);
116
- for (const attr of Array.from(node.attributes || [])) {
117
- // HTML allows colon-bearing names without a namespace (framework
118
- // directives); setAttributeNS rejects those.
119
- if (attr.namespaceURI) copy.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
120
- else copy.setAttribute(attr.name, attr.value);
121
- }
122
- return copy;
123
- }
124
-
125
- let nodes = 0;
126
- let chars = 0;
127
- let shadowHosts = 0;
128
-
129
- function isAssigningSlot(node) {
130
- return node.nodeType === 1 && node.localName === "slot"
131
- && typeof node.assignedNodes === "function" && Boolean(node.getRootNode?.().host);
132
- }
133
-
134
- function shadowNode(node, depth) {
135
- if (++nodes > limits.nodes || depth > limits.depth) throw SHADOW_LIMIT;
136
- if (isStripped(node, selectors, opts)) return inert.createDocumentFragment();
137
- const copy = shallow(node);
138
- chars += node.nodeType === 1 ? (copy.outerHTML || "").length : (node.nodeValue || "").length * 6;
139
- if (chars > limits.chars) throw SHADOW_LIMIT;
140
- let children = node.childNodes;
141
- let target = copy;
142
- if (isAssigningSlot(node)) {
143
- const assigned = node.assignedNodes({ flatten: true });
144
- children = assigned.length ? assigned : node.childNodes;
145
- target = inert.createDocumentFragment();
146
- } else if (node.shadowRoot) {
147
- children = node.shadowRoot.childNodes;
148
- } else if (node.nodeType === 1 && node.localName === "template" && node.content) {
149
- children = node.content.childNodes;
150
- target = copy.content || copy;
151
- }
152
- for (const child of Array.from(children || [])) target.appendChild(shadowNode(child, depth + 1));
153
- return isAssigningSlot(node) ? target : copy;
154
- }
155
-
156
- const clone = shallow(source);
157
- // Iterative light-DOM walk: ordinary pages get no depth limit.
158
- const pending = [[source, clone]];
159
- while (pending.length) {
160
- const [live, copy] = pending.pop();
161
- if (live.shadowRoot) {
162
- const fragment = inert.createDocumentFragment();
163
- try {
164
- for (const child of Array.from(live.shadowRoot.childNodes)) fragment.appendChild(shadowNode(child, 1));
165
- while (copy.firstChild) copy.removeChild(copy.firstChild);
166
- copy.appendChild(fragment);
167
- } catch (error) {
168
- if (error !== SHADOW_LIMIT) throw error;
169
- while (copy.firstChild) copy.removeChild(copy.firstChild);
170
- shadowHosts += 1;
171
- }
172
- continue;
173
- }
174
- const children = live.content?.childNodes || live.childNodes;
175
- const target = copy.content || copy;
176
- const pairs = [];
177
- for (const child of Array.from(children || [])) {
178
- if (isStripped(child, selectors, opts)) continue;
179
- const childCopy = shallow(child);
180
- target.appendChild(childCopy);
181
- pairs.push([child, childCopy]);
182
- }
183
- for (let i = pairs.length - 1; i >= 0; i -= 1) pending.push(pairs[i]);
184
- }
185
-
186
- if (clone.localName === "html") {
187
- inert.removeChild(inert.documentElement);
188
- inert.appendChild(clone);
189
- } else {
190
- inert.body.appendChild(clone);
191
- }
192
- return { document: inert, omissions: { shadowHosts } };
193
- }
1
+ // Stage 1: clone the page into an inert document, inline open shadow roots
2
+ // within limits, and strip non-content. The live page is never modified.
3
+
4
+ /** @type {Readonly<Required<import("./types.js").SanitizeOptions>>} */
5
+ export const DEFAULT_SANITIZE_OPTIONS = Object.freeze({
6
+ stripScripts: true,
7
+ stripStyles: true,
8
+ stripNoscript: true,
9
+ stripMeta: true,
10
+ stripLinkTags: true,
11
+ stripAds: true,
12
+ // App-specific overlays or widgets to drop, e.g. "[data-app-overlay]".
13
+ extraStripSelectors: [],
14
+ shadowNodeLimit: 5000,
15
+ shadowCharLimit: 500000,
16
+ shadowDepthLimit: 128,
17
+ });
18
+
19
+ const STRIP_RULES = {
20
+ stripScripts: "script",
21
+ stripStyles: "style",
22
+ stripNoscript: "noscript",
23
+ stripMeta: "meta",
24
+ stripLinkTags: "link",
25
+ };
26
+
27
+ // Advertising placements: explicit ad markup, plus custom elements named for ad
28
+ // slots (e.g. MDN's <mdn-placement-sidebar>).
29
+ const AD_SELECTOR = [
30
+ "ins.adsbygoogle", ".adsbygoogle", ".advertisement",
31
+ "[data-ad-slot]", "[data-ad-client]", "[data-ad-unit]",
32
+ "[aria-label='Advertisement' i]", "[aria-label='Advertisements' i]", "[aria-label='Sponsored' i]",
33
+ "iframe[src*='doubleclick.net']", "iframe[src*='googlesyndication.com']", "iframe[id^='google_ads_iframe']",
34
+ ].join(", ");
35
+ const AD_ELEMENT_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*-(?:placement|advert|advertisement|ads?|sponsor|sponsored)(?:-[a-z0-9]+)*$/;
36
+
37
+ /** @param {any} node */
38
+ export function isAdvertisementElement(node) {
39
+ if (node?.nodeType !== 1) return false;
40
+ // 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
+ try {
44
+ return Boolean(node.matches?.(AD_SELECTOR));
45
+ } catch {
46
+ // DOM implementations without case-insensitive attribute selectors.
47
+ return Boolean(node.matches?.(AD_SELECTOR.replace(/ i\]/g, "]")));
48
+ }
49
+ }
50
+
51
+ function stripSelectors(options) {
52
+ const selectors = Object.entries(STRIP_RULES)
53
+ .filter(([flag]) => options[flag])
54
+ .map(([, selector]) => selector);
55
+ for (const extra of options.extraStripSelectors || []) {
56
+ if (typeof extra === "string" && extra.trim()) selectors.push(extra.trim());
57
+ }
58
+ return selectors;
59
+ }
60
+
61
+ function isStripped(node, selectors, options) {
62
+ if (node.nodeType !== 1) return false;
63
+ if (selectors.some((selector) => node.matches?.(selector))) return true;
64
+ return Boolean(options.stripAds && isAdvertisementElement(node));
65
+ }
66
+
67
+ /**
68
+ * Creates an empty HTML document unconnected to the page: no scripts run and
69
+ * no custom elements upgrade in it.
70
+ * @param {Document} doc
71
+ */
72
+ export function createInertDocument(doc) {
73
+ const impl = doc?.implementation;
74
+ if (typeof impl?.createHTMLDocument === "function") return impl.createHTMLDocument("");
75
+ const Parser = doc?.defaultView?.DOMParser || globalThis.DOMParser;
76
+ if (typeof Parser === "function") {
77
+ return new Parser().parseFromString("<!doctype html><html><head></head><body></body></html>", "text/html");
78
+ }
79
+ throw new Error("page-markdown: cannot create an inert document (no createHTMLDocument or DOMParser)");
80
+ }
81
+
82
+ const SHADOW_LIMIT = Symbol("shadow limit");
83
+
84
+ function positiveLimit(value, name) {
85
+ if (!Number.isSafeInteger(value) || value < 1) {
86
+ throw new RangeError(`page-markdown: ${name} must be a positive integer`);
87
+ }
88
+ return value;
89
+ }
90
+
91
+ /**
92
+ * Clones `root` (a Document or Element) into a new inert document.
93
+ * Open shadow roots replace their host's light children (native slots supply
94
+ * assigned children). Limits apply only to shadow content; a host over the
95
+ * limit is emptied and counted as an omission.
96
+ *
97
+ * @param {Document|Element} root
98
+ * @param {import("./types.js").SanitizeOptions} [options]
99
+ * @returns {{ document: Document, omissions: { shadowHosts: number } }}
100
+ */
101
+ export function sanitizeToInertDocument(root, options = {}) {
102
+ 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
+ };
108
+ 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;
111
+ const inert = createInertDocument(sourceDoc);
112
+
113
+ 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
+ const copy = node.namespaceURI
117
+ ? inert.createElementNS(node.namespaceURI, name)
118
+ : inert.createElement(name);
119
+ for (const attr of Array.from(node.attributes || [])) {
120
+ // HTML allows colon-bearing names without a namespace (framework
121
+ // directives); setAttributeNS rejects those.
122
+ if (attr.namespaceURI) copy.setAttributeNS(attr.namespaceURI, attr.name, attr.value);
123
+ else copy.setAttribute(attr.name, attr.value);
124
+ }
125
+ return copy;
126
+ }
127
+
128
+ let nodes = 0;
129
+ let chars = 0;
130
+ let shadowHosts = 0;
131
+
132
+ function isAssigningSlot(node) {
133
+ return node.nodeType === 1 && node.localName === "slot"
134
+ && typeof node.assignedNodes === "function" && Boolean(node.getRootNode?.().host);
135
+ }
136
+
137
+ function shadowNode(node, depth) {
138
+ if (++nodes > limits.nodes || depth > limits.depth) throw SHADOW_LIMIT;
139
+ if (isStripped(node, selectors, opts)) return inert.createDocumentFragment();
140
+ const copy = shallow(node);
141
+ chars += node.nodeType === 1 ? (copy.outerHTML || "").length : (node.nodeValue || "").length * 6;
142
+ if (chars > limits.chars) throw SHADOW_LIMIT;
143
+ let children = node.childNodes;
144
+ let target = copy;
145
+ if (isAssigningSlot(node)) {
146
+ const assigned = node.assignedNodes({ flatten: true });
147
+ children = assigned.length ? assigned : node.childNodes;
148
+ target = inert.createDocumentFragment();
149
+ } else if (node.shadowRoot) {
150
+ children = node.shadowRoot.childNodes;
151
+ } else if (node.nodeType === 1 && node.localName === "template" && node.content) {
152
+ children = node.content.childNodes;
153
+ target = copy.content || copy;
154
+ }
155
+ for (const child of Array.from(children || [])) target.appendChild(shadowNode(child, depth + 1));
156
+ return isAssigningSlot(node) ? target : copy;
157
+ }
158
+
159
+ const clone = shallow(source);
160
+ // Iterative light-DOM walk: ordinary pages get no depth limit.
161
+ const pending = [[source, clone]];
162
+ while (pending.length) {
163
+ const [live, copy] = /** @type {any[]} */ (pending.pop());
164
+ if (live.shadowRoot) {
165
+ const fragment = inert.createDocumentFragment();
166
+ try {
167
+ for (const child of Array.from(live.shadowRoot.childNodes)) fragment.appendChild(shadowNode(child, 1));
168
+ while (copy.firstChild) copy.removeChild(copy.firstChild);
169
+ copy.appendChild(fragment);
170
+ } catch (error) {
171
+ if (error !== SHADOW_LIMIT) throw error;
172
+ while (copy.firstChild) copy.removeChild(copy.firstChild);
173
+ shadowHosts += 1;
174
+ }
175
+ continue;
176
+ }
177
+ const children = live.content?.childNodes || live.childNodes;
178
+ const target = copy.content || copy;
179
+ const pairs = [];
180
+ for (const child of Array.from(children || [])) {
181
+ if (isStripped(child, selectors, opts)) continue;
182
+ const childCopy = shallow(child);
183
+ target.appendChild(childCopy);
184
+ pairs.push([child, childCopy]);
185
+ }
186
+ for (let i = pairs.length - 1; i >= 0; i -= 1) pending.push(pairs[i]);
187
+ }
188
+
189
+ if (clone.localName === "html") {
190
+ inert.removeChild(inert.documentElement);
191
+ inert.appendChild(clone);
192
+ } else {
193
+ inert.body.appendChild(clone);
194
+ }
195
+ return { document: inert, omissions: { shadowHosts } };
196
+ }
@@ -15,6 +15,10 @@ const NON_LANGUAGES = new Set(["default", "none", "text", "plain", "plaintext",
15
15
  const EDIT_WIDGETS = ".mw-editsection, .headerlink, .anchor, .heading-anchor, a.anchor, [aria-hidden='true'] a";
16
16
  // Content without text that a heading wrapper may still hold.
17
17
  const MEDIA = "img, picture, video, audio";
18
+ // Highlighters mark up each code line with one of these.
19
+ const CODE_LINE_ELEMENTS = new Set(["div", "p", "li", "tr", "section"]);
20
+ // As URL parsers read a scheme: control characters and spaces don't count.
21
+ const isUnsafeUrl = (value) => /^(?:javascript|vbscript|data):/i.test(String(value).replace(/[\x00-\x20]+/g, ""));
18
22
 
19
23
  function resolveUrl(value, base) {
20
24
  const raw = String(value || "").trim();
@@ -44,7 +48,7 @@ function languageFrom(el, maxDepth = 3) {
44
48
 
45
49
  function hasMeaningfulContent(el) {
46
50
  if ((el.textContent || "").trim()) return true;
47
- return Boolean(el.querySelector?.("img[alt]:not([alt='']), svg title"));
51
+ return Boolean(el.querySelector?.("img, svg title"));
48
52
  }
49
53
 
50
54
  /**
@@ -62,7 +66,7 @@ function standardizeImages(doc, base, stats) {
62
66
  }
63
67
  }
64
68
  const current = img.getAttribute("src") || "";
65
- if (!current || current.startsWith("data:")) {
69
+ if (!current || isUnsafeUrl(current)) {
66
70
  img.remove();
67
71
  stats.inlineImagesDropped += 1;
68
72
  continue;
@@ -74,7 +78,7 @@ function standardizeImages(doc, base, stats) {
74
78
  function standardizeLinks(doc, base, stats) {
75
79
  for (const a of Array.from(doc.querySelectorAll("a"))) {
76
80
  const href = a.getAttribute("href") || "";
77
- if (/^\s*javascript:/i.test(href)) {
81
+ if (isUnsafeUrl(href)) {
78
82
  a.replaceWith(...Array.from(a.childNodes));
79
83
  stats.scriptLinksUnwrapped += 1;
80
84
  continue;
@@ -88,20 +92,41 @@ function standardizeLinks(doc, base, stats) {
88
92
  }
89
93
  }
90
94
 
95
+ /** Text of a code block, with <br> and block-level lines as line breaks. */
96
+ function codeText(root) {
97
+ let text = "";
98
+ const walk = (node) => {
99
+ for (const child of Array.from(node.childNodes)) {
100
+ if (child.nodeType === 3) text += child.nodeValue;
101
+ else if (child.nodeType !== 1) continue;
102
+ else if (child.localName === "br") text += "\n";
103
+ else {
104
+ const block = CODE_LINE_ELEMENTS.has(child.localName);
105
+ if (block && text && !text.endsWith("\n")) text += "\n";
106
+ walk(child);
107
+ if (block && text && !text.endsWith("\n")) text += "\n";
108
+ }
109
+ }
110
+ };
111
+ walk(root);
112
+ return text;
113
+ }
114
+
91
115
  /** <pre> without <code> (and code without a language) gets a language-tagged <code>. */
92
116
  function standardizeCode(doc, stats) {
93
117
  for (const pre of Array.from(doc.querySelectorAll("pre"))) {
94
- const lang = languageFrom(pre);
95
- let code = Array.from(pre.children || []).length === 1 && pre.firstElementChild?.localName === "code"
96
- ? pre.firstElementChild
97
- : null;
98
- if (!code) {
99
- code = doc.createElement("code");
100
- code.textContent = pre.textContent || "";
101
- while (pre.firstChild) pre.removeChild(pre.firstChild);
102
- pre.appendChild(code);
103
- stats.preWrapped += 1;
104
- }
118
+ for (const button of Array.from(pre.querySelectorAll("button"))) button.remove();
119
+ const inner = pre.querySelector("code");
120
+ const lang = languageFrom(inner || pre, inner ? 4 : 3);
121
+ // One <code> and only whitespace around it: that <code> is the block.
122
+ const sole = pre.children.length === 1 && pre.firstElementChild === inner
123
+ && !(pre.textContent || "").replace(inner.textContent || "", "").trim();
124
+ const code = sole ? inner : doc.createElement("code");
125
+ if (!sole) stats.preWrapped += 1;
126
+ // Turndown reads textContent, so lines kept apart by markup become text.
127
+ code.textContent = codeText(sole ? inner : pre);
128
+ while (pre.firstChild) pre.removeChild(pre.firstChild);
129
+ pre.appendChild(code);
105
130
  if (lang && !/(?:^|\s)language-/.test(code.getAttribute("class") || "")) {
106
131
  code.setAttribute("class", `${code.getAttribute("class") || ""} language-${lang}`.trim());
107
132
  }
@@ -124,7 +149,8 @@ function standardizeHeadings(doc, stats) {
124
149
  const parent = heading.parentElement;
125
150
  if (!parent || parent.localName !== "div") continue;
126
151
  const others = Array.from(parent.children).filter((el) => el !== heading);
127
- if (others.every((el) => !(el.textContent || "").trim() && !el.matches?.(MEDIA) && !el.querySelector?.(MEDIA))) {
152
+ const hasBareText = Array.from(parent.childNodes).some((node) => node.nodeType === 3 && node.nodeValue.trim());
153
+ if (!hasBareText && others.every((el) => !(el.textContent || "").trim() && !el.matches?.(MEDIA) && !el.querySelector?.(MEDIA))) {
128
154
  parent.replaceWith(heading);
129
155
  stats.headingsUnwrapped += 1;
130
156
  }
package/src/types.js ADDED
@@ -0,0 +1,118 @@
1
+ /// <reference lib="dom" preserve="true" />
2
+ // Types only: nothing here exists at runtime. `npm run build:types` turns these
3
+ // and the JSDoc in the other modules into dist/types, which is what TypeScript
4
+ // consumers see.
5
+ export {};
6
+
7
+ /** @typedef {"full"|"no_links"|"text_only"} OutputMode */
8
+
9
+ /**
10
+ * @typedef {object} SanitizeOptions
11
+ * @property {boolean} [stripScripts]
12
+ * @property {boolean} [stripStyles]
13
+ * @property {boolean} [stripNoscript]
14
+ * @property {boolean} [stripMeta]
15
+ * @property {boolean} [stripLinkTags]
16
+ * @property {boolean} [stripAds]
17
+ * @property {readonly string[]} [extraStripSelectors] app-specific overlays or widgets to drop, e.g. "[data-app-overlay]"
18
+ * @property {number} [shadowNodeLimit]
19
+ * @property {number} [shadowCharLimit]
20
+ * @property {number} [shadowDepthLimit]
21
+ */
22
+
23
+ /** @typedef {NonNullable<ConstructorParameters<typeof import("@mozilla/readability").Readability<string>>[1]>} ReadabilityOptions */
24
+
25
+ /** @typedef {import("./extract.js").Extraction} Extraction */
26
+
27
+ /** @typedef {import("./extract.js").Extractor} Extractor */
28
+
29
+ /** @typedef {ReturnType<typeof import("./metadata.js").collectMetadata>} CollectedMetadata */
30
+
31
+ /**
32
+ * @typedef {object} ExtractedMetadata
33
+ * @property {string} [headline] the heading chosen for the capture
34
+ * @property {string} [extractedTitle] the title the extractor reported
35
+ * @property {string} [byline] the byline the extractor reported
36
+ */
37
+
38
+ /** @typedef {CollectedMetadata & ExtractedMetadata} PageMarkdownMetadata */
39
+
40
+ /**
41
+ * @typedef {object} PageMarkdownExtraction
42
+ * @property {Extraction["method"]} method
43
+ * @property {string} [fallbackReason] why extraction fell back to the full page
44
+ * @property {true} [leadRecovered] a dropped lead paragraph was restored
45
+ * @property {number} [pageFacts] how many facts `pageFacts` appended
46
+ */
47
+
48
+ /** @typedef {"tag"|"variation_selector"|"invisible_operator"|"reserved_tag_plane"} HiddenRunKind */
49
+
50
+ /**
51
+ * A run as text-sanctifier's findHiddenRuns reports it.
52
+ * @typedef {object} HiddenRun
53
+ * @property {HiddenRunKind} kind
54
+ * @property {number} offset UTF-16 index of the first character
55
+ * @property {number} end UTF-16 index just past the last character
56
+ * @property {number} count code points in the run
57
+ * @property {number} line 1-based line of the first character
58
+ * @property {number} column 1-based column of the first character, in UTF-16 units
59
+ * @property {string} [decoded] tag runs only: the ASCII the tags spell
60
+ * @property {boolean} [decodedTruncated] tag runs only: true when `decoded` stops short of the run
61
+ */
62
+
63
+ /**
64
+ * @typedef {object} HiddenTextOmission
65
+ * @property {`hidden_${HiddenRunKind}`} kind
66
+ * @property {number} count runs removed from `markdown`
67
+ * @property {number} chars code points in those runs
68
+ * @property {string} note
69
+ */
70
+
71
+ /**
72
+ * Check `kind` before reading `chars`: only hidden-text omissions have it.
73
+ * @typedef {{ kind: "shadow_content", count: number, note: string }
74
+ * | { kind: "json_line", count: number, note: string }
75
+ * | HiddenTextOmission} PageMarkdownOmission
76
+ */
77
+
78
+ /** @typedef {ReturnType<typeof import("./standardize.js").standardizeDocument>} StandardizationStats */
79
+
80
+ /**
81
+ * @typedef {object} PageMarkdownStats
82
+ * @property {OutputMode} mode
83
+ * @property {number} extractedHtmlChars
84
+ * @property {number} convertedChars
85
+ * @property {number} markdownChars
86
+ * @property {StandardizationStats} standardization
87
+ */
88
+
89
+ /**
90
+ * @typedef {object} PageMarkdownResult
91
+ * @property {string} version the package version that made this result
92
+ * @property {string} markdown
93
+ * @property {PageMarkdownMetadata} metadata
94
+ * @property {PageMarkdownExtraction} extraction
95
+ * @property {PageMarkdownOmission[]} omissions
96
+ * @property {string[]} warnings
97
+ * @property {PageMarkdownStats} stats
98
+ * @property {string} [fullPageMarkdown] present only with `includeFullPage`
99
+ */
100
+
101
+ /** @typedef {{ label: string, value: string }} PageFact */
102
+
103
+ /**
104
+ * @typedef {string | string[] | ((node: HTMLElement, options: Record<string, unknown>) => boolean)} TurndownFilter
105
+ */
106
+
107
+ /**
108
+ * Turndown's public methods; Turndown ships no types of its own.
109
+ * @typedef {object} TurndownInstance
110
+ * @property {(input: string | Node) => string} turndown
111
+ * @property {(plugin: TurndownPlugin | TurndownPlugin[]) => TurndownInstance} use
112
+ * @property {(key: string, rule: { filter: TurndownFilter, replacement: (content: string, node: Node, options: Record<string, unknown>) => string }) => TurndownInstance} addRule
113
+ * @property {(filter: TurndownFilter) => TurndownInstance} keep
114
+ * @property {(filter: TurndownFilter) => TurndownInstance} remove
115
+ * @property {(text: string) => string} escape
116
+ */
117
+
118
+ /** @typedef {(service: TurndownInstance) => void} TurndownPlugin */