fix: recover formula note parse failures

This commit is contained in:
lucaronin
2026-05-01 05:44:22 +02:00
parent 1c010ddc22
commit 7a01ef6952
3 changed files with 123 additions and 14 deletions

View File

@@ -0,0 +1,43 @@
import { normalizeParsedImageBlocks } from './editorTabContent'
type EditorBlocks = unknown[]
type ParseMarkdownBlocks = (markdown: string) => EditorBlocks | Promise<EditorBlocks>
export type MarkdownParseResult = {
blocks: EditorBlocks
usedSourceFallback: boolean
}
function buildSourceLineBlock(line: string): Record<string, unknown> {
return {
type: 'paragraph',
content: line ? [{ type: 'text', text: line, styles: {} }] : [],
children: [],
}
}
function buildMarkdownSourceBlocks(markdown: string): EditorBlocks {
return markdown.split('\n').map(buildSourceLineBlock)
}
export async function parseMarkdownBlocksWithFallback(options: {
parseMarkdownBlocks: ParseMarkdownBlocks
preprocessed: string
sourceMarkdown: string
context: string
}): Promise<MarkdownParseResult> {
const { parseMarkdownBlocks, preprocessed, sourceMarkdown, context } = options
try {
return {
blocks: normalizeParsedImageBlocks(await parseMarkdownBlocks(preprocessed)),
usedSourceFallback: false,
}
} catch (error) {
console.warn(`[editor] Rendering ${context} as plain Markdown because BlockNote could not parse it:`, error)
return {
blocks: buildMarkdownSourceBlocks(sourceMarkdown),
usedSourceFallback: true,
}
}
}

View File

@@ -992,6 +992,60 @@ describe('useEditorTabSwap raw mode sync', () => {
expect(mockEditor.replaceBlocks).toHaveBeenCalled()
})
it('keeps formula Markdown visible when raw mode exits into a parser failure', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })
const parseError = new Error('BlockNote parser failed on math')
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const formulaTab = {
...makeTab('a.md', 'Formula'),
content: [
'---',
'title: Formula',
'---',
'',
'# Formula',
'',
'Pasted math:',
'',
'$$',
'\\begin{aligned}',
'x &= y + 1',
'\\end{aligned}',
'$$',
].join('\n'),
}
const { mockEditor, rerenderWith } = await createSwapHarness({
initialProps: { tabs: [makeTab('a.md', 'Formula')], activeTabPath: 'a.md', rawMode: false },
setupEditor: (editor) => {
editor.tryParseMarkdownToBlocks.mockImplementation((markdown: string) => {
if (markdown.includes('@@TOLARIA_MATH_BLOCK:')) throw parseError
return blocksA
})
},
})
await rerenderWith({ rawMode: true })
mockEditor.replaceBlocks.mockClear()
await rerenderWith({ tabs: [formulaTab], rawMode: false })
const appliedBlocks = mockEditor.replaceBlocks.mock.calls[0]?.[1] as Array<{ content?: Array<{ text?: string }> }>
const renderedText = appliedBlocks
.flatMap(block => block.content?.map(part => part.text ?? '') ?? [])
.join('\n')
expect(renderedText).toContain('# Formula')
expect(renderedText).toContain('Pasted math:')
expect(renderedText).toContain('\\begin{aligned}')
expect(warnSpy).toHaveBeenCalledWith(
'[editor] Rendering a.md as plain Markdown because BlockNote could not parse it:',
parseError,
)
})
it('does not skip swap when rawMode is on (editor hidden)', async () => {
vi.spyOn(document, 'querySelector').mockReturnValue({ scrollTop: 0 } as unknown as Element)
vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { cb(0); return 0 })

View File

@@ -27,11 +27,14 @@ import {
extractEditorBody,
getH1TextFromBlocks,
isUntitledPath,
normalizeParsedImageBlocks,
pathStem,
slugifyPathStem,
} from './editorTabContent'
import { clearEditorDomSelection, EDITOR_CONTAINER_SELECTOR } from './editorDomSelection'
import {
parseMarkdownBlocksWithFallback,
type MarkdownParseResult,
} from './editorMarkdownParseFallback'
export { extractEditorBody, getH1TextFromBlocks, replaceTitleInFrontmatter } from './editorTabContent'
export { RICH_EDITOR_CHANGE_DEBOUNCE_MS } from './editorChangeDebounce'
@@ -192,6 +195,12 @@ function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
return injectMermaidInBlocks(withMath) as EditorBlocks
}
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {
const parseSafeBlocks = repairMalformedEditorBlocks(parsed.blocks) as EditorBlocks
if (parsed.usedSourceFallback) return parseSafeBlocks
return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks
}
async function resolveBlocksForTarget(
options: {
editor: ReturnType<typeof useCreateBlockNote>
@@ -218,10 +227,14 @@ async function resolveBlocksForTarget(
return nextState
}
const parsed = normalizeParsedImageBlocks(await parseMarkdownBlocks(editor, preprocessed)) as EditorBlocks
const parseSafeBlocks = repairMalformedEditorBlocks(parsed) as EditorBlocks
const parsed = await parseMarkdownBlocksWithFallback({
parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown),
preprocessed,
sourceMarkdown: body,
context: targetPath,
})
const nextState = {
blocks: repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks,
blocks: repairParsedMarkdownBlocks(parsed),
scrollTop: 0,
sourceContent: content,
}
@@ -245,24 +258,23 @@ async function prepareCachedNoteContent(options: {
await resolveBlocksForTarget({ editor, cache, targetPath: path, content: cached.content, vaultPath })
}
function repairInjectedBlocks(blocks: EditorBlocks): EditorBlocks {
const parseSafeBlocks = repairMalformedEditorBlocks(blocks) as EditorBlocks
return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks
}
async function resolveEmptyHeadingBlocks(
editor: ReturnType<typeof useCreateBlockNote>,
content: string,
vaultPath?: string,
targetPath = 'empty heading note',
): Promise<EditorBlocks | null> {
const remainder = extractBodyRemainderAfterEmptyH1({ content })
if (remainder === null) return null
if (!remainder.trim()) return [emptyHeadingBlock(), ...blankParagraphBlocks()] as EditorBlocks
const parsed = normalizeParsedImageBlocks(
await parseMarkdownBlocks(editor, preProcessEditorMarkdown(remainder, vaultPath)),
) as EditorBlocks
return [emptyHeadingBlock(), ...repairInjectedBlocks(parsed)] as EditorBlocks
const parsed = await parseMarkdownBlocksWithFallback({
parseMarkdownBlocks: markdown => parseMarkdownBlocks(editor, markdown),
preprocessed: preProcessEditorMarkdown(remainder, vaultPath),
sourceMarkdown: remainder,
context: targetPath,
})
return [emptyHeadingBlock(), ...repairParsedMarkdownBlocks(parsed)] as EditorBlocks
}
function findActiveTab(options: {
@@ -780,7 +792,7 @@ function scheduleEmptyHeadingSwap(options: {
if (extractBodyRemainderAfterEmptyH1({ content }) === null) return false
void resolveEmptyHeadingBlocks(editor, content, vaultPath)
void resolveEmptyHeadingBlocks(editor, content, vaultPath, targetPath)
.then((blocks) => {
if (prevActivePathRef.current !== targetPath || !blocks) return
applyBlocksToEditor({ editor, blocks, scrollTop: 0, suppressChangeRef, editorContentPathRef, targetPath })