@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 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:![](images/x.png)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.22",
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"
@@ -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![](x)` (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 &nbsp; entities for clear marking
187
- return he.decode(turndown.turndown($.html())).replace(/\u00A0/g, '&nbsp;');
187
+ return coalesceBrRunsToParagraphBreak(
188
+ he.decode(turndown.turndown($.html())).replace(/\u00A0/g, '&nbsp;')
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 = he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ');
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
+ });