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 <noreply@anthropic.com>
This commit is contained in:
Samraaj Bath
2026-07-03 22:51:50 -07:00
co-authored by Claude Fable 5
parent dfcd4a6563
commit 77be868ee3
13 changed files with 548 additions and 73 deletions
+22
View File
@@ -44,6 +44,10 @@ export type RawNode = {
computed: RawStyle; computed: RawStyle;
bbox: RawBBox; bbox: RawBBox;
visible: boolean; 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; sizing?: RawSizing;
before?: RawStyle; before?: RawStyle;
after?: RawStyle; after?: RawStyle;
@@ -286,6 +290,23 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
return true; 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 => { const serializeElement = (el: Element): RawNode | null => {
if (nodeCount >= MAX_NODES) { truncated = true; return null; } if (nodeCount >= MAX_NODES) { truncated = true; return null; }
const tag = el.tagName.toLowerCase(); const tag = el.tagName.toLowerCase();
@@ -401,6 +422,7 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
computed, computed,
bbox, bbox,
visible: isVisible(el, cs, bbox), visible: isVisible(el, cs, bbox),
...(isProbe(cs, bbox) ? { probe: true } : {}),
...(sizing ? { sizing } : {}), ...(sizing ? { sizing } : {}),
children: [], children: [],
}; };
+64 -22
View File
@@ -17,7 +17,7 @@ import { buildClassMap } from "./classMap.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";
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"; import { emitGeneratedDocs } from "./docs.js";
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]); 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)}}`; 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 <b>x</b>`
* 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 /** 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, * by renderAttrs. Each valueExpr is ready-to-emit JS source (a JSON literal,
* `true`, or a `{ __html: … }` object). Component extraction reuses this so the * `true`, or a `{ __html: … }` object). Component extraction reuses this so the
@@ -591,7 +602,7 @@ function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: stri
return `${pad}<${tag}${attrs} />`; 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) { if (childParts.length === 0) {
return `${pad}<${tag}${attrs} />`; return `${pad}<${tag}${attrs} />`;
} }
@@ -602,7 +613,7 @@ function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: stri
* `{Name_data.map(...)}` call for any run of extracted-component instances. Shared * `{Name_data.map(...)}` call for any run of extracted-component instances. Shared
* by renderNode (parentTag is the element tag) and the body/chrome fragment * by renderNode (parentTag is the element tag) and the body/chrome fragment
* renderers (parentTag null → no element-only-parent whitespace rule). */ * renderers (parentTag null → no element-only-parent whitespace rule). */
function emitChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, indent: number, childInteractive: boolean, ctx?: RenderCtx, insideTable = false): string[] { function emitChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, indent: number, childInteractive: boolean, ctx?: RenderCtx, insideTable = false, preserveWs = false): string[] {
const pad = " ".repeat(indent); const pad = " ".repeat(indent);
const parts: string[] = []; const parts: string[] = [];
// Coalesce consecutive text children into one. Emitting them as separate JSX // 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 = ""; let textBuf = "";
const flushText = () => { const flushText = () => {
if (parentTag && ELEMENT_ONLY_PARENTS.has(parentTag) && textBuf.trim() === "") { textBuf = ""; return; } 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 = ""; textBuf = "";
}; };
const reg = ctx?.components; const reg = ctx?.components;
@@ -708,12 +724,20 @@ function takeCid(coll: CidCollector, instances: IRNode[]): number {
return idx; return idx;
} }
/** A self-contained class-name merger emitted into each component module that needs it /** The single shared `cn()` module (`src/lib/utils.ts`), imported by every module that
* (kept import-free so component files stay standalone). Skips falsy parts and joins — * merges class names — one definition per clone instead of a copy per component file.
* exact for our output because a node's baked base classes and its per-instance overrides * Skips falsy parts and joins — exact for our output because a node's baked base classes
* are disjoint token sets. Swap in `tailwind-merge` if you want conflict-aware merging * and its per-instance overrides are disjoint token sets. Swap in `tailwind-merge` if you
* when hand-editing the ./_styles overrides. */ * want conflict-aware merging when hand-editing the ./_styles overrides. */
const CN_HELPER = `function cn(...parts: Array<string | false | null | undefined>) {\n return parts.filter(Boolean).join(" ");\n}`; export const CN_UTILS_MODULE = `export function cn(...parts: Array<string | false | null | undefined>) {\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 /** 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. */ * whitespace-separated tokens. Returns [] for a non-string / unparseable source. */
@@ -852,6 +876,15 @@ function canonicalViewportFor(n: IRNode): number {
return keys[0] ?? 1280; 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. `<pre>`/`<textarea>`
* default to pre even without an explicit declaration. Normal/nowrap collapse per CSS. */
function preservesWhitespace(n: IRNode): boolean {
const ws = (n.computedByVp[canonicalViewportFor(n)] ?? 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";
}
type TextLeaf = { text: string; node: IRNode; index: number; ancestorTags: string[] }; type TextLeaf = { text: string; node: IRNode; index: number; ancestorTags: string[] };
function normalizeTextValue(text: string): string { function normalizeTextValue(text: string): string {
@@ -1594,7 +1627,8 @@ export function componentPreamble(reg: ComponentRegistry | undefined): string {
const cids = reg.cidDecls.map((c) => `const ${c.varName}: string[][] = ${c.body};`); const cids = reg.cidDecls.map((c) => `const ${c.varName}: string[][] = ${c.body};`);
const styles = reg.styleDecls.map((s) => `const ${s.varName} = ${s.body};`); const styles = reg.styleDecls.map((s) => `const ${s.varName} = ${s.body};`);
const fns = [...reg.funcDefs.values()]; const fns = [...reg.funcDefs.values()];
const cn = fns.some((f) => f.includes("cn(")) ? [CN_HELPER] : []; // Inlined into a chrome module at src/app (layout.tsx) or src/ (Chrome.tsx) — depth 1.
const cn = fns.some((f) => f.includes("cn(")) ? [cnImportLine(1)] : [];
const parts = [...cn, ...fns, ...data, ...cids, ...styles]; const parts = [...cn, ...fns, ...data, ...cids, ...styles];
return parts.length ? parts.join("\n\n") : ""; return parts.length ? parts.join("\n\n") : "";
} }
@@ -1818,13 +1852,13 @@ function describeComponent(name: string): string {
return map[base] ?? `${base.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()} component.`; return map[base] ?? `${base.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()} component.`;
} }
export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgRegistry): Array<{ name: string; module: string }> { export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgRegistry, depth = 2): Array<{ name: string; module: string }> {
if (!reg || reg.funcDefs.size === 0) return []; if (!reg || reg.funcDefs.size === 0) return [];
return [...reg.funcDefs].map(([name, src]) => { return [...reg.funcDefs].map(([name, src]) => {
const agg = reg.byName.get(name); const agg = reg.byName.get(name);
void agg; void agg;
const header = `/** ${describeComponent(name)} */`; const header = `/** ${describeComponent(name)} */`;
const cnDef = src.includes("cn(") ? CN_HELPER + "\n" : ""; const cnImport = src.includes("cn(") ? cnImportLine(depth) : "";
// The component's props-data type is DEFINED here (colocated), not imported from a central content // The component's props-data type is DEFINED here (colocated), not imported from a central content
// file — and exported so the section that supplies the data can type its array. Per-instance class // file — and exported so the section that supplies the data can type its array. Per-instance class
// overrides still come from _styles.ts (pure styling plumbing). // overrides still come from _styles.ts (pure styling plumbing).
@@ -1842,9 +1876,9 @@ export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgReg
for (const c of scanRefs(src).comps) { for (const c of scanRefs(src).comps) {
if (svgs?.defs.has(c)) svgImports.push(`import ${c} from "../svgs/${svgFileBase(c)}";`); if (svgs?.defs.has(c)) svgImports.push(`import ${c} from "../svgs/${svgFileBase(c)}";`);
} }
const imports = [...typeImports, ...svgImports]; const imports = [...typeImports, ...svgImports, ...(cnImport ? [cnImport] : [])];
const importBlock = imports.length ? imports.join("\n") + "\n" : ""; const importBlock = imports.length ? imports.join("\n") + "\n" : "";
return { name, module: `${importBlock}${typeDef}${header}\n${cnDef}export default ${src}\n` }; return { name, module: `${importBlock}${typeDef}${header}\nexport default ${src}\n` };
}); });
} }
@@ -2082,6 +2116,7 @@ export function sectionFiles(sreg: SectionRegistry | undefined, reg: ComponentRe
]; ];
const params = paramParts.length ? `{ ${paramParts.join(", ")} } = {}` : ""; const params = paramParts.length ? `{ ${paramParts.join(", ")} } = {}` : "";
const header = `/** ${describeSection(name)} */`; const header = `/** ${describeSection(name)} */`;
if (/\bcn\(/.test(body)) lines.push(cnImportLine(2)); // sections live at src/app/sections
const importBlock = lines.length ? lines.join("\n") + "\n" : ""; const importBlock = lines.length ? lines.join("\n") + "\n" : "";
const allConsts = consts; const allConsts = consts;
const constBlock = allConsts.length ? allConsts.join("\n") + "\n" : ""; const constBlock = allConsts.length ? allConsts.join("\n") + "\n" : "";
@@ -2121,6 +2156,7 @@ export function generatePageTsx(ir: IR, assetMap: Map<string, string>, sourceUrl
hasMotion ? `import DittoMotion from "${dittoMotionImportPath(0)}";` : "", hasMotion ? `import DittoMotion from "${dittoMotionImportPath(0)}";` : "",
hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "", hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "",
hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "", hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "",
/\bcn\(/.test(body) ? cnImportLine(1) : "", // page.tsx lives at src/app
...compImports, ...compImports,
].filter(Boolean).join("\n"); ].filter(Boolean).join("\n");
const importBlock = imports ? imports + "\n\n" : ""; const importBlock = imports ? imports + "\n\n" : "";
@@ -2137,31 +2173,33 @@ ${body}${wiresBlock}${accordionBlock}${motionBlock}${lottieBlock}${menusBlock}
/** SEO scaffolding (Next App Router): robots.ts + sitemap.ts + an llms.txt route, generated /** SEO scaffolding (Next App Router): robots.ts + sitemap.ts + an llms.txt route, generated
* from the captured URL / title / description — the discovery files a real site ships. */ * from the captured URL / title / description — the discovery files a real site ships. */
function seoFiles(ir: IR): Array<[string, string]> { function seoFiles(ir: IR): Array<[string, string]> {
let origin = "https://example.com";
try { origin = new URL(ir.doc.sourceUrl).origin; } catch { /* keep default */ }
const url = ir.doc.sourceUrl || origin + "/";
const title = ir.doc.title || "Home"; const title = ir.doc.title || "Home";
const desc = ir.doc.head?.description || ""; const desc = ir.doc.head?.description || "";
// robots.ts / sitemap.ts live at src/app → depth 1 below src. URLs resolve against the
// clone's own origin (SITE_ORIGIN, relative by default), never the source domain.
const siteImport = siteOriginImportLine(1);
const robots = `import type { MetadataRoute } from "next"; const robots = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: { userAgent: "*", allow: "/" }, rules: { userAgent: "*", allow: "/" },
sitemap: ${JSON.stringify(origin + "/sitemap.xml")}, sitemap: SITE_ORIGIN + "/sitemap.xml",
}; };
} }
`; `;
const sitemap = `import type { MetadataRoute } from "next"; const sitemap = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
return [{ url: ${JSON.stringify(url)}, changeFrequency: "weekly", priority: 1 }]; return [{ url: SITE_ORIGIN + "/", changeFrequency: "weekly", priority: 1 }];
} }
`; `;
const llmsText = [`# ${title}`, ...(desc ? ["", `> ${desc}`] : []), "", "## Pages", "", `- [${title}](${url})`, ""].join("\n"); const llmsText = [`# ${title}`, ...(desc ? ["", `> ${desc}`] : []), "", "## Pages", "", `- [${title}](/)`, ""].join("\n");
const llms = `export const dynamic = "force-static"; const llms = `export const dynamic = "force-static";
export function GET() { export function GET() {
@@ -2183,10 +2221,11 @@ function generateLayoutTsx(ir: IR, bodyClass?: string, seo?: SeoInventory): stri
const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`; const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`;
const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : ""; const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : ""; const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
return `import "./globals.css"; return `import "./globals.css";
import "./ditto.css"; import "./ditto.css";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
${siteImport}
${metadata}${viewport} ${metadata}${viewport}
export default function RootLayout({ children }: { children: ReactNode }) { export default function RootLayout({ children }: { children: ReactNode }) {
@@ -2541,6 +2580,9 @@ 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);
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
// 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 (wires.length) writeText(join(rootDir, "ditto", "DittoWire.tsx"), DITTO_WIRE_TSX); if (wires.length) writeText(join(rootDir, "ditto", "DittoWire.tsx"), DITTO_WIRE_TSX);
if (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_TSX); if (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_TSX);
if (motionHasContent(motionSpec)) writeText(join(rootDir, "ditto", "DittoMotion.tsx"), DITTO_MOTION_TSX); if (motionHasContent(motionSpec)) writeText(join(rootDir, "ditto", "DittoMotion.tsx"), DITTO_MOTION_TSX);
+4 -1
View File
@@ -298,7 +298,10 @@ export type WidthPlan = { kind: "fixed" } | { kind: "auto" } | { kind: "percent"
const PLAN_FIXED: WidthPlan = { kind: "fixed" }; const PLAN_FIXED: WidthPlan = { kind: "fixed" };
const pf = (v: string | undefined): number => { const n = parseFloat(v ?? ""); return Number.isFinite(n) ? n : 0; }; const pf = (v: string | undefined): number => { const n = parseFloat(v ?? ""); return Number.isFinite(n) ? n : 0; };
const fmtPx = (n: number): string => `${Math.round(n * 1000) / 1000}px`; // Snap sub-pixel geometry: integer when within 0.1px (measurement jitter), else at most 1 decimal
// — matching the Tailwind arbitrary-length rounding (tailwind.ts:snapLen) so CSS and utility output
// agree and frozen sub-pixel noise (204.797px) never ships. Transforms/border widths format elsewhere.
const fmtPx = (n: number): string => `${(Math.abs(n - Math.round(n)) < 0.1 ? Math.round(n) : Math.round(n * 10) / 10)}px`;
const viewportHeightFor = (vp: number): number => CAPTURE_VIEWPORT_HEIGHTS[vp] ?? Math.round(vp * 0.66); const viewportHeightFor = (vp: number): number => CAPTURE_VIEWPORT_HEIGHTS[vp] ?? Math.round(vp * 0.66);
type GeometryPlan = { type GeometryPlan = {
+21 -21
View File
@@ -151,7 +151,7 @@ export function accordionJsx(specs: AccordionRuntimeSpec[], indent: number): str
} }
export const ACCORDION_TSX = `"use client"; export const ACCORDION_TSX = `"use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
type CapStyle = Record<string, string>; type CapStyle = Record<string, string>;
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle }; type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
@@ -167,10 +167,9 @@ function applyStyle(el: HTMLElement | null, s: CapStyle) {
/** Wires captured accordion rows with small explicit state. /** Wires captured accordion rows with small explicit state.
* Hydration initializes the captured base state, then clicks toggle only the target row. */ * Hydration initializes the captured base state, then clicks toggle only the target row. */
export default function Accordion({ specs }: { specs: AccordionSpec[] }) { export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
const wired = useRef(false);
useEffect(() => { useEffect(() => {
if (wired.current) return; const ac = new AbortController();
wired.current = true; const { signal } = ac;
for (const spec of specs) { for (const spec of specs) {
const state = spec.items.map((it) => it.expanded); const state = spec.items.map((it) => it.expanded);
const renderItem = (i: number) => { const renderItem = (i: number) => {
@@ -193,10 +192,11 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
e.preventDefault(); e.preventDefault();
state[i] = !state[i]; state[i] = !state[i];
renderItem(i); renderItem(i);
}); }, { signal });
renderItem(i); renderItem(i);
}); });
} }
return () => ac.abort();
}, [specs]); }, [specs]);
return null; return null;
} }
@@ -204,7 +204,7 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
/** The fixed DittoWire client component, written once per generated app. */ /** The fixed DittoWire client component, written once per generated app. */
export const DITTO_WIRE_TSX = `"use client"; export const DITTO_WIRE_TSX = `"use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
type CapStyle = Record<string, string>; type CapStyle = Record<string, string>;
type RTTab = { trigger: string; panel: string; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; descendants?: Record<string, CapStyle> }; type RTTab = { trigger: string; panel: string; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; descendants?: Record<string, CapStyle> };
@@ -235,10 +235,9 @@ function applyDesc(d?: Record<string, CapStyle>) {
* on mount: the server-rendered markup + per-node CSS already reproduce the captured * on mount: the server-rendered markup + per-node CSS already reproduce the captured
* base state exactly, so state styles are applied only on user interaction. */ * base state exactly, so state styles are applied only on user interaction. */
export default function DittoWire({ spec }: { spec: Spec }) { export default function DittoWire({ spec }: { spec: Spec }) {
const wired = useRef(false);
useEffect(() => { useEffect(() => {
if (wired.current) return; const ac = new AbortController();
wired.current = true; const { signal } = ac;
if (spec.kind === "tabs") { if (spec.kind === "tabs") {
let active = spec.active; let active = spec.active;
const render = () => spec.tabs.forEach((t, i) => { const render = () => spec.tabs.forEach((t, i) => {
@@ -254,7 +253,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
spec.tabs.forEach((t, i) => { spec.tabs.forEach((t, i) => {
const trig = byCid(t.trigger); const trig = byCid(t.trigger);
if (!trig) return; if (!trig) return;
trig.addEventListener("click", (e) => { e.preventDefault(); active = i; render(); }); trig.addEventListener("click", (e) => { e.preventDefault(); active = i; render(); }, { signal });
trig.addEventListener("keydown", (e) => { trig.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key; const k = (e as KeyboardEvent).key;
if (k === "ArrowRight" || k === "ArrowLeft") { if (k === "ArrowRight" || k === "ArrowLeft") {
@@ -263,7 +262,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
render(); render();
byCid(spec.tabs[active].trigger)?.focus(); byCid(spec.tabs[active].trigger)?.focus();
} }
}); }, { signal });
}); });
// No initial render() — the static base state is already correct. // No initial render() — the static base state is already correct.
} else if (spec.kind === "accordion") { } else if (spec.kind === "accordion") {
@@ -278,7 +277,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
}; };
spec.items.forEach((it, i) => { spec.items.forEach((it, i) => {
const trig = byCid(it.trigger); const trig = byCid(it.trigger);
if (trig) trig.addEventListener("click", (e) => { e.preventDefault(); state[i] = !state[i]; renderItem(i); }); if (trig) trig.addEventListener("click", (e) => { e.preventDefault(); state[i] = !state[i]; renderItem(i); }, { signal });
}); });
// No initial renderItem — the static base state is already correct. // No initial renderItem — the static base state is already correct.
} else if (spec.kind === "carousel") { } else if (spec.kind === "carousel") {
@@ -293,9 +292,9 @@ export default function DittoWire({ spec }: { spec: Spec }) {
}; };
const nextEl = spec.next ? byCid(spec.next) : null; const nextEl = spec.next ? byCid(spec.next) : null;
const prevEl = spec.prev ? byCid(spec.prev) : null; const prevEl = spec.prev ? byCid(spec.prev) : null;
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); }); nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); }, { signal });
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); }); prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); }, { signal });
spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); })); spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); }, { signal }));
// No initial go() — the static base state is already correct. // No initial go() — the static base state is already correct.
} else { } else {
// Disclosure: dropdown / mega-menu / modal — a trigger reveals a hidden overlay. // Disclosure: dropdown / mega-menu / modal — a trigger reveals a hidden overlay.
@@ -311,18 +310,19 @@ export default function DittoWire({ spec }: { spec: Spec }) {
if (o) applyDesc(it.descendants); if (o) applyDesc(it.descendants);
if (o) panel.removeAttribute("hidden"); else panel.setAttribute("hidden", ""); if (o) panel.removeAttribute("hidden"); else panel.setAttribute("hidden", "");
}; };
trig.addEventListener("click", (e) => { e.preventDefault(); set(it.isDialog ? true : !open); }); trig.addEventListener("click", (e) => { e.preventDefault(); set(it.isDialog ? true : !open); }, { signal });
if (it.hoverOpen) { if (it.hoverOpen) {
const root = trig.parentElement ?? trig; const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", () => set(true)); root.addEventListener("mouseenter", () => set(true), { signal });
root.addEventListener("mouseleave", () => set(false)); root.addEventListener("mouseleave", () => set(false), { signal });
} }
it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); })); it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); }, { signal }));
if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); }); if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); }, { signal });
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); }); document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); }, { signal });
}); });
// No initial set() — the static base state is already correct. // No initial set() — the static base state is already correct.
} }
return () => ac.abort();
}, [spec]); }, [spec]);
return null; return null;
} }
+26 -12
View File
@@ -94,7 +94,7 @@ export function dropdownMenuImportPath(depth: number): string {
/** The fixed DropdownMenu client component, written once per generated app. */ /** The fixed DropdownMenu client component, written once per generated app. */
export const DROPDOWN_MENU_TSX = `"use client"; export const DROPDOWN_MENU_TSX = `"use client";
import { useEffect, useRef } from "react"; import { useEffect } from "react";
type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string }; type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]'); const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
@@ -103,10 +103,10 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data
* user interaction does it inject the captured panel fragment under its trigger. The base * user interaction does it inject the captured panel fragment under its trigger. The base
* render is therefore unchanged. */ * render is therefore unchanged. */
export default function DropdownMenu({ menus }: { menus: RTMenu[] }) { export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
const wired = useRef(false);
useEffect(() => { useEffect(() => {
if (wired.current) return; const ac = new AbortController();
wired.current = true; const { signal } = ac;
const openPanels: HTMLElement[] = [];
for (const m of menus) { for (const m of menus) {
const trig = byCid(m.trigger); const trig = byCid(m.trigger);
if (!trig) continue; if (!trig) continue;
@@ -127,27 +127,41 @@ export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
panel = wrap.firstElementChild as HTMLElement | null; panel = wrap.firstElementChild as HTMLElement | null;
if (!panel) return; if (!panel) return;
document.body.appendChild(panel); document.body.appendChild(panel);
openPanels.push(panel);
place(); place();
trig.setAttribute("aria-expanded", "true"); trig.setAttribute("aria-expanded", "true");
}; };
const close = () => { if (panel) { panel.remove(); panel = null; } trig.setAttribute("aria-expanded", "false"); }; const close = () => {
if (panel) {
const i = openPanels.indexOf(panel);
if (i !== -1) openPanels.splice(i, 1);
panel.remove();
panel = null;
}
trig.setAttribute("aria-expanded", "false");
};
const toggle = () => (panel ? close() : open()); const toggle = () => (panel ? close() : open());
if (m.hoverOpen) { if (m.hoverOpen) {
const root = trig.parentElement ?? trig; const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", open); root.addEventListener("mouseenter", open, { signal });
root.addEventListener("mouseleave", close); root.addEventListener("mouseleave", close, { signal });
} else { } else {
trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); }); trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); }, { signal });
} }
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }); document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }, { signal });
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
const t = e.target as Node; const t = e.target as Node;
if (panel && !trig.contains(t) && !panel.contains(t)) close(); if (panel && !trig.contains(t) && !panel.contains(t)) close();
}); }, { signal });
window.addEventListener("resize", place); window.addEventListener("resize", place, { signal });
window.addEventListener("scroll", place, { passive: true }); window.addEventListener("scroll", place, { passive: true, signal });
} }
(window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically (window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically
return () => {
ac.abort();
// Remove any still-open panels appended to document.body so unmount leaves no orphan nodes.
for (const p of openPanels.splice(0)) p.remove();
};
}, [menus]); }, [menus]);
return null; return null;
} }
+83 -11
View File
@@ -289,8 +289,15 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
if (keywords) metadata.keywords = keywords; if (keywords) metadata.keywords = keywords;
if (report.robots) metadata.robots = report.robots; if (report.robots) metadata.robots = report.robots;
if (report.referrer) metadata.referrer = report.referrer; if (report.referrer) metadata.referrer = report.referrer;
const sourceOrigin = originOf(report.sourceUrl);
const alternates: Record<string, unknown> = {}; const alternates: Record<string, unknown> = {};
if (report.canonicalUrl) alternates.canonical = report.canonicalUrl; // Canonical is relativized to the source origin path so metadataBase (the clone's own
// origin) resolves it — never hard-coding the source domain. Off-origin canonicals are rare;
// keep them verbatim.
if (report.canonicalUrl) {
const c = relativizeToSource(report.canonicalUrl, sourceOrigin);
alternates.canonical = c.path;
}
if (report.alternates.length) { if (report.alternates.length) {
const languages: Record<string, string> = {}; const languages: Record<string, string> = {};
for (const alt of report.alternates) languages[alt.hrefLang] = alt.href; for (const alt of report.alternates) languages[alt.hrefLang] = alt.href;
@@ -314,7 +321,7 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
else other["og:type"] = ogType; else other["og:type"] = ogType;
} }
if (ogSiteName) og.siteName = ogSiteName; if (ogSiteName) og.siteName = ogSiteName;
if (ogUrl) og.url = ogUrl; if (ogUrl) og.url = relativizeToSource(ogUrl, sourceOrigin).path;
if (ogImages.length) og.images = ogImages; if (ogImages.length) og.images = ogImages;
if (Object.keys(og).length) metadata.openGraph = og; if (Object.keys(og).length) metadata.openGraph = og;
@@ -352,8 +359,23 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
return metadata; return metadata;
} }
// A sentinel object value that metadataExport rewrites into the runtime `metadataBase`
// expression (JSON can't hold `new URL(...)`). Chosen so it never collides with real content.
const METADATA_BASE_SENTINEL = "__DITTO_METADATA_BASE__";
// The metadata/JSON-LD layout module references SITE_ORIGIN — callers must hoist this import.
export const SITE_ORIGIN_LAYOUT_IMPORT = siteOriginImportLine(1);
export function metadataExport(report: SeoInventory): string { export function metadataExport(report: SeoInventory): string {
return `export const metadata = ${JSON.stringify(metadataObject(report), null, 2)};\n`; const obj = metadataObject(report);
// metadataBase lets Next resolve the (now relative) canonical/openGraph URLs against the
// clone's OWN origin — SITE_ORIGIN when set, localhost in dev. Placed first for readability.
const withBase = { metadataBase: METADATA_BASE_SENTINEL, ...obj };
const body = JSON.stringify(withBase, null, 2).replace(
JSON.stringify(METADATA_BASE_SENTINEL),
`new URL(SITE_ORIGIN || "http://localhost:3000")`,
);
return `export const metadata = ${body};\n`;
} }
export function viewportExport(report: SeoInventory): string { export function viewportExport(report: SeoInventory): string {
@@ -367,13 +389,31 @@ function safeJsonLd(text: string): string {
return text.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--"); return text.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--");
} }
/** Rewrite a JSON-LD block's on-origin @id/url/contentUrl/target values off the SOURCE domain.
* The frozen JSON escapes slashes (`https:\/\/host\/…`), so we split on both the escaped and
* plain origin forms and rejoin with SITE_ORIGIN at RUNTIME the source domain never appears
* in output, and setting NEXT_PUBLIC_SITE_ORIGIN re-hosts the ids under the clone's own origin.
* Returns a JS expression string. Off-origin links (genuinely external) are left untouched. */
function jsonLdHtmlExpr(text: string, sourceOrigin: string): string {
const safe = safeJsonLd(text);
if (!sourceOrigin) return JSON.stringify(safe);
const escaped = sourceOrigin.replace(/\//g, "\\/");
// Split on whichever origin form appears; only one form is present in a given block.
const forms = safe.includes(escaped) ? escaped : (safe.includes(sourceOrigin) ? sourceOrigin : null);
if (!forms) return JSON.stringify(safe);
const segments = safe.split(forms);
if (segments.length === 1) return JSON.stringify(safe);
return `[${segments.map((s) => JSON.stringify(s)).join(", ")}].join(SITE_ORIGIN)`;
}
export function jsonLdHeadMarkup(report: SeoInventory, indent = 8): string { export function jsonLdHeadMarkup(report: SeoInventory, indent = 8): string {
if (!report.jsonLd.length) return ""; if (!report.jsonLd.length) return "";
const pad = " ".repeat(indent); const pad = " ".repeat(indent);
const sourceOrigin = originOf(report.sourceUrl);
return report.jsonLd.map((entry, index) => `${pad}<script return report.jsonLd.map((entry, index) => `${pad}<script
${pad} key="ditto-json-ld-${index}" ${pad} key="ditto-json-ld-${index}"
${pad} type="application/ld+json" ${pad} type="application/ld+json"
${pad} dangerouslySetInnerHTML={{ __html: ${JSON.stringify(safeJsonLd(entry.text))} }} ${pad} dangerouslySetInnerHTML={{ __html: ${jsonLdHtmlExpr(entry.text, sourceOrigin)} }}
${pad}/>`).join("\n"); ${pad}/>`).join("\n");
} }
@@ -397,6 +437,27 @@ function originOf(url: string): string {
try { return new URL(url).origin; } catch { return "https://example.com"; } try { return new URL(url).origin; } catch { return "https://example.com"; }
} }
/** The single origin constant every generated SEO/metadata route resolves against. Empty by
* default so canonical/sitemap/JSON-LD URLs render RELATIVE to wherever the clone is served
* (never the source domain); set NEXT_PUBLIC_SITE_ORIGIN to make them absolute to the clone's
* own origin. Emitted once as src/lib/site.ts. */
export const SITE_ORIGIN_MODULE = `export const SITE_ORIGIN = (process.env.NEXT_PUBLIC_SITE_ORIGIN ?? "").replace(/\\/$/, "");\n`;
/** The `import { SITE_ORIGIN } from "…/lib/site"` line; `depth` mirrors cnImportLine. */
export function siteOriginImportLine(depth: number): string {
return `import { SITE_ORIGIN } from "${"../".repeat(Math.max(1, depth))}lib/site";`;
}
/** Path portion of a URL that sits on the source origin (so it can be re-hosted under
* SITE_ORIGIN); returns the input unchanged for off-origin (genuinely external) URLs. */
function relativizeToSource(url: string, sourceOrigin: string): { onOrigin: boolean; path: string } {
if (sourceOrigin && url.startsWith(sourceOrigin)) {
const path = url.slice(sourceOrigin.length) || "/";
return { onOrigin: true, path: path.startsWith("/") ? path : "/" + path };
}
return { onOrigin: false, path: url };
}
function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string { function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string {
const title = report.title || routes[0]?.title || "Generated Clone"; const title = report.title || routes[0]?.title || "Generated Clone";
const lines: string[] = [`# ${title}`, ""]; const lines: string[] = [`# ${title}`, ""];
@@ -422,15 +483,17 @@ function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string
export function seoRouteFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> { export function seoRouteFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
const origin = originOf(report.sourceUrl); const origin = originOf(report.sourceUrl);
const sitemapUrl = origin + "/sitemap.xml"; // robots.ts / sitemap.ts live at src/app → depth 1 below src.
const siteImport = siteOriginImportLine(1);
const robots = `import type { MetadataRoute } from "next"; const robots = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots { export default function robots(): MetadataRoute.Robots {
return { return {
rules: { userAgent: "*", allow: "/" }, rules: { userAgent: "*", allow: "/" },
sitemap: ${JSON.stringify(sitemapUrl)}, sitemap: SITE_ORIGIN + "/sitemap.xml",
}; };
} }
`; `;
@@ -441,13 +504,21 @@ export default function robots(): MetadataRoute.Robots {
title: report.title || "Home", title: report.title || "Home",
description: report.description, description: report.description,
excerpt: "", excerpt: "",
}]).map((route, index) => ({ url: route.url, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 })); }]).map((route, index) => ({ path: relativizeToSource(route.url, origin).path, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 }));
// Emit `url: SITE_ORIGIN + "<path>"` so URLs resolve to the clone's own origin (relative by
// default), never the source domain.
const entryLines = sitemapEntries.map((e) =>
` {\n url: SITE_ORIGIN + ${JSON.stringify(e.path)},\n changeFrequency: ${JSON.stringify(e.changeFrequency)},\n priority: ${e.priority},\n }`
).join(",\n");
const sitemap = `import type { MetadataRoute } from "next"; const sitemap = `import type { MetadataRoute } from "next";
${siteImport}
export const dynamic = "force-static"; export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
return ${JSON.stringify(sitemapEntries, null, 2)}; return [
${entryLines},
];
} }
`; `;
const sourceLlms = resourceText(report, "llms"); const sourceLlms = resourceText(report, "llms");
@@ -472,11 +543,12 @@ function xmlEscape(value: string): string {
export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> { export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
const origin = originOf(report.sourceUrl); const origin = originOf(report.sourceUrl);
const sitemapUrl = origin + "/sitemap.xml"; // Static output (Vite) has no runtime env: emit the clone's own paths RELATIVE to the source
// origin so the source domain is never baked in (the requirement's relative default).
const robots = [ const robots = [
"User-agent: *", "User-agent: *",
"Allow: /", "Allow: /",
`Sitemap: ${sitemapUrl}`, "Sitemap: /sitemap.xml",
"", "",
].join("\n"); ].join("\n");
const sitemapRoutes = routes.length ? routes : [{ const sitemapRoutes = routes.length ? routes : [{
@@ -492,7 +564,7 @@ export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]):
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...sitemapRoutes.map((route, index) => [ ...sitemapRoutes.map((route, index) => [
" <url>", " <url>",
` <loc>${xmlEscape(route.url)}</loc>`, ` <loc>${xmlEscape(relativizeToSource(route.url, origin).path)}</loc>`,
" <changefreq>weekly</changefreq>", " <changefreq>weekly</changefreq>",
` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`, ` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`,
" </url>", " </url>",
+20 -1
View File
@@ -28,6 +28,25 @@ import type { InteractionCapture, StyleDelta } from "../capture/interactions.js"
function arb(v: string): string { function arb(v: string): string {
return v.replace(/"/g, "'").replace(/_/g, "\\_").replace(/ /g, "_"); return v.replace(/"/g, "'").replace(/_/g, "\\_").replace(/ /g, "_");
} }
/** Round a single simple `<number>px|rem` length, killing frozen sub-pixel noise: snap to an
* integer when the value is within 0.1px of one (measurement/rounding jitter `204.9994px` was
* meant to be `205px`), otherwise keep at most 1 decimal (`204.797px` `204.8px`, a genuine
* fraction). `627px` is unchanged. Only touches a lone number+unit token: multi-token values,
* calc()/clamp()/var(), percentages, and unitless numbers pass through untouched, so nothing that
* needs exact sub-pixel precision (borders, transforms handled on their own branches) is moved. */
export function snapLen(value: string): string {
const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(value.trim());
if (!m) return value;
const n = parseFloat(m[1]!);
const unit = m[2]!;
// rem thresholds scale by the 16px root so "within 0.1px" is unit-consistent.
const pxPerUnit = unit === "rem" ? 16 : 1;
const rounded = Math.abs(n - Math.round(n)) * pxPerUnit < 0.1
? Math.round(n)
: Math.round(n * 10) / 10;
return `${rounded}${unit}`;
}
function arbList(v: string): string { function arbList(v: string): string {
return arb(v).replace(/,_/g, ","); return arb(v).replace(/,_/g, ",");
} }
@@ -243,7 +262,7 @@ export function declToUtil(prop: string, value: string): string {
if (value === "0" || value === "0px" || value === "0rem") { if (value === "0" || value === "0px" || value === "0rem") {
return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`; return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`;
} }
return `${ARB[prop]}-[${arb(value)}]`; return `${ARB[prop]}-[${arb(snapLen(value))}]`;
} }
if (/^border-(top|right|bottom|left)-width$/.test(prop)) { if (/^border-(top|right|bottom|left)-width$/.test(prop)) {
const side = prop.split("-")[1]![0]!; // t/r/b/l const side = prop.split("-")[1]![0]!; // t/r/b/l
+4
View File
@@ -304,6 +304,10 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
const convert = (raw: RawNode, matched: Record<number, RawNode | undefined>): IRNode | null => { const convert = (raw: RawNode, matched: Record<number, RawNode | undefined>): IRNode | null => {
if (NOISE_TAGS.has(raw.tag)) return null; if (NOISE_TAGS.has(raw.tag)) return null;
if (isThirdPartyOverlay(raw)) return null; if (isThirdPartyOverlay(raw)) return null;
// Font-metric / measurement scratch nodes the source's own JS injects (tagged by the walker):
// absolutely positioned, parked far off-screen, non-painting. Never user-visible — drop so they
// don't ship as page markup (e.g. the `<div … -top-[6249rem] invisible>Mgy</div>` probe).
if (raw.probe) return null;
const visibleByVp: Record<number, boolean> = {}; const visibleByVp: Record<number, boolean> = {};
const bboxByVp: Record<number, BBox> = {}; const bboxByVp: Record<number, BBox> = {};
+15 -5
View File
@@ -15,7 +15,7 @@ import { generateCss, RESET_CSS } from "../generate/css.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";
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 { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, cnImportLine, CN_UTILS_MODULE, 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 { buildTailwind, tailwindGlobalsCss, createColorInterner, colorDefsCssOf, type TailwindOutput } from "../generate/tailwind.js";
import type { InteractionCapture } from "../capture/interactions.js"; import type { InteractionCapture } from "../capture/interactions.js";
import type { IRChild } from "../normalize/ir.js"; import type { IRChild } from "../normalize/ir.js";
@@ -29,7 +29,7 @@ import type { CaptureResult } from "../capture/capture.js";
import { backfillLazyBackgrounds } from "../normalize/ir.js"; import { backfillLazyBackgrounds } from "../normalize/ir.js";
import { toRoutePath, segmentsOf } from "../crawl/url.js"; import { toRoutePath, segmentsOf } from "../crawl/url.js";
import { buildCanonicalChrome, chromeCssIr, middleChildren, middleIncludeFilter, CHROME_PREFIX, type ChromePlan } from "./sharedLayout.js"; import { buildCanonicalChrome, chromeCssIr, middleChildren, middleIncludeFilter, CHROME_PREFIX, type ChromePlan } from "./sharedLayout.js";
import { buildSeoInventory, emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, viewportExport, type SeoInventory, type SeoRouteSummary } from "../generate/seo.js"; import { buildSeoInventory, emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, SITE_ORIGIN_LAYOUT_IMPORT, SITE_ORIGIN_MODULE, viewportExport, type SeoInventory, type SeoRouteSummary } from "../generate/seo.js";
import { emitGeneratedDocs } from "../generate/docs.js"; import { emitGeneratedDocs } from "../generate/docs.js";
export type RouteArtifact = { export type RouteArtifact = {
@@ -132,9 +132,10 @@ function layoutTsx(entry: RouteArtifact, bodyClass: string | undefined, chrome?:
const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`; const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`;
const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : ""; const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : ""; const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
return `import "./globals.css"; return `import "./globals.css";
${chromeImport}import type { ReactNode } from "react"; ${chromeImport}import type { ReactNode } from "react";
${siteImport}
${metadata}${viewport} ${metadata}${viewport}
${pre}export default function RootLayout({ children }: { children: ReactNode }) { ${pre}export default function RootLayout({ children }: { children: ReactNode }) {
@@ -271,6 +272,9 @@ export function generateSiteApp(opts: {
// package.json is written after the route loop (below) so the lottie-web dependency can be // 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. // added when any route actually replays a Lottie animation.
writeText(join(appDir, "tsconfig.json"), isVite ? TSCONFIG_JSON_VITE : TSCONFIG_JSON); writeText(join(appDir, "tsconfig.json"), isVite ? TSCONFIG_JSON_VITE : TSCONFIG_JSON);
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
// SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files).
if (!isVite) writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE);
writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n"); writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n");
if (isVite) { if (isVite) {
rmSync(join(appDir, "next.config.mjs"), { force: true }); rmSync(join(appDir, "next.config.mjs"), { force: true });
@@ -384,11 +388,17 @@ export function generateSiteApp(opts: {
// Stage 6: split each route component into its own route-local `components/Name.tsx` // Stage 6: split each route component into its own route-local `components/Name.tsx`
// (page imports them); per-route data stays inline. Route-local dirs keep names from // (page imports them); per-route data stays inline. Route-local dirs keep names from
// colliding across routes, which each name their components independently. // colliding across routes, which each name their components independently.
for (const { name, module } of componentFiles(routeReg)) writeText(join(routeDir, "components", fileBase(name) + ".tsx"), module); // Depth of a route-local `components/Name.tsx` below `src`: src/app/<dir>/components
// (Next) or src/routes/<key>/components (Vite). +1 for the components dir itself.
const compDepth = (isVite ? 2 : (dir ? dir.split("/").length + 1 : 1)) + 1;
for (const { name, module } of componentFiles(routeReg, undefined, compDepth)) writeText(join(routeDir, "components", fileBase(name) + ".tsx"), module);
const compImport = componentImports(routeReg, 0); // route-local → ./components/Name const compImport = componentImports(routeReg, 0); // route-local → ./components/Name
const dataDecls = componentDataDecls(routeReg); const dataDecls = componentDataDecls(routeReg);
const preBlock = dataDecls ? dataDecls + "\n\n" : ""; const preBlock = dataDecls ? dataDecls + "\n\n" : "";
const importLines = [wireImport.trimEnd(), lottieImport.trimEnd(), compImport].filter(Boolean).join("\n"); // page.tsx sits at routeDir (src/app/<dir> or src/routes/<key>); depth below src.
const pageDepth = isVite ? 2 : (dir ? dir.split("/").length + 1 : 1);
const cnImport = /\bcn\(/.test(bodyJsx) ? cnImportLine(pageDepth) + "\n" : "";
const importLines = [wireImport.trimEnd(), lottieImport.trimEnd(), cnImport.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} 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}
</> </>
); );
+167
View File
@@ -0,0 +1,167 @@
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 {
CN_UTILS_MODULE,
cnImportLine,
componentFiles,
generatePageTsx,
injectLottieDep,
PACKAGE_JSON,
PACKAGE_JSON_TW,
PACKAGE_JSON_VITE,
PACKAGE_JSON_VITE_TW,
type ComponentRegistry,
} from "../src/generate/app.js";
import { DITTO_WIRE_TSX, ACCORDION_TSX } from "../src/generate/interactive.js";
import { DROPDOWN_MENU_TSX } from "../src/generate/menu.js";
import { declToUtil, snapLen } from "../src/generate/tailwind.js";
const VPS = [1280];
const CANONICAL = 1280;
function computed(over: StyleMap = {}): StyleMap {
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...over };
}
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = []): 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/",
title: "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,
};
}
// ---- Fix 1: emitted runtime components clean up their listeners ----
describe("runtime components abort their listeners on unmount (fix 1)", () => {
const templates: Array<[string, string]> = [
["DittoWire", DITTO_WIRE_TSX],
["Accordion", ACCORDION_TSX],
["DropdownMenu", DROPDOWN_MENU_TSX],
];
for (const [name, tsx] of templates) {
it(`${name}: one AbortController, every addEventListener signalled, cleanup returns abort`, () => {
assert.ok(tsx.includes("new AbortController()"), "creates an AbortController");
assert.ok(tsx.includes("ac.abort()"), "effect cleanup aborts");
// Every addEventListener must carry a listener-options object with the signal (either
// `, { signal })` or `, { passive: true, signal })`). Count-match guarantees none is missed.
const adds = (tsx.match(/addEventListener\(/g) ?? []).length;
const signalled = (tsx.match(/, \{ signal \}\)/g) ?? []).length
+ (tsx.match(/, \{ passive: true, signal \}\)/g) ?? []).length;
assert.ok(adds > 0, "has listeners");
assert.equal(signalled, adds, "every addEventListener passes the abort signal");
// The stale re-wire guard is gone; effects are idempotent + cleaned up instead.
assert.ok(!tsx.includes("wired.current"), "no wired.current guard");
});
}
it("DropdownMenu removes any still-open panels on unmount", () => {
assert.ok(DROPDOWN_MENU_TSX.includes("openPanels"), "tracks open panels");
assert.ok(DROPDOWN_MENU_TSX.includes("openPanels.splice(0)) p.remove()"), "removes panels on cleanup");
});
});
// ---- Fix 2: cn() is a single shared module, imported (not copied per file) ----
describe("cn() is deduplicated into src/lib/utils (fix 2)", () => {
it("exports a single cn from the shared utils module", () => {
assert.ok(CN_UTILS_MODULE.includes("export function cn("), "utils module exports cn");
});
it("cnImportLine resolves to the shared module at the right depth", () => {
assert.equal(cnImportLine(1), 'import { cn } from "../lib/utils";');
assert.equal(cnImportLine(2), 'import { cn } from "../../lib/utils";');
});
it("a component module that uses cn() imports it rather than redefining it", () => {
// componentFiles reads only funcDefs / byName / fieldTypes / dataDecls / styleDecls.
const reg = {
byName: new Map([["Card", { runs: 1, instances: 2, cids: ["n1"] }]]),
funcDefs: new Map([["Card", 'function Card({ styles }: { styles: string }) {\n return <div className={cn("p-4", styles)} />;\n}']]),
fieldTypes: new Map(),
dataDecls: [],
cidDecls: [],
styleDecls: [],
} as unknown as ComponentRegistry;
const files = componentFiles(reg);
const card = files.find((f) => f.name === "Card")!.module;
assert.ok(card.includes('import { cn } from "../../lib/utils";'), "imports shared cn");
assert.ok(!card.includes("function cn("), "no inline cn definition");
});
});
// ---- Fix 3: JSX whitespace is collapsed under white-space: normal ----
describe("JSX whitespace collapses under white-space:normal (fix 3)", () => {
it("collapses captured \\n\\t indentation and multi-space runs to single spaces", () => {
const p = node("n1", "p", computed(), [{ text: "\n\t\tSkip to content\n\t\t" }]);
const root = node("n0", "body", computed(), [p]);
const tsx = generatePageTsx(irWith(root), new Map(), "https://example.test/");
assert.ok(!/\{"[^"]*\\n[^"]*"\}/.test(tsx), "no literal \\n frozen in text");
assert.ok(!/\{"\s{2,}"\}/.test(tsx), "no multi-space whitespace literal");
assert.ok(tsx.includes("Skip to content"), "content preserved");
});
it("preserves whitespace verbatim inside a <pre> (white-space: pre)", () => {
const pre = node("n1", "pre", computed({ whiteSpace: "pre" }), [{ text: "line1\n\tline2" }]);
const root = node("n0", "body", computed(), [pre]);
const tsx = generatePageTsx(irWith(root), new Map(), "https://example.test/");
assert.ok(tsx.includes("line1\\n\\tline2"), "pre keeps raw newlines/tabs");
});
});
// ---- Fix 5: sub-pixel arbitrary values snap ----
describe("sub-pixel lengths snap (fix 5)", () => {
it("snapLen keeps genuine fractions at 1 decimal, snaps near-integer jitter to an integer", () => {
assert.equal(snapLen("204.797px"), "204.8px");
assert.equal(snapLen("627.188px"), "627.2px");
assert.equal(snapLen("100.3px"), "100.3px");
assert.equal(snapLen("204.98px"), "205px"); // within 0.1px of an integer → snap
assert.equal(snapLen("2.996px"), "3px"); // rem/px jitter snaps
assert.equal(snapLen("627px"), "627px"); // already clean
});
it("snapLen leaves non-simple values (calc/percent/multi-token) untouched", () => {
assert.equal(snapLen("calc(100% - 3.333px)"), "calc(100% - 3.333px)");
assert.equal(snapLen("50.5%"), "50.5%");
assert.equal(snapLen("0px 2.5px"), "0px 2.5px");
});
it("declToUtil snaps a width arbitrary value but keeps border-width exact", () => {
assert.equal(declToUtil("width", "204.797px"), "w-[204.8px]");
// Border width sub-pixel precision is load-bearing — left untouched.
assert.equal(declToUtil("border-width", "0.667px"), "border-[0.667px]");
});
});
// ---- Fix 7: every emitted runtime import is a declared dependency ----
describe("lottie-web is declared when its import is emitted (fix 7)", () => {
it("injectLottieDep pins lottie-web into every package.json template", () => {
for (const pkg of [PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW]) {
const injected = injectLottieDep(pkg);
const deps = JSON.parse(injected).dependencies as Record<string, string>;
assert.equal(deps["lottie-web"], "5.12.2", "lottie-web pinned to the harness version");
}
});
});
+18
View File
@@ -98,3 +98,21 @@ describe("IR prune keeps <source> media candidates", () => {
assert.equal(findByTag(root, "picture"), null); assert.equal(findByTag(root, "picture"), null);
}); });
}); });
describe("IR drops font-metric probe nodes (fix 4)", () => {
it("excludes a walker-tagged probe node from the IR, keeping its siblings", () => {
const probe: RawNode = {
...raw("div", { class: "font-probe" }, [{ text: "Mgy" }]),
probe: true,
};
const real = raw("h1", { class: "title" }, [{ text: "Heading" }]);
const body = raw("body", {}, [real, probe]);
const root = buildFixtureIR(body);
const kept = root.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
assert.deepEqual(kept, ["h1"], "probe div is dropped, the real heading survives");
// Its text must not leak into the tree either.
assert.equal(findByTag(root, "div"), null);
});
});
+51
View File
@@ -195,3 +195,54 @@ describe("generated Next config", () => {
assert.ok(NEXT_CONFIG.includes("devIndicators: false")); assert.ok(NEXT_CONFIG.includes("devIndicators: false"));
}); });
}); });
describe("SEO origin references the clone, not the source domain (fix 6)", () => {
it("sitemap.ts / robots.ts resolve against SITE_ORIGIN, not the source origin", () => {
const ir = fixtureIr();
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
const files = seoRouteFiles(report, [routeSummaryFromIr(ir, "/", "/", ir.doc.sourceUrl)]);
const robots = files.find(([p]) => p === "robots.ts")![1];
const sitemap = files.find(([p]) => p === "sitemap.ts")![1];
for (const body of [robots, sitemap]) {
assert.ok(body.includes('import { SITE_ORIGIN } from "../lib/site";'), "imports SITE_ORIGIN");
assert.ok(body.includes("SITE_ORIGIN +"), "builds URLs from SITE_ORIGIN");
assert.ok(!body.includes("example.test"), "never bakes the source domain");
}
// sitemap path is relativized off the source origin.
assert.ok(sitemap.includes('SITE_ORIGIN + "/seo"') || sitemap.includes('SITE_ORIGIN + "/"'));
});
it("metadata sets metadataBase from SITE_ORIGIN and relativizes the canonical", () => {
const ir = fixtureIr();
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
const metadata = metadataExport(report);
assert.ok(metadata.includes('new URL(SITE_ORIGIN || "http://localhost:3000")'), "metadataBase from SITE_ORIGIN");
assert.ok(metadata.includes('"canonical": "/seo"'), "canonical relativized to a path");
// The source domain must not survive in canonical (og:image assets are localized elsewhere).
assert.ok(!metadata.includes('"canonical": "https://example.test'), "canonical is not absolute to source");
});
it("relativizes og:url off the source origin", () => {
const ir = fixtureIr();
ir.doc.head!.meta!.push({ property: "og:url", content: "https://example.test/seo" });
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
const metadata = metadataExport(report);
assert.ok(metadata.includes('"url": "/seo"'), "og:url relativized to a path");
assert.ok(!metadata.includes('"url": "https://example.test/seo"'), "og:url not absolute to source");
});
it("rewrites on-origin JSON-LD @id/url off the source domain via SITE_ORIGIN", () => {
const ir = fixtureIr();
// JSON-LD carrying the source origin in @id/url (escaped-slash form, like WordPress emits).
ir.doc.head!.jsonLd = [{
id: "graph",
text: '{"@context":"https:\\/\\/schema.org","@id":"https:\\/\\/example.test\\/#website","url":"https:\\/\\/example.test\\/"}',
}];
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
const markup = jsonLdHeadMarkup(report);
assert.ok(markup.includes(".join(SITE_ORIGIN)"), "rejoins segments with SITE_ORIGIN at runtime");
// The source origin must not survive as a literal (schema.org context is off-origin, kept).
assert.ok(!markup.includes("example.test"), "source origin removed from JSON-LD");
assert.ok(markup.includes("schema.org"), "off-origin @context left untouched");
});
});
+53
View File
@@ -158,3 +158,56 @@ describe("walker off-screen visibility", () => {
assert.equal(fixed.visible, false, "fixed box below the viewport is invisible"); assert.equal(fixed.visible, false, "fixed box below the viewport is invisible");
}); });
}); });
describe("walker font-metric probe tagging (fix 4)", () => {
let browser: Browser;
let page: Page;
before(async () => {
browser = await chromium.launch();
page = await browser.newPage();
await page.setViewportSize({ width: 375, height: 768 });
});
after(async () => {
await browser.close();
});
const capture = async (html: string) => {
await page.setContent(html);
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
return page.evaluate(collectPage);
};
it("tags a far-off-screen, non-painting measurement scratch node as a probe", async () => {
// The classic font-metric probe pattern (WordPress/typography libs): absolutely positioned,
// parked ~100000px off-screen, visibility:hidden, holding measurement text.
const snap = await capture(`
<p class="real">Real content</p>
<div class="probe" style="position:absolute;top:-99999px;left:-99999px;
visibility:hidden;white-space:nowrap;">Mgy</div>`);
const probe = findByClass(snap.root, "probe")!;
assert.equal(probe.probe, true, "far-off-screen hidden scratch node is a probe");
const real = findByClass(snap.root, "real")!;
assert.ok(!real.probe, "real content is not a probe");
});
it("does NOT tag a near-off-screen hidden drawer (real content) as a probe", async () => {
// A slide-in drawer parked just off the left edge (x:-375, visibility:hidden) is real
// content that a controller can reveal — it must NOT be mistaken for a measurement probe.
const snap = await capture(`
<div class="drawer" style="position:fixed;left:0;top:0;width:375px;height:768px;
transform:translateX(-100%);visibility:hidden;">
<h2 class="dtitle">Menu</h2>
</div>`);
const drawer = findByClass(snap.root, "drawer")!;
assert.ok(!drawer.probe, "a near-off-screen drawer is not a probe");
});
it("does NOT tag an sr-only (visible, on-screen-adjacent) accessibility label as a probe", async () => {
// Screen-reader-only text stays visibility:visible so AT can read it; even parked far off
// via left:-9999px it must survive (and 9999px is under the 10000px probe threshold anyway).
const snap = await capture(`
<a href="/"><span class="sr" style="position:absolute;left:-9999px;">Skip to content</span>Home</a>`);
const sr = findByClass(snap.root, "sr")!;
assert.ok(!sr.probe, "sr-only accessible text is not a probe");
});
});