page-markdown 0.1.5 → 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 +353 -186
- package/LICENSE +694 -694
- package/README.md +113 -41
- package/THIRD_PARTY_NOTICES.md +264 -264
- package/dist/types/concealed.d.ts +59 -0
- package/dist/types/extract.d.ts +10 -4
- package/dist/types/facts.d.ts +1 -0
- package/dist/types/index.d.ts +20 -6
- package/dist/types/sanitize.d.ts +22 -3
- package/dist/types/standardize.d.ts +15 -1
- package/dist/types/types.d.ts +51 -2
- 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 +95 -13
- package/src/extract.js +178 -8
- package/src/facts.js +6 -3
- package/src/index.js +219 -178
- package/src/refine.js +9 -3
- package/src/sanitize.js +106 -57
- package/src/standardize.js +167 -3
- package/src/types.js +23 -2
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,
|
|
@@ -27,7 +29,7 @@ const STRIP_RULES = {
|
|
|
27
29
|
// Advertising placements: explicit ad markup, plus custom elements named for ad
|
|
28
30
|
// slots (e.g. MDN's <mdn-placement-sidebar>).
|
|
29
31
|
const AD_SELECTOR = [
|
|
30
|
-
"ins.adsbygoogle", ".adsbygoogle", ".advertisement",
|
|
32
|
+
"ins.adsbygoogle", ".adsbygoogle", ".advertisement", ".ad-label",
|
|
31
33
|
"[data-ad-slot]", "[data-ad-client]", "[data-ad-unit]",
|
|
32
34
|
"[aria-label='Advertisement' i]", "[aria-label='Advertisements' i]", "[aria-label='Sponsored' i]",
|
|
33
35
|
"iframe[src*='doubleclick.net']", "iframe[src*='googlesyndication.com']", "iframe[id^='google_ads_iframe']",
|
|
@@ -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?.
|
|
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
|
|
42
|
-
if (AD_ELEMENT_NAME.test(node
|
|
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
|
|
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
|
}
|
|
@@ -79,8 +81,6 @@ export function createInertDocument(doc) {
|
|
|
79
81
|
throw new Error("page-markdown: cannot create an inert document (no createHTMLDocument or DOMParser)");
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
const SHADOW_LIMIT = Symbol("shadow limit");
|
|
83
|
-
|
|
84
84
|
function positiveLimit(value, name) {
|
|
85
85
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
86
86
|
throw new RangeError(`page-markdown: ${name} must be a positive integer`);
|
|
@@ -91,32 +91,38 @@ function positiveLimit(value, name) {
|
|
|
91
91
|
/**
|
|
92
92
|
* Clones `root` (a Document or Element) into a new inert document.
|
|
93
93
|
* Open shadow roots replace their host's light children (native slots supply
|
|
94
|
-
* assigned children). Limits apply only to shadow content
|
|
95
|
-
*
|
|
94
|
+
* assigned children). Limits apply only to shadow content, per host in the
|
|
95
|
+
* page's own tree, so slotted page content is not charged: docs sites put a
|
|
96
|
+
* whole article in one host's slot. A host over the limit keeps what fits and
|
|
97
|
+
* is counted as an omission.
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
110
|
-
const source = 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
|
|
115
|
-
|
|
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);
|
|
@@ -127,70 +133,113 @@ export function sanitizeToInertDocument(root, options = {}) {
|
|
|
127
133
|
|
|
128
134
|
let nodes = 0;
|
|
129
135
|
let chars = 0;
|
|
136
|
+
let overLimit = false;
|
|
130
137
|
let shadowHosts = 0;
|
|
138
|
+
function carry(work, pair) {
|
|
139
|
+
for (let i = 2; i <= 8; i++) pair[i] = work[i];
|
|
140
|
+
return pair;
|
|
141
|
+
}
|
|
131
142
|
|
|
132
143
|
function isAssigningSlot(node) {
|
|
133
|
-
return node
|
|
134
|
-
&& typeof node.assignedNodes === "function" &&
|
|
144
|
+
return isElement(node) && node[DOM_LOCAL_NAME] === "slot"
|
|
145
|
+
&& typeof node.assignedNodes === "function" && !!node[DOM_GET_ROOT_NODE]?.().host;
|
|
135
146
|
}
|
|
136
147
|
|
|
137
|
-
function shadowNode(
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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])) {
|
|
152
|
+
overLimit = true;
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
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; }
|
|
165
|
+
}
|
|
166
|
+
let children = node[DOM_CHILD_NODES];
|
|
144
167
|
let target = copy;
|
|
145
|
-
if (
|
|
168
|
+
if (host) children = host[DOM_CHILD_NODES];
|
|
169
|
+
else if (isAssigningSlot(node)) {
|
|
146
170
|
const assigned = node.assignedNodes({ flatten: true });
|
|
147
|
-
children = assigned.length ? assigned : node
|
|
171
|
+
children = assigned.length ? assigned : node[DOM_CHILD_NODES];
|
|
148
172
|
target = inert.createDocumentFragment();
|
|
149
|
-
} else if (node
|
|
150
|
-
children = node
|
|
151
|
-
} else if (node
|
|
152
|
-
children = node
|
|
153
|
-
target = 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;
|
|
178
|
+
}
|
|
179
|
+
const pairs = [];
|
|
180
|
+
let textAllocations = 0;
|
|
181
|
+
for (const child of Array.from(children || [])) {
|
|
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);
|
|
199
|
+
if (overLimit) break;
|
|
200
|
+
}
|
|
201
|
+
if (host) {
|
|
202
|
+
if (overLimit) shadowHosts++;
|
|
203
|
+
overLimit = false;
|
|
204
|
+
[nodes, chars] = outer;
|
|
205
|
+
return copy;
|
|
154
206
|
}
|
|
155
|
-
for (const child of Array.from(children || [])) target.appendChild(shadowNode(child, depth + 1));
|
|
156
207
|
return isAssigningSlot(node) ? target : copy;
|
|
157
208
|
}
|
|
158
209
|
|
|
159
210
|
const clone = shallow(source);
|
|
211
|
+
const initial = concealment.initial(source);
|
|
160
212
|
// Iterative light-DOM walk: ordinary pages get no depth limit.
|
|
161
|
-
const pending = [[source, clone]];
|
|
213
|
+
const pending = [carry(initial, [source, clone])];
|
|
162
214
|
while (pending.length) {
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
}
|
|
215
|
+
const work = /** @type {any[]} */ (pending.pop());
|
|
216
|
+
const [live, copy] = work;
|
|
217
|
+
if (live[DOM_SHADOW_ROOT]) {
|
|
218
|
+
shadowNode(work, 0, true);
|
|
175
219
|
continue;
|
|
176
220
|
}
|
|
177
|
-
const children = live
|
|
178
|
-
const target = copy
|
|
221
|
+
const children = live[DOM_CONTENT]?.[DOM_CHILD_NODES] || live[DOM_CHILD_NODES];
|
|
222
|
+
const target = copy[DOM_CONTENT] || copy;
|
|
179
223
|
const pairs = [];
|
|
180
224
|
for (const child of Array.from(children || [])) {
|
|
181
225
|
if (isStripped(child, selectors, opts)) continue;
|
|
182
226
|
const childCopy = shallow(child);
|
|
183
|
-
target
|
|
227
|
+
target[DOM_APPEND_CHILD](childCopy);
|
|
184
228
|
pairs.push([child, childCopy]);
|
|
185
229
|
}
|
|
186
|
-
|
|
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
|
+
}
|
|
187
236
|
}
|
|
188
237
|
|
|
189
|
-
if (clone
|
|
238
|
+
if (clone[DOM_LOCAL_NAME] === "html") {
|
|
190
239
|
inert.removeChild(inert.documentElement);
|
|
191
|
-
inert
|
|
240
|
+
inert[DOM_APPEND_CHILD](clone);
|
|
192
241
|
} else {
|
|
193
|
-
inert.body
|
|
242
|
+
inert.body[DOM_APPEND_CHILD](clone);
|
|
194
243
|
}
|
|
195
|
-
return { document: inert, omissions: { shadowHosts } };
|
|
244
|
+
return { document: inert, omissions: { shadowHosts }, concealment };
|
|
196
245
|
}
|
package/src/standardize.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Stage 2: normalise markup on the inert clone so extraction and conversion
|
|
2
2
|
// see consistent structure. Runs before extraction.
|
|
3
|
+
import { isHiddenElement } from "./extract.js";
|
|
3
4
|
|
|
4
5
|
const LAZY_SRC_ATTRS = ["data-src", "data-lazy-src", "data-original", "data-lazy", "data-url"];
|
|
5
6
|
const LANGUAGE_CLASS = [
|
|
@@ -12,11 +13,22 @@ const LANGUAGE_CLASS = [
|
|
|
12
13
|
const LANGUAGE_ALIASES = { python3: "python", py3: "python" };
|
|
13
14
|
const NON_LANGUAGES = new Set(["default", "none", "text", "plain", "plaintext", "nohighlight", "notranslate"]);
|
|
14
15
|
// Wrappers sites put around a heading next to "edit" or permalink widgets.
|
|
15
|
-
|
|
16
|
+
// Removed whatever their text: "edit", "Section titled ...", a zero-width space.
|
|
17
|
+
const ALWAYS_REMOVED_WIDGETS = ".mw-editsection, .hash-link, .sl-anchor-link";
|
|
18
|
+
const EDIT_WIDGETS = `${ALWAYS_REMOVED_WIDGETS}, .headerlink, .anchor, .heading-anchor, a.anchor, [aria-hidden='true'] a`;
|
|
16
19
|
// Content without text that a heading wrapper may still hold.
|
|
17
20
|
const MEDIA = "img, picture, video, audio";
|
|
18
21
|
// Highlighters mark up each code line with one of these.
|
|
19
22
|
const CODE_LINE_ELEMENTS = new Set(["div", "p", "li", "tr", "section"]);
|
|
23
|
+
const TAB_PANELS = "[role='tabpanel'], sp-tab-panel, sl-tab-panel";
|
|
24
|
+
const TABS = "[role='tab'], sp-tab, sl-tab";
|
|
25
|
+
// Longer than this, an inactive panel is a page of its own (an API or change log
|
|
26
|
+
// tab), not a variant of the one shown (pnpm, Yarn).
|
|
27
|
+
const MAX_INACTIVE_PANEL_CHARS = 5000;
|
|
28
|
+
const MAX_TAB_LABEL_CHARS = 40;
|
|
29
|
+
// Asides that are part of the text: notes and warnings. Readability drops every <aside>.
|
|
30
|
+
const NOTE_ASIDE = /(?:^|[\s_-])(?:aside|admonition|callout|note|tip|caution|warning|danger)(?:$|[\s_-])/i;
|
|
31
|
+
const OTHER_ASIDE = /related|sidebar|share|promo|newsletter|toc\b/i;
|
|
20
32
|
// As URL parsers read a scheme: control characters and spaces don't count.
|
|
21
33
|
const isUnsafeUrl = (value) => /^(?:javascript|vbscript|data):/i.test(String(value).replace(/[\x00-\x20]+/g, ""));
|
|
22
34
|
|
|
@@ -56,6 +68,21 @@ function hasMeaningfulContent(el) {
|
|
|
56
68
|
* data: placeholder. Then drop images that are still inline data.
|
|
57
69
|
*/
|
|
58
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
|
+
}
|
|
59
86
|
for (const img of Array.from(doc.querySelectorAll("img"))) {
|
|
60
87
|
const src = img.getAttribute("src") || "";
|
|
61
88
|
if (!src || src.startsWith("data:")) {
|
|
@@ -88,6 +115,19 @@ function standardizeLinks(doc, base, stats) {
|
|
|
88
115
|
stats.emptyLinksRemoved += 1;
|
|
89
116
|
continue;
|
|
90
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
|
+
}
|
|
91
131
|
if (href) a.setAttribute("href", resolveUrl(href, base));
|
|
92
132
|
}
|
|
93
133
|
}
|
|
@@ -133,19 +173,43 @@ function standardizeCode(doc, stats) {
|
|
|
133
173
|
}
|
|
134
174
|
}
|
|
135
175
|
|
|
176
|
+
/**
|
|
177
|
+
* A link to the heading's own id, inside it or right beside it, is a permalink
|
|
178
|
+
* whatever its class or wording ("Link to ...", "Section titled ...", "#"). A
|
|
179
|
+
* heading whose whole text is that link keeps the text. Left in place, the link
|
|
180
|
+
* makes a short heading's wrapper look like clutter to Readability.
|
|
181
|
+
*/
|
|
182
|
+
function removeOwnPermalinks(heading, stats) {
|
|
183
|
+
const id = heading.getAttribute("id");
|
|
184
|
+
if (!id) return;
|
|
185
|
+
const title = (heading.textContent || "").trim();
|
|
186
|
+
const inside = Array.from(heading.querySelectorAll("[href]"));
|
|
187
|
+
for (const link of [...inside, heading.previousElementSibling, heading.nextElementSibling]) {
|
|
188
|
+
if (link?.getAttribute("href") !== `#${id}`) continue;
|
|
189
|
+
if (inside.includes(link) && (link.textContent || "").trim() === title) link.replaceWith(...Array.from(link.childNodes));
|
|
190
|
+
else link.remove();
|
|
191
|
+
stats.headingWidgetsRemoved += 1;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
136
195
|
/**
|
|
137
196
|
* Headings wrapped with edit/permalink widgets (Wikipedia's div.mw-heading,
|
|
138
197
|
* Sphinx headerlinks): drop the widgets, then replace a wrapper whose only
|
|
139
198
|
* remaining element is the heading with the heading itself.
|
|
140
199
|
*/
|
|
141
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
|
+
}
|
|
142
205
|
for (const widget of Array.from(doc.querySelectorAll(EDIT_WIDGETS))) {
|
|
143
|
-
if (!(widget.textContent || "").trim() || widget.matches?.(
|
|
206
|
+
if (!(widget.textContent || "").trim() || widget.matches?.(ALWAYS_REMOVED_WIDGETS)) {
|
|
144
207
|
widget.remove();
|
|
145
208
|
stats.headingWidgetsRemoved += 1;
|
|
146
209
|
}
|
|
147
210
|
}
|
|
148
211
|
for (const heading of Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6"))) {
|
|
212
|
+
removeOwnPermalinks(heading, stats);
|
|
149
213
|
const parent = heading.parentElement;
|
|
150
214
|
if (!parent || parent.localName !== "div") continue;
|
|
151
215
|
const others = Array.from(parent.children).filter((el) => el !== heading);
|
|
@@ -157,9 +221,105 @@ function standardizeHeadings(doc, stats) {
|
|
|
157
221
|
}
|
|
158
222
|
}
|
|
159
223
|
|
|
224
|
+
function isInactivePanel(panel) {
|
|
225
|
+
if (isHiddenElement(panel) || panel.getAttribute("aria-hidden") === "true" || panel.classList.contains("hidden")) return true;
|
|
226
|
+
// Custom elements that mark only the group: <sp-tabs selected="overview">.
|
|
227
|
+
const value = panel.getAttribute("value");
|
|
228
|
+
const selected = value && panel.parentElement?.closest("[selected]")?.getAttribute("selected");
|
|
229
|
+
return Boolean(selected) && selected !== value;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function tabOf(panel, tabs) {
|
|
233
|
+
const id = panel.getAttribute("id");
|
|
234
|
+
const labelledBy = panel.getAttribute("aria-labelledby");
|
|
235
|
+
const value = panel.getAttribute("value") || panel.getAttribute("name");
|
|
236
|
+
const named = tabs.find((tab) => (labelledBy && tab.getAttribute("id") === labelledBy)
|
|
237
|
+
|| (id && (tab.getAttribute("aria-controls") === id || tab.getAttribute("href") === `#${id}`))
|
|
238
|
+
|| (value && (tab.getAttribute("value") === value || tab.getAttribute("panel") === value)));
|
|
239
|
+
if (named) return named;
|
|
240
|
+
// Docusaurus ties them by position alone.
|
|
241
|
+
const parent = panel.parentElement;
|
|
242
|
+
const index = Array.from(parent?.children || []).filter((el) => el.matches?.(TAB_PANELS)).indexOf(panel);
|
|
243
|
+
for (let node = parent, depth = 0; node && depth < 4; node = node.parentElement, depth += 1) {
|
|
244
|
+
const list = node.querySelector("[role='tablist']");
|
|
245
|
+
if (list) return list.querySelectorAll("[role='tab']")[index];
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Tabs read as one document: inactive panels are shown, each under its tab's
|
|
252
|
+
* name, in place of a tab strip that points nowhere. A long inactive panel is
|
|
253
|
+
* left out, and named in stats.omittedTabs.
|
|
254
|
+
*/
|
|
255
|
+
export function prepareConcealment(doc, concealment) {
|
|
256
|
+
// Docker's docs write aria-role, and call a panel a tab: only the strip's is right.
|
|
257
|
+
for (const list of Array.from(doc.querySelectorAll("[aria-role='tablist']"))) {
|
|
258
|
+
list.setAttribute("role", "tablist");
|
|
259
|
+
for (const tab of Array.from(list.children)) tab.setAttribute("role", "tab");
|
|
260
|
+
for (const panel of Array.from(list.nextElementSibling?.children || [])) panel.setAttribute("role", "tabpanel");
|
|
261
|
+
}
|
|
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);
|
|
279
|
+
if (!panels.length) return;
|
|
280
|
+
const tabs = Array.from(doc.querySelectorAll(TABS));
|
|
281
|
+
for (const panel of panels) {
|
|
282
|
+
const label = (tabOf(panel, tabs)?.textContent || "").replace(/\s+/g, " ").trim().slice(0, MAX_TAB_LABEL_CHARS);
|
|
283
|
+
const text = (panel.textContent || "").replace(/\s+/g, " ").trim();
|
|
284
|
+
if (hasProvenance ? inactive.get(panel) : isInactivePanel(panel)) {
|
|
285
|
+
if ((hasProvenance ? panelLengths.get(panel) : text.length) > MAX_INACTIVE_PANEL_CHARS) {
|
|
286
|
+
stats.omittedTabs.push(label);
|
|
287
|
+
panel.remove();
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
for (const attr of ["hidden", "aria-hidden", "style"]) panel.removeAttribute(attr);
|
|
291
|
+
panel.classList.remove("hidden");
|
|
292
|
+
}
|
|
293
|
+
if (label && panels.length > 1 && !text.toLowerCase().startsWith(label.toLowerCase())) {
|
|
294
|
+
const heading = doc.createElement("p");
|
|
295
|
+
heading.appendChild(doc.createElement("strong")).textContent = label;
|
|
296
|
+
panel.insertBefore(heading, panel.firstChild);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const el of Array.from(doc.querySelectorAll(`[role='tablist'], ${TABS}`))) el.remove();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function standardizeAsides(doc) {
|
|
303
|
+
for (const aside of Array.from(doc.querySelectorAll("main aside, article aside, [role='main'] aside"))) {
|
|
304
|
+
const name = `${aside.getAttribute("class") || ""} ${aside.getAttribute("aria-label") || ""} ${aside.getAttribute("role") || ""}`;
|
|
305
|
+
if (!NOTE_ASIDE.test(name) || OTHER_ASIDE.test(name)) continue;
|
|
306
|
+
const quote = doc.createElement("blockquote");
|
|
307
|
+
quote.append(...Array.from(aside.childNodes));
|
|
308
|
+
aside.replaceWith(quote);
|
|
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
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
160
320
|
/**
|
|
161
321
|
* @param {Document} doc the inert clone (mutated in place)
|
|
162
|
-
* @param {{ url?: string }} [options]
|
|
322
|
+
* @param {{ url?: string, concealment?: ReturnType<typeof import('./concealed.js').createConcealmentWalk> }} [options]
|
|
163
323
|
*/
|
|
164
324
|
export function standardizeDocument(doc, options = {}) {
|
|
165
325
|
const base = options.url || "";
|
|
@@ -167,11 +327,15 @@ export function standardizeDocument(doc, options = {}) {
|
|
|
167
327
|
lazyImagesResolved: 0,
|
|
168
328
|
inlineImagesDropped: 0,
|
|
169
329
|
scriptLinksUnwrapped: 0,
|
|
330
|
+
invalidLinksUnwrapped: 0,
|
|
170
331
|
emptyLinksRemoved: 0,
|
|
171
332
|
preWrapped: 0,
|
|
172
333
|
headingWidgetsRemoved: 0,
|
|
173
334
|
headingsUnwrapped: 0,
|
|
335
|
+
omittedTabs: /** @type {string[]} */ ([]),
|
|
174
336
|
};
|
|
337
|
+
standardizeTabPanels(doc, stats, options.concealment);
|
|
338
|
+
standardizeAsides(doc);
|
|
175
339
|
standardizeHeadings(doc, stats);
|
|
176
340
|
standardizeCode(doc, stats);
|
|
177
341
|
standardizeImages(doc, base, 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]
|
|
@@ -40,7 +41,7 @@ export {};
|
|
|
40
41
|
/**
|
|
41
42
|
* @typedef {object} PageMarkdownExtraction
|
|
42
43
|
* @property {Extraction["method"]} method
|
|
43
|
-
* @property {string} [fallbackReason] why extraction fell back to the full page
|
|
44
|
+
* @property {string} [fallbackReason] why extraction fell back to the full page or the main content
|
|
44
45
|
* @property {true} [leadRecovered] a dropped lead paragraph was restored
|
|
45
46
|
* @property {number} [pageFacts] how many facts `pageFacts` appended
|
|
46
47
|
*/
|
|
@@ -69,9 +70,13 @@ export {};
|
|
|
69
70
|
*/
|
|
70
71
|
|
|
71
72
|
/**
|
|
72
|
-
* Check `kind` before reading `chars`:
|
|
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 }
|
|
77
|
+
* | { kind: "inactive_tabs", count: number, note: string }
|
|
78
|
+
* | { kind: "comments", count: number, note: string }
|
|
79
|
+
* | { kind: "concealed_text", count: number, chars: number, note: string }
|
|
75
80
|
* | HiddenTextOmission} PageMarkdownOmission
|
|
76
81
|
*/
|
|
77
82
|
|
|
@@ -83,12 +88,17 @@ export {};
|
|
|
83
88
|
* @property {number} extractedHtmlChars
|
|
84
89
|
* @property {number} convertedChars
|
|
85
90
|
* @property {number} markdownChars
|
|
91
|
+
* @property {number} pageTextChars the page's text at capture time: a capture is a
|
|
92
|
+
* snapshot of the DOM, so a caller can re-capture until this number settles
|
|
86
93
|
* @property {StandardizationStats} standardization
|
|
87
94
|
*/
|
|
88
95
|
|
|
89
96
|
/**
|
|
90
97
|
* @typedef {object} PageMarkdownResult
|
|
91
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
|
|
92
102
|
* @property {string} markdown
|
|
93
103
|
* @property {PageMarkdownMetadata} metadata
|
|
94
104
|
* @property {PageMarkdownExtraction} extraction
|
|
@@ -100,6 +110,17 @@ export {};
|
|
|
100
110
|
|
|
101
111
|
/** @typedef {{ label: string, value: string }} PageFact */
|
|
102
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
|
+
|
|
103
124
|
/**
|
|
104
125
|
* @typedef {string | string[] | ((node: HTMLElement, options: Record<string, unknown>) => boolean)} TurndownFilter
|
|
105
126
|
*/
|