/** * URL pattern detection and unfurling for smart link previews. * Supports internal URLs (issues, PRs, commits, repos) and external URLs. */ export type LinkType = | 'issue' | 'pull_request' | 'commit' | 'repository' | 'room' | 'project' | 'external'; export interface UnfurlResult { type: LinkType; url: string; /** Parsed entity ID fields */ ids: { project?: string; namespace?: string; repo?: string; issueNumber?: number; prNumber?: number; commitOid?: string; roomId?: string; }; /** Display title (populated after fetch) */ title?: string; /** Extra metadata (populated after fetch) */ meta?: Record; /** Whether the URL is external */ isExternal?: boolean; /** Domain for external URLs */ domain?: string; } /** Internal URL patterns */ const INTERNAL_PATTERNS: Array<{ type: LinkType; regex: RegExp; extract: (match: RegExpMatchArray) => UnfurlResult['ids']; }> = [ { type: 'issue', regex: /^\/project\/([^/]+)\/issues\/(\d+)/, extract: (m) => ({ project: m[1], issueNumber: parseInt(m[2], 10) }), }, { type: 'pull_request', regex: /^\/repository\/([^/]+)\/([^/]+)\/pulls?\/(\d+)/, extract: (m) => ({ namespace: m[1], repo: m[2], prNumber: parseInt(m[3], 10) }), }, { type: 'commit', regex: /^\/repository\/([^/]+)\/([^/]+)\/commit\/([a-f0-9]+)/, extract: (m) => ({ namespace: m[1], repo: m[2], commitOid: m[3] }), }, { type: 'repository', regex: /^\/repository\/([^/]+)\/([^/]+)/, extract: (m) => ({ namespace: m[1], repo: m[2] }), }, { type: 'project', regex: /^\/project\/([^/]+)/, extract: (m) => ({ project: m[1] }), }, { type: 'room', regex: /^\/project\/([^/]+)\/room(?:\/([^/?#]+))?/, extract: (m) => ({ project: m[1], roomId: m[2] }), }, ]; /** Detect the type and IDs of a URL */ export function detectLinkType(url: string): UnfurlResult | null { // Remove trailing slashes and hash const normalized = url.split('?')[0].split('#')[0].replace(/\/+$/, ''); // Check internal patterns for (const pattern of INTERNAL_PATTERNS) { const match = normalized.match(pattern.regex); if (match) { return { type: pattern.type, url: `/${normalized.replace(/^\//, '')}`, ids: pattern.extract(match), }; } } // External URL try { const parsed = new URL(url); const isExternal = typeof window === 'undefined' ? true : !parsed.hostname.includes(window.location.hostname); return { type: 'external', url, ids: {}, isExternal, domain: parsed.hostname, }; } catch { return null; } } /** Extract all URLs from a text string */ export function extractUrls(text: string): Array<{ url: string; index: number }> { // Match URLs (including internal paths starting with /) const urlPattern = /(?:https?:\/\/[^\s<>"]+|^\/[^\s<>"]+|\/[^\s<>"]+)/gm; const results: Array<{ url: string; index: number }> = []; let match; while ((match = urlPattern.exec(text)) !== null) { const url = match[0]; // Filter out very short or malformed URLs if (url.length > 3) { results.push({ url, index: match.index }); } } return results; } /** Simple cache for unfurl results */ const unfurlCache = new Map(); const CACHE_TTL = 5 * 60 * 1000; // 5 minutes export function getCachedUnfurl(url: string): (UnfurlResult & { expiresAt: number }) | undefined { const cached = unfurlCache.get(url); if (cached && cached.expiresAt > Date.now()) { return cached; } return undefined; } export function setCachedUnfurl(url: string, result: UnfurlResult): void { unfurlCache.set(url, { ...result, expiresAt: Date.now() + CACHE_TTL }); }