From 7a01ef695207a0a73aa2cb0a333f2d200bb5b372 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Fri, 1 May 2026 05:44:22 +0200 Subject: [PATCH] fix: recover formula note parse failures --- src/hooks/editorMarkdownParseFallback.ts | 43 +++++++++++++++++++ src/hooks/useEditorTabSwap.test.ts | 54 ++++++++++++++++++++++++ src/hooks/useEditorTabSwap.ts | 40 ++++++++++++------ 3 files changed, 123 insertions(+), 14 deletions(-) create mode 100644 src/hooks/editorMarkdownParseFallback.ts diff --git a/src/hooks/editorMarkdownParseFallback.ts b/src/hooks/editorMarkdownParseFallback.ts new file mode 100644 index 00000000..d175c790 --- /dev/null +++ b/src/hooks/editorMarkdownParseFallback.ts @@ -0,0 +1,43 @@ +import { normalizeParsedImageBlocks } from './editorTabContent' + +type EditorBlocks = unknown[] +type ParseMarkdownBlocks = (markdown: string) => EditorBlocks | Promise + +export type MarkdownParseResult = { + blocks: EditorBlocks + usedSourceFallback: boolean +} + +function buildSourceLineBlock(line: string): Record { + 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 { + 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, + } + } +} diff --git a/src/hooks/useEditorTabSwap.test.ts b/src/hooks/useEditorTabSwap.test.ts index a8b08177..6883a66b 100644 --- a/src/hooks/useEditorTabSwap.test.ts +++ b/src/hooks/useEditorTabSwap.test.ts @@ -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 }) diff --git a/src/hooks/useEditorTabSwap.ts b/src/hooks/useEditorTabSwap.ts index 6bade330..1bc6a01f 100644 --- a/src/hooks/useEditorTabSwap.ts +++ b/src/hooks/useEditorTabSwap.ts @@ -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 @@ -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, content: string, vaultPath?: string, + targetPath = 'empty heading note', ): Promise { 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 })