@link-assistant/web-capture 1.7.19 → 1.7.21
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 +133 -0
- package/tests/fixtures/heading-numbering.html +19 -0
- package/tests/fixtures/ol-continuous-numbering.html +12 -0
- package/tests/unit/heading-numbering.test.js +28 -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.21
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
## 1.7.20
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 5a3e588: Preserve hierarchical heading numbering (e.g. 13, 13.1) in API-path Markdown conversion. Numbered headings wrapped in `<ol><li><hN>` no longer get renumbered to 1, and sub-numbered headings render with their original number on a clean line.
|
|
14
|
+
|
|
3
15
|
## 1.7.19
|
|
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
|
@@ -52,6 +52,17 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
52
52
|
}
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
+
// Preserve hierarchical heading numbering in source text (e.g. 13, 13.1).
|
|
56
|
+
// 1) <ol><li><hN>13. Foo</hN></li></ol> → <ol start="13"><li><hN>Foo</hN></li></ol>
|
|
57
|
+
// Then Turndown emits "13. #### Foo" instead of restarting at "1.".
|
|
58
|
+
// 2) <hN>13.1 Bar</hN> (sub-numbering with decimal) → <p><strong>13.1 Bar</strong></p>
|
|
59
|
+
// Demoting avoids ATX heading prefix, leaving the sub-number on a clean line.
|
|
60
|
+
preserveLeadingHeadingNumbering($);
|
|
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
|
+
|
|
55
66
|
// Remove <a> tags with no direct text content (including only whitespace or only child elements)
|
|
56
67
|
$('a').each(function () {
|
|
57
68
|
// Get all text nodes directly under this <a>
|
|
@@ -172,6 +183,122 @@ export function convertHtmlToMarkdown(html, baseUrl) {
|
|
|
172
183
|
return he.decode(turndown.turndown($.html())).replace(/\u00A0/g, ' ');
|
|
173
184
|
}
|
|
174
185
|
|
|
186
|
+
function preserveLeadingHeadingNumbering($) {
|
|
187
|
+
// Hoist the OL counter onto a heading inside its first <li>.
|
|
188
|
+
// Example: <ol><li><h4>13. Foo</h4></li></ol> → <ol start="13"><li><h4>Foo</h4></li></ol>
|
|
189
|
+
$('ol > li').each(function () {
|
|
190
|
+
const $li = $(this);
|
|
191
|
+
const $heading = $li.children('h1, h2, h3, h4, h5, h6').first();
|
|
192
|
+
if (!$heading.length) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Only act when the <li> has just one heading child and no other meaningful text.
|
|
196
|
+
if ($li.children().length !== 1) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const text = $heading.text();
|
|
200
|
+
const m = text.match(/^\s*(\d+)\.\s+(.*)$/s);
|
|
201
|
+
if (!m) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const [, num, rest] = m;
|
|
205
|
+
const $ol = $li.parent();
|
|
206
|
+
if ($ol.children('li').length !== 1) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!$ol.attr('start')) {
|
|
210
|
+
$ol.attr('start', num);
|
|
211
|
+
}
|
|
212
|
+
stripLeadingTextFromHeading($, $heading, m[0].length - rest.length);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Demote <hN> with sub-numbering text (e.g. "13.1 Foo") to a bold paragraph.
|
|
216
|
+
// Avoids emitting `##### 13.1 Foo` (which collides with renderers that strip
|
|
217
|
+
// numbering or blockquote-wrap subsections downstream).
|
|
218
|
+
$('h1, h2, h3, h4, h5, h6').each(function () {
|
|
219
|
+
const $h = $(this);
|
|
220
|
+
const text = $h.text();
|
|
221
|
+
if (!/^\s*\d+\.\d+/.test(text)) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const inner = $h.html() || '';
|
|
225
|
+
const $p = $('<p></p>');
|
|
226
|
+
if (/^\s*<strong[\s>]/i.test(inner)) {
|
|
227
|
+
$p.html(inner);
|
|
228
|
+
} else {
|
|
229
|
+
$p.html(`<strong>${inner}</strong>`);
|
|
230
|
+
}
|
|
231
|
+
$h.replaceWith($p);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Walk every top-level <ol> in document order and assign a `start` attribute
|
|
236
|
+
// so consecutive lists number continuously (1, 2, 3, ... N). An explicit
|
|
237
|
+
// `start="N"` resets the running counter to N and is preserved.
|
|
238
|
+
//
|
|
239
|
+
// Top-level here means "not inside another <ol> or <ul>" — nested ordered
|
|
240
|
+
// lists keep their own numbering and Turndown's per-list start handling.
|
|
241
|
+
function applyContinuousOrderedListNumbering($) {
|
|
242
|
+
let counter = 1;
|
|
243
|
+
$('ol').each(function () {
|
|
244
|
+
if ($(this).parents('ol, ul').length > 0) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const explicitStart = parseInt($(this).attr('start') ?? '', 10);
|
|
248
|
+
if (Number.isFinite(explicitStart)) {
|
|
249
|
+
counter = explicitStart;
|
|
250
|
+
} else {
|
|
251
|
+
$(this).attr('start', String(counter));
|
|
252
|
+
}
|
|
253
|
+
counter += $(this).children('li').length;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function stripLeadingTextFromHeading($, $heading, prefixLen) {
|
|
258
|
+
// Walk text nodes from the start of the heading and remove prefixLen characters.
|
|
259
|
+
let remaining = prefixLen;
|
|
260
|
+
const stack = [$heading.get(0)];
|
|
261
|
+
while (stack.length && remaining > 0) {
|
|
262
|
+
const node = stack.shift();
|
|
263
|
+
const children = node.children || [];
|
|
264
|
+
for (const child of children) {
|
|
265
|
+
if (remaining <= 0) {
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
if (child.type === 'text') {
|
|
269
|
+
const data = child.data || '';
|
|
270
|
+
if (data.length <= remaining) {
|
|
271
|
+
remaining -= data.length;
|
|
272
|
+
child.data = '';
|
|
273
|
+
} else {
|
|
274
|
+
child.data = data.slice(remaining);
|
|
275
|
+
remaining = 0;
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
stack.push(child);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Trim leading whitespace from the first non-empty text node.
|
|
283
|
+
const walk = (node) => {
|
|
284
|
+
const children = node.children || [];
|
|
285
|
+
for (const child of children) {
|
|
286
|
+
if (child.type === 'text') {
|
|
287
|
+
if (child.data) {
|
|
288
|
+
child.data = child.data.replace(/^\s+/, '');
|
|
289
|
+
if (child.data) {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
} else if (walk(child)) {
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return false;
|
|
298
|
+
};
|
|
299
|
+
walk($heading.get(0));
|
|
300
|
+
}
|
|
301
|
+
|
|
175
302
|
function preserveTableCellLineBreaks(turndown) {
|
|
176
303
|
turndown.addRule('tableCellLineBreak', {
|
|
177
304
|
filter(node) {
|
|
@@ -402,6 +529,12 @@ export function convertHtmlToMarkdownEnhanced(html, baseUrl, options = {}) {
|
|
|
402
529
|
}
|
|
403
530
|
});
|
|
404
531
|
|
|
532
|
+
// Preserve hierarchical heading numbering (see convertHtmlToMarkdown).
|
|
533
|
+
preserveLeadingHeadingNumbering($);
|
|
534
|
+
|
|
535
|
+
// Continuous numbering across consecutive top-level <ol>s (see convertHtmlToMarkdown).
|
|
536
|
+
applyContinuousOrderedListNumbering($);
|
|
537
|
+
|
|
405
538
|
// Remove empty links (same logic as convertHtmlToMarkdown)
|
|
406
539
|
$('a').each(function () {
|
|
407
540
|
const directText = $(this)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html>
|
|
3
|
+
<body>
|
|
4
|
+
<ol>
|
|
5
|
+
<li>
|
|
6
|
+
<h4><strong>13. Top-level numbered heading in source</strong></h4>
|
|
7
|
+
</li>
|
|
8
|
+
</ol>
|
|
9
|
+
|
|
10
|
+
<h5><strong>13.1 First subsection heading</strong></h5>
|
|
11
|
+
<p>Where it activates: ...</p>
|
|
12
|
+
|
|
13
|
+
<h5><strong>13.2 Second subsection heading</strong></h5>
|
|
14
|
+
<p>Where it activates: ...</p>
|
|
15
|
+
|
|
16
|
+
<h5><strong>13.3 Third subsection heading</strong></h5>
|
|
17
|
+
<p>Where it activates: ...</p>
|
|
18
|
+
</body>
|
|
19
|
+
</html>
|
|
@@ -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,28 @@
|
|
|
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', 'heading-numbering.html'),
|
|
10
|
+
'utf8'
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
it('preserves hierarchical heading numbering (13, 13.1, 13.2, 13.3)', () => {
|
|
14
|
+
const md = convertHtmlToMarkdown(HTML);
|
|
15
|
+
|
|
16
|
+
// Parent number 13 must survive — either as Markdown heading or as numbered-list item.
|
|
17
|
+
expect(md).toMatch(/(^|\n)#{1,6}\s+13\.|(^|\n)13\.\s/);
|
|
18
|
+
|
|
19
|
+
// Sub-numbers 13.1, 13.2, 13.3 must each appear in heading-like context (line start, not buried mid-paragraph).
|
|
20
|
+
for (const sub of ['13.1', '13.2', '13.3']) {
|
|
21
|
+
expect(md).toMatch(
|
|
22
|
+
new RegExp(`(^|\\n)\\s*\\*{0,3}\\s*${sub.replace('.', '\\.')}`, 'm')
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Subsections must NOT be wrapped in markdown blockquotes — there is no <blockquote> in the fixture.
|
|
27
|
+
expect(md).not.toMatch(/^>\s+\*{1,3}13\.\d/m);
|
|
28
|
+
});
|
|
@@ -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
|
+
});
|