From 9a89fed365a61c14b3427f8663b531fb5a443a50 Mon Sep 17 00:00:00 2001 From: devteamaegis Date: Tue, 30 Jun 2026 00:52:09 -0400 Subject: [PATCH] feat(compiler): Lottie in multi-page output + materialize inline animationData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two follow-ups to the single-page Lottie support: 1. Multi-page (clone-site). generateSiteApp emitted only DittoWire; it now wires DittoLottie per route exactly like wires — builds the route's lottie spec, emits the import + JSX, writes DittoLottie.tsx once, and injects lottie-web into the site package.json when any route replays an animation (the write moved after the route loop so the dep reflects actual usage). cloneSite now captures motion on full route captures (motion: full) so lotties are observed; light sibling probes skip it. Per-route gating verified: a route with no animation gets no DittoLottie. 2. Inline animationData. When a lottie is only in memory (data-driven init, no fetchable URL), capture now writes the JSON to the assets store as a real, content-hashed .json so it materializes to /assets/cloned/lottie like any source — instead of embedding the blob in the page spec. buildLottieSpec then resolves it to a local path; the spec carries path, not animationData. Verified end-to-end: a 2-page fixture emits DittoLottie only on the animated route with lottie-web added; a data-driven inline fixture materializes fd7c…json and the spec references it by path. Co-Authored-By: claude-flow --- compiler/src/capture/capture.ts | 15 ++++++++++++++- compiler/src/site/cloneSite.ts | 2 +- compiler/src/site/generateSite.ts | 23 ++++++++++++++++++----- 3 files changed, 33 insertions(+), 7 deletions(-) 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 };