Files
ditto.site/compiler/src/infer/assets.ts
T
Samraaj BathandClaude Fable 5 9aed2540aa Clone-fidelity fix waves 1-3 (+ wave 4 in flight): capture settling, media, iframes
Fixes from the cropin.com / ooni.com audit reviews:
- walker: preserve whitespace-only text in inline elements ("ofthe" bug);
  capture ::placeholder styles for inputs
- css: emit visibility (hidden-at-rest wordmark artifact); exempt list
  markers from inherited elision (lost bullets); ::placeholder rules
- seo: sanitize og:type to Next's enum (render crash -> dev badge leak);
  generated next.config disables devIndicators
- capture/stabilize: promote lazy-loader data attrs before snapshots
  (collapsed sections, viewport-inconsistent captures); settle autoplay
  carousels to home slide before every snapshot; force-reveal hidden
  videos for poster shots + log failures
- generate: ship downloaded video files as local <video src>; keep
  picture>source through IR prune with srcset rewrite (mobile-crop-on-
  desktop heroes); pin Tailwind named screens to computeBands boundaries
- capture/graft: capture cross-origin iframe subtrees (Klaviyo signup
  forms) with element-screenshot fallback; asset download retry +
  visual-assets-missing reporting

Checkpoint commit: wave 4 (reveal settling, 206 range-response fix,
hidden-geometry banding, bare-zero utility gating) is mid-implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 17:45:42 -07:00

114 lines
3.9 KiB
TypeScript

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<string, AssetEntry>;
};
const TYPE_DIR: Record<string, string> = {
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<string, AssetEntry>();
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<string>): { 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 <video src>/<source src>
// whenever the file materialized (only streaming/undownloaded videos fall back
// to poster-only rendering).
if (seenPublicPaths?.has(e.localPath)) continue;
const src = join(storeDir, e.storedFile);
const from = fileExists(src) ? src : join(cssDir, e.storedFile);
if (!fileExists(from)) { missing.push(e.sourceUrl); continue; }
const dest = join(appPublicDir, e.localPath.replace(/^\//, ""));
ensureDir(join(dest, ".."));
copyFileSync(from, dest);
seenPublicPaths?.add(e.localPath);
copied++;
}
return { copied, missing };
}