Emit a flat preview.html at generate time for instant first-page display
A self-contained, runtime-free render of the entry page, built directly from the IR using the SAME decision code as the app: collectNodeRules/ assembleCss for per-node banded CSS, propsList/resolveTag for tags, attributes, and asset mapping - so the preview cannot drift from what the built app resolves to. No build toolchain involved (~2s, pure string building, byte-stable). Lottie/video render as captured poster stills; zero script tags; asset paths relative to the app dir so the file serves from the artifact file map as-is. Multi-page runs emit the entry page only. The manifest records preview_html so consumers can key on the generated event, show the preview immediately, and swap to the deployed app when the build finishes. Consistency vs the built app's own 1280px renders: 0.8-1.8% pixel diff on reference runs (5.3% on an animation-heavy page - the frozen-animation class, as expected). 520 tests pass (5 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
c35680eb13
commit
01e59d96bd
@@ -14,6 +14,7 @@ import type { FontGraph } from "../infer/fonts.js";
|
|||||||
import { SYSTEM_FALLBACK } from "../infer/fonts.js";
|
import { SYSTEM_FALLBACK } from "../infer/fonts.js";
|
||||||
import { detectComponents, type ComponentPlan, type ComponentCluster } from "../infer/components.js";
|
import { detectComponents, type ComponentPlan, type ComponentCluster } from "../infer/components.js";
|
||||||
import { buildClassMap } from "./classMap.js";
|
import { buildClassMap } from "./classMap.js";
|
||||||
|
import { generatePreviewHtml } from "./preview.js";
|
||||||
import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js";
|
import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js";
|
||||||
import { planSections, type SectionPlan } from "./sectionSplit.js";
|
import { planSections, type SectionPlan } from "./sectionSplit.js";
|
||||||
import type { RecipeReport } from "../infer/recipes.js";
|
import type { RecipeReport } from "../infer/recipes.js";
|
||||||
@@ -2659,7 +2660,7 @@ export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | und
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[] } {
|
export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[]; previewHtml: string } {
|
||||||
const { ir, assetGraph, fontGraph, appDir, sourceUrl } = input;
|
const { ir, assetGraph, fontGraph, appDir, sourceUrl } = input;
|
||||||
const assetMap = buildAssetMap(assetGraph);
|
const assetMap = buildAssetMap(assetGraph);
|
||||||
const framework = input.framework ?? "next";
|
const framework = input.framework ?? "next";
|
||||||
@@ -2789,6 +2790,16 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
|||||||
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
|
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
|
||||||
}
|
}
|
||||||
writeText(join(rootDir, "ditto.css"), cloneCss);
|
writeText(join(rootDir, "ditto.css"), cloneCss);
|
||||||
|
// Fast, self-contained static preview (runtime-free single HTML file) the service can
|
||||||
|
// render within seconds of `generate`, BEFORE the Next build + deploy completes. Emitted
|
||||||
|
// at generated/app/preview.html (assets relative to public/); shares the same IR + css.ts
|
||||||
|
// rule/banding collectors and the same propsList/resolveTag decision code as the app,
|
||||||
|
// so it shows what the built app will show without a second maintained emission path.
|
||||||
|
const previewPath = "preview.html";
|
||||||
|
writeText(join(appDir, previewPath), generatePreviewHtml({
|
||||||
|
ir, assetMap, fontGraph, tokensCss, sourceUrl,
|
||||||
|
colorVar: input.colorVar, tokenResolver: input.tokenResolver,
|
||||||
|
}));
|
||||||
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
|
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
|
||||||
// SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files).
|
// SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files).
|
||||||
if (framework === "next") writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE);
|
if (framework === "next") writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE);
|
||||||
@@ -2830,7 +2841,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [] };
|
return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [], previewHtml: previewPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add lottie-web to a generated package.json's dependencies — DittoLottie imports it at
|
/** Add lottie-web to a generated package.json's dependencies — DittoLottie imports it at
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ export function buildManifest(args: {
|
|||||||
capture: CaptureResult;
|
capture: CaptureResult;
|
||||||
componentCount: number;
|
componentCount: number;
|
||||||
patternHints?: PatternHints;
|
patternHints?: PatternHints;
|
||||||
|
// Relative path (within generated/app) of the static preview artifact, when emitted.
|
||||||
|
// Lets the service/client show the cloned page from the file map BEFORE the Next build
|
||||||
|
// + deploy finishes, then swap to the deployed app. A fixed string — no timestamps.
|
||||||
|
previewHtml?: string;
|
||||||
}): Record<string, unknown> {
|
}): Record<string, unknown> {
|
||||||
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount, patternHints } = args;
|
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount, patternHints, previewHtml } = args;
|
||||||
|
|
||||||
const byType: Record<string, number> = {};
|
const byType: Record<string, number> = {};
|
||||||
let downloaded = 0, skipped = 0;
|
let downloaded = 0, skipped = 0;
|
||||||
@@ -58,6 +62,9 @@ export function buildManifest(args: {
|
|||||||
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
|
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
|
||||||
},
|
},
|
||||||
components: { count: componentCount },
|
components: { count: componentCount },
|
||||||
|
// Static preview artifact (runtime-free, single HTML file) the client can render
|
||||||
|
// immediately from the file map. Omitted when not emitted.
|
||||||
|
...(previewHtml ? { preview_html: previewHtml } : {}),
|
||||||
// Frozen-catalog pattern evidence (hint-only, additive): library/platform fingerprints
|
// Frozen-catalog pattern evidence (hint-only, additive): library/platform fingerprints
|
||||||
// detected in the IR, with the node cids that carried each signature (bounded pre-order
|
// detected in the IR, with the node cids that carried each signature (bounded pre-order
|
||||||
// sample). Deterministic — matches are id-sorted and cids are pre-order, so the same
|
// sample). Deterministic — matches are id-sorted and cids are pre-order, so the same
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export function generateAll(opts: {
|
|||||||
const codeQuality = buildCodeQualityReport(appDir, recipeReport);
|
const codeQuality = buildCodeQualityReport(appDir, recipeReport);
|
||||||
writeJSON(join(outDir, "code-quality.json"), codeQuality);
|
writeJSON(join(outDir, "code-quality.json"), codeQuality);
|
||||||
writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality));
|
writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality));
|
||||||
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count, patternHints });
|
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count, patternHints, previewHtml: gen.previewHtml });
|
||||||
writeJSON(join(outDir, "manifest.json"), manifest);
|
writeJSON(join(outDir, "manifest.json"), manifest);
|
||||||
|
|
||||||
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, patternHints, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, patternHints, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { isTextChild, type IR, type IRNode, type IRChild } from "../normalize/ir.js";
|
||||||
|
import type { FontGraph } from "../infer/fonts.js";
|
||||||
|
import { SYSTEM_FALLBACK } from "../infer/fonts.js";
|
||||||
|
import { collectNodeRules, keyframesCss, computeBands, assembleCss, RESET_CSS } from "./css.js";
|
||||||
|
import type { TokenResolver } from "../infer/tokens.js";
|
||||||
|
import { propsList, resolveTag, resolveHtmlBg, htmlBgRule } from "./app.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast, self-contained static preview artifact (`preview.html`).
|
||||||
|
*
|
||||||
|
* Product moment: the service shows the cloned page within seconds of `generate`
|
||||||
|
* finishing — BEFORE the Next.js build + deploy completes — then swaps to the
|
||||||
|
* deployed app. This artifact renders WITHOUT any build toolchain (no next / tailwind
|
||||||
|
* / esbuild): it is pure string building over the same frozen IR + rules the JSX
|
||||||
|
* emitter consumes.
|
||||||
|
*
|
||||||
|
* SHARED DECISION CODE (not a parallel emission path):
|
||||||
|
* - CSS: `collectNodeRules` + `keyframesCss` + `computeBands` + `assembleCss` from
|
||||||
|
* css.ts — the identical per-node rule collection + media-query banding the semantic
|
||||||
|
* class-map emitter (classMap.ts) and the legacy per-node emitter (generateCss) use.
|
||||||
|
* We emit `.c<id>` selectors, exactly like `generateCss`, so the preview's computed
|
||||||
|
* styles are byte-for-byte the same rules the built app resolves to. Crucially this
|
||||||
|
* holds even though the app's DEFAULT output is Tailwind (utilities in the JSX, no
|
||||||
|
* `.c<id>` rules) — the preview reuses the css-mode rule collector directly.
|
||||||
|
* - HTML: `propsList` + `resolveTag` from app.ts — the SAME tag resolution, attribute
|
||||||
|
* filtering, asset-URL mapping (src/poster/srcset), video/lottie poster stills, and
|
||||||
|
* class assignment the JSX renderer uses. We translate the JSX-flavoured attribute
|
||||||
|
* pairs to raw HTML attributes; we do NOT re-derive which attrs/assets to keep.
|
||||||
|
*
|
||||||
|
* Runtime-free: no DittoWire / DittoLottie / DittoMotion scripts. Lottie mounts and
|
||||||
|
* <video> render as their captured poster/first-frame stills (the still is already
|
||||||
|
* baked into the node's `poster`/`src` attr by capture + the asset pipeline, so
|
||||||
|
* `propsList` emits it for us). Animations are frozen at their captured start state.
|
||||||
|
*
|
||||||
|
* Determinism (sacred): derived purely from the IR + rules + asset map; no timestamps,
|
||||||
|
* no randomness. Two runs over the same frozen capture emit byte-identical HTML.
|
||||||
|
*
|
||||||
|
* Asset paths: the asset map yields root-absolute `/assets/cloned/...` (public/ served
|
||||||
|
* at the app root under Next). preview.html physically sits at generated/app/preview.html
|
||||||
|
* with assets under generated/app/public/assets/cloned/..., so we rewrite the leading
|
||||||
|
* `/assets/` to `public/assets/` — a relative path that resolves both when the file is
|
||||||
|
* opened at the generated app-dir root and when it is fetched alongside the file map.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
||||||
|
|
||||||
|
// Inverse of app.ts's ATTR_RENAME (HTML → React). The JSX `propsList` renames HTML
|
||||||
|
// attribute names to their React spelling (for → htmlFor, class → className, …); the
|
||||||
|
// preview emits raw HTML, so map them back. Anything not listed is already an HTML name.
|
||||||
|
const REACT_TO_HTML: Record<string, string> = {
|
||||||
|
htmlFor: "for", className: "class", srcSet: "srcset", colSpan: "colspan",
|
||||||
|
rowSpan: "rowspan", dateTime: "datetime", itemProp: "itemprop", hrefLang: "hreflang",
|
||||||
|
autoPlay: "autoplay", playsInline: "playsinline", readOnly: "readonly",
|
||||||
|
maxLength: "maxlength", crossOrigin: "crossorigin", noValidate: "novalidate",
|
||||||
|
tabIndex: "tabindex", contentEditable: "contenteditable", defaultChecked: "checked",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rewrite the asset map's root-absolute `/assets/…` refs to the preview's relative
|
||||||
|
* `public/assets/…` layout (preview.html at generated/app/, assets at generated/app/public/).
|
||||||
|
* Applied to both inline CSS (url(/assets/…)) and HTML attribute values. Pure string op. */
|
||||||
|
function relativizeAssets(s: string): string {
|
||||||
|
return s.replace(/\/assets\//g, "public/assets/");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeAttr(v: string): string {
|
||||||
|
return v.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(raw: string): string {
|
||||||
|
return raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse a text run under white-space:normal the way CSS renders it (mirrors app.ts's
|
||||||
|
* collapseWs): every whitespace run → a single space. Verbatim under pre/pre-wrap etc. */
|
||||||
|
function collapseWs(raw: string): string {
|
||||||
|
return raw.replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function preservesWhitespace(n: IRNode, canonical: number): boolean {
|
||||||
|
const ws = (n.computedByVp[canonical] ?? Object.values(n.computedByVp)[0])?.whiteSpace;
|
||||||
|
if (ws) return /^(pre|pre-wrap|pre-line|break-spaces)$/.test(ws);
|
||||||
|
return n.tag === "pre" || n.tag === "textarea";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Element-only parents whose direct whitespace-only text children are non-significant
|
||||||
|
// (mirrors app.ts's ELEMENT_ONLY_PARENTS): a stray "\n " between <li>s is source
|
||||||
|
// indentation, not content.
|
||||||
|
const ELEMENT_ONLY_PARENTS = new Set(["ul", "ol", "table", "thead", "tbody", "tfoot", "tr", "select", "colgroup", "optgroup", "menu", "dl"]);
|
||||||
|
|
||||||
|
/** Translate one JSX-flavoured [key, valueSrc] pair from propsList into a raw HTML
|
||||||
|
* attribute string (leading space), or "" to drop it. valueSrc is ready-to-emit JS
|
||||||
|
* source: a JSON string literal, `true`, or a `{ __html: … }` object (SVG inner — the
|
||||||
|
* caller handles that separately, so we skip it here). */
|
||||||
|
function htmlAttr(key: string, valueSrc: string): string {
|
||||||
|
// The SVG inner-markup prop is emitted as literal inner HTML by the caller.
|
||||||
|
if (key === "dangerouslySetInnerHTML") return "";
|
||||||
|
const rawName = key.startsWith('"') ? key.slice(1, -1) : key;
|
||||||
|
const name = REACT_TO_HTML[rawName] ?? rawName;
|
||||||
|
if (valueSrc === "true") return ` ${name}`;
|
||||||
|
if (valueSrc === "false") return "";
|
||||||
|
let value: string;
|
||||||
|
if (valueSrc.startsWith('"')) {
|
||||||
|
try { value = JSON.parse(valueSrc) as string; } catch { return ""; }
|
||||||
|
} else {
|
||||||
|
// A `{ __html … }` object or other non-string expression — not a plain HTML attr.
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
// Asset attrs (src/poster/srcset) and any inline url() carry the /assets/ prefix.
|
||||||
|
if (name === "src" || name === "poster" || name === "srcset" || name === "style") value = relativizeAssets(value);
|
||||||
|
return ` ${name}="${escapeAttr(value)}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The raw-HTML attribute string for a node — REUSES propsList (app.ts) so tag/attr/asset
|
||||||
|
* decisions are identical to the JSX path; we only re-serialize to HTML. No ctx is passed,
|
||||||
|
* so propsList assigns the legacy `c<id>` class (matching our `.c<id>` CSS) and emits
|
||||||
|
* data-cid; component/section/svg-module machinery is inert (the composed DOM is the same). */
|
||||||
|
function attrsForNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: string): string {
|
||||||
|
return propsList(node, assetMap, sourceUrl).map(([k, v]) => htmlAttr(k, v)).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inner markup of an inline <svg> node, taken verbatim from the propsList
|
||||||
|
* `dangerouslySetInnerHTML` prop (already browser-serialized, capture-id stripped, start
|
||||||
|
* state revealed) — shared with the JSX path, url()s relativized. */
|
||||||
|
function svgInner(node: IRNode, assetMap: Map<string, string>, sourceUrl: string): string {
|
||||||
|
for (const [k, v] of propsList(node, assetMap, sourceUrl)) {
|
||||||
|
if (k !== "dangerouslySetInnerHTML") continue;
|
||||||
|
const m = /\{\s*__html:\s*(".*")\s*\}$/s.exec(v);
|
||||||
|
if (!m) return "";
|
||||||
|
try { return relativizeAssets(JSON.parse(m[1]!) as string); } catch { return ""; }
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: string, canonical: number, indent: number, insideInteractive: boolean, insideTable: boolean): string {
|
||||||
|
const pad = " ".repeat(indent);
|
||||||
|
const tag = resolveTag(node, insideInteractive, insideTable);
|
||||||
|
const attrs = attrsForNode(node, assetMap, sourceUrl);
|
||||||
|
const childInteractive = insideInteractive || node.tag === "a" || node.tag === "button";
|
||||||
|
const childTable = insideTable || node.tag === "table";
|
||||||
|
|
||||||
|
// Inline SVG: emit the shared inner markup verbatim inside the <svg> box.
|
||||||
|
if (node.rawHTML && tag === "svg") {
|
||||||
|
const inner = svgInner(node, assetMap, sourceUrl);
|
||||||
|
return inner ? `${pad}<svg${attrs}>${inner}</svg>` : `${pad}<svg${attrs}></svg>`;
|
||||||
|
}
|
||||||
|
if (VOID_TAGS.has(tag)) return `${pad}<${tag}${attrs} />`;
|
||||||
|
|
||||||
|
const children = renderChildren(node.children, tag, assetMap, sourceUrl, canonical, indent + 1, childInteractive, childTable, preservesWhitespace(node, canonical));
|
||||||
|
if (children.length === 0) return `${pad}<${tag}${attrs}></${tag}>`;
|
||||||
|
return `${pad}<${tag}${attrs}>\n${children.join("\n")}\n${pad}</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, canonical: number, indent: number, insideInteractive: boolean, insideTable: boolean, preserveWs: boolean): string[] {
|
||||||
|
const pad = " ".repeat(indent);
|
||||||
|
const parts: string[] = [];
|
||||||
|
// Coalesce adjacent text children into one node (the browser merges them anyway).
|
||||||
|
let textBuf = "";
|
||||||
|
const flush = () => {
|
||||||
|
if (parentTag && ELEMENT_ONLY_PARENTS.has(parentTag) && textBuf.trim() === "") { textBuf = ""; return; }
|
||||||
|
const out = preserveWs ? textBuf : collapseWs(textBuf);
|
||||||
|
if (out.length) parts.push(`${pad}${escapeText(out)}`);
|
||||||
|
textBuf = "";
|
||||||
|
};
|
||||||
|
for (const c of children) {
|
||||||
|
if (isTextChild(c)) { textBuf += c.text; continue; }
|
||||||
|
// <source>/<track> children: kept only if the JSX path would keep them. The JSX
|
||||||
|
// emitter filters video <track> and non-materialized <source>; but propsList/resolveTag
|
||||||
|
// don't, so filter here to match. A video/picture <source> without a local candidate
|
||||||
|
// and any <video> <track> would otherwise reference a missing/remote file.
|
||||||
|
if (parentTag === "video" && c.tag === "track") continue;
|
||||||
|
flush();
|
||||||
|
parts.push(renderNode(c, assetMap, sourceUrl, canonical, indent, insideInteractive, insideTable));
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The inline stylesheet: reset + fonts + tokens + page base + per-node rules + keyframes.
|
||||||
|
* The per-node rules and keyframes come from the SHARED css.ts collectors (identical to
|
||||||
|
* what the built app resolves to); all url()/asset refs are relativized. */
|
||||||
|
function previewCss(ir: IR, assetMap: Map<string, string>, tokensCss: string, fontGraph: FontGraph, colorVar?: (v: string) => string | null, tokenResolver?: TokenResolver): string {
|
||||||
|
const rules = collectNodeRules(ir, assetMap, undefined, colorVar, tokenResolver);
|
||||||
|
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
|
||||||
|
const kf = keyframesCss(ir, assetMap);
|
||||||
|
const nodeCss = assembleCss([...rules.keys()], (cid) => rules.get(cid)!, (cid) => `.c${cid}`, bands, kf);
|
||||||
|
|
||||||
|
const pv = ir.doc.perViewport[ir.doc.canonicalViewport];
|
||||||
|
const htmlBg = resolveHtmlBg(pv);
|
||||||
|
const noHScroll = Object.entries(ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03);
|
||||||
|
const clip = noHScroll ? "\nhtml, body { overflow-x: clip; }" : "";
|
||||||
|
|
||||||
|
const doc = `${RESET_CSS}
|
||||||
|
/* fonts */
|
||||||
|
${fontGraph.css}
|
||||||
|
|
||||||
|
/* tokens */
|
||||||
|
${tokensCss}
|
||||||
|
|
||||||
|
/* page base */
|
||||||
|
${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
|
||||||
|
|
||||||
|
/* nodes */
|
||||||
|
${nodeCss}`;
|
||||||
|
return relativizeAssets(doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit the standalone `preview.html` document as a string. Pure + deterministic.
|
||||||
|
* `tokensCss` is the assembled color-palette + token CSS (same string the pipeline
|
||||||
|
* feeds generateApp). `sourceUrl` drives asset-URL resolution (matching the JSX path).
|
||||||
|
*/
|
||||||
|
export function generatePreviewHtml(input: {
|
||||||
|
ir: IR;
|
||||||
|
assetMap: Map<string, string>;
|
||||||
|
fontGraph: FontGraph;
|
||||||
|
tokensCss: string;
|
||||||
|
sourceUrl: string;
|
||||||
|
colorVar?: (v: string) => string | null;
|
||||||
|
tokenResolver?: TokenResolver;
|
||||||
|
}): string {
|
||||||
|
const { ir, assetMap, fontGraph, tokensCss, sourceUrl, colorVar, tokenResolver } = input;
|
||||||
|
const canonical = ir.doc.canonicalViewport;
|
||||||
|
const css = previewCss(ir, assetMap, tokensCss, fontGraph, colorVar, tokenResolver);
|
||||||
|
const body = renderChildren(ir.root.children, ir.root.tag, assetMap, sourceUrl, canonical, 2, false, false, false).join("\n");
|
||||||
|
const bodyAttrs = attrsForNode(ir.root, assetMap, sourceUrl);
|
||||||
|
const lang = ir.doc.lang || "en";
|
||||||
|
const title = ir.doc.title || "Preview";
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="${escapeAttr(lang)}">
|
||||||
|
<head>
|
||||||
|
<meta charset="${escapeAttr(ir.doc.charset || "utf-8")}" />
|
||||||
|
<meta name="viewport" content="${escapeAttr(ir.doc.metaViewport || "width=device-width, initial-scale=1")}" />
|
||||||
|
<title>${escapeText(title)}</title>
|
||||||
|
<style>
|
||||||
|
${css}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body${bodyAttrs}>
|
||||||
|
${body}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { join } from "node:path";
|
|||||||
import { rmSync } from "node:fs";
|
import { rmSync } from "node:fs";
|
||||||
import { writeText, readJSON, fileExists } from "../util/fsx.js";
|
import { writeText, readJSON, fileExists } from "../util/fsx.js";
|
||||||
import { generateCss, RESET_CSS } from "../generate/css.js";
|
import { generateCss, RESET_CSS } from "../generate/css.js";
|
||||||
|
import { generatePreviewHtml } from "../generate/preview.js";
|
||||||
import { generateInteractionCss } from "../generate/interactionCss.js";
|
import { generateInteractionCss } from "../generate/interactionCss.js";
|
||||||
import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js";
|
import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js";
|
||||||
import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js";
|
import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js";
|
||||||
@@ -454,6 +455,22 @@ export function generateSiteApp(opts: {
|
|||||||
const globals = globalsCss(entry, unionFontCss(routes), palette.css);
|
const globals = globalsCss(entry, unionFontCss(routes), palette.css);
|
||||||
writeText(join(appDir, "src", isVite ? "globals.css" : join("app", "globals.css")), isVite ? viteGlobalsCss(globals) : globals);
|
writeText(join(appDir, "src", isVite ? "globals.css" : join("app", "globals.css")), isVite ? viteGlobalsCss(globals) : globals);
|
||||||
}
|
}
|
||||||
|
// Static preview of the ENTRY page only (the product moment — the first page shown
|
||||||
|
// within seconds of generate, before the Next build + deploy completes). Runtime-free
|
||||||
|
// single HTML file over the entry IR, sharing the same css.ts rule collectors +
|
||||||
|
// propsList/resolveTag decisions as the app. Assets relative to public/.
|
||||||
|
const previewTokensCss = tw
|
||||||
|
? palette.css + "\n" + tokensToCss(entry.tokens, true) + (interner.defs.size ? "\n" + colorDefsCssOf(interner) : "")
|
||||||
|
: palette.css + "\n" + tokensToCss(entry.tokens, true);
|
||||||
|
writeText(join(appDir, "preview.html"), generatePreviewHtml({
|
||||||
|
ir: entry.ir,
|
||||||
|
assetMap: buildAssetMap(entry.assetGraph),
|
||||||
|
fontGraph: { entries: entry.fontGraph.entries, css: unionFontCss(routes) },
|
||||||
|
tokensCss: previewTokensCss,
|
||||||
|
sourceUrl: entry.ir.doc.sourceUrl,
|
||||||
|
colorVar: palette.varForColor,
|
||||||
|
}));
|
||||||
|
|
||||||
emitGeneratedDocs(appDir, {
|
emitGeneratedDocs(appDir, {
|
||||||
sourceUrl: entry.ir.doc.sourceUrl,
|
sourceUrl: entry.ir.doc.sourceUrl,
|
||||||
routes: seoRoutes,
|
routes: seoRoutes,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { describe, it } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||||
|
import type { FontGraph } from "../src/infer/fonts.js";
|
||||||
|
import { generatePreviewHtml } from "../src/generate/preview.js";
|
||||||
|
import { generateCss, collectNodeRules, assembleCss, computeBands } from "../src/generate/css.js";
|
||||||
|
|
||||||
|
const VPS = [375, 1280];
|
||||||
|
const CANONICAL = 1280;
|
||||||
|
|
||||||
|
function computed(over: StyleMap = {}): StyleMap {
|
||||||
|
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
|
||||||
|
}
|
||||||
|
|
||||||
|
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = [], attrs: Record<string, string> = {}): IRNode {
|
||||||
|
const computedByVp: Record<number, StyleMap> = {};
|
||||||
|
const bboxByVp: Record<number, BBox> = {};
|
||||||
|
const visibleByVp: Record<number, boolean> = {};
|
||||||
|
for (const vp of VPS) {
|
||||||
|
computedByVp[vp] = { ...cs };
|
||||||
|
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
|
||||||
|
visibleByVp[vp] = true;
|
||||||
|
}
|
||||||
|
return { id, tag, attrs, visibleByVp, bboxByVp, computedByVp, children };
|
||||||
|
}
|
||||||
|
|
||||||
|
function irWith(root: IRNode): IR {
|
||||||
|
return {
|
||||||
|
doc: {
|
||||||
|
sourceUrl: "https://example.test/preview",
|
||||||
|
title: "Preview Fixture",
|
||||||
|
lang: "en",
|
||||||
|
charset: "UTF-8",
|
||||||
|
metaViewport: "width=device-width, initial-scale=1",
|
||||||
|
viewports: VPS,
|
||||||
|
sampleViewports: VPS,
|
||||||
|
canonicalViewport: CANONICAL,
|
||||||
|
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
|
||||||
|
nodeCount: 4,
|
||||||
|
keyframes: [],
|
||||||
|
},
|
||||||
|
root,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A @font-face block whose src points at a root-absolute cloned asset path — exactly the
|
||||||
|
// shape buildFontGraph emits. The preview must relativize the /assets/ prefix.
|
||||||
|
const FONT_GRAPH: FontGraph = {
|
||||||
|
entries: [],
|
||||||
|
css: `@font-face {\n font-family: "Demo";\n src: url("/assets/cloned/fonts/abc123.woff2") format("woff2");\n}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build a small page IR: body → heading (with own styles) + <img> (asset) + text. */
|
||||||
|
function fixtureIr(): IR {
|
||||||
|
const heading = node("n1", "h1", computed({ color: "rgb(10, 20, 30)", fontSize: "40px" }), [{ text: "Hello preview" }]);
|
||||||
|
const img = node("n2", "img", computed(), [], { src: "https://example.test/logo.png", alt: "logo" });
|
||||||
|
const root = node("n0", "body", computed(), [heading, img]);
|
||||||
|
return irWith(root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asset map keyed as buildAssetMap keys it (source URL + pathname) → root-absolute local path. */
|
||||||
|
function assetMap(): Map<string, string> {
|
||||||
|
return new Map([
|
||||||
|
["https://example.test/logo.png", "/assets/cloned/images/logo.png"],
|
||||||
|
["/logo.png", "/assets/cloned/images/logo.png"],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function emit(ir: IR, am: Map<string, string>): string {
|
||||||
|
return generatePreviewHtml({ ir, assetMap: am, fontGraph: FONT_GRAPH, tokensCss: ":root { --x: 1; }", sourceUrl: ir.doc.sourceUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("preview.html static artifact", () => {
|
||||||
|
it("emits a self-contained HTML document with an inline <style> and no runtime scripts", () => {
|
||||||
|
const html = emit(fixtureIr(), assetMap());
|
||||||
|
assert.ok(html.startsWith("<!doctype html>"), "starts with doctype");
|
||||||
|
assert.ok(/<style>[\s\S]*<\/style>/.test(html), "carries an inline <style> block");
|
||||||
|
assert.ok(/<title>Preview Fixture<\/title>/.test(html), "carries the doc title");
|
||||||
|
// Static preview: NO Ditto runtime scripts, no <script> at all.
|
||||||
|
assert.ok(!/<script/i.test(html), "emits no <script> tags (runtime-free)");
|
||||||
|
assert.ok(!/DittoWire|DittoLottie|DittoMotion/.test(html), "no Ditto runtime references");
|
||||||
|
// The tree renders as real HTML with the per-node c<id> class + data-cid.
|
||||||
|
assert.ok(/<body class="cn0" data-cid="n0">/.test(html), "body carries its c<id> class + cid");
|
||||||
|
assert.ok(/<h1 class="cn1" data-cid="n1">/.test(html), "heading rendered as <h1> with class");
|
||||||
|
assert.ok(/Hello preview/.test(html), "text content rendered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("references assets with RELATIVE paths (public/assets/...), never root-absolute /assets", () => {
|
||||||
|
const html = emit(fixtureIr(), assetMap());
|
||||||
|
// The <img> src and the @font-face url() both resolve relative to public/.
|
||||||
|
assert.ok(/src="public\/assets\/cloned\/images\/logo\.png"/.test(html), "img src relativized to public/assets");
|
||||||
|
assert.ok(/url\("public\/assets\/cloned\/fonts\/abc123\.woff2"\)/.test(html), "font url relativized to public/assets");
|
||||||
|
// No leading-slash /assets refs survive (they'd break when opened at the app-dir root).
|
||||||
|
assert.ok(!/["(]\/assets\//.test(html), "no root-absolute /assets references remain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is byte-stable across repeated emission from the same IR (determinism)", () => {
|
||||||
|
const a = emit(fixtureIr(), assetMap());
|
||||||
|
const b = emit(fixtureIr(), assetMap());
|
||||||
|
assert.equal(a, b, "two emissions of the same IR are byte-identical");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SHARES the css.ts rule collector — the inline CSS is the collectNodeRules/assembleCss output, not a re-derived path", () => {
|
||||||
|
const ir = fixtureIr();
|
||||||
|
const am = assetMap();
|
||||||
|
const html = emit(ir, am);
|
||||||
|
|
||||||
|
// Independently produce the per-node CSS the same way generateCss does (both go through
|
||||||
|
// collectNodeRules → assembleCss with `.c<id>` selectors). If the preview shared the
|
||||||
|
// collector, this exact block must appear verbatim inside its <style>.
|
||||||
|
const rules = collectNodeRules(ir, am);
|
||||||
|
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
|
||||||
|
const sharedNodeCss = assembleCss([...rules.keys()], (cid) => rules.get(cid)!, (cid) => `.c${cid}`, bands, "");
|
||||||
|
|
||||||
|
const styleBody = /<style>([\s\S]*)<\/style>/.exec(html)![1]!;
|
||||||
|
assert.ok(styleBody.includes(sharedNodeCss.trim()), "preview inline CSS contains the exact collectNodeRules/assembleCss output");
|
||||||
|
|
||||||
|
// Concretely: the heading's own base rule (color + font-size from collectNodeRules) is present
|
||||||
|
// exactly once — proving it's the shared collector's rule, not a duplicated re-emission.
|
||||||
|
const headingRule = /\.cn1\s*\{[^}]*\}/.exec(sharedNodeCss)![0];
|
||||||
|
assert.ok(headingRule.includes("color:rgb(10, 20, 30)") && headingRule.includes("font-size:40px"), "shared rule carries the node's decls");
|
||||||
|
const occurrences = styleBody.split(headingRule).length - 1;
|
||||||
|
assert.equal(occurrences, 1, "the shared per-node rule appears exactly once (no duplicated emission path)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mirrors generateCss's `.c<id>` selectors so the preview and the css-mode app resolve identical rules", () => {
|
||||||
|
const ir = fixtureIr();
|
||||||
|
const am = assetMap();
|
||||||
|
// The legacy per-node css-mode emitter (generateCss) is the ground truth for `.c<id>` rules.
|
||||||
|
const appCss = generateCss(ir, am);
|
||||||
|
const styleBody = /<style>([\s\S]*)<\/style>/.exec(emit(ir, am))![1]!;
|
||||||
|
// Every `.c<id>{…}` base rule generateCss emits must be present verbatim in the preview.
|
||||||
|
for (const m of appCss.matchAll(/\.cn\d+\s*\{[^}]*\}/g)) {
|
||||||
|
assert.ok(styleBody.includes(m[0]), `preview carries app rule ${m[0].slice(0, 40)}…`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user