From ba7e4dc85b4afdbda920b0c0f65c0ded24faf43f Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 01:56:06 -0700 Subject: [PATCH] fix(editor): preserve prose markdown entities --- .../editor/rich-markdown-extensions.ts | 2 + .../rich-markdown-prose-entities.test.ts | 75 +++++++++++++++++++ .../editor/rich-markdown-prose-entities.ts | 60 +++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 src/renderer/src/components/editor/rich-markdown-prose-entities.test.ts create mode 100644 src/renderer/src/components/editor/rich-markdown-prose-entities.ts diff --git a/src/renderer/src/components/editor/rich-markdown-extensions.ts b/src/renderer/src/components/editor/rich-markdown-extensions.ts index d4b551d8355..f6e69f00862 100644 --- a/src/renderer/src/components/editor/rich-markdown-extensions.ts +++ b/src/renderer/src/components/editor/rich-markdown-extensions.ts @@ -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() ] diff --git a/src/renderer/src/components/editor/rich-markdown-prose-entities.test.ts b/src/renderer/src/components/editor/rich-markdown-prose-entities.test.ts new file mode 100644 index 00000000000..ed19fe95ca7 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-prose-entities.test.ts @@ -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 x', 'Press <kbd>x</kbd>'], + ['A 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 x 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) + }) +}) diff --git a/src/renderer/src/components/editor/rich-markdown-prose-entities.ts b/src/renderer/src/components/editor/rich-markdown-prose-entities.ts new file mode 100644 index 00000000000..38440f3e353 --- /dev/null +++ b/src/renderer/src/components/editor/rich-markdown-prose-entities.ts @@ -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) + } + } +})