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.
@@ -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,9 +13,24 @@ 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
- const EDIT_WIDGETS = ".mw-editsection, .headerlink, .anchor, .heading-anchor, a.anchor, [aria-hidden='true'] a";
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";
21
+ // Highlighters mark up each code line with one of these.
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;
32
+ // As URL parsers read a scheme: control characters and spaces don't count.
33
+ const isUnsafeUrl = (value) => /^(?:javascript|vbscript|data):/i.test(String(value).replace(/[\x00-\x20]+/g, ""));
18
34
 
19
35
  function resolveUrl(value, base) {
20
36
  const raw = String(value || "").trim();
@@ -44,7 +60,7 @@ function languageFrom(el, maxDepth = 3) {
44
60
 
45
61
  function hasMeaningfulContent(el) {
46
62
  if ((el.textContent || "").trim()) return true;
47
- return Boolean(el.querySelector?.("img[alt]:not([alt='']), svg title"));
63
+ return Boolean(el.querySelector?.("img, svg title"));
48
64
  }
49
65
 
50
66
  /**
@@ -62,7 +78,7 @@ function standardizeImages(doc, base, stats) {
62
78
  }
63
79
  }
64
80
  const current = img.getAttribute("src") || "";
65
- if (!current || current.startsWith("data:")) {
81
+ if (!current || isUnsafeUrl(current)) {
66
82
  img.remove();
67
83
  stats.inlineImagesDropped += 1;
68
84
  continue;
@@ -74,7 +90,7 @@ function standardizeImages(doc, base, stats) {
74
90
  function standardizeLinks(doc, base, stats) {
75
91
  for (const a of Array.from(doc.querySelectorAll("a"))) {
76
92
  const href = a.getAttribute("href") || "";
77
- if (/^\s*javascript:/i.test(href)) {
93
+ if (isUnsafeUrl(href)) {
78
94
  a.replaceWith(...Array.from(a.childNodes));
79
95
  stats.scriptLinksUnwrapped += 1;
80
96
  continue;
@@ -88,26 +104,66 @@ function standardizeLinks(doc, base, stats) {
88
104
  }
89
105
  }
90
106
 
107
+ /** Text of a code block, with <br> and block-level lines as line breaks. */
108
+ function codeText(root) {
109
+ let text = "";
110
+ const walk = (node) => {
111
+ for (const child of Array.from(node.childNodes)) {
112
+ if (child.nodeType === 3) text += child.nodeValue;
113
+ else if (child.nodeType !== 1) continue;
114
+ else if (child.localName === "br") text += "\n";
115
+ else {
116
+ const block = CODE_LINE_ELEMENTS.has(child.localName);
117
+ if (block && text && !text.endsWith("\n")) text += "\n";
118
+ walk(child);
119
+ if (block && text && !text.endsWith("\n")) text += "\n";
120
+ }
121
+ }
122
+ };
123
+ walk(root);
124
+ return text;
125
+ }
126
+
91
127
  /** <pre> without <code> (and code without a language) gets a language-tagged <code>. */
92
128
  function standardizeCode(doc, stats) {
93
129
  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
- }
130
+ for (const button of Array.from(pre.querySelectorAll("button"))) button.remove();
131
+ const inner = pre.querySelector("code");
132
+ const lang = languageFrom(inner || pre, inner ? 4 : 3);
133
+ // One <code> and only whitespace around it: that <code> is the block.
134
+ const sole = pre.children.length === 1 && pre.firstElementChild === inner
135
+ && !(pre.textContent || "").replace(inner.textContent || "", "").trim();
136
+ const code = sole ? inner : doc.createElement("code");
137
+ if (!sole) stats.preWrapped += 1;
138
+ // Turndown reads textContent, so lines kept apart by markup become text.
139
+ code.textContent = codeText(sole ? inner : pre);
140
+ while (pre.firstChild) pre.removeChild(pre.firstChild);
141
+ pre.appendChild(code);
105
142
  if (lang && !/(?:^|\s)language-/.test(code.getAttribute("class") || "")) {
106
143
  code.setAttribute("class", `${code.getAttribute("class") || ""} language-${lang}`.trim());
107
144
  }
108
145
  }
109
146
  }
110
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
+
111
167
  /**
112
168
  * Headings wrapped with edit/permalink widgets (Wikipedia's div.mw-heading,
113
169
  * Sphinx headerlinks): drop the widgets, then replace a wrapper whose only
@@ -115,22 +171,96 @@ function standardizeCode(doc, stats) {
115
171
  */
