import { join } from "node:path"; import { copyFileSync } from "node:fs"; import { ensureDir, fileExists } from "../util/fsx.js"; import type { CaptureResult, DiscoveredAsset } from "../capture/capture.js"; /** * Asset graph. Maps every discovered source asset URL to a deterministic local * path under the generated app's public dir, or records why it was skipped * (rubric Gate 2: every reference must be classified, none may 404, and none may * point back to a remote origin unless explicitly external-allowed). */ export type AssetClassification = "downloaded" | "skipped"; export type AssetEntry = { sourceUrl: string; type: string; classification: AssetClassification; localPath: string | null; // public-relative URL, e.g. /assets/cloned/images/ab.png storedFile: string | null; bytes: number; reason: string | null; impact: string | null; via: string[]; }; export type AssetGraph = { entries: AssetEntry[]; byUrl: Map; }; const TYPE_DIR: Record = { image: "images", svg: "svg", video: "videos", font: "fonts", lottie: "lottie", css: "css", manifest: "manifest", other: "other", }; function publicPathFor(type: string, storedFile: string): string { const dir = TYPE_DIR[type] ?? "other"; return `/assets/cloned/${dir}/${storedFile}`; } export function buildAssetGraph(capture: CaptureResult): AssetGraph { const entries: AssetEntry[] = []; const byUrl = new Map(); for (const a of capture.assets) { const entry = classify(a); entries.push(entry); byUrl.set(a.url, entry); } entries.sort((x, y) => x.sourceUrl.localeCompare(y.sourceUrl)); return { entries, byUrl }; } function classify(a: DiscoveredAsset): AssetEntry { // CSS is consumed by the compiler (font/url extraction), not referenced by the // generated app directly, so it is neither downloaded-as-asset nor a gap. const base: AssetEntry = { sourceUrl: a.url, type: a.type, classification: "skipped", localPath: null, storedFile: null, bytes: a.bytes, reason: null, impact: null, via: a.via.slice().sort(), }; if (a.storedAs && a.bytes > 0) { return { ...base, classification: "downloaded", localPath: publicPathFor(a.type, a.storedAs), storedFile: a.storedAs, }; } // Not stored — record a deterministic skip reason. let reason = "not_downloaded"; if (a.status && a.status >= 400) reason = `http_${a.status}`; else if (a.status === null && a.via.every((v) => v.startsWith("css") || v === "css-url")) reason = "css_referenced_unfetched"; return { ...base, reason, impact: a.type === "image" || a.type === "video" ? "visual_missing" : "minor", }; } /** Copy downloaded asset bytes into the generated app's public dir. */ export function materializeAssets(graph: AssetGraph, sourceDir: string, appPublicDir: string, seenPublicPaths?: Set): { copied: number; missing: string[] } { const storeDir = join(sourceDir, "assets-store"); const cssDir = join(sourceDir, "capture", "css"); let copied = 0; const missing: string[] = []; for (const e of graph.entries) { if (e.classification !== "downloaded" || !e.storedFile || !e.localPath) continue; if (e.type === "css") continue; // not served by the app // Video files ARE copied: generation emits a local