feat(compiler): Lottie in multi-page output + materialize inline animationData

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 <ruv@ruv.net>
This commit is contained in:
devteamaegis
2026-06-30 00:52:09 -04:00
co-authored by claude-flow
parent 048fd8cdad
commit 9a89fed365
3 changed files with 33 additions and 7 deletions
+18 -5
View File
@@ -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<string, number> };
const materializedAssets = new Set<string>();
@@ -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 };