@v1nvn/readability 0.45.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 +4 -4
- package/src/cli.js +99 -0
- package/src/config.js +29 -0
- package/src/dev.js +55 -0
- package/src/errors.js +16 -0
- package/src/handler.js +4 -0
- package/src/host-sampling.js +57 -0
- package/src/index.js +23 -0
- package/src/index.ts +0 -0
- package/src/logger.js +2 -0
- package/src/output/format.js +92 -0
- package/src/pipeline/cell-text.js +51 -0
- package/src/pipeline/context.js +1 -0
- package/src/pipeline/dom.js +16 -0
- package/src/pipeline/footnotes.js +148 -0
- package/src/pipeline/math.js +113 -0
- package/src/pipeline/normalize.js +507 -0
- package/src/pipeline/readability.js +10 -0
- package/src/pipeline/resolver.js +113 -0
- package/src/pipeline/sanitize.js +26 -0
- package/src/pipeline/tables.js +239 -0
- package/src/pipeline/turndown.js +153 -0
- package/src/pipeline/urls.js +14 -0
- package/src/policy/chunk.js +365 -0
- package/src/policy/diagnostics.js +53 -0
- package/src/policy/explain.js +104 -0
- package/src/policy/fallback.js +68 -0
- package/src/policy/gating.js +90 -0
- package/src/policy/grid-detector.js +207 -0
- package/src/policy/identifiers.js +62 -0
- package/src/policy/images.js +49 -0
- package/src/policy/list-detector.js +232 -0
- package/src/policy/lost-signal.js +44 -0
- package/src/policy/markdown.js +58 -0
- package/src/policy/metadata.js +216 -0
- package/src/policy/outline-chains.js +165 -0
- package/src/policy/outline.js +71 -0
- package/src/policy/pagination.js +79 -0
- package/src/policy/presets.js +100 -0
- package/src/policy/section.js +111 -0
- package/src/policy/selector-lint.js +143 -0
- package/src/policy/sibling-scan.js +92 -0
- package/src/policy/text.js +18 -0
- package/src/policy/truncate.js +45 -0
- package/src/preset-cache.js +165 -0
- package/src/resources.js +227 -0
- package/src/sampling.js +45 -0
- package/src/server.js +73 -0
- package/src/tools/chunk_text.js +40 -0
- package/src/tools/explain.js +200 -0
- package/src/tools/extract.js +263 -0
- package/src/tools/extract_grid.js +56 -0
- package/src/tools/extract_links.js +98 -0
- package/src/tools/extract_list.js +66 -0
- package/src/tools/extract_metadata.js +67 -0
- package/src/tools/extract_section.js +56 -0
- package/src/tools/extract_tables.js +59 -0
- package/src/tools/html-source.js +30 -0
- package/src/tools/html_to_markdown.js +113 -0
- package/src/tools/outline.js +47 -0
- package/src/tools/output-schema.js +504 -0
- package/src/tools/schemas.js +288 -0
- package/src/tools/suggest-preset.js +504 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Readability.js confirmed defaults.
|
|
2
|
+
const DEFAULT_CHAR_THRESHOLD = 500;
|
|
3
|
+
const DEFAULT_N_TOP_CANDIDATES = 5;
|
|
4
|
+
// Readability keeps a class only by strict `classesToPreserve.includes(cls)`
|
|
5
|
+
// equality (not regex), stripping code-block language tokens by default; list
|
|
6
|
+
// the common highlight.js/prism `language-*` tokens so fences keep their tag.
|
|
7
|
+
// `rdrm-math` is the math marker emitted by `pipeline/math.ts` — Readability
|
|
8
|
+
// would otherwise strip it before turndown's math rule runs. Applies only when
|
|
9
|
+
// `keepClasses` is false.
|
|
10
|
+
const CLASSES_TO_PRESERVE = [
|
|
11
|
+
'hljs',
|
|
12
|
+
'language-asm',
|
|
13
|
+
'language-assembly',
|
|
14
|
+
'language-bash',
|
|
15
|
+
'language-c',
|
|
16
|
+
'language-clojure',
|
|
17
|
+
'language-cpp',
|
|
18
|
+
'language-cs',
|
|
19
|
+
'language-csharp',
|
|
20
|
+
'language-css',
|
|
21
|
+
'language-dart',
|
|
22
|
+
'language-diff',
|
|
23
|
+
'language-dockerfile',
|
|
24
|
+
'language-elixir',
|
|
25
|
+
'language-erlang',
|
|
26
|
+
'language-go',
|
|
27
|
+
'language-graphql',
|
|
28
|
+
'language-haskell',
|
|
29
|
+
'language-html',
|
|
30
|
+
'language-ini',
|
|
31
|
+
'language-java',
|
|
32
|
+
'language-javascript',
|
|
33
|
+
'language-js',
|
|
34
|
+
'language-jsx',
|
|
35
|
+
'language-json',
|
|
36
|
+
'language-kotlin',
|
|
37
|
+
'language-lisp',
|
|
38
|
+
'language-lua',
|
|
39
|
+
'language-md',
|
|
40
|
+
'language-markdown',
|
|
41
|
+
'language-objc',
|
|
42
|
+
'language-objectivec',
|
|
43
|
+
'language-perl',
|
|
44
|
+
'language-php',
|
|
45
|
+
'language-plaintext',
|
|
46
|
+
'language-powershell',
|
|
47
|
+
'language-py',
|
|
48
|
+
'language-python',
|
|
49
|
+
'language-r',
|
|
50
|
+
'language-rb',
|
|
51
|
+
'language-rs',
|
|
52
|
+
'language-ruby',
|
|
53
|
+
'language-rust',
|
|
54
|
+
'language-scala',
|
|
55
|
+
'language-sh',
|
|
56
|
+
'language-shell',
|
|
57
|
+
'language-sql',
|
|
58
|
+
'language-swift',
|
|
59
|
+
'language-text',
|
|
60
|
+
'language-toml',
|
|
61
|
+
'language-ts',
|
|
62
|
+
'language-tsx',
|
|
63
|
+
'language-typescript',
|
|
64
|
+
'language-vim',
|
|
65
|
+
'language-wasm',
|
|
66
|
+
'language-xml',
|
|
67
|
+
'language-yaml',
|
|
68
|
+
'language-yml',
|
|
69
|
+
'rdrm-math',
|
|
70
|
+
];
|
|
71
|
+
// Bare tokens (the `X` in `language-X`) shared by convention resolvers that
|
|
72
|
+
// must validate a candidate against the preserve set, e.g. sandpack's `sp-*`
|
|
73
|
+
// classes, which carry infra strings alongside the language token. Filter to
|
|
74
|
+
// `language-` entries so non-language preserve additions (`hljs`, `rdrm-math`)
|
|
75
|
+
// do not pollute the set with the empty token.
|
|
76
|
+
export const KNOWN_LANGUAGE_TOKENS = new Set(CLASSES_TO_PRESERVE.filter(c => c.startsWith('language-')).map(c => c.slice('language-'.length)));
|
|
77
|
+
function knobsForMode(mode) {
|
|
78
|
+
switch (mode) {
|
|
79
|
+
case 'aggressive':
|
|
80
|
+
return {
|
|
81
|
+
charThreshold: Math.round(DEFAULT_CHAR_THRESHOLD / 2),
|
|
82
|
+
nbTopCandidates: DEFAULT_N_TOP_CANDIDATES * 2,
|
|
83
|
+
};
|
|
84
|
+
case 'balanced':
|
|
85
|
+
// null ⇒ emit nothing; Readability uses its own defaults.
|
|
86
|
+
return null;
|
|
87
|
+
case 'conservative':
|
|
88
|
+
return {
|
|
89
|
+
charThreshold: DEFAULT_CHAR_THRESHOLD * 2,
|
|
90
|
+
nbTopCandidates: Math.max(1, Math.round(DEFAULT_N_TOP_CANDIDATES / 2)),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
export function resolveReadabilityOptions(input) {
|
|
95
|
+
const mode = input.extraction ?? 'balanced';
|
|
96
|
+
const modeKnobs = knobsForMode(mode);
|
|
97
|
+
const keepClasses = input.keepClasses ?? false;
|
|
98
|
+
const charThreshold = input.minArticleLength !== undefined
|
|
99
|
+
? input.minArticleLength
|
|
100
|
+
: modeKnobs?.charThreshold;
|
|
101
|
+
const nbTopCandidates = modeKnobs?.nbTopCandidates;
|
|
102
|
+
// Escape hatch spreads last so it wins verbatim over every derived knob.
|
|
103
|
+
return {
|
|
104
|
+
classesToPreserve: keepClasses ? [] : [...CLASSES_TO_PRESERVE],
|
|
105
|
+
keepClasses,
|
|
106
|
+
...(charThreshold !== undefined ? { charThreshold } : {}),
|
|
107
|
+
...(nbTopCandidates !== undefined ? { nbTopCandidates } : {}),
|
|
108
|
+
...(input.maxNodes !== undefined
|
|
109
|
+
? { maxElemsToParse: input.maxNodes }
|
|
110
|
+
: {}),
|
|
111
|
+
...(input.readabilityOverrides ?? {}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import DOMPurify from 'dompurify';
|
|
2
|
+
function countRemoved(removed, tagName) {
|
|
3
|
+
let count = 0;
|
|
4
|
+
for (const entry of removed) {
|
|
5
|
+
// RemovedAttribute entries have no `element`; count element removals only.
|
|
6
|
+
if ('element' in entry &&
|
|
7
|
+
entry.element.nodeName.toUpperCase() === tagName) {
|
|
8
|
+
count += 1;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return count;
|
|
12
|
+
}
|
|
13
|
+
// DOMPurify scrubs Readability's output (scripts, inline event handlers,
|
|
14
|
+
// iframes) before Turndown converts it to Markdown — it is a pipeline stage,
|
|
15
|
+
// not a product. A sanitized-HTML output mode was rejected: the server emits
|
|
16
|
+
// Markdown, and callers wanting sanitized HTML should run DOMPurify directly.
|
|
17
|
+
export function sanitizeHtml(dirty, window) {
|
|
18
|
+
// DOMPurify's WindowLike is structurally compatible at runtime but not under TS.
|
|
19
|
+
const purify = DOMPurify(window);
|
|
20
|
+
const html = purify.sanitize(dirty);
|
|
21
|
+
return {
|
|
22
|
+
html,
|
|
23
|
+
iframesRemoved: countRemoved(purify.removed, 'IFRAME'),
|
|
24
|
+
scriptsRemoved: countRemoved(purify.removed, 'SCRIPT'),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { resolveCellText } from "./cell-text.js";
|
|
2
|
+
// `<thead>`/`<tbody>`/`<tfoot>` group `<tr>` rows; a `<table>` may also hold `<tr>`
|
|
3
|
+
// directly. Walking these levels (and only these) keeps nested `<table>`s out of
|
|
4
|
+
// the matrix — those are emitted as their own replacement by turndown.
|
|
5
|
+
const SECTION_TAGS = new Set(['TBODY', 'TFOOT', 'THEAD']);
|
|
6
|
+
const CELL_TAGS = new Set(['TD', 'TH']);
|
|
7
|
+
function spanOf(cell, attr) {
|
|
8
|
+
const raw = cell.getAttribute(attr);
|
|
9
|
+
if (raw === null) {
|
|
10
|
+
return 1;
|
|
11
|
+
}
|
|
12
|
+
const parsed = Number.parseInt(raw, 10);
|
|
13
|
+
// HTML treats 0 (and any non-positive / unparseable value) as 1.
|
|
14
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
15
|
+
return 1;
|
|
16
|
+
}
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
function collectRows(table) {
|
|
20
|
+
const rows = [];
|
|
21
|
+
for (const child of Array.from(table.children)) {
|
|
22
|
+
if (child.tagName === 'TR') {
|
|
23
|
+
rows.push(child);
|
|
24
|
+
}
|
|
25
|
+
else if (SECTION_TAGS.has(child.tagName)) {
|
|
26
|
+
for (const tr of Array.from(child.children)) {
|
|
27
|
+
if (tr.tagName === 'TR') {
|
|
28
|
+
rows.push(tr);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return rows;
|
|
34
|
+
}
|
|
35
|
+
function cellsOf(tr) {
|
|
36
|
+
return Array.from(tr.children).filter(child => CELL_TAGS.has(child.tagName));
|
|
37
|
+
}
|
|
38
|
+
// Text projection of buildCellGrid: origin cells → resolveCellText, span/pad slots → ''.
|
|
39
|
+
export function parseTableMatrix(table) {
|
|
40
|
+
return buildCellGrid(table).map(row => row.map(cell => (cell === null ? '' : resolveCellText(cell))));
|
|
41
|
+
}
|
|
42
|
+
function escapeGfmCell(text) {
|
|
43
|
+
return text.replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
|
|
44
|
+
}
|
|
45
|
+
export function renderTableGfm(matrix) {
|
|
46
|
+
if (matrix.length === 0) {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
const cols = matrix[0].length;
|
|
50
|
+
const lines = [];
|
|
51
|
+
lines.push(`| ${matrix[0].map(escapeGfmCell).join(' | ')} |`);
|
|
52
|
+
lines.push(`| ${Array.from({ length: cols }, () => '---').join(' | ')} |`);
|
|
53
|
+
for (let r = 1; r < matrix.length; r++) {
|
|
54
|
+
lines.push(`| ${matrix[r].map(escapeGfmCell).join(' | ')} |`);
|
|
55
|
+
}
|
|
56
|
+
return lines.join('\n');
|
|
57
|
+
}
|
|
58
|
+
function escapeCsvField(text) {
|
|
59
|
+
if (/[",\r\n]/.test(text)) {
|
|
60
|
+
return `"${text.replace(/"/g, '""')}"`;
|
|
61
|
+
}
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
export function renderTableCsv(matrix) {
|
|
65
|
+
if (matrix.length === 0) {
|
|
66
|
+
return '';
|
|
67
|
+
}
|
|
68
|
+
return matrix.map(row => row.map(escapeCsvField).join(',')).join('\n');
|
|
69
|
+
}
|
|
70
|
+
function headerKeys(header) {
|
|
71
|
+
return header.map((cell, i) => (cell === '' ? `column_${i}` : cell));
|
|
72
|
+
}
|
|
73
|
+
// Tracks the originating cell element at each grid position after resolving
|
|
74
|
+
// rowspan/colspan, so resolveHeaderKeys can read td/th attributes the text
|
|
75
|
+
// matrix discards. parseTableMatrix is the text projection of this grid; `null`
|
|
76
|
+
// marks positions filled by a span (no origin cell).
|
|
77
|
+
function buildCellGrid(table) {
|
|
78
|
+
const rows = collectRows(table);
|
|
79
|
+
if (rows.length === 0) {
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
const grid = [];
|
|
83
|
+
const occupied = [];
|
|
84
|
+
let maxCols = 0;
|
|
85
|
+
for (let r = 0; r < rows.length; r++) {
|
|
86
|
+
while (grid.length <= r) {
|
|
87
|
+
grid.push([]);
|
|
88
|
+
occupied.push([]);
|
|
89
|
+
}
|
|
90
|
+
const rowCells = grid[r];
|
|
91
|
+
const rowOccupied = occupied[r];
|
|
92
|
+
let col = 0;
|
|
93
|
+
for (const cell of cellsOf(rows[r])) {
|
|
94
|
+
while (rowOccupied[col]) {
|
|
95
|
+
col++;
|
|
96
|
+
}
|
|
97
|
+
const rowspan = spanOf(cell, 'rowspan');
|
|
98
|
+
const colspan = spanOf(cell, 'colspan');
|
|
99
|
+
rowCells[col] = cell;
|
|
100
|
+
rowOccupied[col] = true;
|
|
101
|
+
for (let dr = 0; dr < rowspan; dr++) {
|
|
102
|
+
for (let dc = 0; dc < colspan; dc++) {
|
|
103
|
+
if (dr === 0 && dc === 0) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const rr = r + dr;
|
|
107
|
+
while (grid.length <= rr) {
|
|
108
|
+
grid.push([]);
|
|
109
|
+
occupied.push([]);
|
|
110
|
+
}
|
|
111
|
+
const occ = occupied[rr];
|
|
112
|
+
while (occ.length <= col + dc) {
|
|
113
|
+
occ.push(false);
|
|
114
|
+
grid[rr].push(null);
|
|
115
|
+
}
|
|
116
|
+
occ[col + dc] = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
col += colspan;
|
|
120
|
+
if (col > maxCols) {
|
|
121
|
+
maxCols = col;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const dense = [];
|
|
126
|
+
for (const row of grid) {
|
|
127
|
+
dense.push(Array.from({ length: maxCols }, (_, i) => row[i] ?? null));
|
|
128
|
+
}
|
|
129
|
+
return dense;
|
|
130
|
+
}
|
|
131
|
+
function isHeaderRow(tr) {
|
|
132
|
+
const cells = cellsOf(tr);
|
|
133
|
+
return cells.length > 0 && cells.every(c => c.tagName === 'TH');
|
|
134
|
+
}
|
|
135
|
+
function slugify(text) {
|
|
136
|
+
return text
|
|
137
|
+
.toLowerCase()
|
|
138
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
139
|
+
.replace(/^_+|_+$/g, '');
|
|
140
|
+
}
|
|
141
|
+
function readCellLabel(cell) {
|
|
142
|
+
return (cell.getAttribute('aria-label') ??
|
|
143
|
+
cell.getAttribute('title') ??
|
|
144
|
+
cell.getAttribute('data-label'));
|
|
145
|
+
}
|
|
146
|
+
export function resolveHeaderKeys(table, matrix) {
|
|
147
|
+
if (matrix.length === 0) {
|
|
148
|
+
return [];
|
|
149
|
+
}
|
|
150
|
+
const width = matrix[0].length;
|
|
151
|
+
const keys = headerKeys(matrix[0]);
|
|
152
|
+
const rows = collectRows(table);
|
|
153
|
+
if (rows.length === 0) {
|
|
154
|
+
return keys;
|
|
155
|
+
}
|
|
156
|
+
const cellGrid = buildCellGrid(table);
|
|
157
|
+
// Headerless columns lose their name without a th; recover from a colspan
|
|
158
|
+
// parent in row 0, falling back to the position under that parent.
|
|
159
|
+
const parents = Array.from({ length: width }, () => '');
|
|
160
|
+
const parentChildren = new Map();
|
|
161
|
+
{
|
|
162
|
+
let col = 0;
|
|
163
|
+
for (const cell of cellsOf(rows[0])) {
|
|
164
|
+
const colspan = spanOf(cell, 'colspan');
|
|
165
|
+
const text = resolveCellText(cell);
|
|
166
|
+
if (colspan > 1 && text) {
|
|
167
|
+
const slug = slugify(text);
|
|
168
|
+
const kids = [];
|
|
169
|
+
for (let dc = 0; dc < colspan && col + dc < width; dc++) {
|
|
170
|
+
parents[col + dc] = slug;
|
|
171
|
+
kids.push(col + dc);
|
|
172
|
+
}
|
|
173
|
+
parentChildren.set(slug, kids);
|
|
174
|
+
}
|
|
175
|
+
col += colspan;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const hasSubRow = rows.length > 1 && isHeaderRow(rows[1]);
|
|
179
|
+
for (let c = 0; c < width; c++) {
|
|
180
|
+
const dataCell = cellGrid[1]?.[c];
|
|
181
|
+
if (dataCell) {
|
|
182
|
+
const label = readCellLabel(dataCell);
|
|
183
|
+
if (label) {
|
|
184
|
+
keys[c] = label;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
const parent = parents[c];
|
|
189
|
+
if (!parent) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (hasSubRow) {
|
|
193
|
+
const sub = matrix[1][c] ? slugify(matrix[1][c]) : '';
|
|
194
|
+
if (sub) {
|
|
195
|
+
keys[c] = `${parent}_${sub}`;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (matrix[0][c] === '') {
|
|
200
|
+
const kids = parentChildren.get(parent);
|
|
201
|
+
const idx = kids ? kids.indexOf(c) + 1 : c;
|
|
202
|
+
keys[c] = `${parent}_${idx}`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return keys;
|
|
206
|
+
}
|
|
207
|
+
function resolveJsonKeys(matrix, keys) {
|
|
208
|
+
const width = matrix[0].length;
|
|
209
|
+
if (!keys) {
|
|
210
|
+
return headerKeys(matrix[0]);
|
|
211
|
+
}
|
|
212
|
+
return Array.from({ length: width }, (_, i) => keys[i] ?? `column_${i}`);
|
|
213
|
+
}
|
|
214
|
+
export function renderTableJson(matrix, keys) {
|
|
215
|
+
if (matrix.length < 2) {
|
|
216
|
+
return '[]';
|
|
217
|
+
}
|
|
218
|
+
const resolved = resolveJsonKeys(matrix, keys);
|
|
219
|
+
const records = [];
|
|
220
|
+
for (let r = 1; r < matrix.length; r++) {
|
|
221
|
+
const row = matrix[r];
|
|
222
|
+
const record = {};
|
|
223
|
+
for (let c = 0; c < resolved.length; c++) {
|
|
224
|
+
record[resolved[c]] = row[c] ?? '';
|
|
225
|
+
}
|
|
226
|
+
records.push(record);
|
|
227
|
+
}
|
|
228
|
+
return JSON.stringify(records, null, 2);
|
|
229
|
+
}
|
|
230
|
+
export function renderTable(matrix, format, keys) {
|
|
231
|
+
switch (format) {
|
|
232
|
+
case 'csv':
|
|
233
|
+
return renderTableCsv(matrix);
|
|
234
|
+
case 'gfm':
|
|
235
|
+
return renderTableGfm(matrix);
|
|
236
|
+
case 'json':
|
|
237
|
+
return renderTableJson(matrix, keys);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -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,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
|
+
}
|