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.
@@ -0,0 +1,31 @@
1
+ /** The text of serialized HTML: tags to spaces, the entities serializers write decoded. */
2
+ export function htmlText(html: any): string;
3
+ /**
4
+ * Many sites state their summary as both the meta description and the first
5
+ * paragraph. Extractors can drop that paragraph when it sits in a section
6
+ * without a heading (MDN). If the description matches a paragraph in the page
7
+ * that the extracted HTML lacks, put that paragraph first.
8
+ *
9
+ * @param {string} html extracted HTML
10
+ * @param {Document} doc standardized clone
11
+ * @param {string} description meta description
12
+ * @returns {{ html: string, recovered: boolean }}
13
+ */
14
+ export function recoverLeadParagraph(html: string, doc: Document, description: string): {
15
+ html: string;
16
+ recovered: boolean;
17
+ };
18
+ /**
19
+ * The heading for the capture: the page's own H1 when the document title or
20
+ * og:title contains it (and it is not just the site name); otherwise og:title;
21
+ * otherwise the title without a trailing site-name segment.
22
+ *
23
+ * @param {Document} doc standardized clone
24
+ * @param {{ documentTitle?: string, title?: string, siteName?: string, extractedTitle?: string }} [meta]
25
+ */
26
+ export function chooseHeadline(doc: Document, meta?: {
27
+ documentTitle?: string;
28
+ title?: string;
29
+ siteName?: string;
30
+ extractedTitle?: string;
31
+ }): string;
@@ -0,0 +1,28 @@
1
+ /** @param {any} node */
2
+ export function isAdvertisementElement(node: any): boolean;
3
+ /**
4
+ * Creates an empty HTML document unconnected to the page: no scripts run and
5
+ * no custom elements upgrade in it.
6
+ * @param {Document} doc
7
+ */
8
+ export function createInertDocument(doc: Document): Document;
9
+ /**
10
+ * Clones `root` (a Document or Element) into a new inert document.
11
+ * Open shadow roots replace their host's light children (native slots supply
12
+ * assigned children). Limits apply only to shadow content, per host in the
13
+ * page's own tree, so slotted page content is not charged: docs sites put a
14
+ * whole article in one host's slot. A host over the limit keeps what fits and
15
+ * is counted as an omission.
16
+ *
17
+ * @param {Document|Element} root
18
+ * @param {import("./types.js").SanitizeOptions} [options]
19
+ * @returns {{ document: Document, omissions: { shadowHosts: number } }}
20
+ */
21
+ export function sanitizeToInertDocument(root: Document | Element, options?: import("./types.js").SanitizeOptions): {
22
+ document: Document;
23
+ omissions: {
24
+ shadowHosts: number;
25
+ };
26
+ };
27
+ /** @type {Readonly<Required<import("./types.js").SanitizeOptions>>} */
28
+ export const DEFAULT_SANITIZE_OPTIONS: Readonly<Required<import("./types.js").SanitizeOptions>>;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @param {Document} doc the inert clone (mutated in place)
3
+ * @param {{ url?: string }} [options]
4
+ */
5
+ export function standardizeDocument(doc: Document, options?: {
6
+ url?: string;
7
+ }): {
8
+ lazyImagesResolved: number;
9
+ inlineImagesDropped: number;
10
+ scriptLinksUnwrapped: number;
11
+ emptyLinksRemoved: number;
12
+ preWrapped: number;
13
+ headingWidgetsRemoved: number;
14
+ headingsUnwrapped: number;
15
+ omittedTabs: string[];
16
+ };
@@ -0,0 +1,167 @@
1
+ /// <reference lib="dom" preserve="true" />
2
+ export type OutputMode = "full" | "no_links" | "text_only";
3
+ export type SanitizeOptions = {
4
+ stripScripts?: boolean | undefined;
5
+ stripStyles?: boolean | undefined;
6
+ stripNoscript?: boolean | undefined;
7
+ stripMeta?: boolean | undefined;
8
+ stripLinkTags?: boolean | undefined;
9
+ stripAds?: boolean | undefined;
10
+ /**
11
+ * app-specific overlays or widgets to drop, e.g. "[data-app-overlay]"
12
+ */
13
+ extraStripSelectors?: readonly string[] | undefined;
14
+ shadowNodeLimit?: number | undefined;
15
+ shadowCharLimit?: number | undefined;
16
+ shadowDepthLimit?: number | undefined;
17
+ };
18
+ export type ReadabilityOptions = NonNullable<ConstructorParameters<typeof import("@mozilla/readability").Readability<string>>[1]>;
19
+ export type Extraction = import("./extract.js").Extraction;
20
+ export type Extractor = import("./extract.js").Extractor;
21
+ export type CollectedMetadata = ReturnType<typeof import("./metadata.js").collectMetadata>;
22
+ export type ExtractedMetadata = {
23
+ /**
24
+ * the heading chosen for the capture
25
+ */
26
+ headline?: string | undefined;
27
+ /**
28
+ * the title the extractor reported
29
+ */
30
+ extractedTitle?: string | undefined;
31
+ /**
32
+ * the byline the extractor reported
33
+ */
34
+ byline?: string | undefined;
35
+ };
36
+ export type PageMarkdownMetadata = CollectedMetadata & ExtractedMetadata;
37
+ export type PageMarkdownExtraction = {
38
+ method: Extraction["method"];
39
+ /**
40
+ * why extraction fell back to the full page or the main content
41
+ */
42
+ fallbackReason?: string | undefined;
43
+ /**
44
+ * a dropped lead paragraph was restored
45
+ */
46
+ leadRecovered?: true | undefined;
47
+ /**
48
+ * how many facts `pageFacts` appended
49
+ */
50
+ pageFacts?: number | undefined;
51
+ };
52
+ export type HiddenRunKind = "tag" | "variation_selector" | "invisible_operator" | "reserved_tag_plane";
53
+ /**
54
+ * A run as text-sanctifier's findHiddenRuns reports it.
55
+ */
56
+ export type HiddenRun = {
57
+ kind: HiddenRunKind;
58
+ /**
59
+ * UTF-16 index of the first character
60
+ */
61
+ offset: number;
62
+ /**
63
+ * UTF-16 index just past the last character
64
+ */
65
+ end: number;
66
+ /**
67
+ * code points in the run
68
+ */
69
+ count: number;
70
+ /**
71
+ * 1-based line of the first character
72
+ */
73
+ line: number;
74
+ /**
75
+ * 1-based column of the first character, in UTF-16 units
76
+ */
77
+ column: number;
78
+ /**
79
+ * tag runs only: the ASCII the tags spell
80
+ */
81
+ decoded?: string | undefined;
82
+ /**
83
+ * tag runs only: true when `decoded` stops short of the run
84
+ */
85
+ decodedTruncated?: boolean | undefined;
86
+ };
87
+ export type HiddenTextOmission = {
88
+ kind: `hidden_${HiddenRunKind}`;
89
+ /**
90
+ * runs removed from `markdown`
91
+ */
92
+ count: number;
93
+ /**
94
+ * code points in those runs
95
+ */
96
+ chars: number;
97
+ note: string;
98
+ };
99
+ /**
100
+ * Check `kind` before reading `chars`: only hidden-text omissions have it.
101
+ */
102
+ export type PageMarkdownOmission = {
103
+ kind: "shadow_content";
104
+ count: number;
105
+ note: string;
106
+ } | {
107
+ kind: "json_line";
108
+ count: number;
109
+ note: string;
110
+ } | {
111
+ kind: "inactive_tabs";
112
+ count: number;
113
+ note: string;
114
+ } | {
115
+ kind: "comments";
116
+ count: number;
117
+ note: string;
118
+ } | HiddenTextOmission;
119
+ export type StandardizationStats = ReturnType<typeof import("./standardize.js").standardizeDocument>;
120
+ export type PageMarkdownStats = {
121
+ mode: OutputMode;
122
+ extractedHtmlChars: number;
123
+ convertedChars: number;
124
+ markdownChars: number;
125
+ /**
126
+ * the page's text at capture time: a capture is a
127
+ * snapshot of the DOM, so a caller can re-capture until this number settles
128
+ */
129
+ pageTextChars: number;
130
+ standardization: StandardizationStats;
131
+ };
132
+ export type PageMarkdownResult = {
133
+ /**
134
+ * the package version that made this result
135
+ */
136
+ version: string;
137
+ markdown: string;
138
+ metadata: PageMarkdownMetadata;
139
+ extraction: PageMarkdownExtraction;
140
+ omissions: PageMarkdownOmission[];
141
+ warnings: string[];
142
+ stats: PageMarkdownStats;
143
+ /**
144
+ * present only with `includeFullPage`
145
+ */
146
+ fullPageMarkdown?: string | undefined;
147
+ };
148
+ export type PageFact = {
149
+ label: string;
150
+ value: string;
151
+ };
152
+ export type TurndownFilter = string | string[] | ((node: HTMLElement, options: Record<string, unknown>) => boolean);
153
+ /**
154
+ * Turndown's public methods; Turndown ships no types of its own.
155
+ */
156
+ export type TurndownInstance = {
157
+ turndown: (input: string | Node) => string;
158
+ use: (plugin: TurndownPlugin | TurndownPlugin[]) => TurndownInstance;
159
+ addRule: (key: string, rule: {
160
+ filter: TurndownFilter;
161
+ replacement: (content: string, node: Node, options: Record<string, unknown>) => string;
162
+ }) => TurndownInstance;
163
+ keep: (filter: TurndownFilter) => TurndownInstance;
164
+ remove: (filter: TurndownFilter) => TurndownInstance;
165
+ escape: (text: string) => string;
166
+ };
167
+ export type TurndownPlugin = (service: TurndownInstance) => void;
package/package.json CHANGED
@@ -1,13 +1,18 @@
1
1
  {
2
2
  "name": "page-markdown",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Faithful page-to-Markdown for humans and agents: sanitize, extract the readable article, convert with GFM, and clean without losing content.",
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./src/index.js"
7
+ ".": {
8
+ "types": "./dist/types/index.d.ts",
9
+ "default": "./src/index.js"
10
+ }
8
11
  },
