mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(editor): preserve prose markdown entities
This commit is contained in:
@@ -39,6 +39,7 @@ import type { RichMarkdownHtmlSuperscriptLinkContext } from './rich-markdown-htm
|
||||
import { RichMarkdownOrderedList } from './rich-markdown-ordered-list'
|
||||
import { RichMarkdownCodeSpanPadding } from './rich-markdown-code-span-padding'
|
||||
import { RichMarkdownListItem } from './rich-markdown-list-item'
|
||||
import { RichMarkdownProseEntities } from './rich-markdown-prose-entities'
|
||||
import { RichMarkdownParagraph } from './rich-markdown-paragraph'
|
||||
import { RichMarkdownInlineMath } from './rich-markdown-inline-math'
|
||||
import { RichMarkdownCodeBlockLowlight } from './rich-markdown-lowlight'
|
||||
@@ -255,6 +256,7 @@ export function createRichMarkdownExtensions({
|
||||
}
|
||||
}),
|
||||
RichMarkdownCodeSpanPadding,
|
||||
RichMarkdownProseEntities,
|
||||
createRichMarkdownAnnotationHighlightExtension()
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Editor } from '@tiptap/core'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html'
|
||||
import { createRichMarkdownExtensions } from './rich-markdown-extensions'
|
||||
import { encodeProseTextForMarkdown } from './rich-markdown-prose-entities'
|
||||
import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'
|
||||
|
||||
function roundTrip(source: string): string {
|
||||
const codec = createRichMarkdownEditorCodec()
|
||||
const editor = new Editor({
|
||||
element: null,
|
||||
extensions: createRichMarkdownExtensions({ codec }),
|
||||
content: encodeRawMarkdownHtmlForRichEditor(source, codec),
|
||||
contentType: 'markdown'
|
||||
})
|
||||
try {
|
||||
return editor.getMarkdown().trimEnd()
|
||||
} finally {
|
||||
editor.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
describe('encodeProseTextForMarkdown', () => {
|
||||
it.each([
|
||||
['a < b && c'],
|
||||
['Finance > Invoices'],
|
||||
['F&B revenue'],
|
||||
['Copyright © 2026'],
|
||||
['A &MadeUpEntity; here'],
|
||||
['5 <6 and 7> 8']
|
||||
])('leaves %j unescaped', (text) => {
|
||||
expect(encodeProseTextForMarkdown(text)).toBe(text)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['Press <kbd>x</kbd>', 'Press <kbd>x</kbd>'],
|
||||
['A <!-- comment --> here', 'A <!-- comment --> here']
|
||||
])('escapes tag openings in %j', (text, expected) => {
|
||||
expect(encodeProseTextForMarkdown(text)).toBe(expected)
|
||||
})
|
||||
|
||||
it('encodes a block-quote marker only in the first column', () => {
|
||||
expect(encodeProseTextForMarkdown('> quoted')).toBe('> quoted')
|
||||
expect(encodeProseTextForMarkdown('Finance > Invoices')).toBe('Finance > Invoices')
|
||||
})
|
||||
})
|
||||
|
||||
describe('prose entity round trip', () => {
|
||||
it.each([
|
||||
['a < b && c'],
|
||||
['Finance > Invoices'],
|
||||
['F&B revenue'],
|
||||
['Copyright © 2026'],
|
||||
['A &MadeUpEntity; here'],
|
||||
['5 <6 and 7> 8'],
|
||||
['Press <kbd>x</kbd> now.'],
|
||||
['`a < b && c` stays literal']
|
||||
])('preserves %j', (source) => {
|
||||
expect(roundTrip(source)).toBe(source)
|
||||
})
|
||||
|
||||
it('preserves prose entities inside a heading and a list item', () => {
|
||||
const source = '# F&B > Revenue\n\n- a < b && c'
|
||||
expect(roundTrip(source)).toBe(source)
|
||||
})
|
||||
|
||||
it('is stable across three cycles', () => {
|
||||
const source = 'Finance > Invoices, F&B, a < b && c, © 2026'
|
||||
let current = source
|
||||
for (let cycle = 0; cycle < 3; cycle += 1) {
|
||||
current = roundTrip(current)
|
||||
}
|
||||
expect(current).toBe(source)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
|
||||
const TAG_OPENING = /^<(?:[a-zA-Z][a-zA-Z0-9-]*|\/[a-zA-Z][a-zA-Z0-9-]*|!|\?)/
|
||||
|
||||
type MarkdownTextEncoder = {
|
||||
encodeTextForMarkdown?: (text: string, node: unknown, parentNode?: unknown) => string
|
||||
}
|
||||
|
||||
function isMarkdownTextEncoder(value: unknown): value is MarkdownTextEncoder {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
function escapeMarkdownSyntax(text: string): string {
|
||||
return text.replace(/([\\`*_[\]~])/g, '\\$1')
|
||||
}
|
||||
|
||||
/** Keep prose punctuation literal unless its raw form would change Markdown parsing. */
|
||||
export function encodeProseTextForMarkdown(text: string): string {
|
||||
let output = ''
|
||||
let lineStart = true
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index]
|
||||
if (character === '<' && TAG_OPENING.test(text.slice(index))) {
|
||||
output += '<'
|
||||
} else if (character === '>' && lineStart) {
|
||||
output += '>'
|
||||
} else {
|
||||
output += character
|
||||
}
|
||||
if (character === '\n') {
|
||||
lineStart = true
|
||||
} else if (lineStart && (character === ' ' || character === '\t')) {
|
||||
lineStart = true
|
||||
} else {
|
||||
lineStart = false
|
||||
}
|
||||
}
|
||||
return escapeMarkdownSyntax(output)
|
||||
}
|
||||
|
||||
/** Restore prose entities after TipTap's serializer has classified code contexts. */
|
||||
export const RichMarkdownProseEntities = Extension.create({
|
||||
name: 'richMarkdownProseEntities',
|
||||
priority: 1,
|
||||
|
||||
onBeforeCreate() {
|
||||
const managerValue: unknown = this.editor.markdown
|
||||
if (!isMarkdownTextEncoder(managerValue)) {
|
||||
return
|
||||
}
|
||||
const base = managerValue.encodeTextForMarkdown
|
||||
if (typeof base !== 'function') {
|
||||
return
|
||||
}
|
||||
managerValue.encodeTextForMarkdown = (text, node, parentNode) => {
|
||||
const encoded = base.call(managerValue, text, node, parentNode)
|
||||
return encoded === text ? text : encodeProseTextForMarkdown(text)
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user