@v1nvn/readability 0.44.0 → 0.45.1

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.
Files changed (132) hide show
  1. package/README.md +3 -3
  2. package/package.json +7 -9
  3. package/src/cli.js +99 -0
  4. package/src/cli.ts +134 -0
  5. package/src/config.js +29 -0
  6. package/src/config.ts +42 -0
  7. package/src/dev.js +55 -0
  8. package/src/dev.ts +77 -0
  9. package/src/errors.js +16 -0
  10. package/src/errors.ts +19 -0
  11. package/src/handler.js +4 -0
  12. package/src/handler.ts +8 -0
  13. package/src/host-sampling.js +57 -0
  14. package/src/host-sampling.ts +86 -0
  15. package/src/index.js +23 -0
  16. package/src/index.ts +27 -0
  17. package/src/logger.js +2 -0
  18. package/src/logger.ts +3 -0
  19. package/src/output/format.js +92 -0
  20. package/src/output/format.ts +132 -0
  21. package/src/pipeline/cell-text.js +51 -0
  22. package/src/pipeline/cell-text.ts +56 -0
  23. package/src/pipeline/context.js +1 -0
  24. package/src/pipeline/context.ts +88 -0
  25. package/src/pipeline/dom.js +16 -0
  26. package/src/pipeline/dom.ts +23 -0
  27. package/src/pipeline/footnotes.js +148 -0
  28. package/src/pipeline/footnotes.ts +181 -0
  29. package/src/pipeline/math.js +113 -0
  30. package/src/pipeline/math.ts +130 -0
  31. package/src/pipeline/normalize.js +507 -0
  32. package/src/pipeline/normalize.ts +594 -0
  33. package/src/pipeline/readability.js +10 -0
  34. package/src/pipeline/readability.ts +36 -0
  35. package/src/pipeline/resolver.js +113 -0
  36. package/src/pipeline/resolver.ts +143 -0
  37. package/src/pipeline/sanitize.js +26 -0
  38. package/src/pipeline/sanitize.ts +42 -0
  39. package/src/pipeline/tables.js +239 -0
  40. package/src/pipeline/tables.ts +286 -0
  41. package/src/pipeline/turndown.js +153 -0
  42. package/src/pipeline/turndown.ts +191 -0
  43. package/src/pipeline/urls.js +14 -0
  44. package/src/pipeline/urls.ts +13 -0
  45. package/src/policy/chunk.js +365 -0
  46. package/src/policy/chunk.ts +482 -0
  47. package/src/policy/diagnostics.js +53 -0
  48. package/src/policy/diagnostics.ts +96 -0
  49. package/src/policy/explain.js +104 -0
  50. package/src/policy/explain.ts +182 -0
  51. package/src/policy/fallback.js +68 -0
  52. package/src/policy/fallback.ts +110 -0
  53. package/src/policy/gating.js +90 -0
  54. package/src/policy/gating.ts +106 -0
  55. package/src/policy/grid-detector.js +207 -0
  56. package/src/policy/grid-detector.ts +313 -0
  57. package/src/policy/identifiers.js +62 -0
  58. package/src/policy/identifiers.ts +75 -0
  59. package/src/policy/images.js +49 -0
  60. package/src/policy/images.ts +64 -0
  61. package/src/policy/list-detector.js +232 -0
  62. package/src/policy/list-detector.ts +308 -0
  63. package/src/policy/lost-signal.js +44 -0
  64. package/src/policy/lost-signal.ts +69 -0
  65. package/src/policy/markdown.js +58 -0
  66. package/src/policy/markdown.ts +76 -0
  67. package/src/policy/metadata.js +216 -0
  68. package/src/policy/metadata.ts +312 -0
  69. package/src/policy/outline-chains.js +165 -0
  70. package/src/policy/outline-chains.ts +215 -0
  71. package/src/policy/outline.js +71 -0
  72. package/src/policy/outline.ts +89 -0
  73. package/src/policy/pagination.js +79 -0
  74. package/src/policy/pagination.ts +104 -0
  75. package/src/policy/presets.js +100 -0
  76. package/src/policy/presets.ts +142 -0
  77. package/src/policy/section.js +111 -0
  78. package/src/policy/section.ts +127 -0
  79. package/src/policy/selector-lint.js +143 -0
  80. package/src/policy/selector-lint.ts +186 -0
  81. package/src/policy/sibling-scan.js +92 -0
  82. package/src/policy/sibling-scan.ts +99 -0
  83. package/src/policy/text.js +18 -0
  84. package/src/policy/text.ts +36 -0
  85. package/src/policy/truncate.js +45 -0
  86. package/src/policy/truncate.ts +59 -0
  87. package/src/preset-cache.js +165 -0
  88. package/src/preset-cache.ts +221 -0
  89. package/src/resources.js +227 -0
  90. package/src/resources.ts +317 -0
  91. package/src/sampling.js +45 -0
  92. package/src/sampling.ts +74 -0
  93. package/src/server.js +73 -0
  94. package/src/server.ts +85 -0
  95. package/src/tools/chunk_text.js +40 -0
  96. package/src/tools/chunk_text.ts +65 -0
  97. package/src/tools/explain.js +200 -0
  98. package/src/tools/explain.ts +299 -0
  99. package/src/tools/extract.js +263 -0
  100. package/src/tools/extract.ts +368 -0
  101. package/src/tools/extract_grid.js +56 -0
  102. package/src/tools/extract_grid.ts +90 -0
  103. package/src/tools/extract_links.js +98 -0
  104. package/src/tools/extract_links.ts +138 -0
  105. package/src/tools/extract_list.js +66 -0
  106. package/src/tools/extract_list.ts +97 -0
  107. package/src/tools/extract_metadata.js +67 -0
  108. package/src/tools/extract_metadata.ts +102 -0
  109. package/src/tools/extract_section.js +56 -0
  110. package/src/tools/extract_section.ts +84 -0
  111. package/src/tools/extract_tables.js +59 -0
  112. package/src/tools/extract_tables.ts +101 -0
  113. package/src/tools/html-source.js +30 -0
  114. package/src/tools/html-source.ts +40 -0
  115. package/src/tools/html_to_markdown.js +113 -0
  116. package/src/tools/html_to_markdown.ts +180 -0
  117. package/src/tools/outline.js +47 -0
  118. package/src/tools/outline.ts +77 -0
  119. package/src/tools/output-schema.js +504 -0
  120. package/src/tools/output-schema.ts +715 -0
  121. package/src/tools/schemas.js +288 -0
  122. package/src/tools/schemas.ts +442 -0
  123. package/src/tools/suggest-preset.js +504 -0
  124. package/src/tools/suggest-preset.ts +708 -0
  125. package/src/types/jsdom.d.ts +15 -0
  126. package/src/types/turndown-plugin-gfm.d.ts +11 -0
  127. package/dist/assets/cli-Dfjg7dOQ.js +0 -76
  128. package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
  129. package/dist/assets/extract-CdDp-KwX.js +0 -2880
  130. package/dist/assets/extract-CdDp-KwX.js.map +0 -1
  131. package/dist/index.js +0 -2267
  132. package/dist/index.js.map +0 -1
