diff --git a/src/utils/compact-markdown.test.ts b/src/utils/compact-markdown.test.ts index 8f52e418..889cea47 100644 --- a/src/utils/compact-markdown.test.ts +++ b/src/utils/compact-markdown.test.ts @@ -4,7 +4,7 @@ import { compactMarkdown } from './compact-markdown' describe('compactMarkdown', () => { it('collapses blank lines between bullet list items (tight list)', () => { const input = '* Item one\n\n* Item two\n\n* Item three\n' - expect(compactMarkdown(input)).toBe('* Item one\n* Item two\n* Item three\n') + expect(compactMarkdown(input)).toBe('- Item one\n- Item two\n- Item three\n') }) it('collapses blank lines between dash list items', () => { @@ -19,7 +19,7 @@ describe('compactMarkdown', () => { it('removes extra blank line after heading', () => { const input = '## Personal\n\n* Back on track\n\n* Good health\n' - expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track\n* Good health\n') + expect(compactMarkdown(input)).toBe('## Personal\n\n- Back on track\n- Good health\n') }) it('preserves single blank line between heading and content', () => { @@ -54,17 +54,17 @@ describe('compactMarkdown', () => { it('handles the exact bug scenario from the issue', () => { const input = '## Personal\n\n* Back on track with Flavia\n\n* Good health vitals in place\n\n' - expect(compactMarkdown(input)).toBe('## Personal\n\n* Back on track with Flavia\n* Good health vitals in place\n') + expect(compactMarkdown(input)).toBe('## Personal\n\n- Back on track with Flavia\n- Good health vitals in place\n') }) it('handles heading followed immediately by list (no blank line)', () => { const input = '## Title\n* Item one\n\n* Item two\n' - expect(compactMarkdown(input)).toBe('## Title\n* Item one\n* Item two\n') + expect(compactMarkdown(input)).toBe('## Title\n- Item one\n- Item two\n') }) it('handles mixed content: heading, paragraph, list', () => { const input = '# Title\n\nSome intro.\n\n* Item one\n\n* Item two\n\nConclusion.\n' - expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n* Item one\n* Item two\n\nConclusion.\n') + expect(compactMarkdown(input)).toBe('# Title\n\nSome intro.\n\n- Item one\n- Item two\n\nConclusion.\n') }) it('preserves empty input', () => { @@ -82,21 +82,41 @@ describe('compactMarkdown', () => { it('handles list after code block', () => { const input = '```\ncode\n```\n\n* Item one\n\n* Item two\n' - expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n* Item one\n* Item two\n') + expect(compactMarkdown(input)).toBe('```\ncode\n```\n\n- Item one\n- Item two\n') }) it('handles nested list items', () => { const input = '* Parent\n\n * Child one\n\n * Child two\n' - expect(compactMarkdown(input)).toBe('* Parent\n * Child one\n * Child two\n') + expect(compactMarkdown(input)).toBe('- Parent\n - Child one\n - Child two\n') }) it('handles checklist items', () => { const input = '* [ ] Todo one\n\n* [ ] Todo two\n\n* [x] Done\n' - expect(compactMarkdown(input)).toBe('* [ ] Todo one\n* [ ] Todo two\n* [x] Done\n') + expect(compactMarkdown(input)).toBe('- [ ] Todo one\n- [ ] Todo two\n- [x] Done\n') }) it('handles blockquotes normally', () => { const input = '> Quote line one\n\n> Quote line two\n' expect(compactMarkdown(input)).toBe('> Quote line one\n\n> Quote line two\n') }) + + it('does not normalize * inside code blocks', () => { + const input = '```\n* not a list\n```\n' + expect(compactMarkdown(input)).toBe('```\n* not a list\n```\n') + }) + + it('decodes HTML entities from BlockNote bold+code output', () => { + const input = '**Remove **`NoteWindow`** and render the full **`App`** component.**\n' + expect(compactMarkdown(input)).toBe('**Remove **`NoteWindow`** and render the full **`App`** component.**\n') + }) + + it('decodes multiple HTML entity types', () => { + const input = 'Use & for ampersand and < for less-than.\n' + expect(compactMarkdown(input)).toBe('Use & for ampersand and < for less-than.\n') + }) + + it('does not decode HTML entities inside code blocks', () => { + const input = '```\n should stay\n```\n' + expect(compactMarkdown(input)).toBe('```\n should stay\n```\n') + }) }) diff --git a/src/utils/compact-markdown.ts b/src/utils/compact-markdown.ts index f73f8e37..3e04007b 100644 --- a/src/utils/compact-markdown.ts +++ b/src/utils/compact-markdown.ts @@ -2,6 +2,8 @@ * Post-process BlockNote's blocksToMarkdownLossy output to produce * standard-convention Markdown: * - Tight lists (no blank lines between consecutive list items) + * - Bullet list markers normalized to `-` (BlockNote outputs `*`) + * - HTML entities like ` ` decoded back to spaces * - No runs of 3+ blank lines (collapsed to one blank line) * - No trailing blank lines * - Code block content is never modified @@ -14,7 +16,7 @@ export function compactMarkdown(md: string): string { let inCodeBlock = false for (let i = 0; i < lines.length; i++) { - const line = lines[i] + let line = lines[i] // Track fenced code blocks — never modify content inside them if (line.trimStart().startsWith('```')) { @@ -28,6 +30,12 @@ export function compactMarkdown(md: string): string { continue } + // Normalize bullet markers: BlockNote uses `*`, convention is `-` + line = normalizeBulletMarker(line) + + // Decode HTML entities that BlockNote inserts (e.g. for spaces) + line = decodeHtmlEntities(line) + // Skip blank lines that sit between two list items (tight list rule) if (line.trim() === '') { if (isBlankBetweenListItems(lines, i)) continue @@ -80,3 +88,15 @@ function findNextNonBlank(lines: string[], idx: number): number | null { } return null } + +const BULLET_RE = /^(\s*)\*(\s)/ +/** Normalize `*` bullet markers to `-` (BlockNote default → standard convention) */ +function normalizeBulletMarker(line: string): string { + return line.replace(BULLET_RE, '$1-$2') +} + +/** Decode HTML entities that BlockNote inserts ( & etc.) */ +function decodeHtmlEntities(line: string): string { + if (!line.includes('&#x')) return line + return line.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16))) +}