export interface MarkupContext { /** `owner/repo` */ repo: string; ref: string; dir?: string; host?: string; camo?: boolean; } const ABSOLUTE = /^[a-z][a-z0-9+.-]*:|^\/\//i; export const isAbsoluteUrl = (url: string): boolean => ABSOLUTE.test(url); export const isRepoRelative = (url: string): boolean => url !== "" && !isAbsoluteUrl(url) && !url.startsWith("#"); // `.` and `..` have to collapse here instead of reaching the url const normalize = (path: string): string => { const parts: string[] = []; for (const part of path.split("/")) { if (part === "" || part === ".") continue; if (part === "..") parts.pop(); else parts.push(part); } return parts.join("/"); }; const withinRepo = (path: string, ctx: MarkupContext): string => normalize(path.startsWith("/") ? path : `${ctx.dir ?? ""}/${path}`); const splitSuffix = (url: string): [string, string] => { const at = url.search(/[?#]/); return at === -1 ? [url, ""] : [url.slice(0, at), url.slice(at)]; }; // markdown-it percent-encodes destinations before we ever see them, so only the // ref still needs encoding const repoUrl = (kind: string, url: string, ctx: MarkupContext): string => { const [path, suffix] = splitSuffix(url); if (path === "") return url; const ref = encodeURIComponent(ctx.ref); return `/${ctx.repo}/${kind}/${ref}/${withinRepo(path, ctx)}${suffix}`; }; export const treeUrl = (url: string, ctx: MarkupContext): string => repoUrl("tree", url, ctx); export const rawUrl = (url: string, ctx: MarkupContext): string => repoUrl("raw", url, ctx); const hostOf = (url: string): string | null => { try { // the base only matters for protocol relative urls, which have a host anyway return new URL(url, "https://invalid.").host; } catch { return null; } }; // camo wants the target hex encoded const toHex = (value: string): string => Array.from(new TextEncoder().encode(value), (byte) => byte.toString(16).padStart(2, "0")).join( "" ); export const mediaUrl = (url: string, ctx: MarkupContext): string => { if (isRepoRelative(url)) return rawUrl(url, ctx); if (!ctx.camo || !isAbsoluteUrl(url) || hostOf(url) === ctx.host) return url; return `/camo/${toHex(url)}`; }; export const mediaSrcset = (srcset: string, ctx: MarkupContext): string => srcset .split(",") .map((candidate) => { const [url, ...descriptors] = candidate.trim().split(/\s+/); return [mediaUrl(url, ctx), ...descriptors].join(" "); }) .join(", ");