diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 8f98fec9..033c99c1 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef } from 'react' import type { useCreateBlockNote } from '@blocknote/react' import type { VaultEntry } from '../types' import { splitFrontmatter, preProcessWikilinks, injectWikilinks, restoreWikilinksInBlocks } from '../utils/wikilinks' +import { compactMarkdown } from '../utils/compact-markdown' interface Tab { entry: VaultEntry @@ -111,7 +112,7 @@ export function useEditorTabSwap({ tabs, activeTabPath, editor, onContentChange, // Convert blocks → markdown, restoring wikilinks first const blocks = editor.document const restored = restoreWikilinksInBlocks(blocks) - const bodyMarkdown = editor.blocksToMarkdownLossy(restored as typeof blocks) + const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks)) // Reconstruct full file: frontmatter + body (which now includes H1 if present) const [frontmatter] = splitFrontmatter(tab.content) diff --git a/src/utils/compact-markdown.test.ts b/src/utils/compact-markdown.test.ts new file mode 100644 index 00000000..8f52e418 --- /dev/null +++ b/src/utils/compact-markdown.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest' +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') + }) + + it('collapses blank lines between dash list items', () => { + 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') + }) + + it('collapses blank lines between numbered list items', () => { + const input = '1. First\n\n2. Second\n\n3. Third\n' + expect(compactMarkdown(input)).toBe('1. First\n2. Second\n3. Third\n') + }) + + 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') + }) + + it('preserves single blank line between heading and content', () => { + const input = '## Title\n\nSome paragraph text.\n' + expect(compactMarkdown(input)).toBe('## Title\n\nSome paragraph text.\n') + }) + + it('collapses multiple blank lines after heading to one', () => { + const input = '## Title\n\n\n\nSome paragraph text.\n' + expect(compactMarkdown(input)).toBe('## Title\n\nSome paragraph text.\n') + }) + + it('preserves blank lines between paragraphs', () => { + const input = 'First paragraph.\n\nSecond paragraph.\n' + expect(compactMarkdown(input)).toBe('First paragraph.\n\nSecond paragraph.\n') + }) + + it('preserves blank lines inside fenced code blocks', () => { + const input = '```\nline one\n\nline two\n\nline three\n```\n' + expect(compactMarkdown(input)).toBe('```\nline one\n\nline two\n\nline three\n```\n') + }) + + it('preserves blank lines inside fenced code blocks with language', () => { + const input = '```js\nconst a = 1\n\nconst b = 2\n```\n' + expect(compactMarkdown(input)).toBe('```js\nconst a = 1\n\nconst b = 2\n```\n') + }) + + it('does not add trailing blank lines', () => { + const input = '## Title\n\nText.\n\n\n' + expect(compactMarkdown(input)).toBe('## Title\n\nText.\n') + }) + + 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') + }) + + 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') + }) + + 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') + }) + + it('preserves empty input', () => { + expect(compactMarkdown('')).toBe('') + }) + + it('preserves single line', () => { + expect(compactMarkdown('Hello\n')).toBe('Hello\n') + }) + + it('collapses 3+ consecutive blank lines to one blank line', () => { + const input = 'Paragraph one.\n\n\n\nParagraph two.\n' + expect(compactMarkdown(input)).toBe('Paragraph one.\n\nParagraph two.\n') + }) + + 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') + }) + + 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') + }) + + 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') + }) + + 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') + }) +}) diff --git a/src/utils/compact-markdown.ts b/src/utils/compact-markdown.ts new file mode 100644 index 00000000..f73f8e37 --- /dev/null +++ b/src/utils/compact-markdown.ts @@ -0,0 +1,82 @@ +/** + * Post-process BlockNote's blocksToMarkdownLossy output to produce + * standard-convention Markdown: + * - Tight lists (no blank lines between consecutive list items) + * - No runs of 3+ blank lines (collapsed to one blank line) + * - No trailing blank lines + * - Code block content is never modified + */ +export function compactMarkdown(md: string): string { + if (!md) return md + + const lines = md.split('\n') + const result: string[] = [] + let inCodeBlock = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // Track fenced code blocks — never modify content inside them + if (line.trimStart().startsWith('```')) { + inCodeBlock = !inCodeBlock + result.push(line) + continue + } + + if (inCodeBlock) { + result.push(line) + continue + } + + // Skip blank lines that sit between two list items (tight list rule) + if (line.trim() === '') { + if (isBlankBetweenListItems(lines, i)) continue + // Collapse runs of 3+ blank lines to a single blank line + if (isExcessiveBlankLine(lines, i)) continue + result.push(line) + continue + } + + result.push(line) + } + + // Trim trailing blank lines, keep exactly one trailing newline + while (result.length > 0 && result[result.length - 1].trim() === '') { + result.pop() + } + if (result.length > 0) result.push('') + + return result.join('\n') +} + +const LIST_RE = /^(\s*)([-*+]|\d+\.)\s/ + +/** True if this blank line sits between two list items (including nested) */ +function isBlankBetweenListItems(lines: string[], idx: number): boolean { + const prev = findPrevNonBlank(lines, idx) + const next = findNextNonBlank(lines, idx) + if (prev === null || next === null) return false + return LIST_RE.test(lines[prev]) && LIST_RE.test(lines[next]) +} + +/** True if this blank line is part of a run of 2+ consecutive blank lines + * (i.e. would create 3+ newlines in a row — collapse to just one blank line) */ +function isExcessiveBlankLine(lines: string[], idx: number): boolean { + // Keep the first blank line in a run, skip subsequent ones + if (idx > 0 && lines[idx - 1].trim() === '') return true + return false +} + +function findPrevNonBlank(lines: string[], idx: number): number | null { + for (let i = idx - 1; i >= 0; i--) { + if (lines[i].trim() !== '') return i + } + return null +} + +function findNextNonBlank(lines: string[], idx: number): number | null { + for (let i = idx + 1; i < lines.length; i++) { + if (lines[i].trim() !== '') return i + } + return null +}