108 lines · 3.4 KB · TypeScript
RawDownload
export interface ParsedGitHubSourceUrl {  readonly owner: string;  readonly name: string;  readonly repo: string;  readonly ref?: string;  readonly url: string;} export type GitHubSourceUrlParseResult =  | { readonly ok: true; readonly value: ParsedGitHubSourceUrl }  | { readonly ok: false; readonly message: string }; const OWNER_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;const REPO_RE = /^[A-Za-z0-9_.-]+$/;const COMMIT_RE = /^[0-9a-f]{7,40}$/i;const URL_RE = /^https?:\/\/([^/?#]+)(?:\/([^?#]*))?$/i; const trimSlashes = (value: string): string => value.replace(/^\/+|\/+$/g, ""); const normalizeRepoName = (value: string): string =>  value.endsWith(".git") ? value.slice(0, -4) : value; const invalidShape =  "Use a GitHub repo URL like https://github.com/owner/repo, optionally with /tree/<ref> or /commit/<sha>."; const validateOwnerRepo = (  owner: string | undefined,  nameInput: string | undefined,): { readonly owner: string; readonly name: string } | null => {  const name = nameInput ? normalizeRepoName(nameInput) : "";  if (!owner || !name) return null;  if (!OWNER_RE.test(owner) || !REPO_RE.test(name)) return null;  return { owner, name };}; const canonicalUrl = (  owner: string,  name: string,  kind: "base" | "tree" | "commit",  ref?: string,): string => {  const base = `https://github.com/${owner}/${name}`;  if (!ref || kind === "base") return base;  return `${base}/${kind}/${ref}`;}; export const parseGitHubSourceUrl = (  input: string,  options?: { readonly ref?: string | undefined },): GitHubSourceUrlParseResult => {  const trimmed = input.trim();  const overrideRef = options?.ref?.trim();  if (trimmed.length === 0) return { ok: false, message: "Enter a GitHub URL." };   const urlMatch = URL_RE.exec(trimmed);  const path = urlMatch ? trimSlashes(urlMatch[2] ?? "") : trimSlashes(trimmed);  if (urlMatch && urlMatch[1]?.toLowerCase() !== "github.com") {    return { ok: false, message: "GitHub source URLs must use github.com." };  }  if (!urlMatch && /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed)) {    return { ok: false, message: "GitHub source URLs must use github.com." };  }   const segments = path.split("/").filter((segment) => segment.length > 0);  const validated = validateOwnerRepo(segments[0], segments[1]);  if (!validated) return { ok: false, message: invalidShape };  const { owner, name } = validated;  const repo = `${owner}/${name}`;   if (segments.length === 2) {    const ref = overrideRef || undefined;    return {      ok: true,      value: {        owner,        name,        repo,        ...(ref ? { ref } : {}),        url: canonicalUrl(owner, name, ref ? "tree" : "base", ref),      },    };  }   const route = segments[2];  if (route === "tree") {    const ref = overrideRef || segments.slice(3).join("/");    if (!ref)      return { ok: false, message: "GitHub tree URLs must include a branch, tag, or commit SHA." };    return {      ok: true,      value: { owner, name, repo, ref, url: canonicalUrl(owner, name, "tree", ref) },    };  }   if (route === "commit") {    const sourceRef = segments.length === 4 ? segments[3] : "";    const ref = overrideRef || sourceRef;    if (!ref || !COMMIT_RE.test(ref)) {      return { ok: false, message: "GitHub commit URLs must include a commit SHA." };    }    return {      ok: true,      value: { owner, name, repo, ref, url: canonicalUrl(owner, name, "commit", ref) },    };  }   return { ok: false, message: invalidShape };};