) closes the / in the parser (" cannot be a
// descendant of
"), so check descendants, not just direct children.
// -
/// : only direct non-list children are foster-parented.
function violatesContentModel(node: IRNode, tag: string): boolean {
if (tag === "p" || /^h[1-6]$/.test(tag)) return hasBlockDescendant(node);
const elementChildren = node.children.filter((c): c is IRNode => !isTextChild(c));
if (tag === "ul" || tag === "ol" || tag === "menu") return elementChildren.some((c) => c.tag !== "li");
if (tag === "dl") return elementChildren.some((c) => c.tag !== "dt" && c.tag !== "dd" && c.tag !== "div");
return false;
}
// HTML attribute name -> React prop name for the cases React requires camelCase.
const ATTR_RENAME: Record = {
for: "htmlFor", 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",
};
const BOOLEAN_ATTRS = new Set(["controls", "autoplay", "loop", "muted", "playsinline", "disabled", "checked", "selected", "readonly", "multiple", "open", "reversed", "default", "hidden", "required", "novalidate"]);
const ASSET_ATTRS = new Set(["src", "poster"]);
// 1x1 transparent gif — used for asset refs that could not be downloaded so the
// generated app never points back to a remote origin or 404s (rubric Gate 2).
const TRANSPARENT_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
export type GenerateInput = {
ir: IR;
assetGraph: AssetGraph;
fontGraph: FontGraph;
appDir: string;
sourceDir?: string;
sourceUrl: string;
seoInventory?: SeoInventory;
colorVar?: (value: string) => string | null; // Stage 3.5: semantic color tokens
tokenResolver?: import("../infer/tokens.js").TokenResolver; // typography/spacing token refs (var(--…))
primitives?: Map; // Stage 3.5: cid → recognized primitive type
interaction?: import("../capture/interactions.js").InteractionCapture; // Stage 4: hover/focus + patterns
rejectedSpecs?: Set; // Stage 4: pattern keys the gate proved don't reproduce → static
components?: boolean; // Stage 4.5: extract repeated subtrees into components (opt-in)
recipeReport?: RecipeReport; // Stage 7.2: high-level recipe hints for section naming/emission
motion?: import("../capture/motion.js").MotionCapture; // Stage 5: WAAPI + rotating-text replay
humanize?: boolean; // Output-quality: semantic class map + (later) section split. Default true.
humanizeMode?: "tailwind" | "css"; // styling output: Tailwind utilities (default) or semantic CSS classes.
framework?: AppFramework; // output framework: Next.js App Router (default) or Vite React.
reflow?: boolean; // Opt-in reflow trade: flow ALL heights incl wrappable
// text, accepting position drift the perceptual gate proves invisible. Default off.
};
export type AppFramework = "next" | "vite";
/** Resolve an internal href to its final value in the generated app. For
* multi-route sites this maps a source path to the cloned route (or a collapsed
* collection's representative). Single-page generation passes none. */
export type LinkRewrite = (href: string) => string;
/** Stage 4.5: a live registry of the components being extracted for one module.
* Runs whose emitted skeleton is byte-identical share ONE component function (keyed
* by `skeletonToName`); each run still gets its own data array, so the rendered DOM
* is unchanged — only the verbose function is deduped. `funcDefs` are the unique
* function sources and `dataDecls` the per-run `const Name_dataK = [...]` arrays (both
* emitted in the page preamble); `failed` holds clusters (by first-instance cid) that
* bailed back to inline; `nodeByCid` resolves instance roots to their IR nodes. */
export type ComponentRegistry = {
plan: ComponentPlan;
nodeByCid: Map;
funcDefs: Map; // component name → function source (unique skeletons)
skeletonToName: Map; // skeleton JSX (name-independent) → component name
nameCounts: Map; // baseName → # distinct skeletons (for the dedup suffix)
dataDecls: Array<{ varName: string; compName: string; body: string; dataModel?: string }>; // per-run data arrays (editable content only)
cidDecls: Array<{ varName: string; body: string }>; // per-run data-cid arrays (internal plumbing, kept out of content.ts)
styleDecls: Array<{ varName: string; compName: string; body: string }>; // per-run class-override arrays (styling plumbing, kept out of content.ts)
dataCounts: Map; // component name → # data arrays emitted
fieldTypes: Map; // Stage 6: component name → its content schema
styleFieldTypes: Map; // component name → its per-instance class-override schema
byName: Map; // for the summary
failed: Set; // cluster first-instance cids that bailed to inline
};
/** Stage 6: one field of an extracted component's content schema. */
export type FieldType = { name: string; type: string; optional: boolean };
/** The TS type of an emitted data value source — strings dominate (every value is a
* JSON literal); booleans are emitted bare; inline-SVG is a `{ __html }` object. */
function tsTypeOf(valueSrc: string): string {
if (valueSrc === "true" || valueSrc === "false") return "boolean";
if (valueSrc.startsWith("{ __html")) return "{ __html: string }";
if (valueSrc.startsWith("<")) return "ReactNode"; // JSX field (e.g. an SVG icon's inner markup)
return "string";
}
/** A field value that is raw JSX (an SVG icon) makes the content module a .tsx with a ReactNode
* import — `<>…>` can't live in a plain `.ts`. */
function isJsxValue(valueSrc: string): boolean {
return valueSrc.startsWith("<");
}
/** Generation context threaded through JSX emission: internal-link rewriting, the
* recognized-primitive map (cid → type) for `data-component`, and (Stage 4.5) the
* component-extraction registry. */
/** Stage: a live registry of section subtrees being hoisted into their own modules.
* `modules` maps a section component name → its rendered JSX body (rendered once, when
* the page first reaches that section root); `order` preserves document order for
* deterministic file + import emission. */
export type SectionRegistry = { plan: SectionPlan; modules: Map; order: string[] };
/** Registry of inline SVGs hoisted into their own `svgs/` modules: `defs` maps a
* component name → its module source, deduped by the SVG's content (`byKey`), so a
* logo reused in mobile + desktop chrome becomes one component. Each instance passes its
* own `cid` (the grader aligns by data-cid). */
export type SvgRegistry = { byKey: Map; defs: Map; order: string[]; nameCount: Map };
export type RenderCtx = { linkRewrite?: LinkRewrite; primitives?: Map; components?: ComponentRegistry; classOf?: (cid: string) => string | undefined; styleOf?: (cid: string) => Map | undefined; sections?: SectionRegistry; svgs?: SvgRegistry };
function buildAssetMap(assetGraph: AssetGraph): Map {
const m = new Map();
for (const e of assetGraph.entries) {
if (e.classification === "downloaded" && e.localPath && e.type !== "css") addAssetUrlAliases(m, e.sourceUrl, e.localPath);
}
return m;
}
function addAssetUrlAliases(map: Map, sourceUrl: string, localPath: string): void {
map.set(sourceUrl, localPath);
try {
const u = new URL(sourceUrl);
const path = u.pathname + u.search + u.hash;
map.set(path, localPath);
const host = u.hostname.startsWith("www.") ? u.hostname.slice(4) : `www.${u.hostname}`;
map.set(`${u.protocol}//${host}${u.port ? `:${u.port}` : ""}${path}`, localPath);
} catch {
// Non-URL asset keys are still handled by the exact map entry above.
}
}
function resolveUrl(url: string, base: string): string {
try { return new URL(url, base).href; } catch { return url; }
}
/** Srcset candidates rewritten to materialized local assets, order preserved;
* candidates whose URL did not materialize are dropped (never placeholders —
* srcset wins over src, so a placeholder would beat the rewritten fallback). */
function keptSrcsetCandidates(value: string, assetMap: Map, sourceUrl: string): string[] {
return value.split(",").map((p) => p.trim()).filter(Boolean).map((seg) => {
const sp = seg.split(/\s+/);
const abs = resolveUrl(sp[0] ?? "", sourceUrl);
const local = assetMap.get(abs);
return local ? [local, ...sp.slice(1)].join(" ") : null;
}).filter((x): x is string => x !== null);
}
/** True when a 's own src or any child materialized locally —
* the clone can then ship the real file instead of the poster-only fallback. */
function videoHasLocalSource(node: IRNode, assetMap: Map, sourceUrl: string): boolean {
if (node.attrs.src && assetMap.get(resolveUrl(node.attrs.src, sourceUrl))) return true;
return node.children.some((c) => !isTextChild(c) && c.tag === "source"
&& !!c.attrs.src && !!assetMap.get(resolveUrl(c.attrs.src, sourceUrl)));
}
/** Whether a element still points at anything after asset rewriting
* (materialized src, or ≥1 surviving srcset candidate). A source with none is
* omitted rather than emitted pointing at placeholders. */
function sourceHasLocalCandidate(c: IRNode, assetMap: Map, sourceUrl: string): boolean {
if (c.attrs.src && assetMap.get(resolveUrl(c.attrs.src, sourceUrl))) return true;
if (c.attrs.srcset && keptSrcsetCandidates(c.attrs.srcset, assetMap, sourceUrl).length > 0) return true;
return false;
}
/** Default single-page link rewrite: a clone is self-contained, so a link that points
* back to the SOURCE origin is rewritten to an app-relative path (`/enterprise`) instead
* of the absolute source URL (`https://www.source.com/enterprise`) — otherwise every nav
* link silently navigates the user off the clone and back to the live original. Matching
* the EXACT origin (not just the registrable host) keeps this gate-neutral: gate 3
* resolves both source and generated hrefs against the source origin, so a same-origin
* link's relative form normalizes back to the identical absolute it had before. Other
* origins (apex/subdomains like trust.*), in-page anchors (`#…`), and non-web schemes
* (mailto:/tel:) are left as captured. Multi-route `clone-site` passes its own
* route-aware rewrite instead. */
export function sameOriginRelativeLinkRewrite(sourceUrl: string): LinkRewrite {
let origin = "";
try { origin = new URL(sourceUrl).origin; } catch { /* ignore */ }
return (href: string): string => {
if (!href || href.startsWith("#") || href.startsWith("mailto:") || href.startsWith("tel:")) return href;
try {
const u = new URL(href, sourceUrl);
if (u.protocol !== "http:" && u.protocol !== "https:") return href;
if (origin && u.origin === origin) return (u.pathname || "/") + u.search + u.hash;
return u.href; // external navigation stays absolute
} catch { return href; }
};
}
function escapeText(text: string): string {
return JSON.stringify(text);
}
/** Render a node's [key, valueExpr] pairs as clean inline JSX attributes (leading space
* per attr). Replaces the old `{...({…} as any)}` spread — the generated app sets
* `typescript.ignoreBuildErrors`, so exotic attrs never fail the build and need no cast.
* - boolean (`true`) → bare attr (e.g. `hidden`)
* - object (`{ __html… }`) → `name={{…}}` (dangerouslySetInnerHTML)
* - string literal → `name="…"` when it round-trips, else `name={"…"}`
* - any other expression → `name={expr}` (component data: `d.href`, `cids[0]`) */
function jsxAttr(key: string, valueSrc: string): string {
const name = key.startsWith('"') ? key.slice(1, -1) : key;
if (valueSrc === "true") return name;
if (valueSrc.startsWith("{")) return `${name}={${valueSrc}}`;
if (valueSrc.startsWith('"')) {
let raw: string;
try { raw = JSON.parse(valueSrc) as string; } catch { return `${name}={${valueSrc}}`; }
return /^[^"&<>\n\r]*$/.test(raw) ? `${name}="${raw}"` : `${name}={${valueSrc}}`;
}
return `${name}={${valueSrc}}`;
}
export function renderAttrs(pairs: Array<[string, string]>): string {
return pairs.map(([k, v]) => " " + jsxAttr(k, v)).join("");
}
/** A text child as JSX: bare when it round-trips exactly (no special chars, no leading/
* trailing or doubled whitespace that JSX would collapse), else the exact `{"…"}` form.
* Bare text reads far cleaner; the guard keeps it byte-faithful for the text gate. */
function jsxText(raw: string): string {
if (raw.length > 0 && raw === raw.trim() && !/\s{2,}/.test(raw) && !/[{}<>&\n\r\t]/.test(raw)) return raw;
return `{${escapeText(raw)}}`;
}
/** The ordered [propKey, valueExpr] list a node emits — rendered to clean JSX attributes
* by renderAttrs. Each valueExpr is ready-to-emit JS source (a JSON literal,
* `true`, or a `{ __html: … }` object). Component extraction reuses this so the
* extracted skeleton resolves attributes/assets/links exactly as inline rendering
* would (no divergent logic): a prop whose valueExpr is identical across instances
* is baked into the skeleton, one that varies becomes a per-instance data field. */
export function propsList(node: IRNode, assetMap: Map, sourceUrl: string, ctx?: RenderCtx): Array<[string, string]> {
// Custom elements (hyphenated tags, e.g. , ) are not
// HTML elements, so React 18 does NOT map `className`→`class` or apply its other
// camelCase attribute renames — it would emit a literal `classname` attribute
// that no CSS rule matches, leaving the whole subtree unstyled. Use raw HTML
// attribute names for them.
const isCustom = node.tag.includes("-");
const props: Array<[string, string]> = [];
// Semantic class (classMap) when provided, else the legacy per-node `c`. A node
// with no own styles gets no class (classOf returns undefined) — keeps markup clean.
const cls = ctx?.classOf ? ctx.classOf(node.id) : `c${node.id}`;
if (cls) props.push([isCustom ? "class" : "className", JSON.stringify(cls)]);
// Inline style for base-only raw values (gradients / url backgrounds) the Tailwind builder
// couldn't safely escape — emitting them here (vs a `[data-cid]` ditto.css rule) lets the shipped
// data-cid be stripped and reads like a hand-written one-off `style={{ backgroundImage: … }}`.
const styleMap = ctx?.styleOf ? ctx.styleOf(node.id) : undefined;
if (styleMap && styleMap.size) {
props.push(["style", svgStyleToObject([...styleMap].map(([k, v]) => `${k}: ${v}`).join("; "))]);
}
props.push(['"data-cid"', JSON.stringify(node.id)]);
// Stage 3.5: stamp the recognized primitive type (button/link/input/…). An
// attribute only — no effect on computed styles or structure matching.
const prim = ctx?.primitives?.get(node.id);
if (prim) props.push(['"data-component"', JSON.stringify(prim)]);
// A whose file materialized locally ships it, mirroring the captured
// playback attrs (autoplay/loop/muted/playsInline) faithfully. Otherwise it is
// rendered as its (first-frame) poster — a streamed source has no deterministic
// frame and its request aborts at snapshot time — dropping the streaming src +
// autoplay so only the poster paints; keep the poster (rewritten to a local
// still below). / children are filtered in emitChildren.
const isVideo = node.tag === "video";
const videoLocal = isVideo && videoHasLocalSource(node, assetMap, sourceUrl);
// An