From cfabe5e706cb67c43ab134adf5b9e7f8e9323e05 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sun, 31 May 2026 20:47:32 +0300 Subject: [PATCH] fix(renderer): preserve task links in markdown code spans --- src/renderer/utils/taskReferenceUtils.ts | 159 +++++++++++++++--- .../renderer/utils/taskReferenceUtils.test.ts | 28 +++ 2 files changed, 167 insertions(+), 20 deletions(-) diff --git a/src/renderer/utils/taskReferenceUtils.ts b/src/renderer/utils/taskReferenceUtils.ts index 7426a8cd..10c448ee 100644 --- a/src/renderer/utils/taskReferenceUtils.ts +++ b/src/renderer/utils/taskReferenceUtils.ts @@ -4,6 +4,7 @@ import type { MentionSuggestion } from '@renderer/types/mention'; import type { TaskRef } from '@shared/types'; const TASK_REF_REGEX = /#([A-Za-z0-9-]+)\b/g; +const EXACT_TASK_REF_REGEX = /^#([A-Za-z0-9-]+)$/; const TASK_META_START = '\u2063'; const TASK_META_END = '\u2064'; const ZERO_WIDTH_ALPHABET = ['\u200B', '\u200C', '\u200D', '\u2060'] as const; @@ -214,39 +215,157 @@ export function parseTaskLinkHref(href: string): ParsedTaskLinkHref | null { } } +function findClosingBacktickRun(text: string, start: number, runLength: number): number { + const marker = '`'.repeat(runLength); + return text.indexOf(marker, start + runLength); +} + +function findClosingFence(text: string, start: number, fence: string): number { + let lineStart = text.indexOf('\n', start); + while (lineStart !== -1) { + lineStart += 1; + if (text.startsWith(fence, lineStart)) { + const lineEnd = text.indexOf('\n', lineStart); + return lineEnd === -1 ? text.length : lineEnd + 1; + } + lineStart = text.indexOf('\n', lineStart); + } + return -1; +} + +function findMarkdownLinkEnd(text: string, start: number): number { + const closeBracket = text.indexOf(']', start + 1); + if (closeBracket === -1 || text[closeBracket + 1] !== '(') return -1; + + let index = closeBracket + 2; + let parenDepth = 1; + while (index < text.length) { + const char = text[index]; + if (char === '\\') { + index += 2; + continue; + } + if (char === '(') { + parenDepth += 1; + } else if (char === ')') { + parenDepth -= 1; + if (parenDepth === 0) return index + 1; + } + index += 1; + } + + return -1; +} + export function linkifyTaskIdsInMarkdown(text: string, taskRefs?: TaskRef[]): string { if (!text) return text; const orderedTaskRefs = taskRefs ?? []; let taskRefIndex = 0; + + const consumeStructuredTaskRef = (ref: string): TaskRef | undefined => { + if ( + taskRefIndex < orderedTaskRefs.length && + orderedTaskRefs[taskRefIndex]?.displayId.toLowerCase() === ref.toLowerCase() + ) { + return orderedTaskRefs[taskRefIndex++]; + } + return undefined; + }; + + const buildLink = (raw: string, ref: string): string => { + const structuredTaskRef = consumeStructuredTaskRef(ref); + const href = structuredTaskRef ? buildTaskLinkHref(structuredTaskRef) : `task://${ref}`; + return `[${raw}](${href})`; + }; + + const linkifyPlainText = (value: string): string => { + let result = ''; + let cursor = 0; + + for (const match of value.matchAll(TASK_REF_REGEX)) { + const raw = match[0]; + const ref = match[1]; + const start = match.index ?? -1; + if (start < 0) continue; + + result += value.slice(cursor, start); + const preceding = start > 0 ? value[start - 1] : undefined; + if (!isAllowedTaskRefBoundary(preceding)) { + result += raw; + cursor = start + raw.length; + continue; + } + + result += buildLink(raw, ref); + cursor = start + raw.length; + } + + result += value.slice(cursor); + return result; + }; + let result = ''; let cursor = 0; - for (const match of text.matchAll(TASK_REF_REGEX)) { - const raw = match[0]; - const ref = match[1]; - const start = match.index ?? -1; - if (start < 0) continue; + while (cursor < text.length) { + const char = text[cursor]; - result += text.slice(cursor, start); - const preceding = start > 0 ? text[start - 1] : undefined; - if (!isAllowedTaskRefBoundary(preceding)) { - result += raw; - cursor = start + raw.length; - continue; + if (char === '[') { + const linkEnd = findMarkdownLinkEnd(text, cursor); + if (linkEnd !== -1) { + const closeBracket = text.indexOf(']', cursor + 1); + const label = text.slice(cursor + 1, closeBracket); + const labelMatch = EXACT_TASK_REF_REGEX.exec(label); + if (labelMatch) { + consumeStructuredTaskRef(labelMatch[1]); + } + result += text.slice(cursor, linkEnd); + cursor = linkEnd; + continue; + } } - const structuredTaskRef = - taskRefIndex < orderedTaskRefs.length && - orderedTaskRefs[taskRefIndex]?.displayId.toLowerCase() === ref.toLowerCase() - ? orderedTaskRefs[taskRefIndex++] - : undefined; - const href = structuredTaskRef ? buildTaskLinkHref(structuredTaskRef) : `task://${ref}`; - result += `[${raw}](${href})`; - cursor = start + raw.length; + if (char === '`') { + const run = /^`+/.exec(text.slice(cursor))?.[0] ?? '`'; + + if ( + run.length >= 3 && + (cursor === 0 || text[cursor - 1] === '\n') && + (text[cursor + run.length] === undefined || /\s/.test(text[cursor + run.length])) + ) { + const fenceEnd = findClosingFence(text, cursor, run); + if (fenceEnd !== -1) { + result += text.slice(cursor, fenceEnd); + cursor = fenceEnd; + continue; + } + } + + const codeEnd = findClosingBacktickRun(text, cursor, run.length); + if (codeEnd !== -1) { + const code = text.slice(cursor + run.length, codeEnd); + const codeMatch = EXACT_TASK_REF_REGEX.exec(code.trim()); + if (codeMatch) { + result += buildLink(code.trim(), codeMatch[1]); + } else { + result += text.slice(cursor, codeEnd + run.length); + } + cursor = codeEnd + run.length; + continue; + } + } + + let nextSpecial = text.length; + const nextLink = text.indexOf('[', cursor + 1); + const nextCode = text.indexOf('`', cursor + 1); + if (nextLink !== -1) nextSpecial = Math.min(nextSpecial, nextLink); + if (nextCode !== -1) nextSpecial = Math.min(nextSpecial, nextCode); + + result += linkifyPlainText(text.slice(cursor, nextSpecial)); + cursor = nextSpecial; } - result += text.slice(cursor); return result; } diff --git a/test/renderer/utils/taskReferenceUtils.test.ts b/test/renderer/utils/taskReferenceUtils.test.ts index bfdcabba..fcbabc9f 100644 --- a/test/renderer/utils/taskReferenceUtils.test.ts +++ b/test/renderer/utils/taskReferenceUtils.test.ts @@ -35,6 +35,34 @@ describe('taskReferenceUtils', () => { const r = linkifyTaskIdsInMarkdown(' #abc-123 '); expect(r).toContain('task://'); }); + + it('linkifies standalone task refs wrapped in inline code', () => { + const taskRef: TaskRef = { + taskId: 'task-1', + displayId: 'aa5d608e', + teamName: 'my-team', + }; + const r = linkifyTaskIdsInMarkdown('done `#aa5d608e`', [taskRef]); + expect(r).toBe( + 'done [#aa5d608e](task://task-1?team=my-team&display=aa5d608e)' + ); + }); + + it('does not linkify non-task inline code', () => { + const r = linkifyTaskIdsInMarkdown('avoid `eval` and `x#task1`'); + expect(r).toBe('avoid `eval` and `x#task1`'); + }); + + it('does not rewrite existing markdown links', () => { + const existing = '[#aa5d608e](task://aa5d608e)'; + const r = linkifyTaskIdsInMarkdown(existing); + expect(r).toBe(existing); + }); + + it('does not linkify task-looking text in fenced code blocks', () => { + const r = linkifyTaskIdsInMarkdown('```\n#aa5d608e\n```\n#bb7c9012'); + expect(r).toBe('```\n#aa5d608e\n```\n[#bb7c9012](task://bb7c9012)'); + }); }); describe('buildTaskLinkHref and parseTaskLinkHref', () => {