116
172
  function standardizeHeadings(doc, stats) {
117
173
  for (const widget of Array.from(doc.querySelectorAll(EDIT_WIDGETS))) {
118
- if (!(widget.textContent || "").trim() || widget.matches?.(".mw-editsection")) {
174
+ if (!(widget.textContent || "").trim() || widget.matches?.(ALWAYS_REMOVED_WIDGETS)) {
119
175
  widget.remove();
120
176
  stats.headingWidgetsRemoved += 1;
121
177
  }
122
178
  }
123
179
  for (const heading of Array.from(doc.querySelectorAll("h1, h2, h3, h4, h5, h6"))) {
180
+ removeOwnPermalinks(heading, stats);
124
181
  const parent = heading.parentElement;
125
182
  if (!parent || parent.localName !== "div") continue;
126
183
  const others = Array.from(parent.children).filter((el) => el !== heading);
127
- if (others.every((el) => !(el.textContent || "").trim() && !el.matches?.(MEDIA) && !el.querySelector?.(MEDIA))) {
184
+ const hasBareText = Array.from(parent.childNodes).some((node) => node.nodeType === 3 && node.nodeValue.trim());
185
+ if (!hasBareText && others.every((el) => !(el.textContent || "").trim() && !el.matches?.(MEDIA) && !el.querySelector?.(MEDIA))) {
128
186
  parent.replaceWith(heading);
129
187
  stats.headingsUnwrapped += 1;
130
188
  }
131
189
  }
132
190
  }
133
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
+
134
264
  /**
135
265
  * @param {Document} doc the inert clone (mutated in place)
136
266
  * @param {{ url?: string }} [options]
@@ -145,7 +275,10 @@ export function standardizeDocument(doc, options = {}) {
145
275
  preWrapped: 0,
146
276
  headingWidgetsRemoved: 0,
147
277
  headingsUnwrapped: 0,
278
+ omittedTabs: /** @type {string[]} */ ([]),
148
279
  };
280
+ standardizeTabPanels(doc, stats);
281
+ standardizeAsides(doc);
149
282
  standardizeHeadings(doc, stats);
150
283
  standardizeCode(doc, stats);
151
284
  standardizeImages(doc, base, stats);
package/src/types.js ADDED
@@ -0,0 +1,122 @@
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 or the main content
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
+ * | { kind: "inactive_tabs", count: number, note: string }
76
+ * | { kind: "comments", count: number, note: string }
77
+ * | HiddenTextOmission} PageMarkdownOmission
78
+ */
79
+
80
+ /** @typedef {ReturnType<typeof import("./standardize.js").standardizeDocument>} StandardizationStats */
81
+
82
+ /**
83
+ * @typedef {object} PageMarkdownStats
84
+ * @property {OutputMode} mode
85
+ * @property {number} extractedHtmlChars
86
+ * @property {number} convertedChars
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
90
+ * @property {StandardizationStats} standardization
91
+ */
92
+
93
+ /**
94
+ * @typedef {object} PageMarkdownResult
95
+ * @property {string} version the package version that made this result
96
+ * @property {string} markdown
97
+ * @property {PageMarkdownMetadata} metadata
98
+ * @property {PageMarkdownExtraction} extraction
99
+ * @property {PageMarkdownOmission[]} omissions
100
+ * @property {string[]} warnings
101
+ * @property {PageMarkdownStats} stats
102
+ * @property {string} [fullPageMarkdown] present only with `includeFullPage`
103
+ */
104
+
105
+ /** @typedef {{ label: string, value: string }} PageFact */
106
+
107
+ /**
108
+ * @typedef {string | string[] | ((node: HTMLElement, options: Record<string, unknown>) => boolean)} TurndownFilter
109
+ */
110
+
111
+ /**
112
+ * Turndown's public methods; Turndown ships no types of its own.
113
+ * @typedef {object} TurndownInstance
114
+ * @property {(input: string | Node) => string} turndown
115
+ * @property {(plugin: TurndownPlugin | TurndownPlugin[]) => TurndownInstance} use
116
+ * @property {(key: string, rule: { filter: TurndownFilter, replacement: (content: string, node: Node, options: Record<string, unknown>) => string }) => TurndownInstance} addRule
117
+ * @property {(filter: TurndownFilter) => TurndownInstance} keep
118
+ * @property {(filter: TurndownFilter) => TurndownInstance} remove
119
+ * @property {(text: string) => string} escape
120
+ */
121
+
122
+ /** @typedef {(service: TurndownInstance) => void} TurndownPlugin */