1import { unified } from "unified";
2import remarkParse from "remark-parse";
3import remarkGfm from "remark-gfm";
4
5type MarkdownNode = {
6 readonly type: string;
7 readonly position?:
8 | {
9 readonly start: { readonly offset?: number | undefined };
10 readonly end: { readonly offset?: number | undefined };
11 }
12 | undefined;
13 readonly children?: readonly MarkdownNode[] | undefined;
14};
15
16const parser = unified().use(remarkParse).use(remarkGfm);
17const reference =
18 /(?<![\w/\\])(?:([\w.-]+\/[\w.-]+|[\w-]+)?#([1-9]\d*)|(?:([\w.-]+\/[\w.-]+|[\w-]+)@)?([a-f\d]{7,40})(?![\w])|@([a-z\d](?:[a-z\d-]*[a-z\d])?(?:\/[a-z\d][a-z\d-]*)?))(?![\w])/gi;
19
20/** Link GitHub references in prose without rewriting code, URLs, HTML, or existing links. */
21export function githubMarkdownLinks(markdown: string, pullRequestUrl: string | null): string {
22 if (!pullRequestUrl || !/[#@]|\b[a-f\d]{7,40}\b/i.test(markdown)) return markdown;
23 let url: URL;
24 try {
25 url = new URL(pullRequestUrl);
26 } catch {
27 return markdown;
28 }
29 const [owner, repo] = url.pathname.split("/").filter(Boolean);
30 if (!owner || !repo || !/^https?:$/.test(url.protocol)) return markdown;
31 const edits: { start: number; end: number; value: string }[] = [];
32 const repository = (qualifier: string | undefined) =>
33 qualifier?.includes("/") ? qualifier : qualifier ? `${qualifier}/${repo}` : `${owner}/${repo}`;
34 const visit = (node: MarkdownNode) => {
35 if (
36 [
37 "link",
38 "linkReference",
39 "image",
40 "imageReference",
41 "code",
42 "inlineCode",
43 "html",
44 "definition",
45 ].includes(node.type)
46 )
47 return;
48 if (node.type === "text") {
49 const start = node.position?.start.offset;
50 const end = node.position?.end.offset;
51 if (start === undefined || end === undefined) return;
52 // Work on source slices so escaped references and Markdown offsets stay intact.
53 const source = markdown.slice(start, end);
54 for (const match of source.matchAll(reference)) {
55 const [label, issueRepo, number, commitRepo, sha, mention] = match;
56 if (sha && !/[a-f]/i.test(sha)) continue;
57 const path = number
58 ? `${repository(issueRepo)}/issues/${number}`
59 : sha
60 ? `${repository(commitRepo)}/commit/${sha}`
61 : mention?.includes("/")
62 ? `orgs/${mention.split("/")[0]}/teams/${mention.split("/")[1]}`
63 : mention;
64 if (!path) continue;
65 edits.push({
66 start: start + match.index,
67 end: start + match.index + label.length,
68 value: `[${label}](${url.origin}/${path})`,
69 });
70 }
71 } else if ("children" in node) {
72 let protectedHtmlDepth = 0;
73 for (const child of node.children ?? []) {
74 if (child.type === "html") {
75 const start = child.position?.start.offset;
76 const end = child.position?.end.offset;
77 const tag = start === undefined || end === undefined ? "" : markdown.slice(start, end);
78 for (const match of tag.matchAll(/<(\/)?(?:a|code|pre|script|style)(?=[\s>])[^>]*>/gi)) {
79 protectedHtmlDepth = Math.max(0, protectedHtmlDepth + (match[1] ? -1 : 1));
80 }
81 } else if (protectedHtmlDepth === 0) visit(child);
82 }
83 }
84 };
85 visit(parser.parse(markdown));
86 for (let index = edits.length - 1; index >= 0; index--) {
87 const edit = edits[index]!;
88 markdown = markdown.slice(0, edit.start) + edit.value + markdown.slice(edit.end);
89 }
90 return markdown;
91}