@link-assistant/web-capture 1.7.22 → 1.7.24
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/gdocs-preprocess.js +171 -0
- package/src/lib.js +16 -2
- package/tests/fixtures/bold-coalesce-and-leakage.html +27 -0
- package/tests/fixtures/paragraph-vs-line-break.html +11 -0
- package/tests/unit/bold-coalesce-and-leakage.test.js +54 -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.24
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 52ccb91: Fix structurally broken bold markup in `--capture api` output. Adjacent `<span style="font-weight:700">` siblings that the Google Docs export emits for headings, list items, and notes used to be wrapped in independent `<strong>...</strong>` pairs, producing split runs (`**13.1** **First subsection**`), stray `****` between adjacent bold blocks, and bold ranges that leaked across `<br>`/`<img>` boundaries (`**Caption A:Caption B:**`). The Google Docs preprocessor now closes `<strong>` runs at `<br>`/`<img>` boundaries, splits the wrapping `<p>` into separate paragraphs at those boundaries, drops empty `<strong></strong>` pairs, and coalesces adjacent bold siblings into a single run. Renderers can now interpret the output as valid CommonMark again.
|
|
8
|
+
|
|
9
|
+
## 1.7.23
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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`.
|
|
14
|
+
|
|
3
15
|
## 1.7.22
|
|
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.24",
|
|
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/gdocs-preprocess.js
CHANGED
|
@@ -62,6 +62,11 @@ export function preprocessGoogleDocsExportHtml(html) {
|
|
|
62
62
|
hoisted += 1;
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
splitStrongAtBlockBoundaries($);
|
|
66
|
+
splitParagraphsAtBoldBoundaries($);
|
|
67
|
+
removeEmptyStrong($);
|
|
68
|
+
coalesceAdjacentStrong($);
|
|
69
|
+
|
|
65
70
|
groupGoogleDocsBlockquotes($, classStyles);
|
|
66
71
|
nestGoogleDocsLists($, classStyles);
|
|
67
72
|
normalizeGoogleDocsTables($);
|
|
@@ -144,6 +149,172 @@ export function normalizeGoogleDocsExportMarkdown(markdown) {
|
|
|
144
149
|
.replace(/\n{3,}/g, '\n\n');
|
|
145
150
|
}
|
|
146
151
|
|
|
152
|
+
// Issue #120: a `<strong>` whose closing tag would land in a different block
|
|
153
|
+
// boundary leaks bolding across paragraphs and images. Split such a strong
|
|
154
|
+
// into multiple bold runs that each stop at the boundary.
|
|
155
|
+
function splitStrongAtBlockBoundaries($) {
|
|
156
|
+
let changed = true;
|
|
157
|
+
while (changed) {
|
|
158
|
+
changed = false;
|
|
159
|
+
const offenders = $('strong').filter(function () {
|
|
160
|
+
return $(this).find('br, img, p, div, li').length > 0;
|
|
161
|
+
});
|
|
162
|
+
if (!offenders.length) {
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
offenders.each(function () {
|
|
166
|
+
const $strong = $(this);
|
|
167
|
+
const segments = [];
|
|
168
|
+
let current = [];
|
|
169
|
+
const flush = () => {
|
|
170
|
+
if (current.length) {
|
|
171
|
+
segments.push({ type: 'inline', nodes: current });
|
|
172
|
+
current = [];
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
$strong.contents().each(function () {
|
|
176
|
+
if (this.type === 'tag' && this.tagName === 'br') {
|
|
177
|
+
flush();
|
|
178
|
+
segments.push({ type: 'br' });
|
|
179
|
+
} else if (this.type === 'tag' && this.tagName === 'img') {
|
|
180
|
+
flush();
|
|
181
|
+
segments.push({ type: 'img', node: this });
|
|
182
|
+
} else if (
|
|
183
|
+
this.type === 'tag' &&
|
|
184
|
+
['p', 'div', 'li'].includes(this.tagName)
|
|
185
|
+
) {
|
|
186
|
+
flush();
|
|
187
|
+
segments.push({ type: 'block', node: this });
|
|
188
|
+
} else {
|
|
189
|
+
current.push(this);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
flush();
|
|
193
|
+
|
|
194
|
+
const pieces = segments.map((segment) => {
|
|
195
|
+
if (segment.type === 'br') {
|
|
196
|
+
return '<br>';
|
|
197
|
+
}
|
|
198
|
+
if (segment.type === 'img') {
|
|
199
|
+
return $.html(segment.node);
|
|
200
|
+
}
|
|
201
|
+
if (segment.type === 'block') {
|
|
202
|
+
const inner = $(segment.node).html() || '';
|
|
203
|
+
const tag = segment.node.tagName;
|
|
204
|
+
const innerWrapped = inner.trim() ? `<strong>${inner}</strong>` : '';
|
|
205
|
+
return `<${tag}>${innerWrapped}</${tag}>`;
|
|
206
|
+
}
|
|
207
|
+
const inner = segment.nodes.map((node) => $.html(node)).join('');
|
|
208
|
+
if (!inner.trim()) {
|
|
209
|
+
return inner;
|
|
210
|
+
}
|
|
211
|
+
return `<strong>${inner}</strong>`;
|
|
212
|
+
});
|
|
213
|
+
$strong.replaceWith(pieces.join(''));
|
|
214
|
+
changed = true;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Issue #120: when a `<p>` contains a `<br>` adjacent to a `<strong>` or an
|
|
220
|
+
// `<img>`, split it into multiple paragraphs so each bold run and image
|
|
221
|
+
// becomes its own block. Without this, the markdown converter renders
|
|
222
|
+
// `**Caption** \n` (inline) instead of separate blocks.
|
|
223
|
+
function splitParagraphsAtBoldBoundaries($) {
|
|
224
|
+
$('p').each(function () {
|
|
225
|
+
const $p = $(this);
|
|
226
|
+
const contents = $p.contents().toArray();
|
|
227
|
+
if (!contents.some((n) => n.type === 'tag' && n.tagName === 'br')) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const hasStrongOrImg = contents.some(
|
|
231
|
+
(n) => n.type === 'tag' && (n.tagName === 'strong' || n.tagName === 'img')
|
|
232
|
+
);
|
|
233
|
+
if (!hasStrongOrImg) {
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const segments = [];
|
|
237
|
+
let current = [];
|
|
238
|
+
const flush = () => {
|
|
239
|
+
const html = current
|
|
240
|
+
.map((node) => $.html(node))
|
|
241
|
+
.join('')
|
|
242
|
+
.trim();
|
|
243
|
+
if (html) {
|
|
244
|
+
segments.push(html);
|
|
245
|
+
}
|
|
246
|
+
current = [];
|
|
247
|
+
};
|
|
248
|
+
for (const node of contents) {
|
|
249
|
+
if (node.type === 'tag' && node.tagName === 'br') {
|
|
250
|
+
flush();
|
|
251
|
+
} else if (node.type === 'tag' && node.tagName === 'img') {
|
|
252
|
+
flush();
|
|
253
|
+
segments.push($.html(node));
|
|
254
|
+
} else {
|
|
255
|
+
current.push(node);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
flush();
|
|
259
|
+
if (segments.length <= 1) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const replacement = segments.map((html) => `<p>${html}</p>`).join('');
|
|
263
|
+
$p.replaceWith(replacement);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Issue #120: drop empty `<strong></strong>` so they cannot pair with a
|
|
268
|
+
// neighbour and emit `****`.
|
|
269
|
+
function removeEmptyStrong($) {
|
|
270
|
+
$('strong').each(function () {
|
|
271
|
+
const $strong = $(this);
|
|
272
|
+
if (!$strong.html() || !$strong.text().trim()) {
|
|
273
|
+
if (!$strong.find('img').length) {
|
|
274
|
+
$strong.remove();
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Issue #120: merge adjacent `<strong>` siblings (optionally separated by
|
|
281
|
+
// whitespace text nodes) into a single `<strong>` so the converter emits
|
|
282
|
+
// `**a b**` instead of `**a** **b**`.
|
|
283
|
+
function coalesceAdjacentStrong($) {
|
|
284
|
+
let changed = true;
|
|
285
|
+
while (changed) {
|
|
286
|
+
changed = false;
|
|
287
|
+
$('strong').each(function () {
|
|
288
|
+
const $strong = $(this);
|
|
289
|
+
if (!$strong.parent().length) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
let sibling = this.next;
|
|
293
|
+
const between = [];
|
|
294
|
+
while (sibling) {
|
|
295
|
+
if (sibling.type === 'text' && /^\s*$/.test(sibling.data || '')) {
|
|
296
|
+
between.push(sibling);
|
|
297
|
+
sibling = sibling.next;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (sibling.type === 'tag' && sibling.tagName === 'strong') {
|
|
301
|
+
const innerSelf = $strong.html() || '';
|
|
302
|
+
const innerNext = $(sibling).html() || '';
|
|
303
|
+
const joiner = between.map((node) => node.data || '').join('');
|
|
304
|
+
$strong.html(`${innerSelf}${joiner}${innerNext}`);
|
|
305
|
+
for (const node of between) {
|
|
306
|
+
$(node).remove();
|
|
307
|
+
}
|
|
308
|
+
$(sibling).remove();
|
|
309
|
+
changed = true;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
147
318
|
function parseCssClassStyles($) {
|
|
148
319
|
const classStyles = new Map();
|
|
149
320
|
$('style').each(function () {
|
package/src/lib.js
CHANGED
|
@@ -184,7 +184,19 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
184
184
|
preserveTableCellLineBreaks(turndown);
|
|
185
185
|
// Decode HTML entities to unicode after markdown conversion
|
|
186
186
|
// Preserve non-breaking spaces as entities for clear marking
|
|
187
|
-
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');
|
|
188
200
|
}
|
|
189
201
|
|
|
190
202
|
// Lift <br> nodes out of inline parents when they sit at the leading or
|
|
@@ -764,7 +776,9 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
764
776
|
|
|
765
777
|
// Decode HTML entities to unicode after markdown conversion
|
|
766
778
|
// Normalize non-breaking spaces to regular spaces in Markdown text
|
|
767
|
-
let markdown =
|
|
779
|
+
let markdown = coalesceBrRunsToParagraphBreak(
|
|
780
|
+
he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ')
|
|
781
|
+
);
|
|
768
782
|
|
|
769
783
|
// Apply post-processing
|
|
770
784
|
if (postProcess) {
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<body>
|
|
4
|
+
<!-- Symptom A: two adjacent bold spans -->
|
|
5
|
+
<p>
|
|
6
|
+
<span style="font-weight: 700">13.1 </span
|
|
7
|
+
><span style="font-weight: 700">First subsection</span>
|
|
8
|
+
</p>
|
|
9
|
+
|
|
10
|
+
<!-- Symptom B: outer bold ends, inner bold begins, no separator -->
|
|
11
|
+
<ol>
|
|
12
|
+
<li><span style="font-weight: 700">Item header.</span></li>
|
|
13
|
+
</ol>
|
|
14
|
+
<p><span style="font-weight: 700">Note:</span> body text.</p>
|
|
15
|
+
|
|
16
|
+
<!-- Symptom C: bold range spans across <br> and <img> -->
|
|
17
|
+
<p>
|
|
18
|
+
<span style="font-weight: 700"
|
|
19
|
+
>Caption A:<img
|
|
20
|
+
alt=""
|
|
21
|
+
src="data:image/png;base64,iVBORw0KGgo=" /><br />Caption B:<img
|
|
22
|
+
alt=""
|
|
23
|
+
src="data:image/png;base64,iVBORw0KGgo="
|
|
24
|
+
/></span>
|
|
25
|
+
</p>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -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,54 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import {
|
|
5
|
+
normalizeGoogleDocsExportMarkdown,
|
|
6
|
+
preprocessGoogleDocsExportHtml,
|
|
7
|
+
} from '../../src/gdocs.js';
|
|
8
|
+
import { convertHtmlToMarkdown } 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', 'bold-coalesce-and-leakage.html'),
|
|
14
|
+
'utf8'
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
describe('bold-run coalescing and block boundary handling (issue #120)', () => {
|
|
18
|
+
let md;
|
|
19
|
+
beforeAll(() => {
|
|
20
|
+
const preprocessed = preprocessGoogleDocsExportHtml(HTML);
|
|
21
|
+
md = normalizeGoogleDocsExportMarkdown(
|
|
22
|
+
convertHtmlToMarkdown(preprocessed.html)
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('A: coalesces adjacent bold spans into a single bold run', () => {
|
|
27
|
+
expect(md).toMatch(/\*\*13\.1 First subsection\*\*/);
|
|
28
|
+
expect(md).not.toMatch(/\*\*13\.1\*\*\s+\*\*First subsection\*\*/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('B: never emits an empty "****" pair between adjacent bold runs', () => {
|
|
32
|
+
expect(md).not.toMatch(/\*{4}/);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('C: closes bold at block-level boundaries (<br>, <img>)', () => {
|
|
36
|
+
// No single bold run may contain an image. CommonMark bold cannot span
|
|
37
|
+
// a blank line (`\n\n`), so the inner content is restricted accordingly.
|
|
38
|
+
const strongRe = /\*\*((?:(?!\*\*|\n\n)[\s\S])+?)\*\*/g;
|
|
39
|
+
const imageRe = /!\[[^\]]*\]\([^)]+\)/;
|
|
40
|
+
for (const match of md.matchAll(strongRe)) {
|
|
41
|
+
expect(imageRe.test(match[1])).toBe(false);
|
|
42
|
+
}
|
|
43
|
+
expect(md).toMatch(/\*\*Caption A:\*\*/);
|
|
44
|
+
expect(md).toMatch(/\*\*Caption B:\*\*/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('D: every "**" opener has a matching closer (balanced asterisks)', () => {
|
|
48
|
+
const stripped = md
|
|
49
|
+
.replace(/`[^`]*`/g, '')
|
|
50
|
+
.replace(/!\[[^\]]*\]\([^)]*\)/g, '');
|
|
51
|
+
const count = (stripped.match(/\*\*/g) || []).length;
|
|
52
|
+
expect(count % 2).toBe(0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -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
|
+
});
|