@link-assistant/web-capture 1.7.21 → 1.7.23
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/CHANGELOG.md +12 -0
- package/package.json +2 -1
- package/src/lib.js +89 -2
- package/src/postprocess.js +3 -2
- package/tests/fixtures/list-item-with-br.html +8 -0
- package/tests/fixtures/paragraph-vs-line-break.html +11 -0
- package/tests/unit/html2md-br-in-list-item.test.js +23 -0
- package/tests/unit/paragraph-vs-line-break.test.js +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.23
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 6b36b5b: Fix `<br><br>` collapsing into two CommonMark hard breaks (` \n \n`) instead of a paragraph break (`\n\n`). Google Docs export-html marks paragraph boundaries with `<br><br>`, which Turndown faithfully emitted as two trailing-two-space-newline pairs. Renderers (GitHub, MkDocs, Pandoc) then joined the surrounding lines into a single `<p>` with a `<br>`, cramming captions against images with no vertical spacing, and the "blank" separator line in the markdown source actually carried trailing whitespace that polluted diffs. Two or more adjacent hard breaks now coalesce to `\n\n` after Turndown runs, restoring true paragraph breaks. Applied in both `convertHtmlToMarkdown` (used by `--capture api`) and `convertHtmlToMarkdownEnhanced`.
|
|
8
|
+
|
|
9
|
+
## 1.7.22
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- a339ca2: Fix `--capture api` collapsing `<br>`-separated lines inside list items into one run. The Google Docs export-html path lost line breaks when a `<br>` was the leading or trailing child of an inline element (e.g. a `<span>` between bold runs), because Turndown trims inner content of inline elements with edge whitespace. The HTML pre-processing now hoists those edge `<br>`s out of their inline parents before Turndown sees them, restoring CommonMark hard breaks. Additionally, the post-processor's double-space collapse no longer eats the two trailing spaces that mark a hard break.
|
|
14
|
+
|
|
3
15
|
## 1.7.21
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@link-assistant/web-capture",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.23",
|
|
4
4
|
"description": "CLI and microservice to render web pages as HTML, Markdown, or PNG",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"jest": "^29.7.0",
|
|
63
63
|
"jscpd": "^4.0.5",
|
|
64
64
|
"lint-staged": "^16.2.6",
|
|
65
|
+
"markdown-it": "^14.1.1",
|
|
65
66
|
"nock": "^13.5.4",
|
|
66
67
|
"prettier": "^3.6.2",
|
|
67
68
|
"supertest": "^6.3.4"
|
package/src/lib.js
CHANGED
|
@@ -164,6 +164,10 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
164
164
|
$table.replaceWith($newTable);
|
|
165
165
|
});
|
|
166
166
|
|
|
167
|
+
// Hoist <br>s out of inline edges so Turndown's flanking-whitespace trim
|
|
168
|
+
// does not eat the hard break. See liftBrFromInlineEdges for details.
|
|
169
|
+
liftBrFromInlineEdges($);
|
|
170
|
+
|
|
167
171
|
// Convert cleaned HTML to Markdown
|
|
168
172
|
const turndown = new TurndownService({
|
|
169
173
|
headingStyle: 'atx',
|
|
@@ -180,7 +184,85 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
180
184
|
preserveTableCellLineBreaks(turndown);
|
|
181
185
|
// Decode HTML entities to unicode after markdown conversion
|
|
182
186
|
// Preserve non-breaking spaces as entities for clear marking
|
|
183
|
-
return
|
|
187
|
+
return coalesceBrRunsToParagraphBreak(
|
|
188
|
+
he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ')
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Coalesce runs of CommonMark hard breaks (` \n`) into a single paragraph
|
|
193
|
+
// break. Google Docs export wraps every visual line break in `<br>`, including
|
|
194
|
+
// `<br><br>` at paragraph boundaries; Turndown faithfully emits two trailing-
|
|
195
|
+
// two-space-newline pairs for that, which renders as `<br><br>` joined inside
|
|
196
|
+
// one `<p>` instead of two paragraphs. Two or more adjacent hard breaks always
|
|
197
|
+
// mean "paragraph break" in the source HTML, so collapse them to `\n\n`.
|
|
198
|
+
function coalesceBrRunsToParagraphBreak(markdown) {
|
|
199
|
+
return markdown.replace(/(?: {2,}\n){2,}/g, '\n\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Lift <br> nodes out of inline parents when they sit at the leading or
|
|
203
|
+
// trailing edge.
|
|
204
|
+
//
|
|
205
|
+
// Turndown calls `content.trim()` on the inner markdown of any inline element
|
|
206
|
+
// whose `flankingWhitespace` is non-empty (turndown.cjs.js:902), which strips
|
|
207
|
+
// the ` \n` hard break that the <br> rule emits. Google Docs export-html
|
|
208
|
+
// commonly produces `<strong>X</strong><span> Y.<br></span><strong>Z</strong>`
|
|
209
|
+
// — the trailing <br> inside a leading-space <span> gets `.trim()`-ed away,
|
|
210
|
+
// so X/Y/Z collapse onto one line.
|
|
211
|
+
//
|
|
212
|
+
// Hoisting trailing/leading <br>s out of their inline wrapper sidesteps the
|
|
213
|
+
// trim entirely. The hard break then attaches at the parent level, which
|
|
214
|
+
// Turndown handles correctly.
|
|
215
|
+
function liftBrFromInlineEdges($) {
|
|
216
|
+
const inlineParents = new Set([
|
|
217
|
+
'span',
|
|
218
|
+
'a',
|
|
219
|
+
'strong',
|
|
220
|
+
'b',
|
|
221
|
+
'em',
|
|
222
|
+
'i',
|
|
223
|
+
'u',
|
|
224
|
+
's',
|
|
225
|
+
'del',
|
|
226
|
+
'ins',
|
|
227
|
+
'sub',
|
|
228
|
+
'sup',
|
|
229
|
+
'mark',
|
|
230
|
+
'small',
|
|
231
|
+
'q',
|
|
232
|
+
'cite',
|
|
233
|
+
'code',
|
|
234
|
+
'abbr',
|
|
235
|
+
'time',
|
|
236
|
+
'kbd',
|
|
237
|
+
'samp',
|
|
238
|
+
'var',
|
|
239
|
+
'font',
|
|
240
|
+
]);
|
|
241
|
+
|
|
242
|
+
// Hoist <br>s repeatedly until none sit at an inline edge — this handles
|
|
243
|
+
// nested wrappers like `<span><em>foo<br></em></span>`.
|
|
244
|
+
let changed = true;
|
|
245
|
+
while (changed) {
|
|
246
|
+
changed = false;
|
|
247
|
+
$('br').each(function () {
|
|
248
|
+
const el = this;
|
|
249
|
+
const parent = el.parent;
|
|
250
|
+
if (!parent || !inlineParents.has(parent.tagName)) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const isFirst = parent.children[0] === el;
|
|
254
|
+
const isLast = parent.children[parent.children.length - 1] === el;
|
|
255
|
+
if (!isFirst && !isLast) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (isLast) {
|
|
259
|
+
$(parent).after(el);
|
|
260
|
+
} else {
|
|
261
|
+
$(parent).before(el);
|
|
262
|
+
}
|
|
263
|
+
changed = true;
|
|
264
|
+
});
|
|
265
|
+
}
|
|
184
266
|
}
|
|
185
267
|
|
|
186
268
|
function preserveLeadingHeadingNumbering($) {
|
|
@@ -674,6 +756,9 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
674
756
|
$table.replaceWith($newTable);
|
|
675
757
|
});
|
|
676
758
|
|
|
759
|
+
// Hoist <br>s out of inline edges (see liftBrFromInlineEdges).
|
|
760
|
+
liftBrFromInlineEdges($);
|
|
761
|
+
|
|
677
762
|
// Convert to Markdown using Turndown
|
|
678
763
|
const turndown = new TurndownService({
|
|
679
764
|
headingStyle: 'atx',
|
|
@@ -691,7 +776,9 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
691
776
|
|
|
692
777
|
// Decode HTML entities to unicode after markdown conversion
|
|
693
778
|
// Normalize non-breaking spaces to regular spaces in Markdown text
|
|
694
|
-
let markdown =
|
|
779
|
+
let markdown = coalesceBrRunsToParagraphBreak(
|
|
780
|
+
he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ')
|
|
781
|
+
);
|
|
695
782
|
|
|
696
783
|
// Apply post-processing
|
|
697
784
|
if (postProcess) {
|
package/src/postprocess.js
CHANGED
|
@@ -50,8 +50,9 @@ export function postProcessMarkdown(markdown, options = {}) {
|
|
|
50
50
|
result = applyBoldFormattingFixes(result);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
// Fix double spaces (but not in code blocks
|
|
54
|
-
|
|
53
|
+
// Fix double spaces (but not in code blocks, and preserve CommonMark hard
|
|
54
|
+
// breaks which require two trailing spaces before a newline).
|
|
55
|
+
result = result.replace(/([^\n`]) +(?!\n)/g, (match, char) => `${char} `);
|
|
55
56
|
|
|
56
57
|
// Clean up extra spaces around em-dashes
|
|
57
58
|
result = result.replace(/\s+—\s+/g, ' — ');
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<ol>
|
|
2
|
+
<li>
|
|
3
|
+
<strong>Glossary entry.</strong><span> Some lead text.<br /></span
|
|
4
|
+
><strong>TAG1</strong><span> – Definition one.<br /></span
|
|
5
|
+
><strong>TAG2</strong><span> – Definition two.<br /></span
|
|
6
|
+
><strong>TAG3</strong><span> – Definition three.</span>
|
|
7
|
+
</li>
|
|
8
|
+
</ol>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<body>
|
|
4
|
+
<p>
|
|
5
|
+
<span>Caption A:</span><br />
|
|
6
|
+
<img alt="" src="data:image/png;base64,iVBORw0KGgo=" /><br /><br />
|
|
7
|
+
<span>Caption B:</span><br />
|
|
8
|
+
<img alt="" src="data:image/png;base64,iVBORw0KGgo=" />
|
|
9
|
+
</p>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { convertHtmlToMarkdown } from '../../src/lib.js';
|
|
5
|
+
|
|
6
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
7
|
+
const __dirname = path.dirname(__filename);
|
|
8
|
+
const HTML = fs.readFileSync(
|
|
9
|
+
path.join(__dirname, '..', 'fixtures', 'list-item-with-br.html'),
|
|
10
|
+
'utf8'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
it('preserves <br> as a line separator inside list items', () => {
|
|
14
|
+
const md = convertHtmlToMarkdown(HTML);
|
|
15
|
+
for (const tag of ['TAG1', 'TAG2', 'TAG3']) {
|
|
16
|
+
expect(md).toMatch(
|
|
17
|
+
new RegExp(`(^|\\n)\\s*\\*{0,2}${tag}\\*{0,2}\\s*–`, 'm')
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
// Definitions must not be glued on the same line (no <br> dropped).
|
|
21
|
+
expect(md).not.toMatch(/Definition one\.[ \t]*\*{0,2}TAG2/);
|
|
22
|
+
expect(md).not.toMatch(/Definition two\.[ \t]*\*{0,2}TAG3/);
|
|
23
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import MarkdownIt from 'markdown-it';
|
|
5
|
+
import {
|
|
6
|
+
convertHtmlToMarkdown,
|
|
7
|
+
convertHtmlToMarkdownEnhanced,
|
|
8
|
+
} from '../../src/lib.js';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
const HTML = fs.readFileSync(
|
|
13
|
+
path.join(__dirname, '..', 'fixtures', 'paragraph-vs-line-break.html'),
|
|
14
|
+
'utf8'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe('<br><br> coalesces to a paragraph break (not two hard breaks)', () => {
|
|
18
|
+
it('renders a paragraph break between Caption A and Caption B (convertHtmlToMarkdown)', () => {
|
|
19
|
+
const md = convertHtmlToMarkdown(HTML);
|
|
20
|
+
const lines = md.split('\n');
|
|
21
|
+
const idxA = lines.findIndex((l) => l.includes('Caption A:'));
|
|
22
|
+
const idxB = lines.findIndex((l) => l.includes('Caption B:'));
|
|
23
|
+
expect(idxA).toBeGreaterThanOrEqual(0);
|
|
24
|
+
expect(idxB).toBeGreaterThan(idxA);
|
|
25
|
+
|
|
26
|
+
const between = lines.slice(idxA + 1, idxB);
|
|
27
|
+
expect(between.some((l) => l === '')).toBe(true);
|
|
28
|
+
|
|
29
|
+
expect(md).not.toMatch(/^[ \t]+ {2}$/m);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('renders a paragraph break between Caption A and Caption B (convertHtmlToMarkdownEnhanced)', () => {
|
|
33
|
+
const { markdown } = convertHtmlToMarkdownEnhanced(HTML);
|
|
34
|
+
const lines = markdown.split('\n');
|
|
35
|
+
const idxA = lines.findIndex((l) => l.includes('Caption A:'));
|
|
36
|
+
const idxB = lines.findIndex((l) => l.includes('Caption B:'));
|
|
37
|
+
expect(idxA).toBeGreaterThanOrEqual(0);
|
|
38
|
+
expect(idxB).toBeGreaterThan(idxA);
|
|
39
|
+
|
|
40
|
+
const between = lines.slice(idxA + 1, idxB);
|
|
41
|
+
expect(between.some((l) => l === '')).toBe(true);
|
|
42
|
+
|
|
43
|
+
expect(markdown).not.toMatch(/^[ \t]+ {2}$/m);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('CommonMark renders Caption A and Caption B as separate paragraphs', () => {
|
|
47
|
+
const md = convertHtmlToMarkdown(HTML);
|
|
48
|
+
const html = new MarkdownIt('commonmark').render(md);
|
|
49
|
+
const aIdx = html.indexOf('Caption A:');
|
|
50
|
+
const bIdx = html.indexOf('Caption B:');
|
|
51
|
+
expect(aIdx).toBeGreaterThan(-1);
|
|
52
|
+
expect(bIdx).toBeGreaterThan(aIdx);
|
|
53
|
+
const between = html.slice(aIdx, bIdx);
|
|
54
|
+
expect(between).toMatch(/<\/p>\s*<p>/);
|
|
55
|
+
});
|
|
56
|
+
});
|