mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-22 17:49:48 +02:00
* feat(editor): Linear-style issue identifier autolink (MUL-4241) Bare issue identifiers (e.g. MUL-123, TES-1) now render as navigable issue chips and can be typed/pasted into a real mention, instead of staying inert text. Covers Phase 1 (readonly render) and Phase 2 (editable editor); the Phase 3 batch resolve API is intentionally deferred. Phase 1 — readonly render autolink - Pure, markdown-aware detector `preprocessIssueIdentifiers` in @multica/ui/markdown rewrites bare identifiers to `[MUL-123](mention://issue/MUL-123)`, skipping code, existing links, URLs, and file/path tokens. Runs before linkify/file-card. - `isIssueIdentifier` distinguishes a bare identifier from a real mention UUID at render time (a UUID never matches the identifier pattern). - Chat markdown and comment/description readonly both resolve identifiers to a real issue via a workspace-scoped, exact-match TanStack Query (`issueIdentifierOptions`), rendering a chip on a hit and plain text on a miss / cross-workspace / while loading. The exact `identifier ===` filter enforces the workspace prefix, since the backend search matches by number. - Autolink is opt-in per surface; the shared editable preprocess pipeline is untouched so editable content is never rewritten with fake mentions. Phase 2 — editable editor input/paste - Async ProseMirror plugin resolves a completed identifier (boundary typed after it, or found in pasted text) and swaps it for an issue mention node, serialising to canonical `[MUL-123](mention://issue/<uuid>)`. Only genuine user edits seed candidates (programmatic setContent is gated), so opening existing content never rewrites it. Resolver injected from the setup layer; no React hooks inside the extension. Tests: detector (code/link/url/path skips, dedupe), core resolver (exact match, wrong-prefix miss, empty response, key shape), chat + readonly render (hit/miss/code/canonical), and the editable extension (type/paste/miss/ inline-code/mount-safe/incomplete-token). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> * fix(editor): scope Phase 2 autolink to the captured candidate range (MUL-4241) Howard final-review blocker: the async resolve rescanned the whole document for every occurrence of the resolved identifier, so completing a new `MUL-1` also rewrote a pre-existing `MUL-1` the user never touched (persisted-content rewrite; violated "opening existing content is not rewritten"). Fix: capture the specific candidate range(s) a user transaction introduces — the token before the caret when typing, the tokens inside the pasted slice on paste — into plugin state, mapping each range forward on every subsequent transaction. After async resolve, replace ONLY those mapped ranges, verifying each still holds exactly that identifier with intact boundaries and no code/link mark. No document-wide scan by identifier. Also skip link-marked text at capture so an existing link label is never converted. Regression tests: (1) typing a new MUL-1 converts only the new occurrence, not a pre-existing identical one; (2) paste converts identifiers inside the paste range but leaves an identical one outside it untouched; (3) an identifier already carrying an explicit link mark is not replaced. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
import LinkifyIt from 'linkify-it'
|
||
|
||
/**
|
||
* Linkify - URL and file path detection for markdown preprocessing
|
||
*
|
||
* Uses linkify-it (12M downloads/week) for battle-tested URL detection,
|
||
* plus custom regex for local file paths.
|
||
*/
|
||
|
||
// Initialize linkify-it with default settings (fuzzy URLs, emails enabled)
|
||
const linkify = new LinkifyIt()
|
||
|
||
// Common source/config file extensions. Shared between the file-path detector
|
||
// and the bare-filename guard below so the two never drift.
|
||
const FILE_EXTENSIONS =
|
||
'ts|tsx|js|jsx|mjs|cjs|md|json|yaml|yml|py|go|rs|css|scss|less|html|htm|txt|log|sh|bash|zsh|swift|kt|java|c|cpp|h|hpp|rb|php|xml|toml|ini|cfg|conf|env|sql|graphql|vue|svelte|astro|prisma|dockerfile|makefile|gitignore'
|
||
|
||
// File path regex - detects /path, ~/path, ./path with common extensions
|
||
// Matches paths that start with /, ~/, or ./ followed by path chars and a file extension
|
||
const FILE_PATH_REGEX = new RegExp(
|
||
`(?:^|[\\s([{<])((\\/|~\\/|\\.\\/)[\\w\\-./@]+\\.(?:${FILE_EXTENSIONS}))(?=[\\s)\\]}.,;:!?>]|$)`,
|
||
'gi'
|
||
)
|
||
|
||
// A bare filename token like "plan.md" or "vite.config.ts": a single path
|
||
// segment ending in a known file extension, with no slash, scheme, or port.
|
||
// linkify-it fuzzy-matches these as domains because several of the extensions
|
||
// (md, sh, rs, py, …) are also valid TLDs. We use this to stop bare
|
||
// filenames from being auto-linked to dead external sites like https://plan.md.
|
||
const BARE_FILENAME_REGEX = new RegExp(`^[\\w.-]+\\.(?:${FILE_EXTENSIONS})$`, 'i')
|
||
|
||
// CJK full-width punctuation that should terminate a URL.
|
||
// linkify-it only treats ASCII punctuation as URL boundaries, so in Chinese /
|
||
// Japanese text a URL followed by e.g. "。" gets the punctuation and every
|
||
// character up to the next whitespace swallowed into the href. We truncate the
|
||
// detected URL at the first occurrence of any of these characters. Character
|
||
// set mirrors the fix applied in mattermost/marked#22.
|
||
const CJK_URL_TERMINATOR_REGEX =
|
||
/[!-/:-@[-`{-~、。「-】]/
|
||
|
||
// Markdown inline-formatting delimiters that linkify-it counts as URL characters
|
||
// but which, at the very end of a bare URL, are almost always the author closing
|
||
// an emphasis / strikethrough span (`**url**`, `*url*`, `~~url~~`) rather than
|
||
// part of the URL. We drop a trailing run of them so the surrounding markdown
|
||
// still parses — mirroring GFM's own autolink trailing-punctuation trim. A URL
|
||
// that genuinely ends in `*` / `~` loses that character from the link, exactly
|
||
// as it does on GitHub (MUL-4242).
|
||
const TRAILING_MD_DELIMITER = /[*~]+$/
|
||
|
||
interface DetectedLink {
|
||
type: 'url' | 'email' | 'file'
|
||
text: string
|
||
url: string
|
||
start: number
|
||
end: number
|
||
}
|
||
|
||
export interface CodeRange {
|
||
start: number
|
||
end: number
|
||
}
|
||
|
||
/**
|
||
* Find all code block and inline code ranges in text
|
||
* These ranges should be excluded from link detection
|
||
*/
|
||
export function findCodeRanges(text: string): CodeRange[] {
|
||
const ranges: CodeRange[] = []
|
||
|
||
// Find fenced code blocks (```...```)
|
||
const fencedRegex = /```[\s\S]*?```/g
|
||
let match
|
||
while ((match = fencedRegex.exec(text)) !== null) {
|
||
ranges.push({ start: match.index, end: match.index + match[0].length })
|
||
}
|
||
|
||
// Find display math blocks ($$...$$)
|
||
const displayMathRegex = /\$\$[\s\S]*?\$\$/g
|
||
while ((match = displayMathRegex.exec(text)) !== null) {
|
||
const pos = match.index
|
||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||
if (!insideOther) {
|
||
ranges.push({ start: pos, end: pos + match[0].length })
|
||
}
|
||
}
|
||
|
||
// Find inline math ($...$)
|
||
const inlineMathRegex = /(?<!\$)\$(?!\$)([^$\n]+)\$(?!\$)/g
|
||
while ((match = inlineMathRegex.exec(text)) !== null) {
|
||
const pos = match.index
|
||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||
if (!insideOther) {
|
||
ranges.push({ start: pos, end: pos + match[0].length })
|
||
}
|
||
}
|
||
|
||
// Find inline code (`...`)
|
||
// But skip escaped backticks and code inside fenced blocks
|
||
const inlineRegex = /(?<!`)`(?!`)([^`\n]+)`(?!`)/g
|
||
while ((match = inlineRegex.exec(text)) !== null) {
|
||
const pos = match.index
|
||
// Check if this is inside a fenced block or math block
|
||
const insideOther = ranges.some((r) => pos >= r.start && pos < r.end)
|
||
if (!insideOther) {
|
||
ranges.push({ start: pos, end: pos + match[0].length })
|
||
}
|
||
}
|
||
|
||
return ranges
|
||
}
|
||
|
||
/**
|
||
* Check if a position is inside any code range
|
||
*/
|
||
export function isInsideCode(pos: number, ranges: CodeRange[]): boolean {
|
||
return ranges.some((r) => pos >= r.start && pos < r.end)
|
||
}
|
||
|
||
function isEscaped(text: string, index: number): boolean {
|
||
let slashCount = 0
|
||
for (let i = index - 1; i >= 0 && text[i] === '\\'; i--) {
|
||
slashCount++
|
||
}
|
||
return slashCount % 2 === 1
|
||
}
|
||
|
||
function findMatchingBracket(text: string, openIndex: number): number {
|
||
let depth = 0
|
||
|
||
for (let i = openIndex; i < text.length; i++) {
|
||
if (isEscaped(text, i)) continue
|
||
|
||
const char = text[i]
|
||
if (char === '[') {
|
||
depth++
|
||
} else if (char === ']') {
|
||
depth--
|
||
if (depth === 0) return i
|
||
}
|
||
}
|
||
|
||
return -1
|
||
}
|
||
|
||
function findInlineLinkEnd(text: string, openParenIndex: number): number {
|
||
let depth = 0
|
||
|
||
for (let i = openParenIndex; i < text.length; i++) {
|
||
if (isEscaped(text, i)) continue
|
||
|
||
const char = text[i]
|
||
if (char === '(') {
|
||
depth++
|
||
} else if (char === ')') {
|
||
depth--
|
||
if (depth === 0) return i + 1
|
||
}
|
||
}
|
||
|
||
return -1
|
||
}
|
||
|
||
/**
|
||
* Find existing markdown link/image spans so auto-linkification does not create
|
||
* nested links inside their labels or destinations.
|
||
*/
|
||
export function findMarkdownLinkRanges(text: string): CodeRange[] {
|
||
const ranges: CodeRange[] = []
|
||
|
||
for (let i = 0; i < text.length; i++) {
|
||
if (text[i] !== '[' || isEscaped(text, i)) continue
|
||
if (ranges.some((r) => i >= r.start && i < r.end)) continue
|
||
|
||
const labelEnd = findMatchingBracket(text, i)
|
||
if (labelEnd === -1) continue
|
||
|
||
const start = i > 0 && text[i - 1] === '!' && !isEscaped(text, i - 1) ? i - 1 : i
|
||
const nextChar = text[labelEnd + 1]
|
||
|
||
if (nextChar === '(') {
|
||
const end = findInlineLinkEnd(text, labelEnd + 1)
|
||
if (end !== -1) {
|
||
ranges.push({ start, end })
|
||
i = end - 1
|
||
}
|
||
continue
|
||
}
|
||
|
||
if (nextChar === '[') {
|
||
const referenceEnd = findMatchingBracket(text, labelEnd + 1)
|
||
if (referenceEnd !== -1) {
|
||
ranges.push({ start, end: referenceEnd + 1 })
|
||
i = referenceEnd
|
||
}
|
||
}
|
||
}
|
||
|
||
return ranges
|
||
}
|
||
|
||
/**
|
||
* Check if a link at given position is already a markdown link
|
||
* Looks for patterns like [text](url) or [text][ref]
|
||
*/
|
||
function isAlreadyLinked(text: string, linkStart: number, linkEnd: number): boolean {
|
||
// Check if preceded by ]( which indicates we're inside a markdown link href
|
||
// Pattern: [text](URL) - we're checking if URL is our link
|
||
const before = text.slice(Math.max(0, linkStart - 2), linkStart)
|
||
if (before.endsWith('](')) return true
|
||
|
||
// Check if preceded by ][ for reference links
|
||
if (before.endsWith('][')) return true
|
||
|
||
// Check if the link text is wrapped in []
|
||
// Pattern: [URL](href) - URL is being used as link text
|
||
const charBefore = text[linkStart - 1]
|
||
const charAfter = text[linkEnd]
|
||
if (charBefore === '[' && charAfter === ']') return true
|
||
|
||
return false
|
||
}
|
||
|
||
/**
|
||
* Check if ranges overlap
|
||
*/
|
||
export function rangesOverlap(
|
||
a: { start: number; end: number },
|
||
b: { start: number; end: number }
|
||
): boolean {
|
||
return a.start < b.end && b.start < a.end
|
||
}
|
||
|
||
/**
|
||
* Run linkify-it on `text` and push normalized link records into `out`,
|
||
* shifted by `offset`. Two boundary corrections linkify-it doesn't make itself:
|
||
* - CJK punctuation ends a URL (linkify-it only breaks on ASCII); when it merged
|
||
* several URLs across CJK punctuation we truncate and re-scan the tail; and
|
||
* - a trailing run of markdown delimiters (`*`, `~`) is dropped from the URL so
|
||
* `**url**` keeps its closing emphasis marker outside the link.
|
||
*/
|
||
function collectLinkifyMatches(text: string, offset: number, out: DetectedLink[]): void {
|
||
const matches = linkify.match(text)
|
||
if (!matches) return
|
||
|
||
for (const match of matches) {
|
||
const cjkIdx = match.text.search(CJK_URL_TERMINATOR_REGEX)
|
||
if (cjkIdx === 0) continue // match starts with CJK punct — skip
|
||
|
||
const truncate = cjkIdx > 0
|
||
// URL text up to the first CJK terminator, then minus a trailing markdown
|
||
// delimiter run (e.g. the closing `**` of `**url**`). matchText stays a
|
||
// prefix of match.text, so the link end is just its length past match.index.
|
||
const matchText = (truncate ? match.text.slice(0, cjkIdx) : match.text).replace(
|
||
TRAILING_MD_DELIMITER,
|
||
''
|
||
)
|
||
|
||
// Bare filenames such as "plan.md" or "README.md" are fuzzy-matched as
|
||
// domains because their extension is also a valid TLD. They are file
|
||
// references, not URLs — leave them as plain text rather than link to a
|
||
// dead external site. Only schemeless (fuzzy) matches are suppressed; an
|
||
// explicit "https://plan.md" the author typed is still honored.
|
||
if (matchText.length > 0 && !(match.schema === '' && BARE_FILENAME_REGEX.test(matchText))) {
|
||
// When we trimmed the raw match, rebuild the href from the scheme prefix
|
||
// linkify-it added (e.g. "http://" / "mailto:") plus the trimmed text;
|
||
// otherwise keep linkify-it's normalized url as-is.
|
||
const trimmed = matchText.length !== match.text.length
|
||
const schemePrefix = match.url.slice(0, match.url.length - match.text.length)
|
||
const matchUrl = trimmed ? schemePrefix + matchText : match.url
|
||
|
||
out.push({
|
||
type: match.schema === 'mailto:' ? 'email' : 'url',
|
||
text: matchText,
|
||
url: matchUrl,
|
||
start: match.index + offset,
|
||
end: match.index + matchText.length + offset
|
||
})
|
||
}
|
||
|
||
if (truncate) {
|
||
// Rescan the tail after the CJK punct — linkify-it had greedily swallowed
|
||
// it, so any additional URLs after the punct were never emitted.
|
||
const tailStart = match.index + cjkIdx + 1
|
||
collectLinkifyMatches(text.slice(tailStart), offset + tailStart, out)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Detect all links (URLs, emails, file paths) in text.
|
||
*/
|
||
export function detectLinks(text: string): DetectedLink[] {
|
||
const links: DetectedLink[] = []
|
||
|
||
// 1. Detect URLs and emails with linkify-it, applying boundary corrections.
|
||
collectLinkifyMatches(text, 0, links)
|
||
|
||
// 2. Detect file paths with custom regex
|
||
// Reset regex state
|
||
FILE_PATH_REGEX.lastIndex = 0
|
||
let fileMatch
|
||
while ((fileMatch = FILE_PATH_REGEX.exec(text)) !== null) {
|
||
const path = fileMatch[1]
|
||
if (!path) continue // Skip if no capture group
|
||
|
||
// Calculate actual start position (after any leading whitespace/punctuation)
|
||
const fullMatch = fileMatch[0]
|
||
const pathOffset = fullMatch.indexOf(path)
|
||
const start = fileMatch.index + pathOffset
|
||
|
||
// Check for overlaps with URL matches (URLs take precedence)
|
||
const pathRange = { start, end: start + path.length }
|
||
const overlapsUrl = links.some((link) => rangesOverlap(pathRange, link))
|
||
if (overlapsUrl) continue
|
||
|
||
links.push({
|
||
type: 'file',
|
||
text: path,
|
||
url: path, // File paths are passed as-is to onFileClick handler
|
||
start,
|
||
end: start + path.length
|
||
})
|
||
}
|
||
|
||
// Sort by position
|
||
return links.sort((a, b) => a.start - b.start)
|
||
}
|
||
|
||
/**
|
||
* Preprocess text to convert raw URLs and file paths into markdown links.
|
||
* Skips code blocks and already-linked content.
|
||
*
|
||
* Shared by the Tiptap editor and the read-only react-markdown renderers, so
|
||
* both surfaces linkify identically. Trailing markdown delimiters are excluded
|
||
* from the URL (see collectLinkifyMatches), which keeps `**url**` bold and its
|
||
* link clean (MUL-4242).
|
||
*/
|
||
export function preprocessLinks(text: string): string {
|
||
// Quick check - if no potential links, return early
|
||
if (!linkify.pretest(text) && !/[~/.]\//.test(text)) {
|
||
return text
|
||
}
|
||
|
||
const codeRanges = findCodeRanges(text)
|
||
const markdownLinkRanges = findMarkdownLinkRanges(text)
|
||
const links = detectLinks(text)
|
||
|
||
if (links.length === 0) return text
|
||
|
||
// Build result, converting raw links to markdown links
|
||
let result = ''
|
||
let lastIndex = 0
|
||
|
||
for (const link of links) {
|
||
// Skip if inside code block
|
||
if (isInsideCode(link.start, codeRanges)) continue
|
||
|
||
// Skip if this match is inside an existing markdown link or image.
|
||
if (markdownLinkRanges.some((range) => rangesOverlap(link, range))) continue
|
||
|
||
// Skip if already a markdown link
|
||
if (isAlreadyLinked(text, link.start, link.end)) continue
|
||
|
||
// Add text before this link
|
||
result += text.slice(lastIndex, link.start)
|
||
|
||
// Convert to markdown link
|
||
result += `[${link.text}](${link.url})`
|
||
|
||
lastIndex = link.end
|
||
}
|
||
|
||
// Add remaining text
|
||
result += text.slice(lastIndex)
|
||
|
||
return result
|
||
}
|
||
|
||
/**
|
||
* Test if text contains any detectable links
|
||
* Useful for optimization - skip preprocessing if no links present
|
||
*/
|
||
export function hasLinks(text: string): boolean {
|
||
return linkify.pretest(text) || /[~/.]\/[\w]/.test(text)
|
||
}
|