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/src/facts.js CHANGED
@@ -2,12 +2,13 @@
2
2
  // license, downloads), price and spec boxes, definition lists, two-column
3
3
  // tables. Readability keeps the article and drops these asides, which is right
4
4
  // for reading and wrong when the question is "which version?". Opt-in.
5
+ import { PAGE_CHROME } from "./extract.js";
5
6
  import { htmlText } from "./refine.js";
6
7
 
7
8
  const MAX_FACTS = 24;
8
9
  const MAX_LABEL_CHARS = 40;
9
10
  const MAX_VALUE_CHARS = 100;
10
- const CHROME = "nav, footer, header, form, [role='navigation'], [role='contentinfo'], [role='banner'], [role='search']";
11
+ const CHROME = `${PAGE_CHROME}, header, [id*='cookie'], [class*='cookie'], [id*='consent'], [class*='consent'], [id*='onetrust'], [class*='onetrust']`;
11
12
 
12
13
  function squash(text) {
13
14
  return String(text || "").replace(/\s+/g, " ").trim();
@@ -19,7 +20,7 @@ function isSentence(text) {
19
20
  }
20
21
 
21
22
  function shortValue(el) {
22
- if (!el || el.querySelector("h1, h2, h3, h4, h5, h6, table, pre")) return "";
23
+ if (!el || el.querySelector("h1, h2, h3, h4, h5, h6, table, pre, form, button, input, select, textarea")) return "";
23
24
  // A list of links is navigation, not a fact.
24
25
  const links = el.tagName === "A" ? [el] : el.querySelectorAll("a");
25
26
  if (links.length > 1) return "";
@@ -32,6 +33,8 @@ function shortValue(el) {
32
33
  function* candidatePairs(doc) {
33
34
  // Heading followed by a short value: <h3>Version</h3><p>1.2.3</p>.
34
35
  for (const h of doc.querySelectorAll("h2, h3, h4, h5, h6")) {
36
+ // A linked heading titles something else: another story's teaser, a card.
37
+ if (h.closest("a") || h.querySelector("a")) continue;
35
38
  yield [h.textContent, shortValue(h.nextElementSibling), h];
36
39
  }
37
40
  for (const dt of doc.querySelectorAll("dt")) {
@@ -84,7 +87,7 @@ export function collectPageFacts(doc, articleHtml) {
84
87
  return facts;
85
88
  }
86
89
 
87
- function escapeMarkdown(text) {
90
+ export function escapeMarkdown(text) {
88
91
  return text.replace(/([\\`*_[\]<>|])/g, "\\$1");
89
92
  }
90
93
 
package/src/index.js CHANGED
@@ -1,178 +1,200 @@
1
- // page-markdown: a page's DOM in, clean Markdown and metadata out.
2
- // sanitize → standardize → extract → convert → clean (→ mode) (→ hidden text)
3
- import { sanitizeToInertDocument } from "./sanitize.js";
4
- import { standardizeDocument } from "./standardize.js";
5
- import { extractContent } from "./extract.js";
6
- import { htmlToMarkdown } from "./convert.js";
7
- import { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
8
- import { collectMetadata } from "./metadata.js";
9
- import { recoverLeadParagraph, chooseHeadline } from "./refine.js";
10
- import { collectPageFacts, pageFactsMarkdown } from "./facts.js";
11
- import { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
12
-
13
- export const VERSION = "0.1.5";
14
-
15
- export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
16
- export { standardizeDocument } from "./standardize.js";
17
- export { extractContent, DEFAULT_MIN_EXTRACT_CHARS } from "./extract.js";
18
- export { htmlToMarkdown, createTurndown, TURNDOWN_OPTIONS } from "./convert.js";
19
- export { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
20
- export { collectMetadata } from "./metadata.js";
21
- export { recoverLeadParagraph, chooseHeadline } from "./refine.js";
22
- export { collectPageFacts, pageFactsMarkdown } from "./facts.js";
23
- export { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
24
- export * from "./types.js";
25
-
26
- function documentOf(input) {
27
- if (input?.nodeType === 9) return input;
28
- if (input?.nodeType === 1) return input.ownerDocument;
29
- throw new TypeError("page-markdown: expected a Document or Element");
30
- }
31
-
32
- function toMarkdown(html, mode) {
33
- const converted = htmlToMarkdown(html);
34
- const { markdown, removedJsonLines } = cleanMarkdown(converted);
35
- return { markdown: applyMode(markdown, mode), removedJsonLines, convertedChars: converted.length };
36
- }
37
-
38
- // What relative links resolve against: the page's <base href>, else its URL.
39
- function baseUrl(doc, url) {
40
- const href = doc.querySelector?.("base[href]")?.getAttribute("href");
41
- try {
42
- return href ? new URL(href, url || undefined).href : url;
43
- } catch {
44
- return url;
45
- }
46
- }
47
-
48
- // A top-level heading in the prose; a "# comment" line in fenced code is not one.
49
- function hasTopHeading(markdown) {
50
- let found = false;
51
- mapOutsideFences(markdown, (prose) => {
52
- found ||= /^# \S/m.test(prose);
53
- return prose;
54
- });
55
- return found;
56
- }
57
-
58
- /**
59
- * @typedef {object} PageMarkdownOptions
60
- * @property {string} [url] page URL for absolute links (default: the document's URL)
61
- * @property {"full"|"no_links"|"text_only"} [mode="full"]
62
- * @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
63
- * "readable": Readability with full-page fallback; "full": the whole body;
64
- * or a custom extractor receiving a copy of the standardized document.
65
- * @property {number} [minExtractChars=100] below this, fall back to the full page
66
- * @property {boolean} [includeFullPage=false] also return the full-page Markdown
67
- * @property {boolean} [includeTitle=true] start readable output with "# <headline>"
68
- * @property {boolean} [recoverLead=true] restore a dropped lead paragraph that matches the meta description
69
- * @property {boolean} [pageFacts=false] append "## Page facts": short label/value pairs
70
- * (sidebar versions, prices, spec rows) that the extracted article left out
71
- * @property {boolean} [removeHiddenText=false] remove tag-character text,
72
- * variation-selector runs and invisible operators from the Markdown (fenced
73
- * code included), fullPageMarkdown and metadata; omissions count the runs
74
- * removed from the Markdown
75
- * @property {import("./types.js").SanitizeOptions} [sanitize] see DEFAULT_SANITIZE_OPTIONS
76
- * @property {import("./types.js").ReadabilityOptions} [readability] options passed to Readability
77
- */
78
-
79
- /**
80
- * Converts a page (or an element of it) to Markdown.
81
- * Pure with respect to the input: the live document is only read.
82
- *
83
- * @param {Document|Element} input
84
- * @param {PageMarkdownOptions} [options]
85
- * @returns {import("./types.js").PageMarkdownResult}
86
- */
87
- export function pageToMarkdown(input, options = {}) {
88
- const doc = documentOf(input);
89
- const url = options.url || doc.URL || doc.location?.href || "";
90
- const mode = options.mode || "full";
91
- /** @type {string[]} */
92
- const warnings = [];
93
-
94
- const { document: inert, omissions } = sanitizeToInertDocument(input, options.sanitize);
95
- const standardization = standardizeDocument(inert, { url: baseUrl(doc, url) });
96
- const extraction = extractContent(inert, {
97
- extract: options.extract,
98
- minExtractChars: options.minExtractChars,
99
- readability: options.readability,
100
- });
101
- if (extraction.fallbackReason) warnings.push(`extraction fell back to the full page: ${extraction.fallbackReason}`);
102
-
103
- const pageMetadata = collectMetadata(doc, { url });
104
- let extractedHtml = extraction.html;
105
- let leadRecovered = false;
106
- if (extraction.method !== "full_page" && options.recoverLead !== false) {
107
- ({ html: extractedHtml, recovered: leadRecovered } = recoverLeadParagraph(extractedHtml, inert, pageMetadata.description));
108
- }
109
- const headline = chooseHeadline(inert, { ...pageMetadata, extractedTitle: extraction.title });
110
-
111
- const main = toMarkdown(extractedHtml, mode);
112
- // Readability lifts the article's H1 out as its title; put a clean heading
113
- // back so the capture stands alone, unless the body still has a top-level one.
114
- if (options.includeTitle !== false && extraction.method === "readability" && mode !== "text_only") {
115
- if (headline && !hasTopHeading(main.markdown)) main.markdown = `# ${headline}\n\n${main.markdown}`;
116
- }
117
- let pageFacts = [];
118
- if (options.pageFacts && extraction.method !== "full_page" && mode !== "text_only") {
119
- pageFacts = collectPageFacts(inert, extractedHtml);
120
- if (pageFacts.length) main.markdown = `${main.markdown}\n\n${pageFactsMarkdown(pageFacts)}`;
121
- }
122
- /** @type {import("./types.js").PageMarkdownOmission[]} */
123
- const omissionList = [];
124
- if (omissions.shadowHosts) {
125
- omissionList.push({ kind: "shadow_content", count: omissions.shadowHosts, note: "Open shadow content omitted: capture limit" });
126
- }
127
- if (main.removedJsonLines) {
128
- omissionList.push({ kind: "json_line", count: main.removedJsonLines, note: "Large single-line JSON documents removed" });
129
- }
130
-
131
- /** @type {import("./types.js").PageMarkdownResult} */
132
- const result = {
133
- version: VERSION,
134
- markdown: main.markdown,
135
- metadata: {
136
- ...pageMetadata,
137
- ...(headline ? { headline } : {}),
138
- ...(extraction.title ? { extractedTitle: extraction.title } : {}),
139
- ...(extraction.byline ? { byline: extraction.byline } : {}),
140
- },
141
- extraction: {
142
- method: extraction.method,
143
- ...(extraction.fallbackReason ? { fallbackReason: extraction.fallbackReason } : {}),
144
- ...(leadRecovered ? { leadRecovered: true } : {}),
145
- ...(pageFacts.length ? { pageFacts: pageFacts.length } : {}),
146
- },
147
- omissions: omissionList,
148
- warnings,
149
- stats: {
150
- mode,
151
- extractedHtmlChars: extraction.html.length,
152
- convertedChars: main.convertedChars,
153
- markdownChars: main.markdown.length,
154
- standardization,
155
- },
156
- };
157
-
158
- if (options.includeFullPage) {
159
- const full = extraction.method === "full_page"
160
- ? main
161
- : toMarkdown((inert.body || inert.documentElement).innerHTML || "", mode);
162
- result.fullPageMarkdown = full.markdown;
163
- }
164
- if (options.removeHiddenText) {
165
- const cleaned = removeHiddenText(result.markdown);
166
- result.markdown = cleaned.text;
167
- result.omissions.push(...hiddenTextOmissions(cleaned.runs));
168
- if (result.fullPageMarkdown !== undefined) result.fullPageMarkdown = removeHiddenText(result.fullPageMarkdown).text;
169
- for (const [key, value] of Object.entries(result.metadata)) {
170
- if (typeof value === "string") result.metadata[key] = removeHiddenText(value).text;
171
- }
172
- }
173
- if (omissions.shadowHosts) {
174
- result.markdown += `\n\n_Open shadow content omitted: capture limit (${omissions.shadowHosts} host(s))._`;
175
- }
176
- result.stats.markdownChars = result.markdown.length;
177
- return result;
178
- }
1
+ // page-markdown: a page's DOM in, clean Markdown and metadata out.
2
+ // sanitize → standardize → extract → convert → clean (→ mode) (→ hidden text)
3
+ import { sanitizeToInertDocument } from "./sanitize.js";
4
+ import { standardizeDocument } from "./standardize.js";
5
+ import { extractContent } from "./extract.js";
6
+ import { htmlToMarkdown } from "./convert.js";
7
+ import { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
8
+ import { collectMetadata } from "./metadata.js";
9
+ import { recoverLeadParagraph, chooseHeadline } from "./refine.js";
10
+ import { collectPageFacts, pageFactsMarkdown, escapeMarkdown } from "./facts.js";
11
+ import { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
12
+
13
+ export const VERSION = "0.2.0";
14
+
15
+ export { sanitizeToInertDocument, DEFAULT_SANITIZE_OPTIONS, isAdvertisementElement, createInertDocument } from "./sanitize.js";
16
+ export { standardizeDocument } from "./standardize.js";
17
+ export { extractContent, DEFAULT_MIN_EXTRACT_CHARS, DEFAULT_MIN_EXTRACT_SHARE } from "./extract.js";
18
+ export { htmlToMarkdown, createTurndown, TURNDOWN_OPTIONS } from "./convert.js";
19
+ export { cleanMarkdown, applyMode, mapOutsideFences } from "./clean.js";
20
+ export { collectMetadata } from "./metadata.js";
21
+ export { recoverLeadParagraph, chooseHeadline } from "./refine.js";
22
+ export { collectPageFacts, pageFactsMarkdown } from "./facts.js";
23
+ export { removeHiddenText, hiddenTextOmissions } from "./hidden.js";
24
+ export * from "./types.js";
25
+
26
+ function documentOf(input) {
27
+ if (input?.nodeType === 9) return input;
28
+ if (input?.nodeType === 1) return input.ownerDocument;
29
+ throw new TypeError("page-markdown: expected a Document or Element");
30
+ }
31
+
32
+ function toMarkdown(html, mode) {
33
+ const converted = htmlToMarkdown(html);
34
+ const { markdown, removedJsonLines } = cleanMarkdown(converted);
35
+ return { markdown: applyMode(markdown, mode), removedJsonLines, convertedChars: converted.length };
36
+ }
37
+
38
+ // What relative links resolve against: the page's <base href>, else its URL.
39
+ function baseUrl(doc, url) {
40
+ const href = doc.querySelector?.("base[href]")?.getAttribute("href");
41
+ try {
42
+ return href ? new URL(href, url || undefined).href : url;
43
+ } catch {
44
+ return url;
45
+ }
46
+ }
47
+
48
+ // A top-level heading in the prose; a "# comment" line in fenced code is not one.
49
+ function hasTopHeading(markdown) {
50
+ let found = false;
51
+ mapOutsideFences(markdown, (prose) => {
52
+ found ||= /^# \S/m.test(prose);
53
+ return prose;
54
+ });
55
+ return found;
56
+ }
57
+
58
+ /**
59
+ * @typedef {object} PageMarkdownOptions
60
+ * @property {string} [url] page URL for absolute links (default: the document's URL)
61
+ * @property {"full"|"no_links"|"text_only"} [mode="full"]
62
+ * @property {"readable"|"full"|import("./extract.js").Extractor} [extract="readable"]
63
+ * "readable": Readability with full-page fallback; "full": the whole body;
64
+ * or a custom extractor receiving a copy of the standardized document.
65
+ * @property {number} [minExtractChars=100] below this, fall back to the page's <main>
66
+ * if it marks one and it holds enough, else to the full page
67
+ * @property {number} [minExtractShare=0.4] when Readability keeps less than this
68
+ * share of the page's main content, use the main content instead; 0 turns it off
69
+ * @property {boolean} [includeFullPage=false] also return the full-page Markdown
70
+ * @property {boolean} [includeTitle=true] start readable output with "# <headline>"
71
+ * @property {boolean} [recoverLead=true] restore a dropped lead paragraph that matches the meta description
72
+ * @property {boolean} [pageFacts=false] append "## Page facts": short label/value pairs
73
+ * (sidebar versions, prices, spec rows) that the extracted article left out
74
+ * @property {boolean} [removeHiddenText=false] remove tag-character text,
75
+ * variation-selector runs and invisible operators from the Markdown (fenced
76
+ * code included), fullPageMarkdown and metadata; omissions count the runs
77
+ * removed from the Markdown
78
+ * @property {import("./types.js").SanitizeOptions} [sanitize] see DEFAULT_SANITIZE_OPTIONS
79
+ * @property {import("./types.js").ReadabilityOptions} [readability] options passed to Readability
80
+ */
81
+
82
+ /**
83
+ * Converts a page (or an element of it) to Markdown.
84
+ * Pure with respect to the input: the live document is only read.
85
+ *
86
+ * @param {Document|Element} input
87
+ * @param {PageMarkdownOptions} [options]
88
+ * @returns {import("./types.js").PageMarkdownResult}
89
+ */
90
+ export function pageToMarkdown(input, options = {}) {
91
+ const doc = documentOf(input);
92
+ const url = options.url || doc.URL || doc.location?.href || "";
93
+ const mode = options.mode || "full";
94
+ /** @type {string[]} */
95
+ const warnings = [];
96
+
97
+ const { document: inert, omissions } = sanitizeToInertDocument(input, options.sanitize);
98
+ const standardization = standardizeDocument(inert, { url: baseUrl(doc, url) });
99
+ const extraction = extractContent(inert, {
100
+ extract: options.extract,
101
+ minExtractChars: options.minExtractChars,
102
+ minExtractShare: options.minExtractShare,
103
+ readability: options.readability,
104
+ });
105
+ const fallbackTarget = extraction.method === "main_content" ? "the main content" : "the full page";
106
+ if (extraction.fallbackReason) {
107
+ warnings.push(`extraction fell back to ${fallbackTarget}: ${extraction.fallbackReason}`);
108
+ }
109
+ if (omissions.shadowHosts) warnings.push(`shadow content cut off at the capture limit in ${omissions.shadowHosts} host(s); the page's main content may be missing`);
110
+
111
+ const pageMetadata = collectMetadata(doc, { url });
112
+ let extractedHtml = extraction.html;
113
+ let leadRecovered = false;
114
+ if (extraction.method !== "full_page" && options.recoverLead !== false) {
115
+ ({ html: extractedHtml, recovered: leadRecovered } = recoverLeadParagraph(extractedHtml, inert, pageMetadata.description));
116
+ }
117
+ const headline = chooseHeadline(inert, { ...pageMetadata, extractedTitle: extraction.title });
118
+
119
+ const main = toMarkdown(extractedHtml, mode);
120
+ // Readability lifts the article's H1 out as its title; put a clean heading
121
+ // back so the capture stands alone, unless the body still has a top-level one.
122
+ if (options.includeTitle !== false && ["readability", "main_content"].includes(extraction.method) && mode !== "text_only") {
123
+ if (headline && !hasTopHeading(main.markdown)) main.markdown = `# ${headline}\n\n${main.markdown}`;
124
+ }
125
+ let pageFacts = [];
126
+ if (options.pageFacts && extraction.method !== "full_page" && mode !== "text_only") {
127
+ pageFacts = collectPageFacts(inert, extractedHtml);
128
+ if (pageFacts.length) main.markdown = `${main.markdown}\n\n${pageFactsMarkdown(pageFacts)}`;
129
+ }
130
+ /** @type {import("./types.js").PageMarkdownOmission[]} */
131
+ const omissionList = [];
132
+ if (omissions.shadowHosts) {
133
+ omissionList.push({ kind: "shadow_content", count: omissions.shadowHosts, note: "Open shadow content omitted: capture limit" });
134
+ }
135
+ if (main.removedJsonLines) {
136
+ omissionList.push({ kind: "json_line", count: main.removedJsonLines, note: "Large single-line JSON documents removed" });
137
+ }
138
+ if (standardization.omittedTabs.length) {
139
+ const count = standardization.omittedTabs.length;
140
+ const labels = standardization.omittedTabs.filter(Boolean).map(escapeMarkdown).join(", ");
141
+ omissionList.push({ kind: "inactive_tabs", count, note: labels ? `Other tabs not included: ${labels}` : `${count} other tab(s) not included` });
142
+ }
143
+ if (extraction.commentsOmitted) {
144
+ omissionList.push({ kind: "comments", count: 1, note: "Comment section not included" });
145
+ }
146
+
147
+ /** @type {import("./types.js").PageMarkdownResult} */
148
+ const result = {
149
+ version: VERSION,
150
+ markdown: main.markdown,
151
+ metadata: {
152
+ ...pageMetadata,
153
+ ...(headline ? { headline } : {}),
154
+ ...(extraction.title ? { extractedTitle: extraction.title } : {}),
155
+ ...(extraction.byline ? { byline: extraction.byline } : {}),
156
+ },
157
+ extraction: {
158
+ method: extraction.method,
159
+ ...(extraction.fallbackReason ? { fallbackReason: extraction.fallbackReason } : {}),
160
+ ...(leadRecovered ? { leadRecovered: true } : {}),
161
+ ...(pageFacts.length ? { pageFacts: pageFacts.length } : {}),
162
+ },
163
+ omissions: omissionList,
164
+ warnings,
165
+ stats: {
166
+ mode,
167
+ extractedHtmlChars: extraction.html.length,
168
+ convertedChars: main.convertedChars,
169
+ markdownChars: main.markdown.length,
170
+ pageTextChars: (inert.body?.textContent || "").replace(/\s+/g, " ").trim().length,
171
+ standardization,
172
+ },
173
+ };
174
+
175
+ if (options.includeFullPage) {
176
+ const full = extraction.method === "full_page"
177
+ ? main
178
+ : toMarkdown((inert.body || inert.documentElement).innerHTML || "", mode);
179
+ result.fullPageMarkdown = full.markdown;
180
+ }
181
+ if (options.removeHiddenText) {
182
+ const cleaned = removeHiddenText(result.markdown);
183
+ result.markdown = cleaned.text;
184
+ result.omissions.push(...hiddenTextOmissions(cleaned.runs));
185
+ if (result.fullPageMarkdown !== undefined) result.fullPageMarkdown = removeHiddenText(result.fullPageMarkdown).text;
186
+ for (const [key, value] of Object.entries(result.metadata)) {
187
+ if (typeof value === "string") result.metadata[key] = removeHiddenText(value).text;
188
+ }
189
+ }
190
+ // A caller may pass on only the Markdown, so what changes the content says so
191
+ // in it too. Tab labels are page text: hidden-text removal applies to them.
192
+ const notices = [
193
+ ...(extraction.fallbackReason ? [`Captured ${fallbackTarget} instead of the article (\`${extraction.fallbackReason}\`)`] : []),
194
+ ...omissionList.filter(({ kind }) => ["json_line", "inactive_tabs", "comments"].includes(kind)).map((omission) => omission.note),
195
+ ...(omissions.shadowHosts ? [`Open shadow content omitted: capture limit (${omissions.shadowHosts} host(s))`] : []),
196
+ ].map((notice) => (options.removeHiddenText ? removeHiddenText(notice).text : notice));
197
+ for (const notice of notices) result.markdown += `\n\n_${notice}._`;
198
+ result.stats.markdownChars = result.markdown.length;
199
+ return result;
200
+ }
package/src/refine.js CHANGED
@@ -100,10 +100,16 @@ function stripSiteSuffix(title, siteName) {
100
100
  export function chooseHeadline(doc, meta = {}) {
101
101
  const h1 = firstHeading(doc);
102
102
  const titles = [meta.documentTitle, meta.title, meta.extractedTitle].map(norm).filter(Boolean);
103
- if (h1 && h1.length <= 200 && norm(h1) !== norm(meta.siteName) && titles.some((t) => t.includes(norm(h1)))) {
104
- return h1;
103
+ const site = norm(meta.siteName);
104
+ if (h1 && h1.length <= 200 && norm(h1) !== site) {
105
+ if (titles.some((t) => t.includes(norm(h1)))) return h1;
106
+ // An H1 with a badge after its title: "Tabs Since v1.0.0" in "Overview | Tabs | Site".
107
+ const segment = titles.flatMap((t) => t.split(/\s(?:[|–—·»-]|::)\s/))
108
+ .find((s) => s && s !== site && norm(h1).startsWith(`${s} `));
109
+ if (segment) return h1.slice(0, segment.length);
105
110
  }
106
111
  const og = String(meta.title || "").trim();
107
- if (og && og !== String(meta.documentTitle || "").trim()) return stripSiteSuffix(og, meta.siteName);
112
+ // Some sites give every page the same og:title, the site's own name.
113
+ if (og && og !== String(meta.documentTitle || "").trim() && norm(og) !== site) return stripSiteSuffix(og, meta.siteName);
108
114
  return stripSiteSuffix(meta.extractedTitle || meta.documentTitle || og, meta.siteName);
109
115
  }
package/src/sanitize.js CHANGED
@@ -27,7 +27,7 @@ const STRIP_RULES = {
27
27
  // Advertising placements: explicit ad markup, plus custom elements named for ad
28
28
  // slots (e.g. MDN's <mdn-placement-sidebar>).
29
29
  const AD_SELECTOR = [
30
- "ins.adsbygoogle", ".adsbygoogle", ".advertisement",
30
+ "ins.adsbygoogle", ".adsbygoogle", ".advertisement", ".ad-label",
31
31
  "[data-ad-slot]", "[data-ad-client]", "[data-ad-unit]",
32
32
  "[aria-label='Advertisement' i]", "[aria-label='Advertisements' i]", "[aria-label='Sponsored' i]",
33
33
  "iframe[src*='doubleclick.net']", "iframe[src*='googlesyndication.com']", "iframe[id^='google_ads_iframe']",
@@ -79,8 +79,6 @@ export function createInertDocument(doc) {
79
79
  throw new Error("page-markdown: cannot create an inert document (no createHTMLDocument or DOMParser)");
80
80
  }
81
81
 
82
- const SHADOW_LIMIT = Symbol("shadow limit");
83
-
84
82
  function positiveLimit(value, name) {
85
83
  if (!Number.isSafeInteger(value) || value < 1) {
86
84
  throw new RangeError(`page-markdown: ${name} must be a positive integer`);
@@ -91,8 +89,10 @@ function positiveLimit(value, name) {
91
89
  /**
92
90
  * Clones `root` (a Document or Element) into a new inert document.
93
91
  * Open shadow roots replace their host's light children (native slots supply
94
- * assigned children). Limits apply only to shadow content; a host over the
95
- * limit is emptied and counted as an omission.
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
96
  *
97
97
  * @param {Document|Element} root
98
98
  * @param {import("./types.js").SanitizeOptions} [options]
@@ -127,6 +127,7 @@ export function sanitizeToInertDocument(root, options = {}) {
127
127
 
128
128
  let nodes = 0;
129
129
  let chars = 0;
130
+ let overLimit = false;
130
131
  let shadowHosts = 0;
131
132
 
132
133
  function isAssigningSlot(node) {
@@ -134,12 +135,37 @@ export function sanitizeToInertDocument(root, options = {}) {
134
135
  && typeof node.assignedNodes === "function" && Boolean(node.getRootNode?.().host);
135
136
  }
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
+
137
152
  function shadowNode(node, depth) {
138
- if (++nodes > limits.nodes || depth > limits.depth) throw SHADOW_LIMIT;
153
+ const inPage = node.getRootNode?.().nodeType === 9;
154
+ if (depth > limits.depth || (!inPage && ++nodes > limits.nodes)) {
155
+ overLimit = true;
156
+ return null;
157
+ }
139
158
  if (isStripped(node, selectors, opts)) return inert.createDocumentFragment();
140
159
  const copy = shallow(node);
141
- chars += node.nodeType === 1 ? (copy.outerHTML || "").length : (node.nodeValue || "").length * 6;
142
- if (chars > limits.chars) throw SHADOW_LIMIT;
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
+ }
143
169
  let children = node.childNodes;
144
170
  let target = copy;
145
171
  if (isAssigningSlot(node)) {
@@ -152,7 +178,11 @@ export function sanitizeToInertDocument(root, options = {}) {
152
178
  children = node.content.childNodes;
153
179
  target = copy.content || copy;
154
180
  }
155
- for (const child of Array.from(children || [])) target.appendChild(shadowNode(child, depth + 1));
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
+ }
156
186
  return isAssigningSlot(node) ? target : copy;
157
187
  }
158
188
 
@@ -162,16 +192,7 @@ export function sanitizeToInertDocument(root, options = {}) {
162
192
  while (pending.length) {
163
193
  const [live, copy] = /** @type {any[]} */ (pending.pop());
164
194
  if (live.shadowRoot) {
165
- const fragment = inert.createDocumentFragment();
166
- try {
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
- }
195
+ copyShadowRoot(live, copy, 1);
175
196
  continue;
176
197
  }
177
198
  const children = live.content?.childNodes || live.childNodes;