192 lines · 7.5 KB · TypeScript
RawDownload
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 `<img>` 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 `<video>` 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 `<img>` 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*<video\b/iu;const VIDEO_TAG_END_PATTERN = /<\/video>\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: `<video>` 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 * `<video>` (or `<source>`) 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<PullRequestBodySegment> {  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,    );  };}