fix(editor): choose safe markdown code fences

This commit is contained in:
Neil
2026-09-18 04:23:07 -07:00
parent 8447710c60
commit 3f950cec0f
3 changed files with 52 additions and 0 deletions
@@ -0,0 +1,38 @@
import type { JSONContent } from '@tiptap/core'
type MarkdownCodeBlockRenderHelpers = {
renderChildren: (nodes: JSONContent[]) => string
}
function longestFenceRun(text: string, character: '`' | '~'): number {
let longest = 0
let current = 0
for (const value of text) {
if (value === character) {
current += 1
longest = Math.max(longest, current)
} else {
current = 0
}
}
return longest
}
function chooseFence(text: string): { character: '`' | '~'; length: number } {
const backtickLength = Math.max(3, longestFenceRun(text, '`') + 1)
const tildeLength = Math.max(3, longestFenceRun(text, '~') + 1)
return tildeLength < backtickLength
? { character: '~', length: tildeLength }
: { character: '`', length: backtickLength }
}
export function renderRichMarkdownCodeBlock(
node: JSONContent,
helpers: MarkdownCodeBlockRenderHelpers
): string {
const language = typeof node.attrs?.language === 'string' ? node.attrs.language : ''
const body = helpers.renderChildren(node.content ?? [])
const fence = chooseFence(body)
const marker = fence.character.repeat(fence.length)
return [`${marker}${language}`, body, marker].join('\n')
}
@@ -45,6 +45,7 @@ import { RichMarkdownInlineMath } from './rich-markdown-inline-math'
import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
import { RichMarkdownTaskList } from './rich-markdown-task-list'
import { createCachedLowlight } from './rich-markdown-lowlight-cache'
import { renderRichMarkdownCodeBlock } from './rich-markdown-code-block-markdown'
const lowlight = createCachedLowlight(createLowlight(common))
@@ -83,6 +84,7 @@ export function createRichMarkdownExtensions({
RichMarkdownParagraph,
RichMarkdownCode,
RichMarkdownCodeBlockLowlight.extend({
renderMarkdown: renderRichMarkdownCodeBlock,
addNodeView() {
// Why: RichMarkdownCodeBlock never reads getPos, so it must not re-render
// just because earlier edits shifted this block's document position.
@@ -60,4 +60,16 @@ describe('ordered list continuation serialization', () => {
}
expect(current).toBe('1. one\n ```ts\n const value = 1\n ```\n2. two')
})
it('preserves meaningful indentation inside an ordered-item fence', () => {
const source = '1. one\n\n ```\n indented\n ```\n2. two'
expect(roundTrip(source)).toBe('1. one\n ```\n indented\n ```\n2. two')
})
it('chooses a non-colliding fence for fence-shaped code content', () => {
const source = '~~~\n```\n~~~'
const canonical = roundTrip(source)
expect(canonical).toBe(source)
expect(roundTrip(canonical)).toBe(canonical)
})
})