| type MarkdownNode = { |
| type: string; |
| value?: string; |
| url?: string; |
| children?: MarkdownNode[]; |
| }; |
|
|
| const BARE_ISSUE_IDENTIFIER_RE = /^[A-Z][A-Z0-9]+-\d+$/i; |
| const ISSUE_SCHEME_RE = /^issue:\/\/:?([^?#\s]+)(?:[?#].*)?$/i; |
| const ISSUE_REFERENCE_TOKEN_RE = /issue:\/\/:?[^\s<>()]+|https?:\/\/[^\s<>()]+|\/(?:[^\s<>()/]+\/)*issues\/[A-Z][A-Z0-9]+-\d+(?=$|[\s<>)\],.;!?:])|\b[A-Z][A-Z0-9]+-\d+\b/gi; |
|
|
| export function parseIssuePathIdFromPath(pathOrUrl: string | null | undefined): string | null { |
| if (!pathOrUrl) return null; |
| let pathname = pathOrUrl.trim(); |
| if (!pathname) return null; |
|
|
| if (/^https?:\/\//i.test(pathname)) { |
| try { |
| const url = new URL(pathname); |
| if (url.hostname === "github.com" || url.hostname === "www.github.com") return null; |
| pathname = url.pathname; |
| } catch { |
| return null; |
| } |
| } |
|
|
| const segments = pathname.split("/").filter(Boolean); |
| const issueIndex = segments.findIndex((segment) => segment === "issues"); |
| if (issueIndex === -1 || issueIndex === segments.length - 1) return null; |
| const issuePathId = decodeURIComponent(segments[issueIndex + 1] ?? ""); |
| if (!issuePathId || issuePathId.startsWith(":")) return null; |
| return BARE_ISSUE_IDENTIFIER_RE.test(issuePathId) ? issuePathId.toUpperCase() : issuePathId; |
| } |
|
|
| export function parseIssueReferenceFromHref(href: string | null | undefined) { |
| if (!href) return null; |
| const trimmed = href.trim(); |
| const issueSchemeMatch = trimmed.match(ISSUE_SCHEME_RE); |
| if (issueSchemeMatch?.[1]) { |
| const issuePathId = decodeURIComponent(issueSchemeMatch[1]); |
| return { |
| issuePathId, |
| href: `/issues/${encodeURIComponent(issuePathId)}`, |
| }; |
| } |
|
|
| const pathId = parseIssuePathIdFromPath(href); |
| if (pathId) { |
| return { |
| issuePathId: pathId, |
| href: `/issues/${encodeURIComponent(pathId)}`, |
| }; |
| } |
|
|
| if (!BARE_ISSUE_IDENTIFIER_RE.test(trimmed)) return null; |
| const normalized = trimmed.toUpperCase(); |
| return { |
| issuePathId: normalized, |
| href: `/issues/${encodeURIComponent(normalized)}`, |
| }; |
| } |
|
|
| function splitTrailingPunctuation(token: string) { |
| let core = token; |
| let trailing = ""; |
|
|
| while (core.length > 0) { |
| const lastChar = core.at(-1); |
| if (!lastChar || !/[),.;!?:\]]/.test(lastChar)) break; |
| if (lastChar === ")") { |
| const openCount = (core.match(/\(/g) ?? []).length; |
| const closeCount = (core.match(/\)/g) ?? []).length; |
| if (closeCount <= openCount) break; |
| } |
| if (lastChar === "]") { |
| const openCount = (core.match(/\[/g) ?? []).length; |
| const closeCount = (core.match(/\]/g) ?? []).length; |
| if (closeCount <= openCount) break; |
| } |
| trailing = `${lastChar}${trailing}`; |
| core = core.slice(0, -1); |
| } |
|
|
| return { core, trailing }; |
| } |
|
|
| function createIssueLinkNode(value: string, href: string, childType: "text" | "inlineCode" = "text"): MarkdownNode { |
| return { |
| type: "link", |
| url: href, |
| children: [{ type: childType, value }], |
| }; |
| } |
|
|
| function linkifyIssueReferencesInText(value: string): MarkdownNode[] | null { |
| const nodes: MarkdownNode[] = []; |
| let cursor = 0; |
| let matched = false; |
|
|
| for (const match of value.matchAll(ISSUE_REFERENCE_TOKEN_RE)) { |
| const raw = match[0]; |
| if (!raw) continue; |
|
|
| const start = match.index ?? 0; |
| const end = start + raw.length; |
| const { core, trailing } = splitTrailingPunctuation(raw); |
| const issueRef = parseIssueReferenceFromHref(core); |
| if (!issueRef) continue; |
|
|
| matched = true; |
| if (start > cursor) { |
| nodes.push({ type: "text", value: value.slice(cursor, start) }); |
| } |
| nodes.push(createIssueLinkNode(core, issueRef.href)); |
| if (trailing) { |
| nodes.push({ type: "text", value: trailing }); |
| } |
| cursor = end; |
| } |
|
|
| if (!matched) return null; |
| if (cursor < value.length) { |
| nodes.push({ type: "text", value: value.slice(cursor) }); |
| } |
| return nodes; |
| } |
|
|
| function rewriteMarkdownTree(node: MarkdownNode) { |
| if (!Array.isArray(node.children) || node.children.length === 0) return; |
| if (node.type === "link" || node.type === "linkReference" || node.type === "code" || node.type === "definition" || node.type === "html") { |
| return; |
| } |
|
|
| const nextChildren: MarkdownNode[] = []; |
| for (const child of node.children) { |
| if (child.type === "inlineCode" && typeof child.value === "string") { |
| const issueRef = parseIssueReferenceFromHref(child.value); |
| if (issueRef) { |
| nextChildren.push(createIssueLinkNode(child.value, issueRef.href, "inlineCode")); |
| continue; |
| } |
| } |
|
|
| if (child.type === "text" && typeof child.value === "string") { |
| const linked = linkifyIssueReferencesInText(child.value); |
| if (linked) { |
| nextChildren.push(...linked); |
| continue; |
| } |
| } |
|
|
| rewriteMarkdownTree(child); |
| nextChildren.push(child); |
| } |
| node.children = nextChildren; |
| } |
|
|
| export function remarkLinkIssueReferences() { |
| return (tree: MarkdownNode) => { |
| rewriteMarkdownTree(tree); |
| }; |
| } |
|
|