fix: the rich editor was escaping every cross-reference it touched

`[[387|Metabolic acidosis]]` is our own syntax, not CommonMark's, so
Milkdown's serializer treats the brackets as literal text and escapes
them on the way out: `\[\[387|Metabolic acidosis]]`. That renders as
literal brackets and resolves to nothing.

ArticleEditor uses RichEditor for section content, so opening an article
and saving it broke every link in it — silently, one article at a time,
against the 2,150 cross-references added earlier today. Nothing in the
database is damaged yet; nobody has edited an article since the links
were made. Found by round-tripping a stem through Milkdown while
investigating whether the question stem could move to it.

restoreMarkers is deliberately narrow: it only undoes an escape 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. The
round-trip is now a test, so this cannot come back unnoticed.

Frontend 301/301.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
This commit is contained in:
Daniel 2026-09-11 13:19:21 +02:00
parent efa0af2244
commit 101b03348a
2 changed files with 83 additions and 2 deletions

View file

@ -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)

View file

@ -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)
})
})