import { isCid, isDid } from "@atcute/lexicons/syntax"; export interface MarkupContext { /** `owner/repo` */ repo: string; ref: string; dir?: string; host?: string; camo?: boolean; sourceLines?: boolean; collapseCode?: 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 export const toHex = (value: string): string => Array.from(new TextEncoder().encode(value), (byte) => byte.toString(16).padStart(2, "0")).join( "" ); // a pds blob written into markdown as blob+at:///, independent of the // pds hostname. resolving the did needs the network, so the url points at the // route that does it const BLOB_URI = /^blob\+at:\/\/([^/]+)\/([^/?#]+)$/; const parseBlobUri = (url: string): { did: string; cid: string } | null => { const match = BLOB_URI.exec(url); if (!match) return null; const [, did, cid] = match; return isDid(did) && isCid(cid) ? { did, cid } : null; }; export const mediaUrl = (url: string, ctx: MarkupContext): string => { const blob = parseBlobUri(url); if (blob) return `/blob/${blob.did}/${blob.cid}`; 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(", ");