page-markdown 0.1.4 → 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/CHANGELOG.md +186 -124
- package/README.md +105 -13
- package/dist/types/clean.d.ts +23 -0
- package/dist/types/convert.d.ts +19 -0
- package/dist/types/extract.d.ts +37 -0
- package/dist/types/facts.d.ts +14 -0
- package/dist/types/hidden.d.ts +14 -0
- package/dist/types/index.d.ts +98 -0
- package/dist/types/metadata.d.ts +18 -0
- package/dist/types/refine.d.ts +31 -0
- package/dist/types/sanitize.d.ts +26 -0
- package/dist/types/standardize.d.ts +15 -0
- package/dist/types/types.d.ts +154 -0
- package/package.json +17 -5
- package/src/clean.js +21 -9
- package/src/convert.js +78 -26
- package/src/extract.js +76 -70
- package/src/facts.js +99 -96
- package/src/hidden.js +46 -45
- package/src/index.js +21 -5
- package/src/metadata.js +79 -78
- package/src/refine.js +109 -104
- package/src/sanitize.js +196 -193
- package/src/standardize.js +41 -15
- package/src/types.js +118 -0
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
"[
|
|
32
|
-
"
|
|
33
|
-
].
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
return Boolean(node.matches?.(AD_SELECTOR
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
children = node.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
inert.
|
|
191
|
-
|
|
192
|
-
|
|
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
|
+
}
|
package/src/standardize.js
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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 */
|