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:
co-authored by
Claude Fable 5
parent
dfcd4a6563
commit
77be868ee3
@@ -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 <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
|
||||
* 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<string, string>, 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<string, string>, 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<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 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<string | false | null | undefined>) {\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<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
|
||||
* 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. `<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[] };
|
||||
|
||||
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 styles = reg.styleDecls.map((s) => `const ${s.varName} = ${s.body};`);
|
||||
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];
|
||||
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.`;
|
||||
}
|
||||
|
||||
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 [];
|
||||
return [...reg.funcDefs].map(([name, src]) => {
|
||||
const agg = reg.byName.get(name);
|
||||
void agg;
|
||||
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
|
||||
// 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).
|
||||
@@ -1842,9 +1876,9 @@ export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgReg
|
||||
for (const c of scanRefs(src).comps) {
|
||||
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" : "";
|
||||
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 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 allConsts = consts;
|
||||
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)}";` : "",
|
||||
hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "",
|
||||
hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "",
|
||||
/\bcn\(/.test(body) ? cnImportLine(1) : "", // page.tsx lives at src/app
|
||||
...compImports,
|
||||
].filter(Boolean).join("\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
|
||||
* from the captured URL / title / description — the discovery files a real site ships. */
|
||||
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 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";
|
||||
${siteImport}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: ${JSON.stringify(origin + "/sitemap.xml")},
|
||||
sitemap: SITE_ORIGIN + "/sitemap.xml",
|
||||
};
|
||||
}
|
||||
`;
|
||||
const sitemap = `import type { MetadataRoute } from "next";
|
||||
${siteImport}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
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";
|
||||
|
||||
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 jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
|
||||
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
|
||||
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
|
||||
return `import "./globals.css";
|
||||
import "./ditto.css";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
${siteImport}
|
||||
${metadata}${viewport}
|
||||
|
||||
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, "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 (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_TSX);
|
||||
if (motionHasContent(motionSpec)) writeText(join(rootDir, "ditto", "DittoMotion.tsx"), DITTO_MOTION_TSX);
|
||||
|
||||
@@ -298,7 +298,10 @@ export type WidthPlan = { kind: "fixed" } | { kind: "auto" } | { kind: "percent"
|
||||
const PLAN_FIXED: WidthPlan = { kind: "fixed" };
|
||||
|
||||
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);
|
||||
|
||||
type GeometryPlan = {
|
||||
|
||||
@@ -151,7 +151,7 @@ export function accordionJsx(specs: AccordionRuntimeSpec[], indent: number): str
|
||||
}
|
||||
|
||||
export const ACCORDION_TSX = `"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type CapStyle = Record<string, string>;
|
||||
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.
|
||||
* Hydration initializes the captured base state, then clicks toggle only the target row. */
|
||||
export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
|
||||
const wired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wired.current) return;
|
||||
wired.current = true;
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
for (const spec of specs) {
|
||||
const state = spec.items.map((it) => it.expanded);
|
||||
const renderItem = (i: number) => {
|
||||
@@ -193,10 +192,11 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
|
||||
e.preventDefault();
|
||||
state[i] = !state[i];
|
||||
renderItem(i);
|
||||
});
|
||||
}, { signal });
|
||||
renderItem(i);
|
||||
});
|
||||
}
|
||||
return () => ac.abort();
|
||||
}, [specs]);
|
||||
return null;
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
|
||||
|
||||
/** The fixed DittoWire client component, written once per generated app. */
|
||||
export const DITTO_WIRE_TSX = `"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type CapStyle = Record<string, string>;
|
||||
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
|
||||
* base state exactly, so state styles are applied only on user interaction. */
|
||||
export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
const wired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wired.current) return;
|
||||
wired.current = true;
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
if (spec.kind === "tabs") {
|
||||
let active = spec.active;
|
||||
const render = () => spec.tabs.forEach((t, i) => {
|
||||
@@ -254,7 +253,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
spec.tabs.forEach((t, i) => {
|
||||
const trig = byCid(t.trigger);
|
||||
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) => {
|
||||
const k = (e as KeyboardEvent).key;
|
||||
if (k === "ArrowRight" || k === "ArrowLeft") {
|
||||
@@ -263,7 +262,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
render();
|
||||
byCid(spec.tabs[active].trigger)?.focus();
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
});
|
||||
// No initial render() — the static base state is already correct.
|
||||
} else if (spec.kind === "accordion") {
|
||||
@@ -278,7 +277,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
};
|
||||
spec.items.forEach((it, i) => {
|
||||
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.
|
||||
} 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 prevEl = spec.prev ? byCid(spec.prev) : null;
|
||||
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); });
|
||||
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); });
|
||||
spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); }));
|
||||
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); }, { signal });
|
||||
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); }, { signal });
|
||||
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.
|
||||
} else {
|
||||
// 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) 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) {
|
||||
const root = trig.parentElement ?? trig;
|
||||
root.addEventListener("mouseenter", () => set(true));
|
||||
root.addEventListener("mouseleave", () => set(false));
|
||||
root.addEventListener("mouseenter", () => set(true), { signal });
|
||||
root.addEventListener("mouseleave", () => set(false), { signal });
|
||||
}
|
||||
it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); }));
|
||||
if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); });
|
||||
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) 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); }, { signal });
|
||||
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); }, { signal });
|
||||
});
|
||||
// No initial set() — the static base state is already correct.
|
||||
}
|
||||
return () => ac.abort();
|
||||
}, [spec]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function dropdownMenuImportPath(depth: number): string {
|
||||
|
||||
/** The fixed DropdownMenu client component, written once per generated app. */
|
||||
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 };
|
||||
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
|
||||
* render is therefore unchanged. */
|
||||
export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
|
||||
const wired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wired.current) return;
|
||||
wired.current = true;
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
const openPanels: HTMLElement[] = [];
|
||||
for (const m of menus) {
|
||||
const trig = byCid(m.trigger);
|
||||
if (!trig) continue;
|
||||
@@ -127,27 +127,41 @@ export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
|
||||
panel = wrap.firstElementChild as HTMLElement | null;
|
||||
if (!panel) return;
|
||||
document.body.appendChild(panel);
|
||||
openPanels.push(panel);
|
||||
place();
|
||||
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());
|
||||
if (m.hoverOpen) {
|
||||
const root = trig.parentElement ?? trig;
|
||||
root.addEventListener("mouseenter", open);
|
||||
root.addEventListener("mouseleave", close);
|
||||
root.addEventListener("mouseenter", open, { signal });
|
||||
root.addEventListener("mouseleave", close, { signal });
|
||||
} 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) => {
|
||||
const t = e.target as Node;
|
||||
if (panel && !trig.contains(t) && !panel.contains(t)) close();
|
||||
});
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, { passive: true });
|
||||
}, { signal });
|
||||
window.addEventListener("resize", place, { signal });
|
||||
window.addEventListener("scroll", place, { passive: true, signal });
|
||||
}
|
||||
(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]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -289,8 +289,15 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
if (keywords) metadata.keywords = keywords;
|
||||
if (report.robots) metadata.robots = report.robots;
|
||||
if (report.referrer) metadata.referrer = report.referrer;
|
||||
const sourceOrigin = originOf(report.sourceUrl);
|
||||
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) {
|
||||
const languages: Record<string, string> = {};
|
||||
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;
|
||||
}
|
||||
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 (Object.keys(og).length) metadata.openGraph = og;
|
||||
|
||||
@@ -352,8 +359,23 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
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 {
|
||||
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 {
|
||||
@@ -367,13 +389,31 @@ function safeJsonLd(text: string): string {
|
||||
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 {
|
||||
if (!report.jsonLd.length) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
const sourceOrigin = originOf(report.sourceUrl);
|
||||
return report.jsonLd.map((entry, index) => `${pad}<script
|
||||
${pad} key="ditto-json-ld-${index}"
|
||||
${pad} type="application/ld+json"
|
||||
${pad} dangerouslySetInnerHTML={{ __html: ${JSON.stringify(safeJsonLd(entry.text))} }}
|
||||
${pad} dangerouslySetInnerHTML={{ __html: ${jsonLdHtmlExpr(entry.text, sourceOrigin)} }}
|
||||
${pad}/>`).join("\n");
|
||||
}
|
||||
|
||||
@@ -397,6 +437,27 @@ function originOf(url: string): string {
|
||||
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 {
|
||||
const title = report.title || routes[0]?.title || "Generated Clone";
|
||||
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]> {
|
||||
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";
|
||||
${siteImport}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
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",
|
||||
description: report.description,
|
||||
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";
|
||||
${siteImport}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return ${JSON.stringify(sitemapEntries, null, 2)};
|
||||
return [
|
||||
${entryLines},
|
||||
];
|
||||
}
|
||||
`;
|
||||
const sourceLlms = resourceText(report, "llms");
|
||||
@@ -472,11 +543,12 @@ function xmlEscape(value: string): string {
|
||||
|
||||
export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
|
||||
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 = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
`Sitemap: ${sitemapUrl}`,
|
||||
"Sitemap: /sitemap.xml",
|
||||
"",
|
||||
].join("\n");
|
||||
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">',
|
||||
...sitemapRoutes.map((route, index) => [
|
||||
" <url>",
|
||||
` <loc>${xmlEscape(route.url)}</loc>`,
|
||||
` <loc>${xmlEscape(relativizeToSource(route.url, origin).path)}</loc>`,
|
||||
" <changefreq>weekly</changefreq>",
|
||||
` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`,
|
||||
" </url>",
|
||||
|
||||
@@ -28,6 +28,25 @@ import type { InteractionCapture, StyleDelta } from "../capture/interactions.js"
|
||||
function arb(v: string): string {
|
||||
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 {
|
||||
return arb(v).replace(/,_/g, ",");
|
||||
}
|
||||
@@ -243,7 +262,7 @@ export function declToUtil(prop: string, value: string): string {
|
||||
if (value === "0" || value === "0px" || value === "0rem") {
|
||||
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)) {
|
||||
const side = prop.split("-")[1]![0]!; // t/r/b/l
|
||||
|
||||
Reference in New Issue
Block a user