12
+ "types": "./dist/types/index.d.ts",
9
13
  "files": [
10
14
  "src",
15
+ "dist/types",
11
16
  "LICENSE",
12
17
  "ADDITIONAL_TERMS.md",
13
18
  "THIRD_PARTY_NOTICES.md",
@@ -22,9 +27,15 @@
22
27
  },
23
28
  "scripts": {
24
29
  "test": "node --test \"test/**/*.test.mjs\"",
30
+ "build:types": "tsc -p tsconfig.json",
31
+ "typecheck": "npm run build:types && node test/types/real-output.mjs && tsc -p test/types && tsc -p test/types/linkedom",
25
32
  "size": "node bench/size.mjs",
26
- "ci": "npm test && npm run size",
27
- "bench": "node bench/compare.mjs"
33
+ "test:browser": "node test/browser/run.mjs",
34
+ "ci": "npm test && npm run typecheck && npm run size",
35
+ "prepack": "npm run build:types",
36
+ "prepublishOnly": "npm run ci",
37
+ "bench": "node bench/compare.mjs",
38
+ "bench:write": "node bench/compare.mjs --write"
28
39
  },
29
40
  "keywords": [
30
41
  "markdown",
@@ -51,6 +62,7 @@
51
62
  },
52
63
  "devDependencies": {
53
64
  "esbuild": "0.27.0",
54
- "linkedom": "0.18.12"
65
+ "linkedom": "0.18.12",
66
+ "typescript": "5.9.3"
55
67
  }
