1/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */
2export type PullRequestBodySegment =
3 | { readonly id: string; readonly kind: "markdown"; readonly text: string }
4 | {
5 readonly id: string;
6 readonly kind: "attachment";
7 readonly url: string;
8 /**
9 * What the reader can be told the upload is. The uuid says nothing on its own — the type
10 * only appears in the signed redirect GitHub answers with — but the shape does: GitHub
11 * writes a dropped image into the body as an `<img>` tag, so a bare attachment link on its
12 * own line is what it does with a video. Anything else keeps the neutral name.
13 */
14 readonly media: "video" | "image" | "unknown";
15 };
16
17const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)$/u;
18/**
19 * How far a `<video>` tag may reach for its closing tag. Real embeds are one to three lines,
20 * and the bound keeps an unclosed tag from making each one rescan the rest of the body.
21 */
22const VIDEO_TAG_MAX_LINES = 8;
23/** Four spaces open an indented code block, so its contents stay verbatim markdown. */
24const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u;
25const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u;
26const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$|[?#])/iu;
27const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u;
28const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu;
29/** A dropped video becomes a bare asset link; a dropped image becomes an `<img>` tag. */
30const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu;
31const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu;
32/** Only a tag that owns its line is an embed; inline, it is prose the renderer should keep. */
33const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu;
34const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu;
35
36/** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */
37function isWebUrl(url: string): boolean {
38 try {
39 const parsed = new URL(url);
40 return parsed.protocol === "https:" || parsed.protocol === "http:";
41 } catch {
42 return false;
43 }
44}
45
46function attachmentFromLine(
47 line: string,
48): { url: string; media: "video" | "image" | "unknown" } | null {
49 const url =
50 BARE_URL_PATTERN.exec(line.trim())?.[1] ?? MARKDOWN_LINK_PATTERN.exec(line.trim())?.[1];
51 if (url === undefined || !isWebUrl(url)) return null;
52 if (VIDEO_EXTENSION_PATTERN.test(url) || GITHUB_ASSET_PATTERN.test(url)) {
53 return { url, media: "video" };
54 }
55 if (IMAGE_EXTENSION_PATTERN.test(url)) return { url, media: "image" };
56 return null;
57}
58
59/**
60 * Lifts standalone media links and video tags out of prose for both clients.
61 * Ordinary links and code examples remain markdown. Images already embedded
62 * with markdown or HTML keep their original renderer.
63 */
64export function splitPullRequestBody(body: string): ReadonlyArray<PullRequestBodySegment> {
65 const segments: PullRequestBodySegment[] = [];
66 const markdown: string[] = [];
67 let openFence: string | null = null;
68
69 // Blank lines around a run are dropped, but never leading spaces: four of them open an
70 // indented code block, so trimming a run would silently turn code into prose.
71 const flushMarkdown = () => {
72 const text = markdown.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, "");
73 markdown.length = 0;
74 if (text.trim().length > 0) {
75 segments.push({ id: `markdown:${segments.length}`, kind: "markdown", text });
76 }
77 };
78
79 const lines = body.split("\n");
80 for (let index = 0; index < lines.length; index += 1) {
81 const line = lines[index]!;
82 const fenceMatch = FENCE_PATTERN.exec(line);
83 if (fenceMatch !== null) {
84 const fence = fenceMatch[1]!;
85 // A fence closes only on the same marker, at least as long as the one that opened it,
86 // and with nothing after it: `~~~` cannot end a ``` block, and ```` ```ts ```` is an
87 // info string opening a nested fence, not a close.
88 const closes =
89 openFence !== null &&
90 fence[0] === openFence[0] &&
91 fence.length >= openFence.length &&
92 fenceMatch[2]!.trim().length === 0;
93 if (openFence === null) {
94 openFence = fence;
95 } else if (closes) {
96 openFence = null;
97 }
98 markdown.push(line);
99 continue;
100 }
101 if (openFence !== null || INDENTED_CODE_PATTERN.test(line)) {
102 markdown.push(line);
103 continue;
104 }
105
106 const bareAttachment = attachmentFromLine(line);
107 if (bareAttachment !== null) {
108 flushMarkdown();
109 segments.push({ id: `attachment:${segments.length}`, kind: "attachment", ...bareAttachment });
110 continue;
111 }
112
113 if (!STANDALONE_VIDEO_TAG_PATTERN.test(line)) {
114 markdown.push(line);
115 continue;
116 }
117 // A tag can span lines, so look ahead for its close — bounded, and without building the
118 // joined text until one is actually found.
119 const lastCandidate = Math.min(index + VIDEO_TAG_MAX_LINES, lines.length) - 1;
120 let cursor = index;
121 while (cursor < lastCandidate && !VIDEO_TAG_END_PATTERN.test(lines[cursor]!)) {
122 cursor += 1;
123 }
124 const source = VIDEO_TAG_END_PATTERN.test(lines[cursor]!)
125 ? VIDEO_TAG_SRC_PATTERN.exec(lines.slice(index, cursor + 1).join("\n"))?.[1]
126 : undefined;
127 if (source !== undefined && isWebUrl(source)) {
128 flushMarkdown();
129 segments.push({
130 id: `attachment:${segments.length}`,
131 kind: "attachment",
132 url: source,
133 // The author wrote the tag, so this one is a video whatever the URL looks like.
134 media: "video",
135 });
136 index = cursor;
137 } else {
138 // Unclosed, or nothing linkable in it: prose. Only this line is consumed, so the
139 // lines that were looked at are still parsed on their own terms.
140 markdown.push(line);
141 }
142 }
143
144 flushMarkdown();
145 return segments;
146}