page-markdown 0.1.4 → 0.2.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,193 +1,217 @@
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", ".ad-label",
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
+ function positiveLimit(value, name) {
83
+ if (!Number.isSafeInteger(value) || value < 1) {
84
+ throw new RangeError(`page-markdown: ${name} must be a positive integer`);
85
+ }
86
+ return value;
87
+ }
88
+
89
+ /**
90
+ * Clones `root` (a Document or Element) into a new inert document.
91
+ * Open shadow roots replace their host's light children (native slots supply
92
+ * assigned children). Limits apply only to shadow content, per host in the
93
+ * page's own tree, so slotted page content is not charged: docs sites put a
94
+ * whole article in one host's slot. A host over the limit keeps what fits and
95
+ * is 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 overLimit = false;
131
+ 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);
136
+ }
137
+
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;
150
+ }
151
+
152
+ function shadowNode(node, depth) {
153
+ const inPage = node.getRootNode?.().nodeType === 9;
154
+ if (depth > limits.depth || (!inPage && ++nodes > limits.nodes)) {
155
+ overLimit = true;
156
+ return null;
157
+ }
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;
163
+ }
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;
170
+ let target = copy;
171
+ if (isAssigningSlot(node)) {
172
+ const assigned = node.assignedNodes({ flatten: true });
173
+ children = assigned.length ? assigned : node.childNodes;
174
+ 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;
180
+ }
181
+ for (const child of Array.from(children || [])) {
182
+ const childCopy = shadowNode(child, depth + 1);
183
+ if (childCopy) target.appendChild(childCopy);
184
+ if (overLimit) break;
185
+ }
186
+ return isAssigningSlot(node) ? target : copy;
187
+ }
188
+
189
+ const clone = shallow(source);
190
+ // Iterative light-DOM walk: ordinary pages get no depth limit.
191
+ const pending = [[source, clone]];
192
+ while (pending.length) {
193
+ const [live, copy] = /** @type {any[]} */ (pending.pop());
194
+ if (live.shadowRoot) {
195
+ copyShadowRoot(live, copy, 1);
196
+ continue;
197
+ }
198
+ const children = live.content?.childNodes || live.childNodes;
199
+ const target = copy.content || copy;
200
+ const pairs = [];
201
+ for (const child of Array.from(children || [])) {
202
+ if (isStripped(child, selectors, opts)) continue;
203
+ const childCopy = shallow(child);
204
+ target.appendChild(childCopy);
205
+ pairs.push([child, childCopy]);
206
+ }
207
+ for (let i = pairs.length - 1; i >= 0; i -= 1) pending.push(pairs[i]);
208
+ }
209
+
210
+ if (clone.localName === "html") {
211
+ inert.removeChild(inert.documentElement);
212
+ inert.appendChild(clone);
213
+ } else {
214
+ inert.body.appendChild(clone);
215
+ }
216
+ return { document: inert, omissions: { shadowHosts } };
217
+ }