export const ISSUE_REFERENCE_IDENTIFIER_RE = /^[A-Z]+-\d+$/; export interface IssueReferenceMatch { index: number; length: number; identifier: string; matchedText: string; } const ISSUE_REFERENCE_TOKEN_RE = /https?:\/\/[^\s<>()]+|\/[^\s<>()]+|[A-Z]+-\d+/gi; function preserveNewlinesAsWhitespace(value: string) { return value.replace(/[^\n]/g, " "); } function stripMarkdownCode(markdown: string): string { if (!markdown) return ""; let output = ""; let index = 0; while (index < markdown.length) { const remaining = markdown.slice(index); const fenceMatch = /^(?:```+|~~~+)/.exec(remaining); const atLineStart = index === 0 || markdown[index - 1] === "\n"; if (atLineStart && fenceMatch) { const fence = fenceMatch[0]!; const blockStart = index; index += fence.length; while (index < markdown.length && markdown[index] !== "\n") index += 1; if (index < markdown.length) index += 1; while (index < markdown.length) { const lineStart = index === 0 || markdown[index - 1] === "\n"; if (lineStart && markdown.startsWith(fence, index)) { index += fence.length; while (index < markdown.length && markdown[index] !== "\n") index += 1; if (index < markdown.length) index += 1; break; } index += 1; } output += preserveNewlinesAsWhitespace(markdown.slice(blockStart, index)); continue; } if (markdown[index] === "`") { let tickCount = 1; while (index + tickCount < markdown.length && markdown[index + tickCount] === "`") { tickCount += 1; } const fence = "`".repeat(tickCount); const inlineStart = index; index += tickCount; const closeIndex = markdown.indexOf(fence, index); if (closeIndex === -1) { output += markdown.slice(inlineStart, inlineStart + tickCount); index = inlineStart + tickCount; continue; } index = closeIndex + tickCount; output += preserveNewlinesAsWhitespace(markdown.slice(inlineStart, index)); continue; } output += markdown[index]!; index += 1; } return output; } function trimTrailingPunctuation(token: string): string { let trimmed = token; while (trimmed.length > 0) { const last = trimmed[trimmed.length - 1]!; if (!".,!?;:".includes(last) && last !== ")" && last !== "]") break; if ( (last === ")" && (trimmed.match(/\(/g)?.length ?? 0) >= (trimmed.match(/\)/g)?.length ?? 0)) || (last === "]" && (trimmed.match(/\[/g)?.length ?? 0) >= (trimmed.match(/\]/g)?.length ?? 0)) ) { break; } trimmed = trimmed.slice(0, -1); } return trimmed; } export function normalizeIssueIdentifier(value: string): string | null { const trimmed = value.trim().toUpperCase(); return ISSUE_REFERENCE_IDENTIFIER_RE.test(trimmed) ? trimmed : null; } export function buildIssueReferenceHref(identifier: string): string { const normalized = normalizeIssueIdentifier(identifier); return `/issues/${normalized ?? identifier.trim()}`; } export function parseIssueReferenceHref(href: string): { identifier: string } | null { const raw = href.trim(); if (!raw) return null; let url: URL; try { url = raw.startsWith("/") ? new URL(raw, "https://paperclip.invalid") : new URL(raw); } catch { return null; } const segments = url.pathname .split("/") .map((segment) => segment.trim()) .filter(Boolean); for (let index = 0; index < segments.length - 1; index += 1) { if (segments[index]?.toLowerCase() !== "issues") continue; const identifier = normalizeIssueIdentifier(segments[index + 1] ?? ""); if (identifier) { return { identifier }; } } return null; } export function findIssueReferenceMatches(text: string): IssueReferenceMatch[] { if (!text) return []; const matches: IssueReferenceMatch[] = []; let match: RegExpExecArray | null; const re = new RegExp(ISSUE_REFERENCE_TOKEN_RE); while ((match = re.exec(text)) !== null) { const rawToken = match[0]; const cleanedToken = trimTrailingPunctuation(rawToken); if (!cleanedToken) continue; const identifier = normalizeIssueIdentifier(cleanedToken) ?? parseIssueReferenceHref(cleanedToken)?.identifier ?? null; if (!identifier) continue; const cleanedIndex = match.index; matches.push({ index: cleanedIndex, length: cleanedToken.length, identifier, matchedText: cleanedToken, }); } return matches; } export function extractIssueReferenceIdentifiers(markdown: string): string[] { const scrubbed = stripMarkdownCode(markdown); const seen = new Set(); const ordered: string[] = []; for (const match of findIssueReferenceMatches(scrubbed)) { if (seen.has(match.identifier)) continue; seen.add(match.identifier); ordered.push(match.identifier); } return ordered; } export function extractIssueReferenceMatches(markdown: string): IssueReferenceMatch[] { const scrubbed = stripMarkdownCode(markdown); const seen = new Set(); const ordered: IssueReferenceMatch[] = []; for (const match of findIssueReferenceMatches(scrubbed)) { if (seen.has(match.identifier)) continue; seen.add(match.identifier); ordered.push(match); } return ordered; }