import { parse, type HTMLElement } from "node-html-parser"; // A local resource referenced by the HTML that must be copied into the // bundle. `originalRef` is the reference verbatim as it appears in the // source (relative to the HTML file); `bundlePath` is where it lands in // the self-contained bundle (always under `assets/`). export interface AssetRef { originalRef: string; bundlePath: string; } export interface RewriteResult { html: string; assets: AssetRef[]; } const ASSETS_DIR = "assets"; // Refs we never bundle: absolute URLs, protocol-relative, data/blob, // in-page anchors, and non-navigational schemes. Everything else is a // workspace-relative path the bundle must localize. Root-absolute // (`/foo`) is left alone too — without a known base it can't be mapped. function isLocalRef(ref: string): boolean { const value = ref.trim(); if (value === "") return false; if (value.startsWith("#") || value.startsWith("//") || value.startsWith("/")) return false; return !/^[a-z][a-z0-9+.-]*:/i.test(value); } function stripQueryHash(ref: string): string { const cut = ref.search(/[?#]/); return cut === -1 ? ref : ref.slice(0, cut); } // Safe single-segment filename for a bundle entry. Splits on BOTH path // separators — a crafted `..\..\evil.png` must not survive into a zip // entry name, where a Windows unzip tool treats `\` as a separator // (zip-slip). `.`/`..`/empty collapse to a neutral name. function safeAssetName(ref: string): string { const segment = stripQueryHash(ref).split(/[/\\]/).pop() ?? ""; return segment === "" || segment === "." || segment === ".." ? "asset" : segment; } function splitSuffix(ref: string): { filePath: string; suffix: string } { const cut = ref.search(/[?#]/); return cut === -1 ? { filePath: ref, suffix: "" } : { filePath: ref.slice(0, cut), suffix: ref.slice(cut) }; } // Maps a local ref to its `assets/` slot. The bundled FILE is // keyed by the path with `?query`/`#fragment` stripped, so `a.png?v=1` // and `a.png?v=2` share one copy; but the rewritten URL keeps each // ref's suffix (an SVG `sprite.svg#icon` fragment must survive). A // basename collision across different dirs is disambiguated by a short // hash of the file path so two `logo.png` don't clobber each other. function createAssetMapper() { const byPath = new Map(); const usedNames = new Set(); const assets: AssetRef[] = []; const hash = (value: string): string => { let acc = 0; for (const char of value) acc = (acc * 31 + char.charCodeAt(0)) | 0; return (acc >>> 0).toString(36); }; const bundlePathFor = (filePath: string): string => { const existing = byPath.get(filePath); if (existing) return existing; const base = safeAssetName(filePath); const name = usedNames.has(base) ? `${hash(filePath)}-${base}` : base; usedNames.add(name); const bundlePath = `${ASSETS_DIR}/${name}`; byPath.set(filePath, bundlePath); assets.push({ originalRef: filePath, bundlePath }); return bundlePath; }; const map = (originalRef: string): string => { const { filePath, suffix } = splitSuffix(originalRef); return bundlePathFor(filePath) + suffix; }; return { map, assets }; } function rewriteCssUrls(css: string, map: (ref: string) => string): string { return css.replace(/url\(([^)]*)\)/gi, (whole, inner: string) => { const raw = inner.trim(); const quote = raw.startsWith('"') || raw.startsWith("'") ? raw[0] : ""; const ref = quote ? raw.slice(1, -1) : raw; if (!isLocalRef(ref)) return whole; return `url(${quote}${map(ref)}${quote})`; }); } function isSrcsetWs(char: string | undefined): boolean { return char === " " || char === "\t" || char === "\n" || char === "\r" || char === "\f"; } // One srcset candidate: a URL (a run of non-whitespace, so a data: URI's // internal commas stay intact) whose trailing commas are separators, then // an optional descriptor up to the next comma. Only local URLs are // rewritten; everything else is emitted verbatim. Returns the new cursor. function rewriteSrcsetCandidate(value: string, start: number, map: (ref: string) => string, out: string[]): number { let index = start; while (index < value.length && !isSrcsetWs(value[index])) index++; let url = value.slice(start, index); let trailing = ""; while (url.endsWith(",")) { url = url.slice(0, -1); trailing += ","; } out.push(isLocalRef(url) ? map(url) : url, trailing); if (trailing !== "") return index; const descStart = index; while (index < value.length && value[index] !== ",") index++; out.push(value.slice(descStart, index)); return index; } // WHATWG-style srcset walk: candidates are comma-separated, but because a // URL is a non-whitespace run, `data:` URIs (whose payload contains commas) // are never split. Separators / whitespace / descriptors are preserved. function rewriteSrcset(value: string, map: (ref: string) => string): string { const out: string[] = []; let index = 0; while (index < value.length) { const sepStart = index; while (index < value.length && (isSrcsetWs(value[index]) || value[index] === ",")) index++; out.push(value.slice(sepStart, index)); if (index < value.length) index = rewriteSrcsetCandidate(value, index, map, out); } return out.join(""); } // Embedded resources only. `a[href]` is intentionally excluded — it is // navigation, not an inlined asset, and rewriting it would point at a // file the single-page bundle doesn't carry. const URL_ATTRS: readonly { selector: string; attr: string }[] = [ { selector: "img[src]", attr: "src" }, { selector: "script[src]", attr: "src" }, { selector: "source[src]", attr: "src" }, { selector: "link[href]", attr: "href" }, { selector: "audio[src]", attr: "src" }, { selector: "video[src]", attr: "src" }, { selector: "video[poster]", attr: "poster" }, { selector: "use[href]", attr: "href" }, ]; function rewriteAttrs(root: HTMLElement, map: (ref: string) => string): void { for (const { selector, attr } of URL_ATTRS) { for (const element of root.querySelectorAll(selector)) { const ref = element.getAttribute(attr); if (ref && isLocalRef(ref)) element.setAttribute(attr, map(ref)); } } for (const element of root.querySelectorAll("[srcset]")) { const value = element.getAttribute("srcset"); if (value) element.setAttribute("srcset", rewriteSrcset(value, map)); } for (const element of root.querySelectorAll("style")) { element.set_content(rewriteCssUrls(element.textContent, map)); } for (const element of root.querySelectorAll("[style]")) { const value = element.getAttribute("style"); if (value) element.setAttribute("style", rewriteCssUrls(value, map)); } } // Rewrites every local resource reference in `html` to point at a // co-located `assets/`, returning the rewritten document plus the // list of refs to copy. Pure: no filesystem or network. export function rewriteHtmlAssets(html: string): RewriteResult { const root = parse(html, { comment: true }); const mapper = createAssetMapper(); rewriteAttrs(root, mapper.map); return { html: root.toString(), assets: mapper.assets }; }