page-markdown 0.1.5 → 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/CHANGELOG.md +273 -186
- package/README.md +62 -35
- package/dist/types/extract.d.ts +9 -4
- package/dist/types/facts.d.ts +1 -0
- package/dist/types/index.d.ts +13 -4
- package/dist/types/sanitize.d.ts +4 -2
- package/dist/types/standardize.d.ts +1 -0
- package/dist/types/types.d.ts +14 -1
- package/package.json +2 -2
- package/src/convert.js +64 -1
- package/src/extract.js +143 -8
- package/src/facts.js +6 -3
- package/src/index.js +200 -178
- package/src/refine.js +9 -3
- package/src/sanitize.js +40 -19
- package/src/standardize.js +109 -2
- package/src/types.js +5 -1
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
|
|
|
@@ -133,6 +145,25 @@ function standardizeCode(doc, stats) {
|
|
|
133
145
|
}
|
|
134
146
|
}
|
|
135
147
|
|
|
148
|
+
/**
|
|
149
|
+
* A link to the heading's own id, inside it or right beside it, is a permalink
|
|
150
|
+
* whatever its class or wording ("Link to ...", "Section titled ...", "#"). A
|
|
151
|
+
* heading whose whole text is that link keeps the text. Left in place, the link
|
|
152
|
+
* makes a short heading's wrapper look like clutter to Readability.
|
|
153
|
+
*/
|
|
154
|
+
function removeOwnPermalinks(heading, stats) {
|
|
155
|
+
const id = heading.getAttribute("id");
|
|
156
|
+
if (!id) return;
|
|
157
|
+
const title = (heading.textContent || "").trim();
|
|
158
|
+
const inside = Array.from(heading.querySelectorAll("[href]"));
|
|
159
|
+
for (const link of [...inside, heading.previousElementSibling, heading.nextElementSibling]) {
|
|
160
|
+
if (link?.getAttribute("href") !== `#${id}`) continue;
|
|
161
|
+
if (inside.includes(link) && (link.textContent || "").trim() === title) link.replaceWith(...Array.from(link.childNodes));
|
|
162
|
+
else link.remove();
|
|
163
|
+
stats.headingWidgetsRemoved += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
136
167
|
/**
|
|
137
168
|
* Headings wrapped with edit/permalink widgets (Wikipedia's div.mw-heading,
|
|
138
169
|
* Sphinx headerlinks): drop the widgets, then replace a wrapper whose only
|
|
@@ -140,12 +171,13 @@ function standardizeCode(doc, stats) {
|
|
|
140
171
|
*/
|
|
141
172
|
function standardizeHeadings(doc, stats) {
|
|
142
173
|
for (const widget of Array.from(doc.querySelectorAll(EDIT_WIDGETS))) {
|
|
143
|
-
if (!(widget.textContent || "").trim() || widget.matches?.(
|
|
174
|
+
if (!(widget.textContent || "").trim() || widget.matches?.(ALWAYS_REMOVED_WIDGETS)) {
|
|
144
175
|
widget.remove();
|
|
145
176
|
stats.headingWidgetsRemoved += 1;
|
|
146
177
|
}
|
|
147
178
|
}
|
|
148
179
|
for (const heading of Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6"))) {
|
|
180
|
+
removeOwnPermalinks(heading, stats);
|
|
149
181
|
const parent = heading.parentElement;
|
|
150
182
|
if (!parent || parent.localName !== "div") continue;
|
|
151
183
|
const others = Array.from(parent.children).filter((el) => el !== heading);
|
|
@@ -157,6 +189,78 @@ function standardizeHeadings(doc, stats) {
|
|
|
157
189
|
}
|
|
158
190
|
}
|
|
159
191
|
|
|
192
|
+
function isInactivePanel(panel) {
|
|
193
|
+
if (isHiddenElement(panel) || panel.getAttribute("aria-hidden") === "true" || panel.classList.contains("hidden")) return true;
|
|
194
|
+
// Custom elements that mark only the group: <sp-tabs selected="overview">.
|
|
195
|
+
const value = panel.getAttribute("value");
|
|
196
|
+
const selected = value && panel.parentElement?.closest("[selected]")?.getAttribute("selected");
|
|
197
|
+
return Boolean(selected) && selected !== value;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function tabOf(panel, tabs) {
|
|
201
|
+
const id = panel.getAttribute("id");
|
|
202
|
+
const labelledBy = panel.getAttribute("aria-labelledby");
|
|
203
|
+
const value = panel.getAttribute("value") || panel.getAttribute("name");
|
|
204
|
+
const named = tabs.find((tab) => (labelledBy && tab.getAttribute("id") === labelledBy)
|
|
205
|
+
|| (id && (tab.getAttribute("aria-controls") === id || tab.getAttribute("href") === `#${id}`))
|
|
206
|
+
|| (value && (tab.getAttribute("value") === value || tab.getAttribute("panel") === value)));
|
|
207
|
+
if (named) return named;
|
|
208
|
+
// Docusaurus ties them by position alone.
|
|
209
|
+
const parent = panel.parentElement;
|
|
210
|
+
const index = Array.from(parent?.children || []).filter((el) => el.matches?.(TAB_PANELS)).indexOf(panel);
|
|
211
|
+
for (let node = parent, depth = 0; node && depth < 4; node = node.parentElement, depth += 1) {
|
|
212
|
+
const list = node.querySelector("[role='tablist']");
|
|
213
|
+
if (list) return list.querySelectorAll("[role='tab']")[index];
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Tabs read as one document: inactive panels are shown, each under its tab's
|
|
220
|
+
* name, in place of a tab strip that points nowhere. A long inactive panel is
|
|
221
|
+
* left out, and named in stats.omittedTabs.
|
|
222
|
+
*/
|
|
223
|
+
function standardizeTabPanels(doc, stats) {
|
|
224
|
+
// Docker's docs write aria-role, and call a panel a tab: only the strip's is right.
|
|
225
|
+
for (const list of Array.from(doc.querySelectorAll("[aria-role='tablist']"))) {
|
|
226
|
+
list.setAttribute("role", "tablist");
|
|
227
|
+
for (const tab of Array.from(list.children)) tab.setAttribute("role", "tab");
|
|
228
|
+
for (const panel of Array.from(list.nextElementSibling?.children || [])) panel.setAttribute("role", "tabpanel");
|
|
229
|
+
}
|
|
230
|
+
const panels = Array.from(doc.querySelectorAll(TAB_PANELS));
|
|
231
|
+
if (!panels.length) return;
|
|
232
|
+
const tabs = Array.from(doc.querySelectorAll(TABS));
|
|
233
|
+
for (const panel of panels) {
|
|
234
|
+
const label = (tabOf(panel, tabs)?.textContent || "").replace(/\s+/g, " ").trim().slice(0, MAX_TAB_LABEL_CHARS);
|
|
235
|
+
const text = (panel.textContent || "").replace(/\s+/g, " ").trim();
|
|
236
|
+
if (isInactivePanel(panel)) {
|
|
237
|
+
if (text.length > MAX_INACTIVE_PANEL_CHARS) {
|
|
238
|
+
stats.omittedTabs.push(label);
|
|
239
|
+
panel.remove();
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
for (const attr of ["hidden", "aria-hidden", "style"]) panel.removeAttribute(attr);
|
|
243
|
+
panel.classList.remove("hidden");
|
|
244
|
+
}
|
|
245
|
+
if (label && panels.length > 1 && !text.toLowerCase().startsWith(label.toLowerCase())) {
|
|
246
|
+
const heading = doc.createElement("p");
|
|
247
|
+
heading.appendChild(doc.createElement("strong")).textContent = label;
|
|
248
|
+
panel.insertBefore(heading, panel.firstChild);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
for (const el of Array.from(doc.querySelectorAll(`[role='tablist'], ${TABS}`))) el.remove();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function standardizeAsides(doc) {
|
|
255
|
+
for (const aside of Array.from(doc.querySelectorAll("main aside, article aside, [role='main'] aside"))) {
|
|
256
|
+
const name = `${aside.getAttribute("class") || ""} ${aside.getAttribute("aria-label") || ""} ${aside.getAttribute("role") || ""}`;
|
|
257
|
+
if (!NOTE_ASIDE.test(name) || OTHER_ASIDE.test(name)) continue;
|
|
258
|
+
const quote = doc.createElement("blockquote");
|
|
259
|
+
quote.append(...Array.from(aside.childNodes));
|
|
260
|
+
aside.replaceWith(quote);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
160
264
|
/**
|
|
161
265
|
* @param {Document} doc the inert clone (mutated in place)
|
|
162
266
|
* @param {{ url?: string }} [options]
|
|
@@ -171,7 +275,10 @@ export function standardizeDocument(doc, options = {}) {
|
|
|
171
275
|
preWrapped: 0,
|
|
172
276
|
headingWidgetsRemoved: 0,
|
|
173
277
|
headingsUnwrapped: 0,
|
|
278
|
+
omittedTabs: /** @type {string[]} */ ([]),
|
|
174
279
|
};
|
|
280
|
+
standardizeTabPanels(doc, stats);
|
|
281
|
+
standardizeAsides(doc);
|
|
175
282
|
standardizeHeadings(doc, stats);
|
|
176
283
|
standardizeCode(doc, stats);
|
|
177
284
|
standardizeImages(doc, base, stats);
|
package/src/types.js
CHANGED
|
@@ -40,7 +40,7 @@ export {};
|
|
|
40
40
|
/**
|
|
41
41
|
* @typedef {object} PageMarkdownExtraction
|
|
42
42
|
* @property {Extraction["method"]} method
|
|
43
|
-
* @property {string} [fallbackReason] why extraction fell back to the full page
|
|
43
|
+
* @property {string} [fallbackReason] why extraction fell back to the full page or the main content
|
|
44
44
|
* @property {true} [leadRecovered] a dropped lead paragraph was restored
|
|
45
45
|
* @property {number} [pageFacts] how many facts `pageFacts` appended
|
|
46
46
|
*/
|
|
@@ -72,6 +72,8 @@ export {};
|
|
|
72
72
|
* Check `kind` before reading `chars`: only hidden-text omissions have it.
|
|
73
73
|
* @typedef {{ kind: "shadow_content", count: number, note: string }
|
|
74
74
|
* | { kind: "json_line", count: number, note: string }
|
|
75
|
+
* | { kind: "inactive_tabs", count: number, note: string }
|
|
76
|
+
* | { kind: "comments", count: number, note: string }
|
|
75
77
|
* | HiddenTextOmission} PageMarkdownOmission
|
|
76
78
|
*/
|
|
77
79
|
|
|
@@ -83,6 +85,8 @@ export {};
|
|
|
83
85
|
* @property {number} extractedHtmlChars
|
|
84
86
|
* @property {number} convertedChars
|
|
85
87
|
* @property {number} markdownChars
|
|
88
|
+
* @property {number} pageTextChars the page's text at capture time: a capture is a
|
|
89
|
+
* snapshot of the DOM, so a caller can re-capture until this number settles
|
|
86
90
|
* @property {StandardizationStats} standardization
|
|
87
91
|
*/
|
|
88
92
|
|