diff --git a/lib/storage-walker.js b/lib/storage-walker.js
index 194ea45..a7c95af 100644
--- a/lib/storage-walker.js
+++ b/lib/storage-walker.js
@@ -33,6 +33,34 @@ const LIST_ITEM_BLOCK_MACROS = new Set([
'include', 'shared-block', 'include-shared-block',
]);
+// Blocks whose markdown ends on a closed line (ATX heading, thematic
+// break, code fence), so a following list line cannot be absorbed into
+// them. Keyed by tag name, or ac:name for structured macros.
+const CLOSED_LIST_ITEM_BLOCKS = new Set([
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
+ 'code', 'mermaid-macro', 'plantuml', 'plantumlcloud',
+]);
+
+// CommonMark ordered list markers are 1–9 digits, so numbers are 0..MAX.
+const MAX_LIST_NUMBER = 999999999;
+// Elements looked through when checking whether a callout body opens with
+// an ordered list.
+const TRANSPARENT_LIST_WRAPPERS = new Set([
+ 'div', 'p', 'span', 'ac:layout', 'ac:layout-section', 'ac:layout-cell',
+]);
+// An ordered list marker other than `1.`, which cannot interrupt a paragraph.
+const NON_ONE_ORDERED_MARKER_RE = /^(?!1\.)\d+\./;
+
+// Resolve an
attribute to the first marker number. Values
+// markdown cannot express (negative, non-integer, or a run that would
+// outgrow a 9-digit marker) fall back to 1.
+function resolveListStart(value, count) {
+ const raw = String(value == null ? '' : value).trim();
+ if (!/^\d+$/.test(raw)) return 1;
+ const start = Number(raw);
+ return start + Math.max(count - 1, 0) <= MAX_LIST_NUMBER ? start : 1;
+}
+
// Decode HTML entity references, matching the original htmlToMarkdown
// bit-for-bit: nbsp / ldquo / rdquo / lsquo / rsquo / hellip → ASCII,
// other named entities (eacute, mdash, copy, …) → Unicode via the
@@ -285,12 +313,14 @@ class StorageWalker {
}
handleList(node, ordered) {
- const items = (node.children || []).filter((c) => c.type === 'tag' && c.name === 'li');
- let counter = 1;
+ const bodies = (node.children || [])
+ .filter((c) => c.type === 'tag' && c.name === 'li')
+ .map((item) => this.renderListItemBody(item.children))
+ .filter(Boolean);
+ const start = ordered ? resolveListStart(node.attribs && node.attribs.start, bodies.length) : 1;
+ let counter = start;
let out = '';
- for (const item of items) {
- const body = this.renderListItemBody(item.children);
- if (!body) continue;
+ for (const body of bodies) {
const marker = ordered ? `${counter++}.` : '-';
// CommonMark: continuation lines (nested lists, extra paragraphs,
// code fences) must be indented to the item's content column.
@@ -306,7 +336,10 @@ class StorageWalker {
out += `${marker}${sep}${lead}\n`;
for (const line of rest) out += (line ? indent + line : '') + '\n';
}
- return out ? '\n' + out : '';
+ // Only `1.` may interrupt a paragraph, so any other start needs a blank
+ // line to stay a list after preceding inline text.
+ if (!out) return '';
+ return (start === 1 ? '\n' : '\n\n') + out;
}
// Render - children as a sequence of blocks. Runs of inline content
@@ -327,7 +360,12 @@ class StorageWalker {
if (!text) continue;
flushInline();
const list = child.name === 'ul' || child.name === 'ol' || child.name === 'ac:task-list';
- blocks.push({ text: child.name === 'p' ? text.replace(/\s+/g, ' ') : text, list });
+ const name = child.name === 'ac:structured-macro' ? child.attribs && child.attribs['ac:name'] : child.name;
+ blocks.push({
+ text: child.name === 'p' ? text.replace(/\s+/g, ' ') : text,
+ list,
+ closed: CLOSED_LIST_ITEM_BLOCKS.has(name),
+ });
} else {
inline += this.walkNode(child);
}
@@ -338,8 +376,13 @@ class StorageWalker {
if (i > 0) {
// A nested list may follow its lead-in line directly (tight). Any
// other boundary needs a blank line, otherwise text after a nested
- // list would lazily continue that list's last item.
- out += block.list && !blocks[i - 1].list ? '\n' : '\n\n';
+ // list would lazily continue that list's last item. An ordered list
+ // not starting at 1 cannot interrupt a paragraph, so it stays tight
+ // only after a block that cannot absorb it.
+ const prev = blocks[i - 1];
+ const tight = block.list && !prev.list
+ && (prev.closed || !NON_ONE_ORDERED_MARKER_RE.test(block.text));
+ out += tight ? '\n' : '\n\n';
}
out += block.text;
});
@@ -436,7 +479,26 @@ class StorageWalker {
const body = this.getMacroBody(node);
const inner = this.walkNodes(body).trim();
const header = `**${marker.toUpperCase()}**`;
- return `\n${quoteLines(inner.length === 0 ? header : `${header}\n${inner}`)}\n`;
+ // The header is a paragraph, so a leading list not starting at 1 needs
+ // a blank quote line to stay a list.
+ const sep = this.opensWithOrderedList(body) && NON_ONE_ORDERED_MARKER_RE.test(inner) ? '\n\n' : '\n';
+ return `\n${quoteLines(inner.length === 0 ? header : `${header}${sep}${inner}`)}\n`;
+ }
+
+ // Whether the first node with text content is an
, looking through
+ // transparent wrappers. Checked structurally so the body is not rendered
+ // twice.
+ opensWithOrderedList(nodes) {
+ for (const node of nodes || []) {
+ if (node.type === 'text') {
+ if (decodeEntities(node.data).trim()) return false;
+ continue;
+ }
+ if (node.type !== 'tag' || !this.getTextContent(node).trim()) continue;
+ if (TRANSPARENT_LIST_WRAPPERS.has(node.name)) return this.opensWithOrderedList(node.children);
+ return node.name === 'ol';
+ }
+ return false;
}
handleAnchor(node) {
diff --git a/tests/macro-converter.test.js b/tests/macro-converter.test.js
index 6367487..b15eb94 100644
--- a/tests/macro-converter.test.js
+++ b/tests/macro-converter.test.js
@@ -1992,3 +1992,214 @@ describe('MacroConverter storageToMarkdown code inside quotes and callouts (#244
.toBe(`> a${E2}b\n>\n> \`\`\`\`js\n> ${E2} \`\`\`\n> \`\`\`\``);
});
});
+
+describe('MacroConverter ordered list start (#241)', () => {
+ const converter = new MacroConverter({ isCloud: true });
+ const roundTrip = (storage) => {
+ const md = converter.storageToMarkdown(storage);
+ const storage1 = converter.markdownToStorage(md);
+ const md2 = converter.storageToMarkdown(storage1);
+ expect(md2).toBe(md);
+ expect(converter.markdownToStorage(md2)).toBe(storage1);
+ return { md, storage1 };
+ };
+
+ test('issue repro: numbering starts at the start attribute', () => {
+ expect(converter.storageToMarkdown('- three
- four
'))
+ .toBe('3. three\n4. four');
+ });
+
+ test('start="0" is a valid CommonMark start', () => {
+ expect(converter.storageToMarkdown('- zero
- one
'))
+ .toBe('0. zero\n1. one');
+ });
+
+ test('surrounding whitespace and leading zeros are ignored', () => {
+ expect(converter.storageToMarkdown('- x
')).toBe('5. x');
+ expect(converter.storageToMarkdown('- x
')).toBe('3. x');
+ });
+
+ test.each([
+ ['negative', '-2'],
+ ['signed', '+3'],
+ ['non-numeric', 'abc'],
+ ['decimal', '3.5'],
+ ['empty', ''],
+ ['over nine digits', '1000000000'],
+ ])('%s start falls back to 1', (_, start) => {
+ expect(converter.storageToMarkdown(`- a
- b
`))
+ .toBe('1. a\n2. b');
+ });
+
+ test('a run that would outgrow a nine-digit marker falls back to 1', () => {
+ expect(converter.storageToMarkdown('- a
')).toBe('999999999. a');
+ expect(converter.storageToMarkdown('- a
- b
'))
+ .toBe('1. a\n2. b');
+ });
+
+ test('empty items are not counted toward the nine-digit limit', () => {
+ expect(converter.storageToMarkdown('- a
- b
'))
+ .toBe('999999998. a\n999999999. b');
+ expect(converter.storageToMarkdown('- a
- b
- c
'))
+ .toBe('1. a\n2. b\n3. c');
+ });
+
+ test('empty items do not consume a number', () => {
+ expect(converter.storageToMarkdown('- a
- b
'))
+ .toBe('3. a\n4. b');
+ });
+
+ test('start is ignored on ', () => {
+ expect(converter.storageToMarkdown('')).toBe('- a');
+ });
+
+ test('marker widening past 9 indents nested content', () => {
+ expect(converter.storageToMarkdown('- nine
- ten
'))
+ .toBe('9. nine\n10. ten\n - sub');
+ });
+
+ test('nested list not starting at 1 is separated from the lead-in by a blank line', () => {
+ expect(converter.storageToMarkdown(''))
+ .toBe('- Parent\n\n 3. Child');
+ });
+
+ test('nested list starting at 1 stays tight', () => {
+ expect(converter.storageToMarkdown(''))
+ .toBe('- Parent\n 1. Child');
+ });
+
+ test('nested list not starting at 1 stays tight after a heading or code fence', () => {
+ expect(converter.storageToMarkdown(''))
+ .toBe('- ## h\n 3. c\n- other');
+ const code = '';
+ expect(converter.storageToMarkdown(``))
+ .toBe('- ```\n x\n ```\n 3. c\n- other');
+ });
+
+ test('nested list not starting at 1 is separated after a
rendered as text', () => {
+ expect(converter.storageToMarkdown(''))
+ .toBe('- x\n\n 3. c');
+ });
+
+ test('callout body opening with a list not starting at 1 is separated from the header', () => {
+ const callout = (body) => `${body}`;
+ expect(converter.storageToMarkdown(callout('\n - a
'))).toBe('> **INFO**\n>\n> 3. a');
+ expect(converter.storageToMarkdown(callout('
- a
')))
+ .toBe('> **INFO**\n>\n> 3. a');
+ expect(converter.storageToMarkdown(callout(''))).toBe('> **INFO**\n>\n> 3. a');
+ expect(converter.storageToMarkdown(callout('- a
'))).toBe('> **INFO**\n>\n> 3. a');
+ expect(converter.storageToMarkdown(callout('- a
'))).toBe('> **INFO**\n>\n> 3. a');
+ expect(converter.storageToMarkdown(callout('- a
'))).toBe('> **INFO**\n> 1. a');
+ expect(converter.storageToMarkdown(callout('3. a
'))).toBe('> **INFO**\n> 3. a');
+ });
+
+ test('list not starting at 1 after inline text is separated by a blank line', () => {
+ expect(converter.storageToMarkdown(''))
+ .toBe('Steps:\n\n3. a');
+ });
+
+ test('markdown → storage emits start for lists not beginning at 1', () => {
+ expect(converter.markdownToStorage('3. three\n4. four'))
+ .toBe('\nthree
\nfour
\n
\n');
+ expect(converter.markdownToStorage('1. one')).toBe('\none
\n
\n');
+ });
+
+ test('markdown → storage → markdown round-trip keeps the start', () => {
+ const md = '3. three\n4. four\n\n 7. seven\n 8. eight';
+ const storage1 = converter.markdownToStorage(md);
+ expect(storage1).toContain('');
+ expect(storage1).toContain('');
+ const md2 = converter.storageToMarkdown(storage1);
+ expect(md2).toBe(md);
+ expect(converter.markdownToStorage(md2)).toBe(storage1);
+ });
+
+ test.each([
+ [
+ 'top-level list',
+ '- three
- four
',
+ '3. three\n4. four',
+ '\nthree
\nfour
\n
\n',
+ ],
+ [
+ 'nested list',
+ '',
+ '- Parent\n\n 3. Child',
+ '\n- \n
Parent
\n\nChild
\n
\n \n
\n',
+ ],
+ [
+ 'list after inline text',
+ '',
+ 'Steps:\n\n3. a',
+ 'Steps:
\n\na
\n
\n',
+ ],
+ [
+ 'widened marker',
+ '- a
- b
',
+ '9. a\n10. b\n - s',
+ '\na
\n- b\n\n
\n
\n',
+ ],
+ ])('storage → markdown → storage is stable: %s', (_, storage, expectedMd, expectedStorage) => {
+ const { md, storage1 } = roundTrip(storage);
+ expect(md).toBe(expectedMd);
+ expect(storage1).toBe(expectedStorage);
+ });
+
+ describe('code macro as the first block of an item', () => {
+ const codeMacro = 'js'
+ + '';
+
+ test('non-1 start keeps the body byte-exact', () => {
+ const { md, storage1 } = roundTrip(`- ${codeMacro}
`);
+ expect(md).toBe('3. ```js\n a = 1\n ```');
+ expect(storage1).toBe(`\n- \n${codeMacro}\n
\n
\n`);
+ });
+
+ test('nine-digit marker indents the body by 11', () => {
+ const { md, storage1 } = roundTrip(`- ${codeMacro}
`);
+ expect(md).toBe('999999999. ```js\n a = 1\n ```');
+ expect(storage1).toBe(`\n- \n${codeMacro}\n
\n
\n`);
+ });
+ });
+
+ test('storage → markdown → storage is stable: tight list after a heading', () => {
+ const { md, storage1 } = roundTrip('');
+ expect(md).toBe('- ## h\n 3. c\n- other');
+ expect(storage1).toBe(
+ '\n',
+ );
+ });
+
+ test('storage → markdown → storage is stable: tight list after a code fence', () => {
+ const code = 'js'
+ + '';
+ const { md, storage1 } = roundTrip(``);
+ expect(md).toBe('- ```js\n x\n ```\n 3. c\n- other');
+ expect(storage1).toBe(
+ `\n- \n${code}\n
\nc
\n
\n \nother
\n
\n`,
+ );
+ });
+
+ test('storage → markdown → storage is stable: start="0"', () => {
+ const { md, storage1 } = roundTrip('- zero
- one
');
+ expect(md).toBe('0. zero\n1. one\n - s');
+ expect(storage1).toBe(
+ '\nzero
\n- one\n\n
\n
\n',
+ );
+ });
+
+ test('storage → markdown → storage is stable: callout list inside a wrapper', () => {
+ const { md } = roundTrip('- a
');
+ expect(md).toBe('> **INFO**\n>\n> 3. a');
+ });
+
+ test('storage → markdown → storage is stable: callout opening with prose before a list', () => {
+ const { md } = roundTrip('3. fake- a
');
+ expect(md).toBe('> **INFO**\n> 3. fake\n>\n> 5. a');
+ });
+
+ test('storage → markdown → storage is stable: callout opening with the list', () => {
+ const { md } = roundTrip('- a
');
+ expect(md).toBe('> **INFO**\n>\n> 3. a');
+ });
+});