@link-assistant/web-capture 1.7.20 → 1.7.22
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 +1 -1
- package/src/gdocs-preprocess.js +21 -1
- package/src/lib.js +102 -0
- package/src/postprocess.js +3 -2
- package/tests/fixtures/list-item-with-br.html +8 -0
- package/tests/fixtures/ol-continuous-numbering.html +12 -0
- package/tests/unit/html2md-br-in-list-item.test.js +23 -0
- package/tests/unit/html2md-ol-numbering.test.js +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/web-capture
|
|
2
2
|
|
|
3
|
+
## 1.7.22
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 1.7.21
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- b8ad97d: Number consecutive top-level `<ol>`s continuously across the document (1, 2, 3, ... N) so JS and Rust HTML→Markdown converters agree. `<ol start="N">` resets the counter and is honoured by both implementations. Nested ordered lists keep their own per-list numbering.
|
|
14
|
+
|
|
3
15
|
## 1.7.20
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/gdocs-preprocess.js
CHANGED
|
@@ -245,6 +245,12 @@ function renderNestedListGroup($, group, classStyles) {
|
|
|
245
245
|
const openTags = [];
|
|
246
246
|
const itemOpen = [];
|
|
247
247
|
|
|
248
|
+
const topLevelStart =
|
|
249
|
+
group[0]?.tagName?.toLowerCase() === 'ol'
|
|
250
|
+
? $(group[0]).attr('start')
|
|
251
|
+
: undefined;
|
|
252
|
+
let topLevelOpened = false;
|
|
253
|
+
|
|
248
254
|
const closeItem = (level) => {
|
|
249
255
|
if (itemOpen[level]) {
|
|
250
256
|
html += '</li>';
|
|
@@ -261,7 +267,21 @@ function renderNestedListGroup($, group, classStyles) {
|
|
|
261
267
|
const openList = (level, tagName) => {
|
|
262
268
|
openTags[level] = tagName;
|
|
263
269
|
itemOpen[level] = false;
|
|
264
|
-
|
|
270
|
+
if (
|
|
271
|
+
level === 0 &&
|
|
272
|
+
!topLevelOpened &&
|
|
273
|
+
tagName === 'ol' &&
|
|
274
|
+
topLevelStart !== undefined &&
|
|
275
|
+
topLevelStart !== ''
|
|
276
|
+
) {
|
|
277
|
+
html += `<ol start="${topLevelStart}">`;
|
|
278
|
+
topLevelOpened = true;
|
|
279
|
+
} else {
|
|
280
|
+
if (level === 0) {
|
|
281
|
+
topLevelOpened = true;
|
|
282
|
+
}
|
|
283
|
+
html += `<${tagName}>`;
|
|
284
|
+
}
|
|
265
285
|
};
|
|
266
286
|
|
|
267
287
|
for (const list of group) {
|
package/src/lib.js
CHANGED
|
@@ -59,6 +59,10 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
59
59
|
// Demoting avoids ATX heading prefix, leaving the sub-number on a clean line.
|
|
60
60
|
preserveLeadingHeadingNumbering($);
|
|
61
61
|
|
|
62
|
+
// Number consecutive top-level <ol>s continuously across the document
|
|
63
|
+
// (1, 2, 3 ... N) so JS and Rust agree. <ol start="N"> resets the counter.
|
|
64
|
+
applyContinuousOrderedListNumbering($);
|
|
65
|
+
|
|
62
66
|
// Remove <a> tags with no direct text content (including only whitespace or only child elements)
|
|
63
67
|
$('a').each(function () {
|
|
64
68
|
// Get all text nodes directly under this <a>
|
|
@@ -160,6 +164,10 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
160
164
|
$table.replaceWith($newTable);
|
|
161
165
|
});
|
|
162
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
|
+
|
|
163
171
|
// Convert cleaned HTML to Markdown
|
|
164
172
|
const turndown = new TurndownService({
|
|
165
173
|
headingStyle: 'atx',
|
|
@@ -179,6 +187,72 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
179
187
|
return he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ');
|
|
180
188
|
}
|
|
181
189
|
|
|
190
|
+
// Lift <br> nodes out of inline parents when they sit at the leading or
|
|
191
|
+
// trailing edge.
|
|
192
|
+
//
|
|
193
|
+
// Turndown calls `content.trim()` on the inner markdown of any inline element
|
|
194
|
+
// whose `flankingWhitespace` is non-empty (turndown.cjs.js:902), which strips
|
|
195
|
+
// the ` \n` hard break that the <br> rule emits. Google Docs export-html
|
|
196
|
+
// commonly produces `<strong>X</strong><span> Y.<br></span><strong>Z</strong>`
|
|
197
|
+
// — the trailing <br> inside a leading-space <span> gets `.trim()`-ed away,
|
|
198
|
+
// so X/Y/Z collapse onto one line.
|
|
199
|
+
//
|
|
200
|
+
// Hoisting trailing/leading <br>s out of their inline wrapper sidesteps the
|
|
201
|
+
// trim entirely. The hard break then attaches at the parent level, which
|
|
202
|
+
// Turndown handles correctly.
|
|
203
|
+
function liftBrFromInlineEdges($) {
|
|
204
|
+
const inlineParents = new Set([
|
|
205
|
+
'span',
|
|
206
|
+
'a',
|
|
207
|
+
'strong',
|
|
208
|
+
'b',
|
|
209
|
+
'em',
|
|
210
|
+
'i',
|
|
211
|
+
'u',
|
|
212
|
+
's',
|
|
213
|
+
'del',
|
|
214
|
+
'ins',
|
|
215
|
+
'sub',
|
|
216
|
+
'sup',
|
|
217
|
+
'mark',
|
|
218
|
+
'small',
|
|
219
|
+
'q',
|
|
220
|
+
'cite',
|
|
221
|
+
'code',
|
|
222
|
+
'abbr',
|
|
223
|
+
'time',
|
|
224
|
+
'kbd',
|
|
225
|
+
'samp',
|
|
226
|
+
'var',
|
|
227
|
+
'font',
|
|
228
|
+
]);
|
|
229
|
+
|
|
230
|
+
// Hoist <br>s repeatedly until none sit at an inline edge — this handles
|
|
231
|
+
// nested wrappers like `<span><em>foo<br></em></span>`.
|
|
232
|
+
let changed = true;
|
|
233
|
+
while (changed) {
|
|
234
|
+
changed = false;
|
|
235
|
+
$('br').each(function () {
|
|
236
|
+
const el = this;
|
|
237
|
+
const parent = el.parent;
|
|
238
|
+
if (!parent || !inlineParents.has(parent.tagName)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const isFirst = parent.children[0] === el;
|
|
242
|
+
const isLast = parent.children[parent.children.length - 1] === el;
|
|
243
|
+
if (!isFirst && !isLast) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (isLast) {
|
|
247
|
+
$(parent).after(el);
|
|
248
|
+
} else {
|
|
249
|
+
$(parent).before(el);
|
|
250
|
+
}
|
|
251
|
+
changed = true;
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
182
256
|
function preserveLeadingHeadingNumbering($) {
|
|
183
257
|
// Hoist the OL counter onto a heading inside its first <li>.
|
|
184
258
|
// Example: <ol><li><h4>13. Foo</h4></li></ol> → <ol start="13"><li><h4>Foo</h4></li></ol>
|
|
@@ -228,6 +302,28 @@ function preserveLeadingHeadingNumbering($) {
|
|
|
228
302
|
});
|
|
229
303
|
}
|
|
230
304
|
|
|
305
|
+
// Walk every top-level <ol> in document order and assign a `start` attribute
|
|
306
|
+
// so consecutive lists number continuously (1, 2, 3, ... N). An explicit
|
|
307
|
+
// `start="N"` resets the running counter to N and is preserved.
|
|
308
|
+
//
|
|
309
|
+
// Top-level here means "not inside another <ol> or <ul>" — nested ordered
|
|
310
|
+
// lists keep their own numbering and Turndown's per-list start handling.
|
|
311
|
+
function applyContinuousOrderedListNumbering($) {
|
|
312
|
+
let counter = 1;
|
|
313
|
+
$('ol').each(function () {
|
|
314
|
+
if ($(this).parents('ol, ul').length > 0) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const explicitStart = parseInt($(this).attr('start') ?? '', 10);
|
|
318
|
+
if (Number.isFinite(explicitStart)) {
|
|
319
|
+
counter = explicitStart;
|
|
320
|
+
} else {
|
|
321
|
+
$(this).attr('start', String(counter));
|
|
322
|
+
}
|
|
323
|
+
counter += $(this).children('li').length;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
231
327
|
function stripLeadingTextFromHeading($, $heading, prefixLen) {
|
|
232
328
|
// Walk text nodes from the start of the heading and remove prefixLen characters.
|
|
233
329
|
let remaining = prefixLen;
|
|
@@ -506,6 +602,9 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
506
602
|
// Preserve hierarchical heading numbering (see convertHtmlToMarkdown).
|
|
507
603
|
preserveLeadingHeadingNumbering($);
|
|
508
604
|
|
|
605
|
+
// Continuous numbering across consecutive top-level <ol>s (see convertHtmlToMarkdown).
|
|
606
|
+
applyContinuousOrderedListNumbering($);
|
|
607
|
+
|
|
509
608
|
// Remove empty links (same logic as convertHtmlToMarkdown)
|
|
510
609
|
$('a').each(function () {
|
|
511
610
|
const directText = $(this)
|
|
@@ -645,6 +744,9 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
645
744
|
$table.replaceWith($newTable);
|
|
646
745
|
});
|
|
647
746
|
|
|
747
|
+
// Hoist <br>s out of inline edges (see liftBrFromInlineEdges).
|
|
748
|
+
liftBrFromInlineEdges($);
|
|
749
|
+
|
|
648
750
|
// Convert to Markdown using Turndown
|
|
649
751
|
const turndown = new TurndownService({
|
|
650
752
|
headingStyle: 'atx',
|
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,12 @@
|
|
|
1
|
+
<ol>
|
|
2
|
+
<li><strong>First</strong></li>
|
|
3
|
+
<li><strong>Second</strong></li>
|
|
4
|
+
</ol>
|
|
5
|
+
<p>Some prose between lists.</p>
|
|
6
|
+
<ol>
|
|
7
|
+
<li><strong>Third</strong></li>
|
|
8
|
+
<li><strong>Fourth</strong></li>
|
|
9
|
+
</ol>
|
|
10
|
+
<ol start="13">
|
|
11
|
+
<li><strong>Top-level numbered heading.</strong></li>
|
|
12
|
+
</ol>
|
|
@@ -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,20 @@
|
|
|
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', 'ol-continuous-numbering.html'),
|
|
10
|
+
'utf8'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
it('numbers consecutive top-level ordered lists continuously', () => {
|
|
14
|
+
const md = convertHtmlToMarkdown(HTML);
|
|
15
|
+
expect(md).toMatch(/^\s*1\.\s+\*\*First\*\*/m);
|
|
16
|
+
expect(md).toMatch(/^\s*2\.\s+\*\*Second\*\*/m);
|
|
17
|
+
expect(md).toMatch(/^\s*3\.\s+\*\*Third\*\*/m);
|
|
18
|
+
expect(md).toMatch(/^\s*4\.\s+\*\*Fourth\*\*/m);
|
|
19
|
+
expect(md).toMatch(/^\s*13\.\s+\*\*Top-level/m);
|
|
20
|
+
});
|