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.
@@ -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
- 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";
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?.(".mw-editsection")) {
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