fix: recover formula note parse failures
This commit is contained in:
43
src/hooks/editorMarkdownParseFallback.ts
Normal file
43
src/hooks/editorMarkdownParseFallback.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user