@link-assistant/web-capture 1.7.23 → 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,11 @@
|
|
|
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
|
+
|
|
3
9
|
## 1.7.23
|
|
4
10
|
|
|
5
11
|
### Patch Changes
|
package/package.json
CHANGED
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 () {
|
|
@@ -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,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
|
+
});
|