diff --git a/compiler/src/capture/capture.ts b/compiler/src/capture/capture.ts index 3412d7e..da15fca 100644 --- a/compiler/src/capture/capture.ts +++ b/compiler/src/capture/capture.ts @@ -829,7 +829,20 @@ export async function captureSite(opts: { // Register lottie source JSONs as assets so the asset stage downloads + materializes // them (the in-page detector only records the URL; the fallback fetch grabs the file). for (const l of motion?.lotties ?? []) { - if (l.src) recordAsset(l.src, "lottie", null, null, "lottie"); + if (l.src) { recordAsset(l.src, "lottie", null, null, "lottie"); continue; } + // Inline animationData (no fetchable URL): store it as a real .json asset so it + // materializes to /assets/cloned/lottie like any other source, instead of being + // embedded in the page spec. Content-hashed so output stays deterministic. + if (l.inlineKey && motion?.lottieInline) { + const data = motion.lottieInline[l.inlineKey]; + if (data === undefined) continue; + const buf = Buffer.from(JSON.stringify(data), "utf8"); + const synthUrl = `ditto-inline:/lottie/${sha1_12(buf.toString("utf8"))}.json`; + recordAsset(synthUrl, "lottie", "application/json", 200, "lottie-inline"); + storeBytes(synthUrl, "lottie", buf); + l.src = synthUrl; // now resolves to a local path through the asset graph + l.inlineKey = null; + } } } diff --git a/compiler/src/site/cloneSite.ts b/compiler/src/site/cloneSite.ts index 2803da2..455dd2e 100644 --- a/compiler/src/site/cloneSite.ts +++ b/compiler/src/site/cloneSite.ts @@ -128,7 +128,7 @@ export async function runCloneSite(opts: CloneSiteOptions): Promise {} }); + const capture = await captureSite({ url, outDir: sourceDir, viewports, interactions: opts.interactions && full, motion: full, screenshots, log: () => {} }); const ir = buildIR(sourceDir, viewports); const assetGraph = buildAssetGraph(capture); const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url); diff --git a/compiler/src/site/generateSite.ts b/compiler/src/site/generateSite.ts index 5e15a40..cebb63a 100644 --- a/compiler/src/site/generateSite.ts +++ b/compiler/src/site/generateSite.ts @@ -14,7 +14,8 @@ import { writeText, readJSON, fileExists } from "../util/fsx.js"; import { generateCss, RESET_CSS } from "../generate/css.js"; import { generateInteractionCss } from "../generate/interactionCss.js"; import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js"; -import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js"; +import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js"; +import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, injectLottieDep, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js"; import { buildTailwind, tailwindGlobalsCss, createColorInterner, colorDefsCssOf, type TailwindOutput } from "../generate/tailwind.js"; import type { InteractionCapture } from "../capture/interactions.js"; import type { IRChild } from "../normalize/ir.js"; @@ -267,7 +268,8 @@ export function generateSiteApp(opts: { }; // Shared scaffold (written once). - writeText(join(appDir, "package.json"), isVite ? (tw ? PACKAGE_JSON_VITE_TW : PACKAGE_JSON_VITE) : (tw ? PACKAGE_JSON_TW : PACKAGE_JSON)); + // package.json is written after the route loop (below) so the lottie-web dependency can be + // added when any route actually replays a Lottie animation. writeText(join(appDir, "tsconfig.json"), isVite ? TSCONFIG_JSON_VITE : TSCONFIG_JSON); writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n"); if (isVite) { @@ -325,6 +327,7 @@ export function generateSiteApp(opts: { let assetsCopied = 0; let assetsMissing = 0; let anyWires = false; + let anyLottie = false; const outRoutes: SiteGenResult["routes"] = []; const components = { count: 0, byType: {} as Record }; const materializedAssets = new Set(); @@ -368,6 +371,12 @@ export function generateSiteApp(opts: { const wireImport = wires.length ? `import DittoWire from "${dittoWireImportPath(depth)}";\n` : ""; const wireBody = wires.length ? "\n" + wiresJsx(wires, 3) : ""; if (wires.length) anyWires = true; + // Lottie replay for this route (third-party JSON animations). Same per-route shape as wires. + const lottieSpec = buildLottieSpec(route.ir, route.capture?.motion, route.assetGraph, include); + const hasLottie = lottieHasContent(lottieSpec); + const lottieImport = hasLottie ? `import DittoLottie from "${dittoLottieImportPath(depth)}";\n` : ""; + const lottieBody = hasLottie ? "\n" + lottieWireJsx(lottieSpec, 3) : ""; + if (hasLottie) anyLottie = true; const rKey = routeKey(route.routePath); const routeDir = isVite ? join(appDir, "src", "routes", rKey) @@ -379,8 +388,8 @@ export function generateSiteApp(opts: { const compImport = componentImports(routeReg, 0); // route-local → ./components/Name const dataDecls = componentDataDecls(routeReg); const preBlock = dataDecls ? dataDecls + "\n\n" : ""; - const importLines = [wireImport.trimEnd(), compImport].filter(Boolean).join("\n"); - const pageTsx = `${isVite ? "" : 'import "./ditto.css";\n'}${importLines ? importLines + "\n" : ""}// Generated by clone-site. Do not edit by hand.\n${preBlock}export default function Page() {\n return (\n <>\n${bodyJsx}${wireBody} + const importLines = [wireImport.trimEnd(), lottieImport.trimEnd(), compImport].filter(Boolean).join("\n"); + const pageTsx = `${isVite ? "" : 'import "./ditto.css";\n'}${importLines ? importLines + "\n" : ""}// Generated by clone-site. Do not edit by hand.\n${preBlock}export default function Page() {\n return (\n <>\n${bodyJsx}${wireBody}${lottieBody} ); } @@ -409,6 +418,10 @@ export function generateSiteApp(opts: { outRoutes.push({ routePath: route.routePath, href, dir, nodeCount: route.ir.doc.nodeCount }); } if (anyWires) writeText(join(appDir, "src", isVite ? "ditto" : join("app", "ditto"), "DittoWire.tsx"), DITTO_WIRE_TSX); + if (anyLottie) writeText(join(appDir, "src", isVite ? "ditto" : join("app", "ditto"), "DittoLottie.tsx"), DITTO_LOTTIE_TSX); + // Deferred package.json — inject lottie-web only when a route replays a Lottie animation. + const sitePkg = isVite ? (tw ? PACKAGE_JSON_VITE_TW : PACKAGE_JSON_VITE) : (tw ? PACKAGE_JSON_TW : PACKAGE_JSON); + writeText(join(appDir, "package.json"), anyLottie ? injectLottieDep(sitePkg) : sitePkg); if (isVite) { writeText(join(appDir, "vite.config.ts"), generateViteConfig(viteEntries)); for (const [rel, body] of seoStaticFiles(seoInventory, seoRoutes)) writeText(join(appDir, "public", rel), body); @@ -442,7 +455,7 @@ export function generateSiteApp(opts: { componentCount: extracted.chrome.length + extracted.routes.reduce((sum, route) => sum + route.components.length, 0), svgCount: 0, hasContentModule: false, - runtimeUtilities: anyWires ? ["DittoWire"] : [], + runtimeUtilities: [...(anyWires ? ["DittoWire"] : []), ...(anyLottie ? ["DittoLottie"] : [])], }); return { routes: outRoutes, assetsCopied, assetsMissing, components, extracted, seoInventory };