import {
findAndReplaceText,
type MarkdownNode,
type TextMatch,
} from "~/vendor/mdast-find-and-replace";
/** `id` is positional on purpose: the same attachment can be embedded twice in one body. */
export type PullRequestBodySegment =
| { readonly id: string; readonly kind: "markdown"; readonly text: string }
| {
readonly id: string;
readonly kind: "attachment";
readonly url: string;
/**
* What the reader can be told the upload is. The uuid says nothing on its own — the type
* only appears in the signed redirect GitHub answers with — but the shape does: GitHub
* writes a dropped image into the body as an ` ` tag, so a bare attachment link on its
* own line is what it does with a video. Anything else keeps the neutral name.
*/
readonly media: "video" | "unknown";
};
const FENCE_PATTERN = /^\s{0,3}((?:`{3,})|(?:~{3,}))(.*)$/u;
/**
* How far a `` tag may reach for its closing tag. Real embeds are one to three lines,
* and the bound keeps an unclosed tag from making each one rescan the rest of the body.
*/
const VIDEO_TAG_MAX_LINES = 8;
/** Four spaces open an indented code block, so its contents stay verbatim markdown. */
const INDENTED_CODE_PATTERN = /^(?: {4}|\t)/u;
const BARE_URL_PATTERN = /^(https?:\/\/\S+?)>?$/u;
const VIDEO_EXTENSION_PATTERN = /\.(?:mp4|webm|mov|m4v|ogv)(?:$|[?#])/iu;
/** A dropped video becomes a bare asset link; a dropped image becomes an ` ` tag. */
const GITHUB_ASSET_PATTERN = /^https:\/\/github\.com\/user-attachments\/assets\/[\w-]+$/iu;
const VIDEO_TAG_SRC_PATTERN = /<(?:video|source)\b[^>]*\bsrc\s*=\s*["']([^"']+)["']/iu;
/** Only a tag that owns its line is an embed; inline, it is prose the renderer should keep. */
const STANDALONE_VIDEO_TAG_PATTERN = /^\s*\s*$/iu;
/** Anything else — `javascript:`, `data:`, a relative path — is not an upload to link out to. */
function isWebUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
}
function attachmentFromLine(line: string): { url: string; media: "video" | "unknown" } | null {
const url = BARE_URL_PATTERN.exec(line.trim())?.[1];
if (url === undefined || !isWebUrl(url)) return null;
if (VIDEO_EXTENSION_PATTERN.test(url) || GITHUB_ASSET_PATTERN.test(url)) {
return { url, media: "video" };
}
return null;
}
/**
* Splits a pull request body into markdown runs and the uploads embedded in it, which the
* markdown renderer drops: `` is not in its sanitizer's schema, and a bare attachment
* link arrives as prose. Two shapes are recognised, both of which GitHub produces itself: a
* `` (or ``) tag, and a bare link on its own line to a video file or an
* uploaded attachment. Fenced code is copied through untouched so a snippet that happens to
* contain a link is never lifted out of it.
*/
export function splitPullRequestBody(body: string): ReadonlyArray {
const segments: PullRequestBodySegment[] = [];
const markdown: string[] = [];
let openFence: string | null = null;
// Blank lines around a run are dropped, but never leading spaces: four of them open an
// indented code block, so trimming a run would silently turn code into prose.
const flushMarkdown = () => {
const text = markdown.join("\n").replace(/^\n+/u, "").replace(/\s+$/u, "");
markdown.length = 0;
if (text.trim().length > 0) {
segments.push({ id: `markdown:${segments.length}`, kind: "markdown", text });
}
};
const lines = body.split("\n");
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index]!;
const fenceMatch = FENCE_PATTERN.exec(line);
if (fenceMatch !== null) {
const fence = fenceMatch[1]!;
// A fence closes only on the same marker, at least as long as the one that opened it,
// and with nothing after it: `~~~` cannot end a ``` block, and ```` ```ts ```` is an
// info string opening a nested fence, not a close.
const closes =
openFence !== null &&
fence[0] === openFence[0] &&
fence.length >= openFence.length &&
fenceMatch[2]!.trim().length === 0;
if (openFence === null) {
openFence = fence;
} else if (closes) {
openFence = null;
}
markdown.push(line);
continue;
}
if (openFence !== null || INDENTED_CODE_PATTERN.test(line)) {
markdown.push(line);
continue;
}
const bareAttachment = attachmentFromLine(line);
if (bareAttachment !== null) {
flushMarkdown();
segments.push({ id: `attachment:${segments.length}`, kind: "attachment", ...bareAttachment });
continue;
}
if (!STANDALONE_VIDEO_TAG_PATTERN.test(line)) {
markdown.push(line);
continue;
}
// A tag can span lines, so look ahead for its close — bounded, and without building the
// joined text until one is actually found.
const lastCandidate = Math.min(index + VIDEO_TAG_MAX_LINES, lines.length) - 1;
let cursor = index;
while (cursor < lastCandidate && !VIDEO_TAG_END_PATTERN.test(lines[cursor]!)) {
cursor += 1;
}
const source = VIDEO_TAG_END_PATTERN.test(lines[cursor]!)
? VIDEO_TAG_SRC_PATTERN.exec(lines.slice(index, cursor + 1).join("\n"))?.[1]
: undefined;
if (source !== undefined && isWebUrl(source)) {
flushMarkdown();
segments.push({
id: `attachment:${segments.length}`,
kind: "attachment",
url: source,
// The author wrote the tag, so this one is a video whatever the URL looks like.
media: "video",
});
index = cursor;
} else {
// Unclosed, or nothing linkable in it: prose. Only this line is consumed, so the
// lines that were looked at are still parsed on their own terms.
markdown.push(line);
}
}
flushMarkdown();
return segments;
}
const AUTOLINK_CANDIDATE_PATTERN = /#[1-9]\d*|[0-9a-f]{40}/giu;
const AUTOLINK_WORD_CHARACTER_PATTERN = /[A-Za-z0-9_]/u;
const AUTOLINK_COMMIT_PREFIX_PATTERN = /[\s([{]/u;
const AUTOLINK_IGNORED_TYPES = new Set(["link", "linkReference"]);
/** GitHub-style same-repository references for pull request prose, never code or authored links. */
export function remarkPullRequestAutolinks(options: { readonly repositoryUrl: string }) {
const repositoryUrl = options.repositoryUrl.replace(/\/+$/u, "");
return (tree: MarkdownNode) => {
findAndReplaceText(
tree,
AUTOLINK_CANDIDATE_PATTERN,
(matched: string, match: TextMatch) => {
const reference = matched.startsWith("#");
const before = match.input[match.index - 1];
const after = match.input[match.index + matched.length];
if (
(before !== undefined &&
(reference
? AUTOLINK_WORD_CHARACTER_PATTERN.test(before)
: !AUTOLINK_COMMIT_PREFIX_PATTERN.test(before))) ||
(after !== undefined && AUTOLINK_WORD_CHARACTER_PATTERN.test(after))
) {
return false;
}
return {
type: "link",
url: reference
? `${repositoryUrl}/issues/${matched.slice(1)}`
: `${repositoryUrl}/commit/${matched}`,
data: {
hProperties: {
dataPullRequestAutolink: reference ? "reference" : "commit",
},
},
children: [{ type: "text", value: reference ? matched : matched.slice(0, 7) }],
};
},
AUTOLINK_IGNORED_TYPES,
);
};
}