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:
parent
efa0af2244
commit
101b03348a
2 changed files with 83 additions and 2 deletions
|
|
@ -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)
|
||||
|
|
|
|||
61
frontend/src/components/RichEditor.test.jsx
Normal file
61
frontend/src/components/RichEditor.test.jsx
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue