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
  1. 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('
      1. three
      2. four
      ')) + .toBe('3. three\n4. four'); + }); + + test('start="0" is a valid CommonMark start', () => { + expect(converter.storageToMarkdown('
      1. zero
      2. one
      ')) + .toBe('0. zero\n1. one'); + }); + + test('surrounding whitespace and leading zeros are ignored', () => { + expect(converter.storageToMarkdown('
      1. x
      ')).toBe('5. x'); + expect(converter.storageToMarkdown('
      1. 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(`
      1. a
      2. b
      `)) + .toBe('1. a\n2. b'); + }); + + test('a run that would outgrow a nine-digit marker falls back to 1', () => { + expect(converter.storageToMarkdown('
      1. a
      ')).toBe('999999999. a'); + expect(converter.storageToMarkdown('
      1. a
      2. b
      ')) + .toBe('1. a\n2. b'); + }); + + test('empty items are not counted toward the nine-digit limit', () => { + expect(converter.storageToMarkdown('
      1. a
      2. b
      ')) + .toBe('999999998. a\n999999999. b'); + expect(converter.storageToMarkdown('
      1. a
      2. b
      3. c
      ')) + .toBe('1. a\n2. b\n3. c'); + }); + + test('empty items do not consume a number', () => { + expect(converter.storageToMarkdown('
      1. a
      2. b
      ')) + .toBe('3. a\n4. b'); + }); + + test('start is ignored on
        ', () => { + expect(converter.storageToMarkdown('
        • a
        ')).toBe('- a'); + }); + + test('marker widening past 9 indents nested content', () => { + expect(converter.storageToMarkdown('
        1. nine
        2. ten
          • sub
        ')) + .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('
        • Parent
          1. Child
        ')) + .toBe('- Parent\n\n 3. Child'); + }); + + test('nested list starting at 1 stays tight', () => { + expect(converter.storageToMarkdown('
        • Parent
          1. Child
        ')) + .toBe('- Parent\n 1. Child'); + }); + + test('nested list not starting at 1 stays tight after a heading or code fence', () => { + expect(converter.storageToMarkdown('
        • h

          1. c
        • other
        ')) + .toBe('- ## h\n 3. c\n- other'); + const code = ''; + expect(converter.storageToMarkdown(`
        • ${code}
          1. c
        • other
        `)) + .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('
        • x
          1. c
        ')) + .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
        1. a
        '))).toBe('> **INFO**\n>\n> 3. a'); + expect(converter.storageToMarkdown(callout('

         


        1. a
        '))) + .toBe('> **INFO**\n>\n> 3. a'); + expect(converter.storageToMarkdown(callout('
        1. a
        '))).toBe('> **INFO**\n>\n> 3. a'); + expect(converter.storageToMarkdown(callout('

        1. a

        '))).toBe('> **INFO**\n>\n> 3. a'); + expect(converter.storageToMarkdown(callout('
        1. a
        '))).toBe('> **INFO**\n>\n> 3. a'); + expect(converter.storageToMarkdown(callout('
        1. 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('
        Steps:
        1. a
        ')) + .toBe('Steps:\n\n3. a'); + }); + + test('markdown → storage emits start for lists not beginning at 1', () => { + expect(converter.markdownToStorage('3. three\n4. four')) + .toBe('
          \n
        1. three

        2. \n
        3. four

        4. \n
        \n'); + expect(converter.markdownToStorage('1. one')).toBe('
          \n
        1. one

        2. \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', + '
            1. three
            2. four
            ', + '3. three\n4. four', + '
              \n
            1. three

            2. \n
            3. four

            4. \n
            \n', + ], + [ + 'nested list', + '
            • Parent
              1. Child
            ', + '- Parent\n\n 3. Child', + '
              \n
            • \n

              Parent

              \n
                \n
              1. Child

              2. \n
              \n
            • \n
            \n', + ], + [ + 'list after inline text', + '
            Steps:
            1. a
            ', + 'Steps:\n\n3. a', + '

            Steps:

            \n
              \n
            1. a

            2. \n
            \n', + ], + [ + 'widened marker', + '
            1. a
            2. b
              • s
            ', + '9. a\n10. b\n - s', + '
              \n
            1. a

            2. \n
            3. b\n
                \n
              • s

              • \n
              \n
            4. \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(`
            1. ${codeMacro}
            `); + expect(md).toBe('3. ```js\n a = 1\n ```'); + expect(storage1).toBe(`
              \n
            1. \n${codeMacro}\n
            2. \n
            \n`); + }); + + test('nine-digit marker indents the body by 11', () => { + const { md, storage1 } = roundTrip(`
            1. ${codeMacro}
            `); + expect(md).toBe('999999999. ```js\n a = 1\n ```'); + expect(storage1).toBe(`
              \n
            1. \n${codeMacro}\n
            2. \n
            \n`); + }); + }); + + test('storage → markdown → storage is stable: tight list after a heading', () => { + const { md, storage1 } = roundTrip('
            • h

              1. c
            • other
            '); + expect(md).toBe('- ## h\n 3. c\n- other'); + expect(storage1).toBe( + '
              \n
            • \n

              h

              \n
                \n
              1. c

              2. \n
              \n
            • \n
            • other

            • \n
            \n', + ); + }); + + test('storage → markdown → storage is stable: tight list after a code fence', () => { + const code = 'js' + + ''; + const { md, storage1 } = roundTrip(`
            • ${code}
              1. c
            • other
            `); + expect(md).toBe('- ```js\n x\n ```\n 3. c\n- other'); + expect(storage1).toBe( + `
              \n
            • \n${code}\n
                \n
              1. c

              2. \n
              \n
            • \n
            • other

            • \n
            \n`, + ); + }); + + test('storage → markdown → storage is stable: start="0"', () => { + const { md, storage1 } = roundTrip('
            1. zero
            2. one
              • s
            '); + expect(md).toBe('0. zero\n1. one\n - s'); + expect(storage1).toBe( + '
              \n
            1. zero

            2. \n
            3. one\n
                \n
              • s

              • \n
              \n
            4. \n
            \n', + ); + }); + + test('storage → markdown → storage is stable: callout list inside a

            wrapper', () => { + const { md } = roundTrip('

            1. 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
            1. 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('
            1. a
            '); + expect(md).toBe('> **INFO**\n>\n> 3. a'); + }); +});