diff --git a/src/renderer/src/components/editor/markdown-reference-link-normalization.ts b/src/renderer/src/components/editor/markdown-reference-link-normalization.ts new file mode 100644 index 000000000..00e7565c7 --- /dev/null +++ b/src/renderer/src/components/editor/markdown-reference-link-normalization.ts @@ -0,0 +1,188 @@ +type ReferenceLinkDefinition = { + label: string + title: string | null + url: string +} + +const REFERENCE_DEFINITION_PATTERN = + /^ {0,3}\[([^\]]+)\]:[ \t]*(<[^>\n]+>|[^\s]+)(?:[ \t]+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\)))?[ \t]*$/ + +function normalizeReferenceLabel(label: string): string { + return label.trim().replace(/\s+/g, ' ').toLowerCase() +} + +function unwrapReferenceUrl(rawUrl: string): string { + return rawUrl.startsWith('<') && rawUrl.endsWith('>') ? rawUrl.slice(1, -1) : rawUrl +} + +function parseReferenceDefinition(line: string): ReferenceLinkDefinition | null { + const match = line.match(REFERENCE_DEFINITION_PATTERN) + if (!match) { + return null + } + + return { + label: normalizeReferenceLabel(match[1]), + url: unwrapReferenceUrl(match[2]), + title: match[3] ?? match[4] ?? match[5] ?? null + } +} + +function splitReferenceDefinitions(content: string): { + definitions: Map + markdown: string +} { + const definitions = new Map() + const lines = content.split(/(\n)/) + let activeFence: '`' | '~' | null = null + let activeFenceLength = 0 + let markdown = '' + + for (let index = 0; index < lines.length; index += 2) { + const line = lines[index] ?? '' + const newline = lines[index + 1] ?? '' + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/) + if (fenceMatch) { + const fenceChar = fenceMatch[1][0] as '`' | '~' + const fenceLength = fenceMatch[1].length + if (activeFence === null) { + activeFence = fenceChar + activeFenceLength = fenceLength + } else if (activeFence === fenceChar && fenceLength >= activeFenceLength) { + activeFence = null + activeFenceLength = 0 + } + } + + const definition = activeFence === null ? parseReferenceDefinition(line) : null + if (definition) { + definitions.set(definition.label, definition) + continue + } + + markdown += line + newline + } + + return { definitions, markdown } +} + +function isEscaped(content: string, index: number): boolean { + let backslashCount = 0 + for (let cursor = index - 1; cursor >= 0 && content[cursor] === '\\'; cursor -= 1) { + backslashCount += 1 + } + return backslashCount % 2 === 1 +} + +function findClosingBracket(content: string, start: number): number { + for (let index = start; index < content.length; index += 1) { + if (content[index] === ']' && !isEscaped(content, index)) { + return index + } + } + return -1 +} + +function formatInlineReferenceLink(text: string, definition: ReferenceLinkDefinition): string { + const escapedUrl = definition.url.replace(/[()\\]/g, '\\$&') + if (!definition.title) { + return `[${text}](${escapedUrl})` + } + const escapedTitle = definition.title.replace(/["\\]/g, '\\$&') + return `[${text}](${escapedUrl} "${escapedTitle}")` +} + +function replaceReferenceLinks( + markdown: string, + definitions: Map +): string { + let result = '' + let index = 0 + let activeFence: '`' | '~' | null = null + let activeFenceLength = 0 + let isLineStart = true + + while (index < markdown.length) { + const lineRest = markdown.slice(index) + if (isLineStart) { + const fenceMatch = lineRest.match(/^\s*(`{3,}|~{3,})/) + if (fenceMatch) { + const fenceChar = fenceMatch[1][0] as '`' | '~' + const fenceLength = fenceMatch[1].length + if (activeFence === null) { + activeFence = fenceChar + activeFenceLength = fenceLength + } else if (activeFence === fenceChar && fenceLength >= activeFenceLength) { + activeFence = null + activeFenceLength = 0 + } + } + } + + if (activeFence || markdown[index] !== '[' || isEscaped(markdown, index)) { + const nextChar = markdown[index] + result += nextChar + isLineStart = nextChar === '\n' + index += 1 + continue + } + + const closingTextIndex = findClosingBracket(markdown, index + 1) + if (closingTextIndex === -1) { + result += markdown[index] + isLineStart = false + index += 1 + continue + } + + const text = markdown.slice(index + 1, closingTextIndex) + const afterText = markdown[closingTextIndex + 1] + if (afterText === '(') { + result += markdown[index] + isLineStart = false + index += 1 + continue + } + + if (afterText === '[') { + const closingLabelIndex = findClosingBracket(markdown, closingTextIndex + 2) + if (closingLabelIndex !== -1) { + const rawLabel = markdown.slice(closingTextIndex + 2, closingLabelIndex) + const label = normalizeReferenceLabel(rawLabel || text) + const definition = definitions.get(label) + if (definition) { + result += formatInlineReferenceLink(text, definition) + isLineStart = false + index = closingLabelIndex + 1 + continue + } + } + } else { + const definition = definitions.get(normalizeReferenceLabel(text)) + if (definition) { + result += formatInlineReferenceLink(text, definition) + isLineStart = false + index = closingTextIndex + 1 + continue + } + } + + result += markdown[index] + isLineStart = false + index += 1 + } + + return result +} + +export function normalizeMarkdownReferenceLinks(content: string): string { + const { definitions, markdown } = splitReferenceDefinitions(content) + if (definitions.size === 0) { + return content + } + + // Why: Tiptap's Markdown parser drops reference definitions but leaves + // shortcut references as plain text. Inline them before parsing so Linear + // issue mentions keep their links in the rich description editor. + return replaceReferenceLinks(markdown, definitions) +} diff --git a/src/renderer/src/components/editor/markdown-round-trip.test.ts b/src/renderer/src/components/editor/markdown-round-trip.test.ts index e26b20e80..cd0ade290 100644 --- a/src/renderer/src/components/editor/markdown-round-trip.test.ts +++ b/src/renderer/src/components/editor/markdown-round-trip.test.ts @@ -152,6 +152,24 @@ describe('rich markdown round trip', () => { expect(roundTripMarkdown('| a | b |\n| - | - |\n| 1 | 2 |\n')).toContain('| a') }) + it('does not surface Linear issue reference definitions as description text', () => { + const input = [ + '- [x] [H-279]', + '- [ ] [H-284]', + '', + '[H-279]: https://linear.app/acme/issue/H-279/child-one "Child one"', + '[H-284]: https://linear.app/acme/issue/H-284/child-two "Child two"', + '' + ].join('\n') + + expect(roundTripMarkdown(input)).toBe( + [ + '- [x] [H-279](https://linear.app/acme/issue/H-279/child-one "Child one")', + '- [ ] [H-284](https://linear.app/acme/issue/H-284/child-two "Child two")' + ].join('\n') + ) + }) + it('preserves doc links', () => { expect(roundTripMarkdown('See [[setup-guide]] for details\n')).toBe( 'See [[setup-guide]] for details' diff --git a/src/renderer/src/components/editor/raw-markdown-html.ts b/src/renderer/src/components/editor/raw-markdown-html.ts index 4bed99aa0..e4403e31c 100644 --- a/src/renderer/src/components/editor/raw-markdown-html.ts +++ b/src/renderer/src/components/editor/raw-markdown-html.ts @@ -1,6 +1,7 @@ import { Node, mergeAttributes } from '@tiptap/core' import { isEditableDetailsHtmlBlock, matchDetailsHtmlBlock } from './details-markdown-html' import { formatMarkdownDocLinkBody, parseMarkdownDocLink } from './markdown-doc-links' +import { normalizeMarkdownReferenceLinks } from './markdown-reference-link-normalization' const INLINE_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_INLINE:' const BLOCK_PLACEHOLDER_PREFIX = '[[ORCA_RAW_HTML_BLOCK:' @@ -90,14 +91,15 @@ function matchBlockHtml(content: string, start: number): string | null { } export function encodeRawMarkdownHtmlForRichEditor(content: string): string { + const normalizedContent = normalizeMarkdownReferenceLinks(content) let index = 0 let isLineStart = true let activeFence: '`' | '~' | null = null let activeFenceLength = 0 let result = '' - while (index < content.length) { - const lineRest = content.slice(index) + while (index < normalizedContent.length) { + const lineRest = normalizedContent.slice(index) if (isLineStart) { const fenceMatch = lineRest.match(/^\s*(`{3,}|~{3,})/) @@ -115,16 +117,16 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string { } if (activeFence) { - const nextChar = content[index] + const nextChar = normalizedContent[index] result += nextChar isLineStart = nextChar === '\n' index += 1 continue } - if (content[index] === '`') { + if (normalizedContent[index] === '`') { let tickCount = 0 - while (content[index + tickCount] === '`') { + while (normalizedContent[index + tickCount] === '`') { tickCount += 1 } @@ -132,15 +134,15 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string { // not a longer run. We scan forward to find the first exact match. let searchFrom = index + tickCount let closingIndex = -1 - while (searchFrom < content.length) { - const candidate = content.indexOf('`'.repeat(tickCount), searchFrom) + while (searchFrom < normalizedContent.length) { + const candidate = normalizedContent.indexOf('`'.repeat(tickCount), searchFrom) if (candidate === -1) { break } // Verify the match is exactly tickCount backticks (no extra backtick before/after) if ( - (candidate === 0 || content[candidate - 1] !== '`') && - content[candidate + tickCount] !== '`' + (candidate === 0 || normalizedContent[candidate - 1] !== '`') && + normalizedContent[candidate + tickCount] !== '`' ) { closingIndex = candidate break @@ -149,7 +151,7 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string { } if (closingIndex !== -1) { - const rawSpan = content.slice(index, closingIndex + tickCount) + const rawSpan = normalizedContent.slice(index, closingIndex + tickCount) result += rawSpan isLineStart = rawSpan.endsWith('\n') index = closingIndex + tickCount @@ -158,7 +160,7 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string { } if (isLineStart) { - const detailsHtml = matchDetailsHtmlBlock(content, index) + const detailsHtml = matchDetailsHtmlBlock(normalizedContent, index) if (detailsHtml && isEditableDetailsHtmlBlock(detailsHtml)) { // Why:
/ is an editable rich-mode node; raw passthrough // would make toggle blocks reopen as inert HTML instead. @@ -173,7 +175,7 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string { continue } - const blockHtml = matchBlockHtml(content, index) + const blockHtml = matchBlockHtml(normalizedContent, index) if (blockHtml) { result += createPlaceholder('block', blockHtml) index += blockHtml.length @@ -181,8 +183,8 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string { } } - if (content[index] === '<' && !isEscaped(content, index)) { - const inlineHtml = matchInlineHtml(content.slice(index)) + if (normalizedContent[index] === '<' && !isEscaped(normalizedContent, index)) { + const inlineHtml = matchInlineHtml(normalizedContent.slice(index)) if (inlineHtml) { result += createPlaceholder('inline', inlineHtml) index += inlineHtml.length @@ -195,14 +197,14 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string { // skipped by the guards above. The [[ORCA_ prefix check prevents re-encoding // sibling placeholders that were already emitted earlier in this pass. if ( - content[index] === '[' && - content[index + 1] === '[' && - !content.startsWith('[[ORCA_', index) && - !isEscaped(content, index) + normalizedContent[index] === '[' && + normalizedContent[index + 1] === '[' && + !normalizedContent.startsWith('[[ORCA_', index) && + !isEscaped(normalizedContent, index) ) { - const closingIndex = content.indexOf(']]', index + 2) + const closingIndex = normalizedContent.indexOf(']]', index + 2) if (closingIndex !== -1) { - const rawTarget = content.slice(index + 2, closingIndex) + const rawTarget = normalizedContent.slice(index + 2, closingIndex) const link = parseMarkdownDocLink(rawTarget) if (link) { result += `${DOC_LINK_PLACEHOLDER_PREFIX}${formatMarkdownDocLinkBody( @@ -215,7 +217,7 @@ export function encodeRawMarkdownHtmlForRichEditor(content: string): string { } } - const nextChar = content[index] + const nextChar = normalizedContent[index] result += nextChar isLineStart = nextChar === '\n' index += 1