@@ -0,0 +1,286 @@
1
+ import { resolveCellText } from './cell-text.ts';
2
+
3
+ export type TableFormat = 'csv' | 'gfm' | 'json';
4
+
5
+ // `<thead>`/`<tbody>`/`<tfoot>` group `<tr>` rows; a `<table>` may also hold `<tr>`
6
+ // directly. Walking these levels (and only these) keeps nested `<table>`s out of
7
+ // the matrix — those are emitted as their own replacement by turndown.
8
+ const SECTION_TAGS = new Set(['TBODY', 'TFOOT', 'THEAD']);
9
+ const CELL_TAGS = new Set(['TD', 'TH']);
10
+
11
+ function spanOf(cell: Element, attr: 'colspan' | 'rowspan'): number {
12
+ const raw = cell.getAttribute(attr);
13
+ if (raw === null) {
14
+ return 1;
15
+ }
16
+ const parsed = Number.parseInt(raw, 10);
17
+ // HTML treats 0 (and any non-positive / unparseable value) as 1.
18
+ if (!Number.isFinite(parsed) || parsed <= 0) {
19
+ return 1;
20
+ }
21
+ return parsed;
22
+ }
23
+
24
+ function collectRows(table: Element): readonly Element[] {
25
+ const rows: Element[] = [];
26
+ for (const child of Array.from(table.children)) {
27
+ if (child.tagName === 'TR') {
28
+ rows.push(child);
29
+ } else if (SECTION_TAGS.has(child.tagName)) {
30
+ for (const tr of Array.from(child.children)) {
31
+ if (tr.tagName === 'TR') {
32
+ rows.push(tr);
33
+ }
34
+ }
35
+ }
36
+ }
37
+ return rows;
38
+ }
39
+
40
+ function cellsOf(tr: Element): readonly Element[] {
41
+ return Array.from(tr.children).filter(child => CELL_TAGS.has(child.tagName));
42
+ }
43
+
44
+ // Text projection of buildCellGrid: origin cells → resolveCellText, span/pad slots → ''.
45
+ export function parseTableMatrix(table: Element): string[][] {
46
+ return buildCellGrid(table).map(row =>
47
+ row.map(cell => (cell === null ? '' : resolveCellText(cell))),
48
+ );
49
+ }
50
+
51
+ function escapeGfmCell(text: string): string {
52
+ return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
53
+ }
54
+
55
+ export function renderTableGfm(matrix: string[][]): string {
56
+ if (matrix.length === 0) {
57
+ return '';
58
+ }
59
+ const cols = matrix[0].length;
60
+ const lines: string[] = [];
61
+ lines.push(`| ${matrix[0].map(escapeGfmCell).join(' | ')} |`);
62
+ lines.push(`| ${Array.from({ length: cols }, () => '---').join(' | ')} |`);
63
+ for (let r = 1; r < matrix.length; r++) {
64
+ lines.push(`| ${matrix[r].map(escapeGfmCell).join(' | ')} |`);
65
+ }
66
+ return lines.join('\n');
67
+ }
68
+
69
+ function escapeCsvField(text: string): string {
70
+ if (/[",\r\n]/.test(text)) {
71
+ return `"${text.replace(/"/g, '""')}"`;
72
+ }
73
+ return text;
74
+ }
75
+
76
+ export function renderTableCsv(matrix: string[][]): string {
77
+ if (matrix.length === 0) {
78
+ return '';
79
+ }
80
+ return matrix.map(row => row.map(escapeCsvField).join(',')).join('\n');
81
+ }
82
+
83
+ function headerKeys(header: readonly string[]): string[] {
84
+ return header.map((cell, i) => (cell === '' ? `column_${i}` : cell));
85
+ }
86
+
87
+ // Tracks the originating cell element at each grid position after resolving
88
+ // rowspan/colspan, so resolveHeaderKeys can read td/th attributes the text
89
+ // matrix discards. parseTableMatrix is the text projection of this grid; `null`
90
+ // marks positions filled by a span (no origin cell).
91
+ function buildCellGrid(table: Element): (Element | null)[][] {
92
+ const rows = collectRows(table);
93
+ if (rows.length === 0) {
94
+ return [];
95
+ }
96
+ const grid: (Element | null)[][] = [];
97
+ const occupied: boolean[][] = [];
98
+ let maxCols = 0;
99
+
100
+ for (let r = 0; r < rows.length; r++) {
101
+ while (grid.length <= r) {
102
+ grid.push([]);
103
+ occupied.push([]);
104
+ }
105
+ const rowCells = grid[r];
106
+ const rowOccupied = occupied[r];
107
+
108
+ let col = 0;
109
+ for (const cell of cellsOf(rows[r])) {
110
+ while (rowOccupied[col]) {
111
+ col++;
112
+ }
113
+ const rowspan = spanOf(cell, 'rowspan');
114
+ const colspan = spanOf(cell, 'colspan');
115
+ rowCells[col] = cell;
116
+ rowOccupied[col] = true;
117
+ for (let dr = 0; dr < rowspan; dr++) {
118
+ for (let dc = 0; dc < colspan; dc++) {
119
+ if (dr === 0 && dc === 0) {
120
+ continue;
121
+ }
122
+ const rr = r + dr;
123
+ while (grid.length <= rr) {
124
+ grid.push([]);
125
+ occupied.push([]);
126
+ }
127
+ const occ = occupied[rr];
128
+ while (occ.length <= col + dc) {
129
+ occ.push(false);
130
+ grid[rr].push(null);
131
+ }
132
+ occ[col + dc] = true;
133
+ }
134
+ }
135
+ col += colspan;
136
+ if (col > maxCols) {
137
+ maxCols = col;
138
+ }
139
+ }
140
+ }
141
+
142
+ const dense: (Element | null)[][] = [];
143
+ for (const row of grid) {
144
+ dense.push(Array.from({ length: maxCols }, (_, i) => row[i] ?? null));
145
+ }
146
+ return dense;
147
+ }
148
+
149
+ function isHeaderRow(tr: Element): boolean {
150
+ const cells = cellsOf(tr);
151
+ return cells.length > 0 && cells.every(c => c.tagName === 'TH');
152
+ }
153
+
154
+ function slugify(text: string): string {
155
+ return text
156
+ .toLowerCase()
157
+ .replace(/[^a-z0-9]+/g, '_')
158
+ .replace(/^_+|_+$/g, '');
159
+ }
160
+
161
+ function readCellLabel(cell: Element): null | string {
162
+ return (
163
+ cell.getAttribute('aria-label') ??
164
+ cell.getAttribute('title') ??
165
+ cell.getAttribute('data-label')
166
+ );
167
+ }
168
+
169
+ export function resolveHeaderKeys(
170
+ table: Element,
171
+ matrix: string[][],
172
+ ): string[] {
173
+ if (matrix.length === 0) {
174
+ return [];
175
+ }
176
+ const width = matrix[0].length;
177
+ const keys = headerKeys(matrix[0]);
178
+
179
+ const rows = collectRows(table);
180
+ if (rows.length === 0) {
181
+ return keys;
182
+ }
183
+
184
+ const cellGrid = buildCellGrid(table);
185
+
186
+ // Headerless columns lose their name without a th; recover from a colspan
187
+ // parent in row 0, falling back to the position under that parent.
188
+ const parents: string[] = Array.from({ length: width }, () => '');
189
+ const parentChildren = new Map<string, number[]>();
190
+ {
191
+ let col = 0;
192
+ for (const cell of cellsOf(rows[0])) {
193
+ const colspan = spanOf(cell, 'colspan');
194
+ const text = resolveCellText(cell);
195
+ if (colspan > 1 && text) {
196
+ const slug = slugify(text);
197
+ const kids: number[] = [];
198
+ for (let dc = 0; dc < colspan && col + dc < width; dc++) {
199
+ parents[col + dc] = slug;
200
+ kids.push(col + dc);
201
+ }
202
+ parentChildren.set(slug, kids);
203
+ }
204
+ col += colspan;
205
+ }
206
+ }
207
+
208
+ const hasSubRow = rows.length > 1 && isHeaderRow(rows[1]);
209
+
210
+ for (let c = 0; c < width; c++) {
211
+ const dataCell = cellGrid[1]?.[c];
212
+ if (dataCell) {
213
+ const label = readCellLabel(dataCell);
214
+ if (label) {
215
+ keys[c] = label;
216
+ continue;
217
+ }
218
+ }
219
+
220
+ const parent = parents[c];
221
+ if (!parent) {
222
+ continue;
223
+ }
224
+
225
+ if (hasSubRow) {
226
+ const sub = matrix[1][c] ? slugify(matrix[1][c]) : '';
227
+ if (sub) {
228
+ keys[c] = `${parent}_${sub}`;
229
+ continue;
230
+ }
231
+ }
232
+
233
+ if (matrix[0][c] === '') {
234
+ const kids = parentChildren.get(parent);
235
+ const idx = kids ? kids.indexOf(c) + 1 : c;
236
+ keys[c] = `${parent}_${idx}`;
237
+ }
238
+ }
239
+ return keys;
240
+ }
241
+
242
+ function resolveJsonKeys(
243
+ matrix: string[][],
244
+ keys: readonly string[] | undefined,
245
+ ): string[] {
246
+ const width = matrix[0].length;
247
+ if (!keys) {
248
+ return headerKeys(matrix[0]);
249
+ }
250
+ return Array.from({ length: width }, (_, i) => keys[i] ?? `column_${i}`);
251
+ }
252
+
253
+ export function renderTableJson(
254
+ matrix: string[][],
255
+ keys?: readonly string[],
256
+ ): string {
257
+ if (matrix.length < 2) {
258
+ return '[]';
259
+ }
260
+ const resolved = resolveJsonKeys(matrix, keys);
261
+ const records: Record<string, string>[] = [];
262
+ for (let r = 1; r < matrix.length; r++) {
263
+ const row = matrix[r];
264
+ const record: Record<string, string> = {};
265
+ for (let c = 0; c < resolved.length; c++) {
266
+ record[resolved[c]] = row[c] ?? '';
267
+ }
268
+ records.push(record);
269
+ }
270
+ return JSON.stringify(records, null, 2);
271
+ }
272
+
273
+ export function renderTable(
274
+ matrix: string[][],
275
+ format: TableFormat,
276
+ keys?: readonly string[],
277
+ ): string {
278
+ switch (format) {
279
+ case 'csv':
280
+ return renderTableCsv(matrix);
281
+ case 'gfm':
282
+ return renderTableGfm(matrix);
283
+ case 'json':
284
+ return renderTableJson(matrix, keys);
285
+ }
286
+ }
@@ -0,0 +1,153 @@
1
+ import TurndownService from 'turndown';
2
+ import { gfm } from 'turndown-plugin-gfm';
3
+ import { processFootnotes } from "./footnotes.js";
4
+ import { parseTableMatrix, renderTable } from "./tables.js";
5
+ import { absolutize } from "./urls.js";
6
+ export function toMarkdown(html, options) {
7
+ const service = new TurndownService({
8
+ bulletListMarker: '-',
9
+ // Code renders inline in the Markdown as fenced blocks; normalize rewrites
10
+ // source conventions to `<code class="language-X">` first, so the fence
11
+ // carries a language tag. A code-only extraction tool was rejected as
12
+ // redundant surface for a niche the main path already covers.
13
+ codeBlockStyle: options?.codeBlockStyle ?? 'fenced',
14
+ emDelimiter: '_',
15
+ fence: '```',
16
+ headingStyle: options?.headingStyle ?? 'atx',
17
+ strongDelimiter: '**',
18
+ });
19
+ if (options?.gfm !== false) {
20
+ service.use(gfm);
21
+ }
22
+ const tableFormat = options?.tables;
23
+ if (tableFormat !== undefined) {
24
+ // addRule prepends, so this shadows the gfm plugin's table rule AND its
25
+ // headerless-table keep filter (which lives in a separate _keep list, only
26
+ // consulted when no rule in the array matches). Matching every TABLE here
27
+ // means the keep list is never reached for tables.
28
+ service.addRule('tableMatrix', {
29
+ filter: node => node.nodeName === 'TABLE',
30
+ replacement: (_content, node) => {
31
+ const matrix = parseTableMatrix(node);
32
+ if (matrix.length === 0) {
33
+ return '';
34
+ }
35
+ const body = renderTable(matrix, tableFormat);
36
+ // GFM rows are already markdown table syntax; csv/json need a fenced block.
37
+ if (tableFormat === 'gfm') {
38
+ return `\n\n${body}\n\n`;
39
+ }
40
+ return `\n\n\`\`\`${tableFormat}\n${body}\n\`\`\`\n\n`;
41
+ },
42
+ });
43
+ }
44
+ const imageMode = options?.images;
45
+ const baseUrl = options?.baseUrl;
46
+ // `reference` mode collects definitions as images render; append them after the pass.
47
+ const references = [];
48
+ applyImagePolicy(service, imageMode, baseUrl, references);
49
+ // Readability pre-absolutizes anchors on the main path, but html_to_markdown
50
+ // and the extract fallback bypass it; turndown is shared, so absolutize here
51
+ // for every path. Empty/absent href falls through to bare content to match
52
+ // turndown's default link rule (which never matches when href is falsy).
53
+ service.addRule('anchorAbsolutize', {
54
+ filter: 'a',
55
+ replacement: (content, node) => {
56
+ const rawHref = node.getAttribute('href');
57
+ if (!rawHref) {
58
+ return content;
59
+ }
60
+ const href = absolutize(rawHref, baseUrl);
61
+ const title = node.getAttribute('title');
62
+ const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : '';
63
+ return `[${content}](${href}${titlePart})`;
64
+ },
65
+ });
66
+ // Math markers are emitted by `pipeline/math.ts` and survive Readability via the
67
+ // `rdrm-math` preserve entry. A rule's replacement is inserted verbatim, so
68
+ // the raw LaTeX backslashes survive turndown untouched.
69
+ service.addRule('mathMarker', {
70
+ filter: node => node.nodeName === 'SPAN' && node.classList.contains('rdrm-math'),
71
+ replacement: (_content, node) => {
72
+ const tex = node.textContent.trim();
73
+ if (!tex) {
74
+ return '';
75
+ }
76
+ const display = node.getAttribute('data-display') === 'true';
77
+ return display ? `$$${tex}$$` : `$${tex}$`;
78
+ },
79
+ });
80
+ const fnResult = processFootnotes(html);
81
+ const sourceHtml = fnResult?.html ?? html;
82
+ let body = service.turndown(sourceHtml);
83
+ if (fnResult) {
84
+ // Turndown sees `[^N]` as a shortcut-reference link and backslash-escapes
85
+ // the brackets; reverse that for the markers we emitted so they render as
86
+ // footnote refs. Code blocks pass through verbatim and never carry the
87
+ // escape, so they are unaffected.
88
+ for (let n = 1; n <= fnResult.footnoteDefs.length; n++) {
89
+ body = body.replaceAll(`\\[^${n}\\]`, `[^${n}]`);
90
+ }
91
+ }
92
+ const trailingBlocks = [];
93
+ if (imageMode === 'reference' && references.length > 0) {
94
+ trailingBlocks.push(references.map((ref, i) => `[img-${i + 1}]: ${ref}`).join('\n'));
95
+ }
96
+ if (fnResult && fnResult.footnoteDefs.length > 0) {
97
+ trailingBlocks.push(fnResult.footnoteDefs.map((def, i) => `[^${i + 1}]: ${def}`).join('\n'));
98
+ }
99
+ if (trailingBlocks.length === 0) {
100
+ return body;
101
+ }
102
+ return `${body.replace(/\n+$/, '')}\n\n${trailingBlocks.join('\n')}`;
103
+ }
104
+ function applyImagePolicy(service, mode, baseUrl, references) {
105
+ switch (mode) {
106
+ case 'drop': {
107
+ // `.remove('img')` doesn't reliably drop void elements in turndown 7.x.
108
+ service.addRule('dropImage', {
109
+ filter: 'img',
110
+ replacement: () => '',
111
+ });
112
+ break;
113
+ }
114
+ case 'keep':
115
+ case undefined: {
116
+ service.addRule('imageKeep', {
117
+ filter: 'img',
118
+ replacement: (_content, node) => {
119
+ const src = absolutize(node.getAttribute('src') ?? '', baseUrl);
120
+ const alt = node.getAttribute('alt') ?? '';
121
+ return src ? `![${alt}](${src})` : '';
122
+ },
123
+ });
124
+ break;
125
+ }
126
+ case 'reference': {
127
+ service.addRule('imageReference', {
128
+ filter: 'img',
129
+ replacement: (_content, node) => {
130
+ const src = absolutize(node.getAttribute('src') ?? '', baseUrl);
131
+ if (!src) {
132
+ return '';
133
+ }
134
+ references.push(src);
135
+ const id = references.length;
136
+ const alt = node.getAttribute('alt') ?? '';
137
+ return `![${alt}][img-${id}]`;
138
+ },
139
+ });
140
+ break;
141
+ }
142
+ case 'src-only': {
143
+ service.addRule('imageSrcOnly', {
144
+ filter: 'img',
145
+ replacement: (_content, node) => {
146
+ const src = absolutize(node.getAttribute('src') ?? '', baseUrl);
147
+ return src ? `\n\n${src}\n\n` : '';
148
+ },
149
+ });
150
+ break;
151
+ }
152
+ }
153
+ }
@@ -0,0 +1,191 @@
1
+ import TurndownService from 'turndown';
2
+ import { gfm } from 'turndown-plugin-gfm';
3
+
4
+ import type { TableFormat } from './tables.ts';
5
+
6
+ import { processFootnotes } from './footnotes.ts';
7
+ import { parseTableMatrix, renderTable } from './tables.ts';
8
+ import { absolutize } from './urls.ts';
9
+
10
+ export type CodeBlockStyle = 'fenced' | 'indented';
11
+ export type HeadingStyle = 'atx' | 'setext';
12
+ export type ImageMode = 'drop' | 'keep' | 'reference' | 'src-only';
13
+ export type { TableFormat };
14
+
15
+ export interface TurndownOptions {
16
+ readonly baseUrl?: string;
17
+ readonly codeBlockStyle?: CodeBlockStyle;
18
+ readonly gfm?: boolean;
19
+ readonly headingStyle?: HeadingStyle;
20
+ readonly images?: ImageMode;
21
+ readonly tables?: TableFormat;
22
+ }
23
+
24
+ export function toMarkdown(
25
+ html: string,
26
+ options?: Readonly<TurndownOptions>,
27
+ ): string {
28
+ const service = new TurndownService({
29
+ bulletListMarker: '-',
30
+ // Code renders inline in the Markdown as fenced blocks; normalize rewrites
31
+ // source conventions to `<code class="language-X">` first, so the fence
32
+ // carries a language tag. A code-only extraction tool was rejected as
33
+ // redundant surface for a niche the main path already covers.
34
+ codeBlockStyle: options?.codeBlockStyle ?? 'fenced',
35
+ emDelimiter: '_',
36
+ fence: '```',
37
+ headingStyle: options?.headingStyle ?? 'atx',
38
+ strongDelimiter: '**',
39
+ });
40
+
41
+ if (options?.gfm !== false) {
42
+ service.use(gfm);
43
+ }
44
+
45
+ const tableFormat = options?.tables;
46
+ if (tableFormat !== undefined) {
47
+ // addRule prepends, so this shadows the gfm plugin's table rule AND its
48
+ // headerless-table keep filter (which lives in a separate _keep list, only
49
+ // consulted when no rule in the array matches). Matching every TABLE here
50
+ // means the keep list is never reached for tables.
51
+ service.addRule('tableMatrix', {
52
+ filter: node => node.nodeName === 'TABLE',
53
+ replacement: (_content, node) => {
54
+ const matrix = parseTableMatrix(node);
55
+ if (matrix.length === 0) {
56
+ return '';
57
+ }
58
+ const body = renderTable(matrix, tableFormat);
59
+ // GFM rows are already markdown table syntax; csv/json need a fenced block.
60
+ if (tableFormat === 'gfm') {
61
+ return `\n\n${body}\n\n`;
62
+ }
63
+ return `\n\n\`\`\`${tableFormat}\n${body}\n\`\`\`\n\n`;
64
+ },
65
+ });
66
+ }
67
+
68
+ const imageMode = options?.images;
69
+ const baseUrl = options?.baseUrl;
70
+ // `reference` mode collects definitions as images render; append them after the pass.
71
+ const references: string[] = [];
72
+ applyImagePolicy(service, imageMode, baseUrl, references);
73
+ // Readability pre-absolutizes anchors on the main path, but html_to_markdown
74
+ // and the extract fallback bypass it; turndown is shared, so absolutize here
75
+ // for every path. Empty/absent href falls through to bare content to match
76
+ // turndown's default link rule (which never matches when href is falsy).
77
+ service.addRule('anchorAbsolutize', {
78
+ filter: 'a',
79
+ replacement: (content, node) => {
80
+ const rawHref = node.getAttribute('href');
81
+ if (!rawHref) {
82
+ return content;
83
+ }
84
+ const href = absolutize(rawHref, baseUrl);
85
+ const title = node.getAttribute('title');
86
+ const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : '';
87
+ return `[${content}](${href}${titlePart})`;
88
+ },
89
+ });
90
+ // Math markers are emitted by `pipeline/math.ts` and survive Readability via the
91
+ // `rdrm-math` preserve entry. A rule's replacement is inserted verbatim, so
92
+ // the raw LaTeX backslashes survive turndown untouched.
93
+ service.addRule('mathMarker', {
94
+ filter: node =>
95
+ node.nodeName === 'SPAN' && node.classList.contains('rdrm-math'),
96
+ replacement: (_content, node) => {
97
+ const tex = node.textContent.trim();
98
+ if (!tex) {
99
+ return '';
100
+ }
101
+ const display = node.getAttribute('data-display') === 'true';
102
+ return display ? `$$${tex}$$` : `$${tex}$`;
103
+ },
104
+ });
105
+
106
+ const fnResult = processFootnotes(html);
107
+ const sourceHtml = fnResult?.html ?? html;
108
+ let body = service.turndown(sourceHtml);
109
+
110
+ if (fnResult) {
111
+ // Turndown sees `[^N]` as a shortcut-reference link and backslash-escapes
112
+ // the brackets; reverse that for the markers we emitted so they render as
113
+ // footnote refs. Code blocks pass through verbatim and never carry the
114
+ // escape, so they are unaffected.
115
+ for (let n = 1; n <= fnResult.footnoteDefs.length; n++) {
116
+ body = body.replaceAll(`\\[^${n}\\]`, `[^${n}]`);
117
+ }
118
+ }
119
+
120
+ const trailingBlocks: string[] = [];
121
+ if (imageMode === 'reference' && references.length > 0) {
122
+ trailingBlocks.push(
123
+ references.map((ref, i) => `[img-${i + 1}]: ${ref}`).join('\n'),
124
+ );
125
+ }
126
+ if (fnResult && fnResult.footnoteDefs.length > 0) {
127
+ trailingBlocks.push(
128
+ fnResult.footnoteDefs.map((def, i) => `[^${i + 1}]: ${def}`).join('\n'),
129
+ );
130
+ }
131
+ if (trailingBlocks.length === 0) {
132
+ return body;
133
+ }
134
+ return `${body.replace(/\n+$/, '')}\n\n${trailingBlocks.join('\n')}`;
135
+ }
136
+
137
+ function applyImagePolicy(
138
+ service: TurndownService,
139
+ mode: ImageMode | undefined,
140
+ baseUrl: string | undefined,
141
+ references: string[],
142
+ ): void {
143
+ switch (mode) {
144
+ case 'drop': {
145
+ // `.remove('img')` doesn't reliably drop void elements in turndown 7.x.
146
+ service.addRule('dropImage', {
147
+ filter: 'img',
148
+ replacement: () => '',
149
+ });
150
+ break;
151
+ }
152
+ case 'keep':
153
+ case undefined: {
154
+ service.addRule('imageKeep', {
155
+ filter: 'img',
156
+ replacement: (_content, node) => {
157
+ const src = absolutize(node.getAttribute('src') ?? '', baseUrl);
158
+ const alt = node.getAttribute('alt') ?? '';
159
+ return src ? `![${alt}](${src})` : '';
160
+ },
161
+ });
162
+ break;
163
+ }
164
+ case 'reference': {
165
+ service.addRule('imageReference', {
166
+ filter: 'img',
167
+ replacement: (_content, node) => {
168
+ const src = absolutize(node.getAttribute('src') ?? '', baseUrl);
169
+ if (!src) {
170
+ return '';
171
+ }
172
+ references.push(src);
173
+ const id = references.length;
174
+ const alt = node.getAttribute('alt') ?? '';
175
+ return `![${alt}][img-${id}]`;
176
+ },
177
+ });
178
+ break;
179
+ }
180
+ case 'src-only': {
181
+ service.addRule('imageSrcOnly', {
182
+ filter: 'img',
183
+ replacement: (_content, node) => {
184
+ const src = absolutize(node.getAttribute('src') ?? '', baseUrl);
185
+ return src ? `\n\n${src}\n\n` : '';
186
+ },
187
+ });
188
+ break;
189
+ }
190
+ }
191
+ }
@@ -0,0 +1,14 @@
1
+ // Shared by the turndown rules (image/anchor absolutization) and the
2
+ // extract_links tool. Returns the input unchanged when either side is missing
3
+ // or the URL constructor rejects the pair, so callers never throw on bad input.
4
+ export function absolutize(src, baseUrl) {
5
+ if (!src || !baseUrl) {
6
+ return src;
7
+ }
8
+ try {
9
+ return new URL(src, baseUrl).href;
10
+ }
11
+ catch {
12
+ return src;
13
+ }
14
+ }
@@ -0,0 +1,13 @@
1
+ // Shared by the turndown rules (image/anchor absolutization) and the
2
+ // extract_links tool. Returns the input unchanged when either side is missing
3
+ // or the URL constructor rejects the pair, so callers never throw on bad input.
4
+ export function absolutize(src: string, baseUrl: string | undefined): string {
5
+ if (!src || !baseUrl) {
6
+ return src;
7
+ }
8
+ try {
9
+ return new URL(src, baseUrl).href;
10
+ } catch {
11
+ return src;
12
+ }
13
+ }