From 77be868ee3565e519954a237926c7567b775a8bf Mon Sep 17 00:00:00 2001 From: Samraaj Bath Date: Fri, 3 Jul 2026 22:51:50 -0700 Subject: [PATCH 01/24] Generated-code hygiene: listener cleanup, shared cn(), whitespace collapse, probe stripping, sub-pixel snapping, clone-origin metadata, dependency pinning - DittoWire/Accordion/DropdownMenu runtime templates: AbortController-based listener cleanup, idempotent effects, orphan panel removal on unmount - Emit a single src/lib/utils.ts cn() instead of a copy per component file - Collapse captured whitespace runs under white-space:normal (pre* preserved) - Tag source-injected font-metric probe nodes at capture; drop them from IR - Snap sub-pixel arbitrary lengths (integer within 0.1px, else 1 decimal) - Emit SITE_ORIGIN (src/lib/site.ts, env-overridable): sitemap/robots/ canonical/og:url/JSON-LD resolve against the clone's own origin instead of the source domain - Pin lottie-web in generated package.json whenever DittoLottie is emitted 144 tests pass (21 new), typecheck clean. Co-Authored-By: Claude Fable 5 --- compiler/src/capture/walker.ts | 22 ++++ compiler/src/generate/app.ts | 86 ++++++++++---- compiler/src/generate/css.ts | 5 +- compiler/src/generate/interactive.ts | 42 +++---- compiler/src/generate/menu.ts | 38 ++++-- compiler/src/generate/seo.ts | 94 +++++++++++++-- compiler/src/generate/tailwind.ts | 21 +++- compiler/src/normalize/ir.ts | 4 + compiler/src/site/generateSite.ts | 20 +++- compiler/test/codegenHygiene.test.ts | 167 +++++++++++++++++++++++++++ compiler/test/irPrune.test.ts | 18 +++ compiler/test/seo.test.ts | 51 ++++++++ compiler/test/walker.test.ts | 53 +++++++++ 13 files changed, 548 insertions(+), 73 deletions(-) create mode 100644 compiler/test/codegenHygiene.test.ts diff --git a/compiler/src/capture/walker.ts b/compiler/src/capture/walker.ts index 360982e..f44e9d1 100644 --- a/compiler/src/capture/walker.ts +++ b/compiler/src/capture/walker.ts @@ -44,6 +44,10 @@ export type RawNode = { computed: RawStyle; bbox: RawBBox; visible: boolean; + // A font-metric / measurement scratch node injected by the SOURCE site's own JS (typography + // libraries, FontFaceObserver): absolutely positioned, parked far off-screen, and non-painting. + // Never user-visible; excluded from emission so it doesn't ship as page markup. + probe?: boolean; sizing?: RawSizing; before?: RawStyle; after?: RawStyle; @@ -286,6 +290,23 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { return true; }; + // A measurement/probe scratch node: out-of-flow (absolute/fixed), parked far off-screen + // (≥10000px beyond any edge — real drawers/sr-only content never live out there), AND + // non-painting (visibility:hidden / collapse / opacity:0). The two together are exclusive to + // font-metric / measurement scratch elements the source's own JS injects (a11y sr-only text + // stays visibility:visible so AT can read it, so it never matches). Tagged so emission drops it. + const OFFSCREEN_PROBE_PX = 10000; + const isProbe = (cs: CSSStyleDeclaration, bbox: RawBBox): boolean => { + if (cs.position !== "absolute" && cs.position !== "fixed") return false; + const nonPainting = cs.visibility === "hidden" || cs.visibility === "collapse" || parseFloat(cs.opacity || "1") === 0; + if (!nonPainting) return false; + const rightEdge = bbox.x + bbox.width; + const bottomEdge = bbox.y + bbox.height; + const pageH = round2(scrollEl.scrollHeight); + return rightEdge <= -OFFSCREEN_PROBE_PX || bottomEdge <= -OFFSCREEN_PROBE_PX + || bbox.x >= OFFSCREEN_PROBE_PX + vpW || bbox.y >= OFFSCREEN_PROBE_PX + pageH; + }; + const serializeElement = (el: Element): RawNode | null => { if (nodeCount >= MAX_NODES) { truncated = true; return null; } const tag = el.tagName.toLowerCase(); @@ -401,6 +422,7 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot { computed, bbox, visible: isVisible(el, cs, bbox), + ...(isProbe(cs, bbox) ? { probe: true } : {}), ...(sizing ? { sizing } : {}), children: [], }; diff --git a/compiler/src/generate/app.ts b/compiler/src/generate/app.ts index 1a10647..534a821 100644 --- a/compiler/src/generate/app.ts +++ b/compiler/src/generate/app.ts @@ -17,7 +17,7 @@ import { buildClassMap } from "./classMap.js"; import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js"; import { planSections, type SectionPlan } from "./sectionSplit.js"; import type { RecipeReport } from "../infer/recipes.js"; -import { emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, viewportExport, type SeoInventory } from "./seo.js"; +import { emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, siteOriginImportLine, SITE_ORIGIN_LAYOUT_IMPORT, SITE_ORIGIN_MODULE, viewportExport, type SeoInventory } from "./seo.js"; import { emitGeneratedDocs } from "./docs.js"; const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]); @@ -282,6 +282,17 @@ function jsxText(raw: string): string { return `{${escapeText(raw)}}`; } +/** Collapse a text run under `white-space: normal` the way CSS renders it: every run of + * whitespace (captured \n\t indentation, doubled spaces) becomes a single space, so the + * markup carries content — not the source file's frozen formatting. Leading/trailing single + * spaces are KEPT (JSX-significant for inline flow); the boundary is preserved so `word x` + * keeps its space. A run that is entirely whitespace collapses to a single space. The text gate + * compares whitespace-normalized (gates.ts:normText), so this stays faithful. */ +function collapseWs(raw: string): string { + const collapsed = raw.replace(/\s+/g, " "); + return collapsed; +} + /** 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 @@ -591,7 +602,7 @@ function renderNode(node: IRNode, assetMap: Map, sourceUrl: stri return `${pad}<${tag}${attrs} />`; } - const childParts = emitChildren(node.children, tag, assetMap, sourceUrl, indent + 1, childInteractive, ctx, childTable); + const childParts = emitChildren(node.children, tag, assetMap, sourceUrl, indent + 1, childInteractive, ctx, childTable, preservesWhitespace(node)); if (childParts.length === 0) { return `${pad}<${tag}${attrs} />`; } @@ -602,7 +613,7 @@ function renderNode(node: IRNode, assetMap: Map, sourceUrl: stri * `{Name_data.map(...)}` call for any run of extracted-component instances. Shared * by renderNode (parentTag is the element tag) and the body/chrome fragment * renderers (parentTag null → no element-only-parent whitespace rule). */ -function emitChildren(children: IRChild[], parentTag: string | null, assetMap: Map, sourceUrl: string, indent: number, childInteractive: boolean, ctx?: RenderCtx, insideTable = false): string[] { +function emitChildren(children: IRChild[], parentTag: string | null, assetMap: Map, sourceUrl: string, indent: number, childInteractive: boolean, ctx?: RenderCtx, insideTable = false, preserveWs = false): string[] { const pad = " ".repeat(indent); const parts: string[] = []; // Coalesce consecutive text children into one. Emitting them as separate JSX @@ -612,7 +623,12 @@ function emitChildren(children: IRChild[], parentTag: string | null, assetMap: M let textBuf = ""; const flushText = () => { if (parentTag && ELEMENT_ONLY_PARENTS.has(parentTag) && textBuf.trim() === "") { textBuf = ""; return; } - if (textBuf.length) parts.push(`${pad}${jsxText(textBuf)}`); + // Under white-space:normal, collapse the captured formatting (\n\t runs, doubled/leading + // multi-space) to CSS-equivalent single spaces so markup ships content, not source-file + // indentation. A whitespace-only run collapses to a single significant space ({" "}) — the + // huge `{" "}` literals disappear. Preserve verbatim under pre/pre-wrap/pre-line. + const out = preserveWs ? textBuf : collapseWs(textBuf); + if (out.length) parts.push(`${pad}${jsxText(out)}`); textBuf = ""; }; const reg = ctx?.components; @@ -708,12 +724,20 @@ function takeCid(coll: CidCollector, instances: IRNode[]): number { return idx; } -/** A self-contained class-name merger emitted into each component module that needs it - * (kept import-free so component files stay standalone). Skips falsy parts and joins — - * exact for our output because a node's baked base classes and its per-instance overrides - * are disjoint token sets. Swap in `tailwind-merge` if you want conflict-aware merging - * when hand-editing the ./_styles overrides. */ -const CN_HELPER = `function cn(...parts: Array) {\n return parts.filter(Boolean).join(" ");\n}`; +/** The single shared `cn()` module (`src/lib/utils.ts`), imported by every module that + * merges class names — one definition per clone instead of a copy per component file. + * Skips falsy parts and joins — exact for our output because a node's baked base classes + * and its per-instance overrides are disjoint token sets. Swap in `tailwind-merge` if you + * want conflict-aware merging when hand-editing the ./_styles overrides. */ +export const CN_UTILS_MODULE = `export function cn(...parts: Array) {\n return parts.filter(Boolean).join(" ");\n}\n`; + +/** The `import { cn } from "…/lib/utils"` line a module emits when it references `cn(`. + * `depth` is how many directory levels the consuming file sits BELOW `src` (page.tsx in + * `src/app` → 1; a component in `src/app/components` → 2), so the relative path always + * resolves to the single `src/lib/utils`. */ +export function cnImportLine(depth: number): string { + return `import { cn } from "${"../".repeat(Math.max(1, depth))}lib/utils";`; +} /** Split a className value SOURCE (a JSON string literal as emitted by propsList) into its * whitespace-separated tokens. Returns [] for a non-string / unparseable source. */ @@ -852,6 +876,15 @@ function canonicalViewportFor(n: IRNode): number { return keys[0] ?? 1280; } +/** Whether a node's `white-space` preserves captured whitespace verbatim (pre/pre-wrap/pre-line/ + * break-spaces) — in which case emission must NOT collapse its text runs. `
`/`