| 1 | /** `id` is positional on purpose: the same attachment can be embedded twice in one body. */ |
| 2 | export 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 | |
| 17 | const 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 | */ |
| 22 | const VIDEO_TAG_MAX_LINES = 8; |
| 23 | /** Four spaces open an indented code block, so its contents stay verbatim markdown. */ |
| 24 | const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u; |
| 25 | const BARE_URL_PATTERN = /^<?(https?:\/\/\S+?)>?$/u; |
| 26 | const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp|avif|heic|heif|svg)(?:$|[?#])/iu; |
| 27 | const MARKDOWN_LINK_PATTERN = /^\[[^\]]*\]\((https?:\/\/[^\s)]+)\)$/u; |
| 28 | const 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. */ |
| 30 | const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu; |
| 31 | const 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. */ |
| 33 | const STANDALONE_VIDEO_TAG_PATTERN = /^\s*<video\b/iu; |
| 34 | const VIDEO_TAG_END_PATTERN = /<\/video>\s*$/iu; |
| 35 | |
| 36 | /** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */ |
| 37 | function 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 | |
| 46 | function 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 | */ |
| 64 | export 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 | } |