56
68
  }
package/src/clean.js CHANGED
@@ -13,6 +13,7 @@ export function mapOutsideFences(markdown, transform) {
13
13
  const blocks = [];
14
14
  let lines = [];
15
15
  let fence = null;
16
+ let indent = 0;
16
17
  const flush = (isCode) => {
17
18
  if (!lines.length) return;
18
19
  const text = lines.join("\n");
@@ -20,12 +21,14 @@ export function mapOutsideFences(markdown, transform) {
20
21
  lines = [];
21
22
  };
22
23
  for (const line of String(markdown).split("\n")) {
23
- const match = line.match(/^ {0,3}(`{3,}|~{3,})(.*)$/);
24
- if (!fence && match && !(match[1][0] === "`" && match[2].includes("`"))) {
24
+ // Any indent or quote prefix: Turndown nests code in lists and blockquotes.
25
+ const match = line.match(/^([ >]*)(`{3,}|~{3,})(.*)$/);
26
+ if (!fence && match && !(match[2][0] === "`" && match[3].includes("`"))) {
25
27
  flush(false);
26
- fence = match[1];
28
+ fence = match[2];
29
+ indent = match[1].length;
27
30
  lines.push(line);
28
- } else if (fence && match && match[1][0] === fence[0] && match[1].length >= fence.length && !match[2].trim()) {
31
+ } else if (fence && match && match[1].length <= indent + 3 && match[2][0] === fence[0] && match[2].length >= fence.length && !match[3].trim()) {
29
32
  lines.push(line);
30
33
  flush(true);
31
34
  fence = null;
@@ -81,8 +84,13 @@ export function cleanMarkdown(markdown) {
81
84
  // Link text may hold escaped brackets and whole images: [![alt](img)](href)
82
85
  // keeps the image, not "![alt" pointing at href.
83
86
  const LINK_TARGET = String.raw`\((?:[^()\s]|\([^()]*\))+(?:\s+"[^"]*")?\)`;
84
- const INLINE_LINK = new RegExp(String.raw`(!?)\[((?:\\.|!\[[^\]]*\]${LINK_TARGET}|[^\[\]\\])*)\]${LINK_TARGET}`, "g");
85
- const stripInlineLinks = (md) => md.replace(INLINE_LINK, (all, bang, text) => (bang ? all : text));
87
+ const INLINE_LINK = new RegExp(String.raw`(!?)\[((?:\\.|!\[[^\]]*\]${LINK_TARGET}|[^\[\]\\])*)\]${LINK_TARGET}`);
88
+ // Replaces matches of `pattern`, leaving inline code spans as they are.
89
+ const replaceOutsideCode = (md, pattern, replace) => md.replace(
90
+ new RegExp(`\`+[^\`\\n]*\`+|${pattern.source}`, "g"),
91
+ (all, ...groups) => (all[0] === "`" ? all : replace(all, ...groups)),
92
+ );
93
+ const stripInlineLinks = (md) => replaceOutsideCode(md, INLINE_LINK, (all, bang, text) => (bang ? all : text));
86
94
  const dropReferenceDefs = (md) => md.replace(/^\s{0,3}\[[^\]]+]:\s+\S+.*$/gm, "");
87
95
  const stripStructure = (md) => md
88
96
  .replace(/^\s{0,3}#{1,6}\s+/gm, "")
@@ -90,14 +98,18 @@ const stripStructure = (md) => md
90
98
  .replace(/^\s*\d+\.\s+/gm, "")
91
99
  .replace(/^\s{0,3}>\s?/gm, "")
92
100
  .replace(/^\s*([-_*])\s*(?:\1\s*){2,}$/gm, "");
93
- const stripEmphasis = (md) => md.replace(/(\*\*|__)(.+?)\1/g, "$2").replace(/(?<![\w*])([*_])(?!\s)(.+?)(?<!\s)\1(?![\w*])/g, "$2");
94
- const stripImages = (md) => md.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1");
101
+ const stripEmphasis = (md) => replaceOutsideCode(
102
+ replaceOutsideCode(md, /(\*\*|__)(.+?)\1/, (all, mark, text) => text),
103
+ /(?<![\w*])([*_])(?!\s)(.+?)(?<!\s)\1(?![\w*])/,
104
+ (all, mark, text) => text,
105
+ );
106
+ const stripImages = (md) => replaceOutsideCode(md, /!\[([^\]]*)\]\([^)]*\)/, (all, alt) => alt);
95
107
 
96
108
  /**
97
109
  * Output modes. "no_links" keeps link text. "text_only" also drops headings,
98
110
  * list markers, emphasis and images. Both leave fenced code verbatim.
99
111
  * @param {string} markdown
100
- * @param {"full"|"no_links"|"text_only"} mode
112
+ * @param {import("./types.js").OutputMode} [mode]
101
113
  */
102
114
  export function applyMode(markdown, mode = "full") {
103
115
  if (mode === "no_links") return mapOutsideFences(markdown, stripInlineLinks);
package/src/convert.js CHANGED
@@ -14,6 +14,15 @@ export const TURNDOWN_OPTIONS = Object.freeze({
14
14
  });
15
15
 
16
16
  function gfmInlineRules(turndown) {
17
+ // Link text is one line: a link around blocks would otherwise split across lines.
18
+ turndown.addRule("pmInlineLink", {
19
+ filter: (node) => node.nodeName === "A" && Boolean(node.getAttribute("href")),
20
+ replacement(content, node) {
21
+ const href = node.getAttribute("href").replace(/([()])/g, "\\$1");
22
+ const title = (node.getAttribute("title") || "").replace(/\s*\n+\s*/g, " ").replace(/"/g, '\\"');
23
+ return `[${content.trim().replace(/\s*\n+\s*/g, " ")}](${href}${title ? ` "${title}"` : ""})`;
24
+ },
25
+ });
17
26
  turndown.addRule("pmStrikethrough", {
18
27
  filter: ["del", "s", "strike"],
19
28
  replacement: (content) => `~~${content}~~`,
@@ -65,25 +74,74 @@ function spanOf(cell) {
65
74
  return Number.isFinite(span) && span > 1 ? Math.min(span, 50) : 1;
66
75
  }
67
76
 
68
- // Layout tables (nesting, a single cell, a single column) read better as
69
- // blocks than as a grid.
70
- function isLayoutTable(table) {
71
- if (table.querySelector?.("table")) return true;
72
- const rows = tableRows(table);
73
- if (!rows.length) return true;
74
- const widest = Math.max(...rows.map((row) => rowCells(row).reduce((sum, cell) => sum + spanOf(cell), 0)));
75
- return widest <= 1;
77
+ // Each cell's column, and the row-spanned cells that fill the slots before it
78
+ // (or at the row's end): null for the rest of a cell's column span. A span
79
+ // stops at its row group.
80
+ function tableGrid(rows) {
81
+ const columns = new Map();
82
+ const before = new Map();
83
+ const after = new Map();
84
+ let carry = [];
85
+ let group = null;
86
+ for (const row of rows) {
87
+ if (row.parentNode !== group) carry = [];
88
+ group = row.parentNode;
89
+ let column = 0;
90
+ let slots = [];
91
+ const take = (gaps) => {
92
+ while (carry[column]?.left > 0 || (gaps && column < carry.length)) {
93
+ const entry = carry[column];
94
+ slots.push(entry?.left > 0 ? entry.cell : null);
95
+ if (entry?.left > 0) entry.left -= 1;
96
+ column += 1;
97
+ }
98
+ };
99
+ for (const cell of rowCells(row)) {
100
+ slots = [];
101
+ take();
102
+ before.set(cell, slots);
103
+ columns.set(cell, column);
104
+ const left = Number.parseInt(cell.getAttribute?.("rowspan") || "1", 10) - 1;
105
+ const span = spanOf(cell);
106
+ for (let i = 0; i < span; i += 1) {
107
+ if (left > 0) carry[column + i] = { cell: i ? null : cell, left };
108
+ }
109
+ column += span;
110
+ }
111
+ slots = [];
112
+ take(true);
113
+ after.set(row, slots);
114
+ }
115
+ return { columns, before, after };
76
116
  }
77
117
 
78
- // A GFM cell is one line, so a data table holding code blocks is rendered as
79
- // blocks too, each cell under its column's header: the code stays fenced.
80
- function isBlockTable(table) {
81
- return isLayoutTable(table) || Boolean(table.querySelector?.("pre"));
118
+ const tables = new WeakMap();
119
+
120
+ // Worked out once per table: cell and row rules run for every cell.
121
+ function tableInfo(table) {
122
+ let info = tables.get(table);
123
+ if (info) return info;
124
+ const rows = tableRows(table);
125
+ // Layout tables (nesting, a single cell, a single column) read better as
126
+ // blocks than as a grid.
127
+ const layout = Boolean(table.querySelector?.("table")) || !rows.length
128
+ || Math.max(...rows.map((row) => rowCells(row).reduce((sum, cell) => sum + spanOf(cell), 0))) <= 1;
129
+ // A GFM cell is one line, so a data table holding code blocks is rendered as
130
+ // blocks too, each cell under its column's header: the code stays fenced.
131
+ const block = layout || Boolean(table.querySelector?.("pre"));
132
+ info = { layout, block, header: columnHeaders(rows[0]), text: new Map(), ...tableGrid(rows) };
133
+ tables.set(table, info);
134
+ return info;
82
135
  }
83
136
 
84
137
  function isBlockCell(node) {
85
138
  const table = closest(node, "TABLE");
86
- return !table || isBlockTable(table);
139
+ return !table || tableInfo(table).block;
140
+ }
141
+
142
+ // A row-spanned cell repeats in each row, so a row reads whole by itself.
143
+ function slotsMarkdown(slots, info) {
144
+ return slots.map((cell) => ` ${info.text.get(cell) || ""} |`).join("");
87
145
  }
88
146
 
89
147
  function labelText(cell) {
@@ -92,8 +150,7 @@ function labelText(cell) {
92
150
 
93
151
  // A first row of two or more <th>: column headers. (A single spanning <th>
94
152
  // is a title, not a header.)
95
- function columnHeaders(table) {
96
- const first = tableRows(table)[0];
153
+ function columnHeaders(first) {
97
154
  const cells = first ? rowCells(first) : [];
98
155
  return cells.length > 1 && cells.every((cell) => cell.nodeName === "TH") ? first : null;
99
156
  }
@@ -108,16 +165,13 @@ function rowHeader(row) {
108
165
  // label; "" for layout tables and unlabelled cells.
109
166
  function cellLabel(cell) {
110
167
  const table = closest(cell, "TABLE");
111
- if (!table || isLayoutTable(table)) return "";
168
+ const info = table && tableInfo(table);
169
+ if (!info || info.layout) return "";
112
170
  const row = cell.parentNode;
113
- const header = columnHeaders(table);
171
+ const { header } = info;
114
172
  if (header) {
115
173
  if (row === header) return null;
116
- let column = 0;
117
- for (const other of rowCells(row)) {
118
- if (other === cell) break;
119
- column += spanOf(other);
120
- }
174
+ const column = info.columns.get(cell);
121
175
  let start = 0;
122
176
  for (const th of rowCells(header)) {
123
177
  if (column < start + spanOf(th)) return labelText(th);
@@ -142,25 +196,83 @@ function cellText(content) {
142
196
  return text.replace(/\|/g, "\\|");
143
197
  }
144
198
 
199
+ // An icon's straight strokes, as lists of points; null if any stroke curves.
200
+ function iconStrokes(svg) {
201
+ const strokes = [];
202
+ for (const shape of Array.from(svg.querySelectorAll?.("path, polyline, line") || [])) {
203
+ const at = (name) => shape.getAttribute(name);
204
+ const d = at("d") ?? `M${at("points") ?? `${at("x1")} ${at("y1")} ${at("x2")} ${at("y2")}`}`;
205
+ const tokens = d.match(/[a-zA-Z]|-?\d*\.?\d+/g) || [];
206
+ let x = 0;
207
+ let y = 0;
208
+ let command = "";
209
+ let points = [];
210
+ for (let i = 0; i < tokens.length;) {
211
+ if (/[a-zA-Z]/.test(tokens[i])) command = tokens[i++];
212
+ const relative = command > "Z";
213
+ const type = command.toUpperCase();
214
+ if (!"MLHV".includes(type)) return null;
215
+ if (type !== "V") x = Number(tokens[i++]) + (relative ? x : 0);
216
+ if (type !== "H") y = Number(tokens[i++]) + (relative ? y : 0);
217
+ if (type === "M") {
218
+ points = [];
219
+ strokes.push(points);
220
+ command = relative ? "l" : "L";
221
+ }
222
+ points.push([x, y]);
223
+ }
224
+ }
225
+ return strokes;
226
+ }
227
+
228
+ // What an icon's shape says, for the tick, dash and cross of comparison tables.
229
+ function iconShape(strokes) {
230
+ const [first, second] = strokes || [];
231
+ if (strokes?.length === 1 && first.length === 2 && first[0][1] === first[1][1]) return "\u2013";
232
+ if (strokes?.length === 1 && first.length === 3) {
233
+ const [a, b, c] = first;
234
+ const arm = (p) => Math.hypot(p[0] - b[0], p[1] - b[1]);
235
+ const ratio = Math.max(arm(a), arm(c)) / Math.min(arm(a), arm(c));
236
+ if (b[1] > a[1] && b[1] > c[1] && (a[0] - b[0]) * (c[0] - b[0]) < 0 && ratio > 1.3) return "\u2713";
237
+ }
238
+ const slope = ([p, q]) => (q[0] - p[0]) * (q[1] - p[1]);
239
+ if (strokes?.length === 2 && first.length === 2 && second.length === 2 && slope(first) * slope(second) < 0) return "\u2717";
240
+ return "";
241
+ }
242
+
243
+ // A cell holding only an icon would be empty, and the table couldn't say which
244
+ // plan has what: its accessible name, else its shape, else that an icon is there.
245
+ function cellIcon(cell) {
246
+ const svg = cell.querySelector?.("svg");
247
+ if (!svg) return "";
248
+ return svg.getAttribute("aria-label")?.trim() || svg.querySelector("title")?.textContent?.trim()
249
+ || iconShape(iconStrokes(svg)) || "[icon]";
250
+ }
251
+
145
252
  function tableRules(turndown) {
146
253
  turndown.addRule("pmTableCell", {
147
254
  filter: ["th", "td"],
148
- replacement(content, node) {
255
+ replacement(rawContent, node) {
256
+ const content = rawContent.trim() ? rawContent : cellIcon(node);
149
257
  if (isBlockCell(node)) {
150
258
  // Header cells become the labels on the other cells.
151
259
  const label = cellLabel(node);
152
260
  if (label === null) return "";
153
261
  return label ? `\n\n**${createTurndown().escape(label)}**\n\n${content}\n\n` : `\n\n${content}\n\n`;
154
262
  }
263
+ const info = tableInfo(closest(node, "TABLE"));
264
+ const text = cellText(content);
265
+ info.text.set(node, text);
155
266
  const pad = " | ".repeat(spanOf(node) - 1);
156
- return ` ${cellText(content)}${pad} |`;
267
+ return `${slotsMarkdown(info.before.get(node), info)} ${text}${pad} |`;
157
268
  },
158
269
  });
159
270
  turndown.addRule("pmTableRow", {
160
271
  filter: "tr",
161
272
  replacement(content, node) {
162
273
  if (isBlockCell(node)) return `\n${content}\n`;
163
- return `\n|${content}`;
274
+ const info = tableInfo(closest(node, "TABLE"));
275
+ return `\n|${content}${slotsMarkdown(info.after.get(node), info)}`;
164
276
  },
165
277
  });
166
278
  turndown.addRule("pmTableSection", {
@@ -172,7 +284,7 @@ function tableRules(turndown) {
172
284
  turndown.addRule("pmTable", {
173
285
  filter: "table",
174
286
  replacement(content, node) {
175
- if (isBlockTable(node)) return `\n\n${content}\n\n`;
287
+ if (tableInfo(node).block) return `\n\n${content}\n\n`;
176
288
  const lines = content.split("\n").filter((line) => line.startsWith("|"));
177
289
  if (!lines.length) return "\n\n";
178
290
  const width = Math.max(...lines.map((line) => line.split(/(?<!\\)\|/).length - 2));
@@ -197,7 +309,10 @@ function tableRules(turndown) {
197
309
 
198
310
  let shared = null;
199
311
 
200
- /** A configured Turndown instance (cached). */
312
+ /**
313
+ * A configured Turndown instance (cached).
314
+ * @returns {import("./types.js").TurndownInstance}
315
+ */
201
316
  export function createTurndown() {
202
317
  if (shared) return shared;
203
318
  const turndown = new TurndownService({ ...TURNDOWN_OPTIONS });