fix: post-process BlockNote serializer to remove extra blank lines
blocksToMarkdownLossy() inserts blank lines between every block, making tight lists loose and polluting git history. Add compactMarkdown() that collapses inter-list-item blanks and excessive blank line runs while preserving code blocks and intentional paragraph spacing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
102
src/utils/compact-markdown.test.ts
Normal file
102
src/utils/compact-markdown.test.ts
Normal file
@@ -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')
|
||||
})
|
||||
})
|
||||
82
src/utils/compact-markdown.ts
Normal file
82
src/utils/compact-markdown.ts
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user