import type { MentionMap } from "./mentions"; const NAME = "[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})"; const REPO = "[A-Za-z0-9._-]{1,100}"; const TEAM = "[A-Za-z0-9][A-Za-z0-9_-]{0,99}"; const SHA = "[0-9a-f]{7,40}"; const SHORT_SHA = 7; /** One pass over plain text so inserted anchors are never re-scanned by a later pattern. */ const REFERENCE = new RegExp( `(?^|[\\s(])(?:${[ `@(?${NAME})\\/(?${TEAM})`, `@(?${NAME})`, `(?${NAME})\\/(?${REPO})#(?\\d+)`, `(?${NAME})\\/(?${REPO})@(?${SHA})`, `#(?\\d+)`, `(?${SHA})`, ].join("|")})\\b`, "g", ); type Groups = Partial>; function escapeHtml(value: string): string { return value.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`); } function anchor(href: string, text: string, kind: string, ref: string, className = ""): string { const classAttr = className ? ` class="${className}"` : ""; return `${escapeHtml(text)}`; } /** * Links `@user`, `@org/team`, `#123`, `owner/repo#123`, `owner/repo@sha` and bare commit SHAs in a text * segment of rendered HTML. Anchors carry `data-hovercard` so the client can show previews. */ export function linkTextReferences( text: string, context: { owner: string; repo: string } | null, mentions: MentionMap | null = null, ): string { let out = ""; let last = 0; for (const match of text.matchAll(REFERENCE)) { const replacement = replaceReference(match.groups ?? {}, context, mentions); if (replacement === null) continue; out += text.slice(last, match.index) + (match.groups?.prefix ?? "") + replacement; last = match.index + match[0].length; } return out + text.slice(last); } /** * With a resolved `mentions` map, handles render under the account's current name (so renames carry over) * and handles that name nobody stay plain text. Without one, every handle links as written. */ function mentionName(handle: string, mentions: MentionMap | null): string | null { if (!mentions) return handle; return mentions.get(handle.toLowerCase())?.name ?? null; } function replaceReference( groups: Groups, context: { owner: string; repo: string } | null, mentions: MentionMap | null, ): string | null { const { teamOrg, team, user, issueOwner, issueRepo, issueNumber, commitOwner, commitRepo, commitSha } = groups; if (teamOrg && team) { const org = mentionName(teamOrg, mentions); return org ? anchor(`/${org}`, `@${org}/${team}`, "team", `${org}/${team}`, "mention") : null; } if (user) { const name = mentionName(user, mentions); return name ? anchor(`/${name}`, `@${name}`, "user", name, "mention") : null; } if (issueOwner && issueRepo && issueNumber) { const ref = `${issueOwner}/${issueRepo}#${issueNumber}`; return anchor(`/${issueOwner}/${issueRepo}/issues/${issueNumber}`, ref, "issue", ref); } if (commitOwner && commitRepo && commitSha) return anchor( `/${commitOwner}/${commitRepo}/commit/${commitSha}`, `${commitOwner}/${commitRepo}@${commitSha.slice(0, SHORT_SHA)}`, "commit", `${commitOwner}/${commitRepo}@${commitSha}`, ); if (!context) return null; const { localNumber, localSha } = groups; if (localNumber) return anchor( `/${context.owner}/${context.repo}/issues/${localNumber}`, `#${localNumber}`, "issue", `${context.owner}/${context.repo}#${localNumber}`, ); if (localSha && /[a-f]/.test(localSha) && /\d/.test(localSha)) return anchor( `/${context.owner}/${context.repo}/commit/${localSha}`, localSha.slice(0, SHORT_SHA), "commit", `${context.owner}/${context.repo}@${localSha}`, "sha", ); return null; }