Files
multica/packages/ui/markdown/linkify.ts
Naiyuan Qing 3f02083fec feat(editor): Linear-style issue identifier autolink (MUL-4241) (#5090)
* 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>
2026-07-09 21:46:33 +08:00

387 lines
13 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}