@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.
- package/README.md +3 -3
- package/package.json +7 -9
- package/src/cli.js +99 -0
- package/src/cli.ts +134 -0
- package/src/config.js +29 -0
- package/src/config.ts +42 -0
- package/src/dev.js +55 -0
- package/src/dev.ts +77 -0
- package/src/errors.js +16 -0
- package/src/errors.ts +19 -0
- package/src/handler.js +4 -0
- package/src/handler.ts +8 -0
- package/src/host-sampling.js +57 -0
- package/src/host-sampling.ts +86 -0
- package/src/index.js +23 -0
- package/src/index.ts +27 -0
- package/src/logger.js +2 -0
- package/src/logger.ts +3 -0
- package/src/output/format.js +92 -0
- package/src/output/format.ts +132 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/cell-text.ts +56 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/context.ts +88 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/dom.ts +23 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/footnotes.ts +181 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/math.ts +130 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/normalize.ts +594 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/readability.ts +36 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/resolver.ts +143 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/sanitize.ts +42 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/tables.ts +286 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/turndown.ts +191 -0
- package/src/pipeline/urls.js +14 -0
- package/src/pipeline/urls.ts +13 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/chunk.ts +482 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/diagnostics.ts +96 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/explain.ts +182 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/fallback.ts +110 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/gating.ts +106 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/grid-detector.ts +313 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/identifiers.ts +75 -0
- package/src/policy/images.js +49 -0
- package/src/policy/images.ts +64 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/list-detector.ts +308 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/lost-signal.ts +69 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/markdown.ts +76 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/metadata.ts +312 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline-chains.ts +215 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/outline.ts +89 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/pagination.ts +104 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/presets.ts +142 -0
- package/src/policy/section.js +111 -0
- package/src/policy/section.ts +127 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/selector-lint.ts +186 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/sibling-scan.ts +99 -0
- package/src/policy/text.js +18 -0
- package/src/policy/text.ts +36 -0
- package/src/policy/truncate.js +45 -0
- package/src/policy/truncate.ts +59 -0
- package/src/preset-cache.js +165 -0
- package/src/preset-cache.ts +221 -0
- package/src/resources.js +227 -0
- package/src/resources.ts +317 -0
- package/src/sampling.js +45 -0
- package/src/sampling.ts +74 -0
- package/src/server.js +73 -0
- package/src/server.ts +85 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/chunk_text.ts +65 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/explain.ts +299 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract.ts +368 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_grid.ts +90 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_links.ts +138 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_list.ts +97 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_metadata.ts +102 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_section.ts +84 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/extract_tables.ts +101 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html-source.ts +40 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/html_to_markdown.ts +180 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/outline.ts +77 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/output-schema.ts +715 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/schemas.ts +442 -0
- package/src/tools/suggest-preset.js +504 -0
- package/src/tools/suggest-preset.ts +708 -0
- package/src/types/jsdom.d.ts +15 -0
- package/src/types/turndown-plugin-gfm.d.ts +11 -0
- package/dist/assets/cli-Dfjg7dOQ.js +0 -76
- package/dist/assets/cli-Dfjg7dOQ.js.map +0 -1
- package/dist/assets/extract-CdDp-KwX.js +0 -2880
- package/dist/assets/extract-CdDp-KwX.js.map +0 -1
- package/dist/index.js +0 -2267
- 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 ? `` : '';
|
|
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 ? `` : '';
|
|
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
|
+
}
|