diff --git a/frontend/src/components/RichEditor.jsx b/frontend/src/components/RichEditor.jsx index 33010f4..df48da9 100644 --- a/frontend/src/components/RichEditor.jsx +++ b/frontend/src/components/RichEditor.jsx @@ -40,6 +40,25 @@ function Toolbar() { ) } +/** + * Put back the cross-reference markers Milkdown escapes on the way out. + * + * `[[387|Metabolic acidosis]]` is our own syntax, not CommonMark's, so the + * serializer treats the brackets as literal text and escapes them: + * `\[\[387|Metabolic acidosis]]`. Saving an article through the editor + * therefore broke every link in it — silently, one article at a time. + * + * Deliberately narrow: it only unescapes a run that reconstitutes a marker we + * actually produce — a numeric id with a label, or a bare slug — so an author + * who genuinely wrote `\[` keeps it. + */ +const ESCAPED_MARKER = /\\\[\\\[(\d+\|[^\]]+|[a-z0-9][a-z0-9-]*)\]\]/g + +export function restoreMarkers(markdown) { + return (markdown || '').replace(ESCAPED_MARKER, (_match, inner) => `[[${inner}]]`) +} + + function MilkdownEditor({ value, onChange, placeholder }) { const isInternalChange = useRef(false) const lastValue = useRef(value) @@ -52,9 +71,10 @@ function MilkdownEditor({ value, onChange, placeholder }) { ctx.set(defaultValueCtx, value || '') const l = ctx.get(listenerCtx) l.markdownUpdated((_, md) => { + const restored = restoreMarkers(md) isInternalChange.current = true - lastValue.current = md - onChange(md) + lastValue.current = restored + onChange(restored) }) }) .use(commonmark) diff --git a/frontend/src/components/RichEditor.test.jsx b/frontend/src/components/RichEditor.test.jsx new file mode 100644 index 0000000..d5870f4 --- /dev/null +++ b/frontend/src/components/RichEditor.test.jsx @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { Editor, rootCtx, defaultValueCtx, editorViewCtx, serializerCtx } from '@milkdown/core' +import { commonmark } from '@milkdown/preset-commonmark' +import { gfm } from '@milkdown/preset-gfm' +import { restoreMarkers } from './RichEditor' + +/** What the editor hands back for a given input, markers restored. */ +async function throughEditor(markdown) { + const editor = await Editor.make() + .config(ctx => { + ctx.set(rootCtx, document.createElement('div')) + ctx.set(defaultValueCtx, markdown) + }) + .use(commonmark).use(gfm).create() + let out = '' + editor.action(ctx => { out = ctx.get(serializerCtx)(ctx.get(editorViewCtx).state.doc) }) + await editor.destroy() + // The serializer adds a trailing newline; that is not what is under test. + return restoreMarkers(out).trim() +} + +describe('restoreMarkers', () => { + it('puts back a cross-reference the serializer escaped', () => { + expect(restoreMarkers('See \\[\\[387|metabolic acidosis]] here.')) + .toBe('See [[387|metabolic acidosis]] here.') + }) + + it('handles the bare-slug form and several in one line', () => { + expect(restoreMarkers('\\[\\[croup]] and \\[\\[12|Asthma]]')).toBe('[[croup]] and [[12|Asthma]]') + }) + + it('leaves an unescaped marker alone', () => { + expect(restoreMarkers('plain [[7|meningitis]]')).toBe('plain [[7|meningitis]]') + }) + + it('leaves a bracket the author actually escaped', () => { + // Narrow on purpose: only a run that reconstitutes a real marker is undone. + expect(restoreMarkers('a literal \\[bracket] stays')).toBe('a literal \\[bracket] stays') + expect(restoreMarkers('\\[\\[not a marker!]]')).toBe('\\[\\[not a marker!]]') + }) + + it('copes with nothing', () => { + expect(restoreMarkers('')).toBe('') + expect(restoreMarkers(undefined)).toBe('') + }) +}) + +describe('editing an article section does not break its cross-references', () => { + it('round-trips a marker through Milkdown unchanged', async () => { + // Without restoreMarkers this comes back as \[\[387|metabolic acidosis]], + // which renders as literal brackets and resolves to nothing — so opening + // and saving an article silently broke every link in it. + const stem = 'See [[387|metabolic acidosis]] for the workup.' + expect(await throughEditor(stem)).toBe(stem) + }) + + it('round-trips several markers in a paragraph', async () => { + const text = 'Consider [[12|Asthma]] and [[7|meningitis]] before [[croup]].' + expect(await throughEditor(text)).toBe(text) + }) +})