diff --git a/config/scripts/raw-markdown-doc-link-scan-benchmark.mjs b/config/scripts/raw-markdown-doc-link-scan-benchmark.mjs new file mode 100644 index 00000000000..7a4c1d3a708 --- /dev/null +++ b/config/scripts/raw-markdown-doc-link-scan-benchmark.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import { buildCounterbalancedSchedule } from './counterbalanced-benchmark-schedule.mjs' + +const baseline = process.argv[2] ?? '20ab9950654' +const file = 'src/renderer/src/components/editor/raw-markdown-html.ts' +async function load(contents) { + const result = await build({ + stdin: { + contents: `${contents}\nexport { createRichMarkdownEditorCodec } from './rich-markdown-source-transport'`, + loader: 'ts', + resolveDir: dirname(resolve(file)) + }, + bundle: true, + platform: 'node', + format: 'esm', + write: false, + banner: { + js: `import { createRequire } from 'node:module'; const require = createRequire(${JSON.stringify(resolve('package.json'))});` + } + }) + return import( + `data:text/javascript;base64,${Buffer.from(result.outputFiles[0].text).toString('base64')}` + ) +} +const arms = { + before: await load( + execFileSync('git', ['show', `${baseline}:${file}`], { encoding: 'utf8', windowsHide: true }) + ), + after: await load(readFileSync(file, 'utf8')) +} +const key = '0123456789abcdef0123456789abcdef' +const codecs = Object.fromEntries( + Object.entries(arms).map(([arm, module]) => [arm, module.createRichMarkdownEditorCodec(key)]) +) +const results = [] +for (const [name, input] of [ + ['plain', '# Heading\nOrdinary prose.'], + ['complete', 'prefix [[README.md]] text '.repeat(100)], + ['unclosed-1000', `prefix ${'[[x'.repeat(1000)}`], + ['unclosed-8000', `prefix ${'[[x'.repeat(8000)} tail`], + ['invalid-closed-8000', `prefix ${'[[x'.repeat(8000)}]]`], + ['authored-1000', `prefix ${`[[ORCA_RICH_MD:${key}:`.repeat(1000)}`], + ['protected', '\\[[README.md]] `[[code.md]]`\n```md\n[[fenced.md]]\n```\n[[tail'], + ['transport', `before [[ORCA_RICH_MD:${key}:literal:hello]] [[tail`] +]) { + for (const htmlSuperscriptLinks of [false, true]) { + const options = { htmlSuperscriptLinks } + const invoke = (arm) => + arms[arm].encodeRawMarkdownHtmlForRichEditor(input, codecs[arm], options) + assert.equal(invoke('after'), invoke('before')) + const iterations = name.includes('8000') || name.includes('1000') ? 2 : 100 + function run(arm) { + global.gc?.() + const start = performance.now() + const cpuStart = process.cpuUsage() + for (let i = 0; i < iterations; i++) { + invoke(arm) + } + const cpu = process.cpuUsage(cpuStart) + return { + ms: (performance.now() - start) / iterations, + cpuMs: (cpu.user + cpu.system) / 1000 / iterations + } + } + run('before') + run('after') + const samples = { before: [], after: [] } + for (const pair of buildCounterbalancedSchedule(10, 'before', 'after')) { + for (const arm of pair) { + samples[arm].push(run(arm)) + } + } + const median = (values) => { + const sorted = [...values].sort((a, b) => a - b) + return (sorted[4] + sorted[5]) / 2 + } + results.push({ + name, + htmlSuperscriptLinks, + beforeCpuMs: median(samples.before.map((s) => s.cpuMs)), + afterCpuMs: median(samples.after.map((s) => s.cpuMs)), + beforeMs: median(samples.before.map((s) => s.ms)), + afterMs: median(samples.after.map((s) => s.ms)), + samples + }) + } +} +console.log( + JSON.stringify({ baseline, node: process.version, platform: process.platform, results }, null, 2) +) diff --git a/src/renderer/src/components/editor/raw-markdown-doc-link-scan.test.ts b/src/renderer/src/components/editor/raw-markdown-doc-link-scan.test.ts new file mode 100644 index 00000000000..2dd06b6cd7b --- /dev/null +++ b/src/renderer/src/components/editor/raw-markdown-doc-link-scan.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from 'vitest' +import { encodeRawMarkdownHtmlForRichEditor } from './raw-markdown-html' +import { createRichMarkdownEditorCodec } from './rich-markdown-source-transport' + +const key = '0123456789abcdef0123456789abcdef' + +describe('raw Markdown document-link closing searches', () => { + it.each([false, true])('skips impossible suffix searches (superscript links: %s)', (enabled) => { + const codec = createRichMarkdownEditorCodec(key) + const suffix = '[[missing '.repeat(300) + const input = `prefix [[README.md]] ${suffix}` + const original = String.prototype.indexOf + let searches = 0 + const spy = vi.spyOn(String.prototype, 'indexOf').mockImplementation(function ( + this: string, + search: string, + position?: number + ) { + if (search === ']]') { + searches++ + } + return original.call(this, search, position) + }) + let output: string + try { + output = encodeRawMarkdownHtmlForRichEditor(input, codec, { htmlSuperscriptLinks: enabled }) + } finally { + spy.mockRestore() + } + expect(output).toBe(`prefix ${codec.transport.create('document-link', 'README.md')} ${suffix}`) + expect(searches).toBe(1) + }) + + it('protects every unclosed authored prefix without rescanning the suffix', () => { + const codec = createRichMarkdownEditorCodec(key) + const prefix = codec.transport.authoredPrefix + const input = `before ${`${prefix}x `.repeat(300)}` + const spy = vi.spyOn(String.prototype, 'indexOf') + let output: string + let searches: number + try { + output = encodeRawMarkdownHtmlForRichEditor(input, codec) + searches = spy.mock.calls.filter(([search]) => search === ']]').length + } finally { + spy.mockRestore() + } + expect(output).toBe(`before ${`${codec.transport.create('literal', prefix)}x `.repeat(300)}`) + expect(searches).toBe(0) + }) + + it('preserves escaped and code-protected links around the final closing marker', () => { + const codec = createRichMarkdownEditorCodec(key) + const input = '\\[[README.md]] `[[inline.md]]`\n```md\n[[fenced.md]]\n```\n[[unclosed' + expect(encodeRawMarkdownHtmlForRichEditor(input, codec)).toBe(input) + }) + + it('preserves a complete authored envelope before an unclosed document link', () => { + const codec = createRichMarkdownEditorCodec(key) + const authored = `${codec.transport.authoredPrefix}literal:hello]]` + expect(encodeRawMarkdownHtmlForRichEditor(`before ${authored} [[tail`, codec)).toBe( + `before ${codec.transport.create('literal', authored)} [[tail` + ) + }) +}) diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index cde5311e80b..a4360366717 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -59,6 +59,7 @@ export function encodeRawMarkdownHtmlForRichEditor( { htmlSuperscriptLinks = false }: { htmlSuperscriptLinks?: boolean } = {} ): string { const normalizedContent = normalizeMarkdownReferenceLinks(content) + const lastBracketClose = normalizedContent.lastIndexOf(']]') const { transport } = codec let index = 0 let isLineStart = true @@ -156,7 +157,10 @@ export function encodeRawMarkdownHtmlForRichEditor( // Why: authored text that happens to contain this editor's random envelope // prefix must remain literal even in HTML-free documents and after edits. if (normalizedContent.startsWith(transport.authoredPrefix, index)) { - const authoredEnd = normalizedContent.indexOf(']]', index + transport.authoredPrefix.length) + const authoredEnd = + index + transport.authoredPrefix.length > lastBracketClose + ? -1 + : normalizedContent.indexOf(']]', index + transport.authoredPrefix.length) const authoredOccurrence = authoredEnd === -1 ? transport.authoredPrefix @@ -190,7 +194,8 @@ export function encodeRawMarkdownHtmlForRichEditor( normalizedContent[index + 1] === '[' && !isEscaped(normalizedContent, index) ) { - const closingIndex = normalizedContent.indexOf(']]', index + 2) + const closingIndex = + index + 2 > lastBracketClose ? -1 : normalizedContent.indexOf(']]', index + 2) if (closingIndex !== -1) { const rawTarget = normalizedContent.slice(index + 2, closingIndex) const link = parseMarkdownDocLink(rawTarget)