Initial commit
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Semantic class map (output-quality, fidelity-neutral).
|
||||
*
|
||||
* The per-node fidelity engine (css.ts) emits one `.c<id>{…}` rule per DOM node —
|
||||
* pixel-exact but a wall of opaque, unshared rules. This module rewrites that into a
|
||||
* small set of SHARED, semantically-named classes:
|
||||
*
|
||||
* 1. Compute each node's full rule (base + per-band deltas + pseudos) via the same
|
||||
* collectNodeRules used by css.ts — one source of truth.
|
||||
* 2. Dedup nodes whose rule is BYTE-IDENTICAL into one class. Identical own-decls ⇒
|
||||
* identical own-style; each node still inherits from its real parent, so the
|
||||
* grader (which compares each node's FINAL computed style by data-cid) is
|
||||
* unaffected. This is the safety invariant: sharing a class never changes a
|
||||
* node's computed style.
|
||||
* 3. Name each class by ROLE (primitive type → btn/link/icon/…, else tag/layout →
|
||||
* heading/list/row/stack/box), deduped with a numeric suffix.
|
||||
*
|
||||
* Result: ditto.css shrinks dramatically and reads as a component stylesheet, and
|
||||
* every element carries a meaningful class instead of `c142`. Determinism holds —
|
||||
* grouping + naming are pure functions of document order.
|
||||
*/
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { PrimitiveType } from "../infer/primitives.js";
|
||||
import type { TokenResolver } from "../infer/tokens.js";
|
||||
import { collectNodeRules, assembleCss, keyframesCss, computeBands, type NodeRule } from "./css.js";
|
||||
|
||||
export type ClassMap = {
|
||||
/** cid → semantic class name (null/absent when the node has no own styles). */
|
||||
classOf: Map<string, string>;
|
||||
css: string;
|
||||
stats: { nodes: number; classes: number; reusePct: number };
|
||||
};
|
||||
|
||||
function serDecls(m: Map<string, string>): string {
|
||||
let s = "";
|
||||
for (const [k, v] of m) s += k + ":" + v + ";";
|
||||
return s;
|
||||
}
|
||||
/** Canonical (exact, order-preserving) serialization of a node's full rule. Two nodes
|
||||
* with the same string have byte-identical declarations at every viewport + pseudo. */
|
||||
function signature(nr: NodeRule): string {
|
||||
let s = serDecls(nr.base);
|
||||
for (const b of nr.bands) s += `@${b.media}{${serDecls(b.decls)}}`;
|
||||
if (nr.before) { s += "::before{" + serDecls(nr.before.base); for (const b of nr.before.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
||||
if (nr.after) { s += "::after{" + serDecls(nr.after.base); for (const b of nr.after.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
|
||||
return s;
|
||||
}
|
||||
|
||||
const PRIM_CLASS: Record<PrimitiveType, string> = {
|
||||
button: "btn", link: "link", input: "input", select: "select", textarea: "textarea",
|
||||
icon: "icon", image: "image", avatar: "avatar", badge: "badge", heading: "heading", nav: "nav",
|
||||
};
|
||||
|
||||
/** A node's display role for class naming (only used for divs/spans/generic boxes). */
|
||||
function layoutHint(nr: NodeRule): string | null {
|
||||
const disp = nr.base.get("display");
|
||||
if (disp === "grid" || disp === "inline-grid") return "grid";
|
||||
if (disp === "flex" || disp === "inline-flex") {
|
||||
const dir = nr.base.get("flex-direction");
|
||||
return dir === "column" || dir === "column-reverse" ? "stack" : "row";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const TAG_CLASS: Record<string, string> = {
|
||||
section: "section", header: "header", footer: "footer", nav: "nav", main: "main",
|
||||
aside: "aside", article: "article", ul: "list", ol: "list", li: "list-item",
|
||||
a: "link", button: "btn", p: "text", span: "text", label: "label", form: "form",
|
||||
figure: "figure", figcaption: "caption", blockquote: "quote", table: "table",
|
||||
thead: "thead", tbody: "tbody", tr: "row", td: "cell", th: "cell", img: "image",
|
||||
picture: "image", svg: "icon", video: "video", input: "input", select: "select",
|
||||
textarea: "textarea", h1: "h1", h2: "h2", h3: "h3", h4: "h4", h5: "h5", h6: "h6",
|
||||
};
|
||||
|
||||
/** Semantic base name for a node's class — primitive type first (the strongest signal),
|
||||
* then tag, then layout role for generic boxes. */
|
||||
function classBaseName(node: IRNode, prim: string | undefined, nr: NodeRule): string {
|
||||
if (prim && PRIM_CLASS[prim as PrimitiveType]) return PRIM_CLASS[prim as PrimitiveType]!;
|
||||
const t = node.tag;
|
||||
if (TAG_CLASS[t]) return TAG_CLASS[t]!;
|
||||
if (t.includes("-")) return "widget"; // custom element
|
||||
// div / unknown: name by layout role so the markup reads (stack/row/grid/box).
|
||||
return layoutHint(nr) ?? "box";
|
||||
}
|
||||
|
||||
function indexNodes(ir: IR): Map<string, IRNode> {
|
||||
const m = new Map<string, IRNode>();
|
||||
const walk = (n: IRNode): void => { m.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) walk(c); };
|
||||
walk(ir.root);
|
||||
return m;
|
||||
}
|
||||
|
||||
// Typographic / inherited properties. Split into their own SHARED class so the (few
|
||||
// distinct) text styles on a page are reused across all elements that share them — the
|
||||
// way a real stylesheet factors typography out of per-box layout. The remaining box/
|
||||
// layout/visual props stay on a role-named box class. The two prop sets are disjoint, so
|
||||
// an element carrying both classes resolves to exactly its original declarations.
|
||||
const TYPO_PROPS = new Set([
|
||||
"color", "font-family", "font-size", "font-weight", "font-style", "line-height",
|
||||
"letter-spacing", "word-spacing", "text-align", "text-transform", "text-decoration-line",
|
||||
"text-decoration-style", "text-decoration-color", "white-space", "word-break", "overflow-wrap",
|
||||
"text-indent", "text-shadow", "font-variant-caps", "font-feature-settings", "list-style-type",
|
||||
"list-style-position", "writing-mode", "direction", "-webkit-text-fill-color", "-webkit-text-stroke",
|
||||
]);
|
||||
// Layout / flow properties (flex/grid container + item, gaps, overflow). These few
|
||||
// patterns repeat heavily across a page, so factoring them into a shared layout class
|
||||
// (.row/.stack/.grid/.flow) lifts reuse far above what whole-block classes allow — while
|
||||
// geometry (width/height/padding/margin/border/position/visuals) stays per-element.
|
||||
const LAYOUT_PROPS = new Set([
|
||||
"display", "flex-direction", "flex-wrap", "justify-content", "align-items", "align-content",
|
||||
"align-self", "flex-grow", "flex-shrink", "flex-basis", "order", "gap", "row-gap", "column-gap",
|
||||
"grid-template-columns", "grid-template-rows", "grid-template-areas", "grid-auto-flow",
|
||||
"grid-auto-rows", "grid-auto-columns", "justify-items", "overflow-x", "overflow-y",
|
||||
]);
|
||||
|
||||
function partition(m: Map<string, string>): { typo: Map<string, string>; layout: Map<string, string>; box: Map<string, string> } {
|
||||
const typo = new Map<string, string>(), layout = new Map<string, string>(), box = new Map<string, string>();
|
||||
for (const [k, v] of m) (TYPO_PROPS.has(k) ? typo : LAYOUT_PROPS.has(k) ? layout : box).set(k, v);
|
||||
return { typo, layout, box };
|
||||
}
|
||||
/** Split a node's rule into typography / layout / geometry(box) rules (disjoint prop
|
||||
* sets), with banded deltas split too. Pseudo-elements stay with the box rule. */
|
||||
function splitRule(nr: NodeRule): { typo: NodeRule; layout: NodeRule; box: NodeRule } {
|
||||
const b0 = partition(nr.base);
|
||||
const typo: NodeRule = { base: b0.typo, bands: [] };
|
||||
const layout: NodeRule = { base: b0.layout, bands: [] };
|
||||
const box: NodeRule = { base: b0.box, bands: [], before: nr.before, after: nr.after };
|
||||
for (const band of nr.bands) {
|
||||
const bs = partition(band.decls);
|
||||
if (bs.typo.size) typo.bands.push({ media: band.media, decls: bs.typo });
|
||||
if (bs.layout.size) layout.bands.push({ media: band.media, decls: bs.layout });
|
||||
if (bs.box.size) box.bands.push({ media: band.media, decls: bs.box });
|
||||
}
|
||||
return { typo, layout, box };
|
||||
}
|
||||
|
||||
/** Layout-class base name from the display/flex role. */
|
||||
function layoutName(nr: NodeRule): string {
|
||||
const disp = nr.base.get("display");
|
||||
if (disp === "grid" || disp === "inline-grid") return "grid";
|
||||
if (disp === "flex" || disp === "inline-flex") {
|
||||
const dir = nr.base.get("flex-direction");
|
||||
return dir === "column" || dir === "column-reverse" ? "stack" : "row";
|
||||
}
|
||||
return "flow";
|
||||
}
|
||||
|
||||
export function buildClassMap(ir: IR, assetMap: Map<string, string>, colorVar?: (v: string) => string | null, primitives?: Map<string, string>, tokenResolver?: TokenResolver): ClassMap {
|
||||
const rules = collectNodeRules(ir, assetMap, undefined, colorVar, tokenResolver);
|
||||
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
|
||||
const nodeByCid = indexNodes(ir);
|
||||
|
||||
const sigToClass = new Map<string, string>();
|
||||
const classToRule = new Map<string, NodeRule>();
|
||||
const order: string[] = [];
|
||||
const nameCount = new Map<string, number>();
|
||||
const classOf = new Map<string, string>();
|
||||
|
||||
// Dedup a (typo or box) rule by its signature → one shared class; name it `base`,
|
||||
// suffixing for distinct rules that want the same base name. Disjoint typo/box prop
|
||||
// sets mean their signatures never collide, so one map is safe.
|
||||
const assign = (sig: string, base: string, rule: NodeRule): string => {
|
||||
if (sig === "") return "";
|
||||
let cls = sigToClass.get(sig);
|
||||
if (!cls) {
|
||||
const n = (nameCount.get(base) ?? 0) + 1;
|
||||
nameCount.set(base, n);
|
||||
cls = n === 1 ? base : `${base}-${n}`;
|
||||
sigToClass.set(sig, cls);
|
||||
classToRule.set(cls, rule);
|
||||
order.push(cls);
|
||||
}
|
||||
return cls;
|
||||
};
|
||||
|
||||
let usages = 0;
|
||||
for (const [cid, nr] of rules) { // document pre-order
|
||||
const { typo, layout, box } = splitRule(nr);
|
||||
const boxCls = assign(signature(box), classBaseName(nodeByCid.get(cid)!, primitives?.get(cid), box), box);
|
||||
const layoutCls = assign(signature(layout), layoutName(layout), layout);
|
||||
const typoCls = assign(signature(typo), "type", typo);
|
||||
// role/geometry class first, then layout, then typography
|
||||
const full = [boxCls, layoutCls, typoCls].filter(Boolean).join(" ");
|
||||
if (full) { classOf.set(cid, full); usages += (boxCls ? 1 : 0) + (layoutCls ? 1 : 0) + (typoCls ? 1 : 0); }
|
||||
}
|
||||
|
||||
const kf = keyframesCss(ir, assetMap);
|
||||
const css = assembleCss(order, (c) => classToRule.get(c)!, (c) => `.${c}`, bands, kf);
|
||||
const reusePct = usages ? Math.round((1 - order.length / usages) * 1000) / 10 : 0;
|
||||
return { classOf, css, stats: { nodes: classOf.size, classes: order.length, reusePct } };
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { basename } from "node:path";
|
||||
import { collectFiles, scoreApp, type QualityReport, type SrcFile } from "../runner/qualityScore.js";
|
||||
import type { RecipeCandidate, RecipeReport } from "../infer/recipes.js";
|
||||
|
||||
export type CodeQualityMetrics = {
|
||||
files: number;
|
||||
jsxTags: number;
|
||||
arbitraryPxRem: number;
|
||||
decimalArbitraryPx: number;
|
||||
arbitraryBands: number;
|
||||
breakpointUtilities: number;
|
||||
dataCid: number;
|
||||
dataDittoId: number;
|
||||
styleRefs: number;
|
||||
switchCases: number;
|
||||
variantSlotComponents: number;
|
||||
mapCalls: number;
|
||||
};
|
||||
|
||||
export type RecipeCodeQuality = {
|
||||
id: string;
|
||||
kind: RecipeCandidate["kind"];
|
||||
confidence: number;
|
||||
rootCid: string;
|
||||
itemParentCid: string | null;
|
||||
dataModel: string | null;
|
||||
itemCount: number | null;
|
||||
files: string[];
|
||||
metrics: CodeQualityMetrics;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export type CodeQualityReport = {
|
||||
version: 1;
|
||||
appDir: string;
|
||||
quality: QualityReport;
|
||||
summary: CodeQualityMetrics & {
|
||||
componentModules: number;
|
||||
totalTags: number;
|
||||
qualityTotal: number;
|
||||
};
|
||||
hotspots: Array<{ file: string; metrics: CodeQualityMetrics }>;
|
||||
recipes: RecipeCodeQuality[];
|
||||
};
|
||||
|
||||
function count(text: string, re: RegExp): number {
|
||||
return (text.match(re) ?? []).length;
|
||||
}
|
||||
|
||||
function countJsxTags(text: string): number {
|
||||
return count(text, /<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g);
|
||||
}
|
||||
|
||||
function metricsFor(files: SrcFile[]): CodeQualityMetrics {
|
||||
let arbitraryPxRem = 0;
|
||||
let decimalArbitraryPx = 0;
|
||||
const text = files.map((f) => f.text).join("\n");
|
||||
for (const m of text.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) {
|
||||
arbitraryPxRem++;
|
||||
const px = parseFloat(m[1] ?? "0") * (m[2] === "rem" ? 16 : 1);
|
||||
if (Math.abs(px - Math.round(px)) > 0.02) decimalArbitraryPx++;
|
||||
}
|
||||
|
||||
return {
|
||||
files: files.length,
|
||||
jsxTags: files.reduce((sum, f) => sum + countJsxTags(f.text), 0),
|
||||
arbitraryPxRem,
|
||||
decimalArbitraryPx,
|
||||
arbitraryBands: count(text, /\b(?:min|max)-\[[0-9]+px\]:/g),
|
||||
breakpointUtilities: count(text, /\b(?:sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g),
|
||||
dataCid: count(text, /data-cid/g),
|
||||
dataDittoId: count(text, /data-ditto-id/g),
|
||||
styleRefs: count(text, /\b(?:styles\.[A-Za-z_]\w*|\w+Styles|styles)\b/g),
|
||||
switchCases: count(text, /\bcase\s+["']/g),
|
||||
variantSlotComponents: count(text, /function\s+\w+Slot\d+\b/g),
|
||||
mapCalls: count(text, /\.map\(/g),
|
||||
};
|
||||
}
|
||||
|
||||
function addMetrics(a: CodeQualityMetrics, b: CodeQualityMetrics): CodeQualityMetrics {
|
||||
return {
|
||||
files: a.files + b.files,
|
||||
jsxTags: a.jsxTags + b.jsxTags,
|
||||
arbitraryPxRem: a.arbitraryPxRem + b.arbitraryPxRem,
|
||||
decimalArbitraryPx: a.decimalArbitraryPx + b.decimalArbitraryPx,
|
||||
arbitraryBands: a.arbitraryBands + b.arbitraryBands,
|
||||
breakpointUtilities: a.breakpointUtilities + b.breakpointUtilities,
|
||||
dataCid: a.dataCid + b.dataCid,
|
||||
dataDittoId: a.dataDittoId + b.dataDittoId,
|
||||
styleRefs: a.styleRefs + b.styleRefs,
|
||||
switchCases: a.switchCases + b.switchCases,
|
||||
variantSlotComponents: a.variantSlotComponents + b.variantSlotComponents,
|
||||
mapCalls: a.mapCalls + b.mapCalls,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyMetrics(): CodeQualityMetrics {
|
||||
return {
|
||||
files: 0,
|
||||
jsxTags: 0,
|
||||
arbitraryPxRem: 0,
|
||||
decimalArbitraryPx: 0,
|
||||
arbitraryBands: 0,
|
||||
breakpointUtilities: 0,
|
||||
dataCid: 0,
|
||||
dataDittoId: 0,
|
||||
styleRefs: 0,
|
||||
switchCases: 0,
|
||||
variantSlotComponents: 0,
|
||||
mapCalls: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function filesForRecipe(files: SrcFile[], c: RecipeCandidate): SrcFile[] {
|
||||
const cidNeedles = [
|
||||
c.rootCid,
|
||||
c.itemParentCid,
|
||||
...(c.repeatedItems ?? []).map((item) => item.cid),
|
||||
].filter((x): x is string => !!x);
|
||||
const cidPatterns = cidNeedles.map((cid) => new RegExp(`(?<![A-Za-z0-9_-])${escapeRegExp(cid)}(?![A-Za-z0-9_-])`));
|
||||
const kindNeedle = c.kind.replace(/-/g, "-");
|
||||
const componentNeedle = c.componentName;
|
||||
const componentPattern = componentNeedle ? new RegExp(`\\b${escapeRegExp(componentNeedle)}\\b`) : null;
|
||||
const matched = files.filter((f) => {
|
||||
if (cidPatterns.some((re) => re.test(f.text))) return true;
|
||||
const rel = f.rel.toLowerCase();
|
||||
if (componentPattern?.test(f.text)) return true;
|
||||
if (rel.includes(kindNeedle)) return true;
|
||||
if (c.kind === "card-grid" && rel.includes("card-grid-item")) return true;
|
||||
if (c.kind === "feature-grid" && rel.includes("feature-grid-item")) return true;
|
||||
if (c.kind === "logo-cloud" && rel.includes("logo-cloud-item")) return true;
|
||||
return false;
|
||||
});
|
||||
return [...new Map(matched.map((f) => [f.rel, f])).values()].sort((a, b) => a.rel.localeCompare(b.rel));
|
||||
}
|
||||
|
||||
function notesForRecipe(c: RecipeCandidate, m: CodeQualityMetrics): string[] {
|
||||
const notes: string[] = [];
|
||||
if (m.variantSlotComponents > 0) notes.push("uses variant-slot helper(s) for heterogeneous media/content");
|
||||
if (m.switchCases > 0 && m.variantSlotComponents === 0) notes.push("still emitted as a full switch fallback");
|
||||
if (m.styleRefs > 0) notes.push("uses per-instance style override plumbing");
|
||||
if (m.arbitraryBands === 0) notes.push("no arbitrary breakpoint bands in matched files");
|
||||
if (m.decimalArbitraryPx > 0) notes.push("contains frozen non-integer arbitrary measurements");
|
||||
if (m.breakpointUtilities > 0 && m.arbitraryBands === 0) notes.push("responsive logic uses named breakpoints");
|
||||
if (m.files === 0) notes.push("no generated file could be matched back to this recipe");
|
||||
if (c.confidence < 0.86) notes.push("low-confidence recipe; report-only for now");
|
||||
return notes;
|
||||
}
|
||||
|
||||
export function buildCodeQualityReport(appDir: string, recipes: RecipeReport): CodeQualityReport {
|
||||
const files = collectFiles(appDir);
|
||||
const quality = { ...scoreApp(appDir), dir: "app" };
|
||||
const summaryMetrics = metricsFor(files);
|
||||
const hotspots = files
|
||||
.map((f) => ({ file: f.rel, metrics: metricsFor([f]) }))
|
||||
.filter((h) => h.metrics.arbitraryPxRem || h.metrics.decimalArbitraryPx || h.metrics.arbitraryBands || h.metrics.switchCases || h.metrics.variantSlotComponents || h.metrics.styleRefs)
|
||||
.sort((a, b) => {
|
||||
const aScore = a.metrics.decimalArbitraryPx * 8 + a.metrics.arbitraryPxRem * 3 + a.metrics.arbitraryBands * 6 + a.metrics.switchCases + a.metrics.styleRefs + a.metrics.variantSlotComponents;
|
||||
const bScore = b.metrics.decimalArbitraryPx * 8 + b.metrics.arbitraryPxRem * 3 + b.metrics.arbitraryBands * 6 + b.metrics.switchCases + b.metrics.styleRefs + b.metrics.variantSlotComponents;
|
||||
return bScore - aScore || a.file.localeCompare(b.file);
|
||||
})
|
||||
.slice(0, 20);
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
appDir: "app",
|
||||
quality,
|
||||
summary: {
|
||||
...summaryMetrics,
|
||||
componentModules: quality.raw.componentModules ?? 0,
|
||||
totalTags: quality.raw.totalTags ?? 0,
|
||||
qualityTotal: quality.total,
|
||||
},
|
||||
hotspots,
|
||||
recipes: recipes.candidates.map((c) => {
|
||||
const matched = filesForRecipe(files, c);
|
||||
const metrics = matched.reduce((sum, f) => addMetrics(sum, metricsFor([f])), emptyMetrics());
|
||||
return {
|
||||
id: c.id,
|
||||
kind: c.kind,
|
||||
confidence: c.confidence,
|
||||
rootCid: c.rootCid,
|
||||
itemParentCid: c.itemParentCid ?? null,
|
||||
dataModel: c.dataModel ?? null,
|
||||
itemCount: c.itemCount ?? null,
|
||||
files: matched.map((f) => f.rel),
|
||||
metrics,
|
||||
notes: notesForRecipe(c, metrics),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function metricSummary(m: CodeQualityMetrics): string {
|
||||
return [
|
||||
`${m.files} files`,
|
||||
`${m.jsxTags} tags`,
|
||||
`${m.arbitraryPxRem} arb px/rem`,
|
||||
`${m.decimalArbitraryPx} decimal`,
|
||||
`${m.breakpointUtilities} breakpoints`,
|
||||
`${m.arbitraryBands} arb bands`,
|
||||
`${m.switchCases} cases`,
|
||||
`${m.variantSlotComponents} slots`,
|
||||
].join(" · ");
|
||||
}
|
||||
|
||||
export function codeQualityReportToMarkdown(report: CodeQualityReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# Code Quality Report`);
|
||||
lines.push("");
|
||||
lines.push(`App: \`${basename(report.appDir)}\``);
|
||||
lines.push(`Quality: **${report.quality.total}/100**`);
|
||||
lines.push("");
|
||||
lines.push(`## Summary`);
|
||||
lines.push("");
|
||||
lines.push(`- ${metricSummary(report.summary)}`);
|
||||
lines.push(`- component modules: ${report.summary.componentModules}; total tags: ${report.summary.totalTags}; data-cid in generated validation tree: ${report.summary.dataCid}; data-ditto-id: ${report.summary.dataDittoId}`);
|
||||
lines.push("");
|
||||
lines.push(`## Recipes`);
|
||||
lines.push("");
|
||||
if (!report.recipes.length) {
|
||||
lines.push(`No recipe candidates detected.`);
|
||||
} else {
|
||||
for (const r of report.recipes) {
|
||||
lines.push(`### ${r.id} · ${r.kind} · confidence ${r.confidence}`);
|
||||
lines.push("");
|
||||
lines.push(`- model: ${r.dataModel ?? "none"}; items: ${r.itemCount ?? "n/a"}; root: \`${r.rootCid}\`; item parent: \`${r.itemParentCid ?? "none"}\``);
|
||||
lines.push(`- ${metricSummary(r.metrics)}`);
|
||||
if (r.files.length) lines.push(`- files: ${r.files.map((f) => `\`${f}\``).join(", ")}`);
|
||||
if (r.notes.length) lines.push(`- notes: ${r.notes.join("; ")}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
lines.push(`## Hotspots`);
|
||||
lines.push("");
|
||||
if (!report.hotspots.length) {
|
||||
lines.push(`No code-quality hotspots detected.`);
|
||||
} else {
|
||||
for (const h of report.hotspots.slice(0, 12)) {
|
||||
lines.push(`- \`${h.file}\`: ${metricSummary(h.metrics)}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
return lines.join("\n");
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
import { join } from "node:path";
|
||||
import { writeText } from "../util/fsx.js";
|
||||
import type { SeoRouteSummary } from "./seo.js";
|
||||
|
||||
export type GeneratedDocsInput = {
|
||||
sourceUrl: string;
|
||||
routes: SeoRouteSummary[];
|
||||
styling: "tailwind" | "css";
|
||||
framework: "next" | "vite";
|
||||
multiRoute: boolean;
|
||||
components: boolean;
|
||||
sectionCount: number;
|
||||
componentCount: number;
|
||||
svgCount: number;
|
||||
hasContentModule: boolean;
|
||||
runtimeUtilities: string[];
|
||||
};
|
||||
|
||||
function routeLine(route: SeoRouteSummary): string {
|
||||
return `- ${route.href} - ${route.title || route.routePath}`;
|
||||
}
|
||||
|
||||
function runtimeLine(runtimeUtilities: string[]): string {
|
||||
return runtimeUtilities.length ? runtimeUtilities.sort().join(", ") : "none emitted for this capture";
|
||||
}
|
||||
|
||||
export function agentsMd(input: GeneratedDocsInput): string {
|
||||
const routes = input.routes.length ? input.routes.map(routeLine).join("\n") : "- / - generated route";
|
||||
const root = input.framework === "next" ? "src/app" : "src";
|
||||
const routeBody = input.framework === "next" ? "`src/app/page.tsx` and nested route `page.tsx` files" : input.multiRoute ? "`src/routes/*/page.tsx` route modules" : "`src/page.tsx`";
|
||||
const seoFiles = input.framework === "next"
|
||||
? "`src/app/robots.ts`, `src/app/sitemap.ts`, and `src/app/llms.txt/route.ts`"
|
||||
: "`public/robots.txt`, `public/sitemap.xml`, and `public/llms.txt`";
|
||||
return `# AGENTS.md
|
||||
|
||||
This is a generated ditto.site clone app for ${input.sourceUrl}. It is a static ${input.framework === "next" ? "Next.js App Router" : "Vite React"} project produced from captured DOM, CSS, assets, metadata, and interaction recipes.
|
||||
|
||||
## Run
|
||||
|
||||
- \`npm install\`
|
||||
- \`npm run dev\`
|
||||
- \`npm run build\`
|
||||
- \`npm run start\`
|
||||
|
||||
## Safe Edit Areas
|
||||
|
||||
- \`${root}/content.ts\` or \`${root}/content.tsx\`: editable structured content extracted from repeated components and sections when present.
|
||||
- \`${root}/components/\`: generated component modules. Edit copy, links, and simple JSX structure with care.
|
||||
- \`${root}/sections/\`: generated section modules for single-page section splits when present.
|
||||
- \`${root}/svgs/\`: hoisted inline SVG modules. Edit only when intentionally changing artwork.
|
||||
- \`${root}/ditto.css\`: fidelity CSS for captured layout, pseudos, keyframes, and interaction states. Small visual tweaks are reasonable; broad rewrites can break clone fidelity.
|
||||
- Root SEO/docs files such as \`AGENTS.md\`, \`ARCHITECTURE.md\`, and ${seoFiles}.
|
||||
|
||||
## Generated Runtime
|
||||
|
||||
\`${root}/ditto\` contains generated runtime utilities for captured interactions and motion. Current runtime utilities: ${runtimeLine(input.runtimeUtilities)}. Do not casually rewrite these files; they are plumbing that maps captured recipes to stable \`data-ditto-id\` anchors in delivered apps.
|
||||
|
||||
## File Meanings
|
||||
|
||||
- ${routeBody}: generated route bodies.
|
||||
- \`${root}/content.ts\`: structured data extracted from repeated clone regions.
|
||||
- \`${root}/components/\`: reusable JSX components promoted from repeated captured subtrees.
|
||||
- \`${root}/sections/\`: page sections split from the captured body.
|
||||
- \`${root}/svgs/\`: inline SVGs hoisted out of page/section files.
|
||||
- \`${root}/ditto.css\`: generated CSS that preserves source layout and visual details not represented by Tailwind utilities.
|
||||
- \`${root}/ditto-meta.ts\`: delivered-app metadata for anchors that still need runtime or stylesheet targeting after validation-only ids are stripped.
|
||||
|
||||
## Routes
|
||||
|
||||
${routes}
|
||||
|
||||
## Do Not Edit Casually
|
||||
|
||||
- \`${root}/ditto/\` runtime utilities.
|
||||
- Generated anchor metadata such as \`ditto-meta.ts\`.
|
||||
- Validation-only files in working captures, including \`_cids.ts\` and \`_styles.ts\` before export stripping.
|
||||
- Framework shell plumbing unless you are intentionally changing global metadata or page mounting behavior.
|
||||
`;
|
||||
}
|
||||
|
||||
export function architectureMd(input: GeneratedDocsInput): string {
|
||||
const routes = input.routes.length ? input.routes.map(routeLine).join("\n") : "- / - generated route";
|
||||
const root = input.framework === "next" ? "src/app" : "src";
|
||||
return `# ARCHITECTURE.md
|
||||
|
||||
## Overview
|
||||
|
||||
This app is a generated ditto.site clone. The generator captured the source page${input.multiRoute ? "s" : ""}, normalized the rendered DOM into an IR, inferred assets/tokens/sections/recipes, and emitted a static ${input.framework === "next" ? "Next.js App Router" : "Vite React"} project.
|
||||
|
||||
## Structure
|
||||
|
||||
- ${input.framework === "next" ? "`src/app/layout.tsx`: root App Router layout, language, metadata, viewport, JSON-LD, and shared shell." : "`index.html` and route HTML files: Vite HTML entries with captured language, metadata, JSON-LD, and body attributes."}
|
||||
- ${input.framework === "next" ? "`src/app/page.tsx` and nested route `page.tsx` files" : input.multiRoute ? "`src/routes/*/page.tsx` files" : "`src/page.tsx`"}: generated route bodies.
|
||||
- \`${root}/globals.css\`: reset, font faces, design tokens, and global page base.
|
||||
- \`${root}/ditto.css\`: route or page fidelity CSS.
|
||||
- \`${root}/content.ts\`: editable data layer when repeated regions were promoted.
|
||||
- \`${root}/components/\`, \`${root}/sections/\`, \`${root}/svgs/\`: generated JSX modules.
|
||||
- \`${root}/ditto/\`: runtime helpers for interaction and motion recipes.
|
||||
- \`public/assets/cloned/\`: materialized source assets.
|
||||
|
||||
## Styling
|
||||
|
||||
The generator uses ${input.styling === "tailwind" ? "Tailwind classes for declarations that can be represented as stable utilities" : "CSS classes for generated visual declarations"}. Some styles remain in \`ditto.css\` because they are route-scoped, pseudo-element based, keyframe based, interaction-state based, or too specific to translate safely without changing the rendered result.
|
||||
|
||||
## Anchors
|
||||
|
||||
\`data-ditto-id\` exists in delivered apps where runtime utilities or generated CSS still need a stable DOM anchor. Validation-only capture ids are stripped from production output and should not be reintroduced.
|
||||
|
||||
## Recipes And Runtime
|
||||
|
||||
Recipes identify higher-level patterns such as repeated cards, logo clouds, navigation, disclosures, accordions, tabs, carousels, and motion. Sections and components provide editable structure, SVG modules preserve source artwork, and \`${root}/ditto\` applies the small runtime behaviors that were captured safely. Runtime utilities emitted for this clone: ${runtimeLine(input.runtimeUtilities)}.
|
||||
|
||||
## Clone Metadata
|
||||
|
||||
- routes: ${input.routes.length}
|
||||
- extracted components: ${input.componentCount}
|
||||
- section modules: ${input.sectionCount}
|
||||
- SVG modules: ${input.svgCount}
|
||||
- content module: ${input.hasContentModule ? "yes" : "no"}
|
||||
- component extraction requested: ${input.components ? "yes" : "no"}
|
||||
|
||||
## Routes
|
||||
|
||||
${routes}
|
||||
|
||||
## Tradeoffs
|
||||
|
||||
The clone prioritizes deterministic static fidelity, accessible markup, local asset materialization, and source metadata preservation. It may keep measured CSS where inferred layout intent is uncertain. It intentionally defers arbitrary JavaScript replay, video-like animation replay, and full third-party application behavior. External services, live personalization, analytics, payments, auth, and complex client app state are not reconstructed unless a specific safe recipe exists.
|
||||
`;
|
||||
}
|
||||
|
||||
export function emitGeneratedDocs(appDir: string, input: GeneratedDocsInput): void {
|
||||
writeText(join(appDir, "AGENTS.md"), agentsMd(input));
|
||||
writeText(join(appDir, "ARCHITECTURE.md"), architectureMd(input));
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { InteractionCapture, StyleDelta } from "../capture/interactions.js";
|
||||
|
||||
/**
|
||||
* Stage 4 (M1) — emit pure-CSS `:hover` / `:focus` rules from the captured pseudo-
|
||||
* state deltas. The deltas are keyed by `data-cid-cap`; we map them to the IR's
|
||||
* `c{id}` selectors. Deterministic: capture-ids are emitted in numeric order. The
|
||||
* values are the captured computed deltas, so the rendered hover/focus state matches
|
||||
* the source (verified by the interaction gate, which re-drives and compares).
|
||||
*/
|
||||
|
||||
function kebab(prop: string): string {
|
||||
if (prop.startsWith("webkit")) return "-webkit-" + kebab(prop[6]!.toLowerCase() + prop.slice(7));
|
||||
return prop.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
}
|
||||
function rule(sel: string, d: StyleDelta): string {
|
||||
const body = Object.keys(d).sort().map((k) => `${kebab(k)}:${d[k]}`).join(";");
|
||||
return `${sel}{${body}}`;
|
||||
}
|
||||
|
||||
/** Map every IR node's capture-id → its cid, and cid → node. */
|
||||
function capToCid(ir: IR): { capCid: Map<string, string>; cidNode: Map<string, IRNode> } {
|
||||
const capCid = new Map<string, string>();
|
||||
const cidNode = new Map<string, IRNode>();
|
||||
const walk = (n: IRNode): void => {
|
||||
cidNode.set(n.id, n);
|
||||
const cap = n.attrs["data-cid-cap"];
|
||||
if (cap !== undefined) capCid.set(cap, n.id);
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return { capCid, cidNode };
|
||||
}
|
||||
|
||||
/** The captured `transition` shorthand, but only when it actually animates (some segment
|
||||
* has a positive duration). Pure state styling — `transition: all 0s` / `none` — returns
|
||||
* null. This is what makes a captured `:hover`/`:focus` change EASE like the source
|
||||
* instead of snapping; `transition` is not a graded property (gate 4) and the validator
|
||||
* screenshots with animations disabled, so emitting it is gate-neutral. */
|
||||
function animatedTransition(v: string | undefined): string | null {
|
||||
if (!v) return null;
|
||||
const t = v.trim();
|
||||
if (!t || t === "none" || t === "all") return null;
|
||||
const animates = t.split(",").some((seg) => {
|
||||
const m = seg.trim().match(/(\d*\.?\d+)(ms|s)\b/); // first time token in a segment = its duration
|
||||
return m ? parseFloat(m[1]!) * (m[2] === "ms" ? 0.001 : 1) > 0 : false;
|
||||
});
|
||||
return animates ? t : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit `:hover`/`:focus` CSS for the given IR + capture.
|
||||
* - `include`: only emit for cids passing this predicate (route-body vs. chrome split).
|
||||
* - `prefix`: prepend to the cid in the selector — hoisted chrome is rendered with
|
||||
* namespaced `L`-prefixed cids, so its interaction rules must match (`.cLn15:hover`).
|
||||
*/
|
||||
export function generateInteractionCss(
|
||||
ir: IR,
|
||||
interaction: InteractionCapture | undefined,
|
||||
opts?: { include?: (cid: string) => boolean; prefix?: string; selector?: (cid: string) => string },
|
||||
): string {
|
||||
if (!interaction) return "";
|
||||
const { capCid: map, cidNode } = capToCid(ir);
|
||||
const prefix = opts?.prefix ?? "";
|
||||
// How a cid maps to a CSS selector: default is the per-node `.c<id>` class (legacy CSS
|
||||
// mode); Tailwind mode passes `[data-cid="<id>"]` since nodes carry no `c<id>` class.
|
||||
const sel = opts?.selector ?? ((cid: string) => `.c${prefix}${cid}`);
|
||||
const canon = ir.doc.canonicalViewport;
|
||||
const lines: string[] = [];
|
||||
const transition = new Map<string, string>();
|
||||
const emit = (deltas: Record<string, StyleDelta>, pseudo: string): void => {
|
||||
for (const cap of Object.keys(deltas).sort((a, b) => parseInt(a, 10) - parseInt(b, 10))) {
|
||||
const cid = map.get(cap);
|
||||
const d = deltas[cap]!;
|
||||
if (!cid || !Object.keys(d).length) continue;
|
||||
if (opts?.include && !opts.include(cid)) continue;
|
||||
lines.push(rule(`${sel(cid)}${pseudo}`, d));
|
||||
if (!transition.has(cid)) {
|
||||
const node = cidNode.get(cid);
|
||||
const t = node && animatedTransition(node.computedByVp[canon]?.transition ?? node.computedByVp[ir.doc.viewports[0]!]?.transition);
|
||||
if (t) transition.set(cid, t);
|
||||
}
|
||||
}
|
||||
};
|
||||
emit(interaction.hover, ":hover");
|
||||
emit(interaction.focus, ":focus");
|
||||
// Descendant reveals: a hidden child overlay/CTA shown when the parent is hovered
|
||||
// (framer's "Read story" card hover). Emit `parent:hover child { delta }` + the child's own
|
||||
// transition on its base rule so the reveal eases. The child's hidden base (opacity:0)
|
||||
// already lives in the styling output, so it stays hidden at rest and appears on parent hover.
|
||||
// Uses sel() for both ends so it works in legacy-CSS (.c<id>) and Tailwind ([data-cid]) modes.
|
||||
for (const parentCap of Object.keys(interaction.hoverDesc ?? {}).sort((a, b) => parseInt(a, 10) - parseInt(b, 10))) {
|
||||
const pcid = map.get(parentCap); if (!pcid || (opts?.include && !opts.include(pcid))) continue;
|
||||
const descs = interaction.hoverDesc![parentCap]!;
|
||||
for (const childCap of Object.keys(descs).sort((a, b) => parseInt(a, 10) - parseInt(b, 10))) {
|
||||
const ccid = map.get(childCap); const d = descs[childCap]!;
|
||||
if (!ccid || !Object.keys(d).length) continue;
|
||||
lines.push(rule(`${sel(pcid)}:hover ${sel(ccid)}`, d));
|
||||
if (!transition.has(ccid)) {
|
||||
const node = cidNode.get(ccid);
|
||||
const t = node && animatedTransition(node.computedByVp[canon]?.transition ?? node.computedByVp[ir.doc.viewports[0]!]?.transition);
|
||||
if (t) transition.set(ccid, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
const transLines = [...transition.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([cid, t]) => `${sel(cid)}{transition:${t}}`);
|
||||
const all = [...transLines, ...lines];
|
||||
return all.length ? "\n/* Stage 4 — interaction states (hover/focus + eased transitions) */\n" + all.join("\n") + "\n" : "";
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { InteractionCapture, CapStyle, RelBox } from "../capture/interactions.js";
|
||||
|
||||
/**
|
||||
* Stage 4 (M2) — recognized interactive patterns (tabs / accordion) are reproduced
|
||||
* by a single fixed `'use client'` controller, `DittoWire`, parameterized by captured
|
||||
* per-state styles. The page renders the captured subtree statically (so the base
|
||||
* DOM/style/perceptual gates are unaffected); DittoWire renders `null` and, after
|
||||
* hydration, finds the trigger/panel elements by `data-cid` and toggles the captured
|
||||
* inline styles on interaction. Its initial application reproduces the captured base
|
||||
* state exactly, so it never perturbs the default render — only adds behavior.
|
||||
*/
|
||||
|
||||
type RTTab = { trigger: string; panel: string; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; descendants?: Record<string, CapStyle> };
|
||||
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
|
||||
type RTDisc = { trigger: string; panel: string; isDialog: boolean; hoverOpen: boolean; backdropClose: boolean; closes: string[]; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; shownBox: RelBox | null; descendants?: Record<string, CapStyle> };
|
||||
export type RuntimeSpec =
|
||||
| { kind: "tabs"; active: number; tabs: RTTab[] }
|
||||
| { kind: "accordion"; items: RTAcc[] }
|
||||
| { kind: "carousel"; track: string; next: string | null; prev: string | null; bullets: string[]; base: number; transforms: string[]; bulletOn: CapStyle; bulletOff: CapStyle }
|
||||
| { kind: "disclosure"; items: RTDisc[] };
|
||||
export type AccordionRuntimeSpec = Extract<RuntimeSpec, { kind: "accordion" }>;
|
||||
|
||||
export const INTERACTION_REJECTION_VERSION = 2;
|
||||
|
||||
export type InteractionRejectedArtifact = {
|
||||
version: number;
|
||||
rejected: string[];
|
||||
};
|
||||
|
||||
export function interactionRejectedArtifact(rejected: string[]): InteractionRejectedArtifact {
|
||||
return { version: INTERACTION_REJECTION_VERSION, rejected };
|
||||
}
|
||||
|
||||
export function interactionRejectedSet(raw: unknown): Set<string> | undefined {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
||||
const artifact = raw as Partial<InteractionRejectedArtifact>;
|
||||
if (artifact.version !== INTERACTION_REJECTION_VERSION || !Array.isArray(artifact.rejected)) return undefined;
|
||||
return new Set(artifact.rejected.filter((x): x is string => typeof x === "string" && x.length > 0));
|
||||
}
|
||||
|
||||
/** Map every IR node's capture-id → its cid. */
|
||||
function capToCid(ir: IR): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
const walk = (n: IRNode): void => {
|
||||
const cap = n.attrs["data-cid-cap"];
|
||||
if (cap !== undefined) m.set(cap, n.id);
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the cid-keyed runtime specs for the patterns whose nodes all survived into
|
||||
* this IR (and pass the optional include filter, for multi-route body scoping). A
|
||||
* pattern missing any of its trigger/panel cids is dropped (falls back to static).
|
||||
*/
|
||||
/** Stable identity for a runtime spec (its primary trigger/track cid). The gate marks
|
||||
* patterns that don't reproduce by this key; generation then skips them (left static).
|
||||
* Both sides operate on the same cid-specs, so the keys line up. */
|
||||
export function specKey(s: RuntimeSpec): string {
|
||||
if (s.kind === "tabs") return "t:" + s.tabs[0]?.trigger;
|
||||
if (s.kind === "accordion") return "a:" + s.items[0]?.trigger;
|
||||
if (s.kind === "carousel") return "c:" + s.track;
|
||||
return "d:" + s.items[0]?.trigger;
|
||||
}
|
||||
|
||||
export function buildRuntimeSpecs(ir: IR, interaction: InteractionCapture | undefined, include?: (cid: string) => boolean, rejected?: Set<string>): RuntimeSpec[] {
|
||||
if (!interaction?.patterns?.length) return [];
|
||||
const map = capToCid(ir);
|
||||
const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid));
|
||||
// Remap a panel's open-state descendant overrides from capture-ids to surviving cids.
|
||||
// Descendants that didn't survive into this IR are simply dropped (best-effort reveal).
|
||||
const mapDesc = (d?: Record<string, CapStyle>): Record<string, CapStyle> | undefined => {
|
||||
if (!d) return undefined;
|
||||
const out: Record<string, CapStyle> = {};
|
||||
for (const cap of Object.keys(d)) { const cid = map.get(cap); if (ok(cid)) out[cid] = d[cap]!; }
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
};
|
||||
const specs: RuntimeSpec[] = [];
|
||||
for (const p of interaction.patterns) {
|
||||
if (p.kind === "tabs") {
|
||||
const tabs: RTTab[] = [];
|
||||
let bad = false;
|
||||
for (const t of p.tabs) {
|
||||
const tr = map.get(t.triggerCap), pa = map.get(t.panelCap);
|
||||
if (!ok(tr) || !ok(pa)) { bad = true; break; }
|
||||
tabs.push({ trigger: tr, panel: pa, triggerOn: t.triggerOn, triggerOff: t.triggerOff, panelShown: t.panelShown, panelHidden: t.panelHidden, descendants: mapDesc(t.descendants) });
|
||||
}
|
||||
if (!bad && tabs.length >= 2) specs.push({ kind: "tabs", active: Math.min(p.activeIndex, tabs.length - 1), tabs });
|
||||
} else if (p.kind === "accordion") {
|
||||
const items: RTAcc[] = [];
|
||||
for (const it of p.items) {
|
||||
const tr = map.get(it.triggerCap), rg = map.get(it.regionCap);
|
||||
if (!ok(tr) || !ok(rg)) continue;
|
||||
items.push({ trigger: tr, region: rg, expanded: it.expandedAtBase, triggerOn: it.triggerOn, triggerOff: it.triggerOff, regionShown: it.regionShown, regionHidden: it.regionHidden });
|
||||
}
|
||||
if (items.length) specs.push({ kind: "accordion", items });
|
||||
} else if (p.kind === "carousel") {
|
||||
const track = map.get(p.trackCap);
|
||||
if (!ok(track)) continue;
|
||||
const next = p.nextCap ? map.get(p.nextCap) ?? null : null;
|
||||
const prev = p.prevCap ? map.get(p.prevCap) ?? null : null;
|
||||
// Pagination bullets are only usable if ALL survived (index-aligned with the
|
||||
// transforms); otherwise fall back to prev/next navigation.
|
||||
const mapped = p.bulletCaps.map((c) => map.get(c));
|
||||
const bullets = mapped.every((b) => ok(b)) ? (mapped as string[]) : [];
|
||||
if (!next && bullets.length < 2) continue;
|
||||
if (p.transforms.length < 2) continue;
|
||||
specs.push({ kind: "carousel", track, next, prev, bullets, base: p.baseIndex, transforms: p.transforms, bulletOn: p.bulletOn, bulletOff: p.bulletOff });
|
||||
} else if (p.kind === "disclosure") {
|
||||
const items: RTDisc[] = [];
|
||||
for (const it of p.items) {
|
||||
const trigger = map.get(it.triggerCap), panel = map.get(it.panelCap);
|
||||
if (!ok(trigger) || !ok(panel)) continue;
|
||||
const closes = it.closeCaps.map((c) => map.get(c)).filter((c): c is string => ok(c));
|
||||
items.push({ trigger, panel, isDialog: it.isDialog, hoverOpen: it.hoverOpen, backdropClose: it.backdropClose, closes, triggerOn: it.triggerOn, triggerOff: it.triggerOff, panelShown: it.panelShown, panelHidden: it.panelHidden, shownBox: it.shownBox, descendants: mapDesc(it.descendants) });
|
||||
}
|
||||
if (items.length) specs.push({ kind: "disclosure", items });
|
||||
}
|
||||
}
|
||||
// Drop patterns the interaction gate proved don't reproduce in the clone (they're
|
||||
// left static rather than shipped broken).
|
||||
return rejected && rejected.size ? specs.filter((s) => !rejected.has(specKey(s))) : specs;
|
||||
}
|
||||
|
||||
/** Relative import path from a route page at the given app-segment depth to the
|
||||
* shared DittoWire (single page / entry route: depth 0 → "./ditto/DittoWire"). */
|
||||
export function dittoWireImportPath(depth: number): string {
|
||||
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DittoWire";
|
||||
}
|
||||
|
||||
/** JSX for the pattern controllers, rendered at the end of a page fragment. Returns
|
||||
* "" when there are no recognized patterns (no import/scaffold needed). */
|
||||
export function wiresJsx(specs: RuntimeSpec[], indent: number): string {
|
||||
if (!specs.length) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
return specs.map((s) => `${pad}<DittoWire spec={${JSON.stringify(s)}} />`).join("\n");
|
||||
}
|
||||
|
||||
export function accordionImportPath(depth: number): string {
|
||||
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/Accordion";
|
||||
}
|
||||
|
||||
export function accordionJsx(specs: AccordionRuntimeSpec[], indent: number): string {
|
||||
if (!specs.length) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
return `${pad}<Accordion specs={${JSON.stringify(specs)}} />`;
|
||||
}
|
||||
|
||||
export const ACCORDION_TSX = `"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type CapStyle = Record<string, string>;
|
||||
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
|
||||
export type AccordionSpec = { kind: "accordion"; items: RTAcc[] };
|
||||
|
||||
const kebab = (p: string) => p.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
|
||||
function applyStyle(el: HTMLElement | null, s: CapStyle) {
|
||||
if (!el) return;
|
||||
for (const k in s) el.style.setProperty(kebab(k), s[k]);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
for (const spec of specs) {
|
||||
const state = spec.items.map((it) => it.expanded);
|
||||
const renderItem = (i: number) => {
|
||||
const it = spec.items[i];
|
||||
if (!it) return;
|
||||
const on = state[i];
|
||||
const trig = byCid(it.trigger), region = byCid(it.region);
|
||||
applyStyle(trig, on ? it.triggerOn : it.triggerOff);
|
||||
trig?.setAttribute("aria-expanded", on ? "true" : "false");
|
||||
applyStyle(region, on ? it.regionShown : it.regionHidden);
|
||||
if (region) {
|
||||
if (on) region.removeAttribute("hidden");
|
||||
else region.setAttribute("hidden", "");
|
||||
}
|
||||
};
|
||||
spec.items.forEach((it, i) => {
|
||||
const trig = byCid(it.trigger);
|
||||
if (!trig) return;
|
||||
trig.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
state[i] = !state[i];
|
||||
renderItem(i);
|
||||
});
|
||||
renderItem(i);
|
||||
});
|
||||
}
|
||||
}, [specs]);
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
|
||||
/** The fixed DittoWire client component, written once per generated app. */
|
||||
export const DITTO_WIRE_TSX = `"use client";
|
||||
import { useEffect, useRef } 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> };
|
||||
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
|
||||
type RTDisc = { trigger: string; panel: string; isDialog: boolean; hoverOpen: boolean; backdropClose: boolean; closes: string[]; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; shownBox?: unknown; descendants?: Record<string, CapStyle> };
|
||||
export type Spec =
|
||||
| { kind: "tabs"; active: number; tabs: RTTab[] }
|
||||
| { kind: "accordion"; items: RTAcc[] }
|
||||
| { kind: "carousel"; track: string; next: string | null; prev: string | null; bullets: string[]; base: number; transforms: string[]; bulletOn: CapStyle; bulletOff: CapStyle }
|
||||
| { kind: "disclosure"; items: RTDisc[] };
|
||||
|
||||
const kebab = (p: string) => p.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
||||
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
|
||||
function applyStyle(el: HTMLElement | null, s: CapStyle) {
|
||||
if (!el) return;
|
||||
for (const k in s) el.style.setProperty(kebab(k), s[k]);
|
||||
}
|
||||
// Apply a panel's open-state descendant overrides (cid → style). Reveals content whose
|
||||
// visibility is gated by a JS-toggled class the clone doesn't carry (e.g. Elementor
|
||||
// e-active). Applied only when the panel is shown; the panel's own hide masks it.
|
||||
function applyDesc(d?: Record<string, CapStyle>) {
|
||||
if (!d) return;
|
||||
for (const cid in d) applyStyle(byCid(cid), d[cid]);
|
||||
}
|
||||
|
||||
/** Reproduces a captured interactive pattern by toggling captured inline styles on
|
||||
* the existing DOM nodes (found by data-cid). Renders nothing, and applies NOTHING
|
||||
* 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;
|
||||
if (spec.kind === "tabs") {
|
||||
let active = spec.active;
|
||||
const render = () => spec.tabs.forEach((t, i) => {
|
||||
const on = i === active;
|
||||
const trig = byCid(t.trigger), panel = byCid(t.panel);
|
||||
applyStyle(trig, on ? t.triggerOn : t.triggerOff);
|
||||
trig?.setAttribute("aria-selected", on ? "true" : "false");
|
||||
if (trig) (trig as HTMLElement).tabIndex = on ? 0 : -1;
|
||||
applyStyle(panel, on ? t.panelShown : t.panelHidden);
|
||||
if (on) applyDesc(t.descendants);
|
||||
if (panel) { if (on) { panel.removeAttribute("hidden"); } else { panel.setAttribute("hidden", ""); } }
|
||||
});
|
||||
spec.tabs.forEach((t, i) => {
|
||||
const trig = byCid(t.trigger);
|
||||
if (!trig) return;
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); active = i; render(); });
|
||||
trig.addEventListener("keydown", (e) => {
|
||||
const k = (e as KeyboardEvent).key;
|
||||
if (k === "ArrowRight" || k === "ArrowLeft") {
|
||||
e.preventDefault();
|
||||
active = (active + (k === "ArrowRight" ? 1 : spec.tabs.length - 1)) % spec.tabs.length;
|
||||
render();
|
||||
byCid(spec.tabs[active].trigger)?.focus();
|
||||
}
|
||||
});
|
||||
});
|
||||
// No initial render() — the static base state is already correct.
|
||||
} else if (spec.kind === "accordion") {
|
||||
const state = spec.items.map((it) => it.expanded);
|
||||
const renderItem = (i: number) => {
|
||||
const it = spec.items[i], on = state[i];
|
||||
const trig = byCid(it.trigger), region = byCid(it.region);
|
||||
applyStyle(trig, on ? it.triggerOn : it.triggerOff);
|
||||
trig?.setAttribute("aria-expanded", on ? "true" : "false");
|
||||
applyStyle(region, on ? it.regionShown : it.regionHidden);
|
||||
if (region) { if (on) { region.removeAttribute("hidden"); } else { region.setAttribute("hidden", ""); } }
|
||||
};
|
||||
spec.items.forEach((it, i) => {
|
||||
const trig = byCid(it.trigger);
|
||||
if (trig) trig.addEventListener("click", (e) => { e.preventDefault(); state[i] = !state[i]; renderItem(i); });
|
||||
});
|
||||
// No initial renderItem — the static base state is already correct.
|
||||
} else if (spec.kind === "carousel") {
|
||||
// Carousel: move the track's transform between captured per-index positions.
|
||||
const n = spec.transforms.length;
|
||||
let index = spec.base;
|
||||
const track = byCid(spec.track);
|
||||
const go = (k: number) => {
|
||||
index = Math.max(0, Math.min(n - 1, k));
|
||||
if (track) track.style.transform = spec.transforms[index];
|
||||
spec.bullets.forEach((b, bi) => applyStyle(byCid(b), bi === index ? spec.bulletOn : spec.bulletOff));
|
||||
};
|
||||
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); }));
|
||||
// No initial go() — the static base state is already correct.
|
||||
} else {
|
||||
// Disclosure: dropdown / mega-menu / modal — a trigger reveals a hidden overlay.
|
||||
spec.items.forEach((it) => {
|
||||
const trig = byCid(it.trigger), panel = byCid(it.panel);
|
||||
if (!trig || !panel) return;
|
||||
let open = false;
|
||||
const set = (o: boolean) => {
|
||||
open = o;
|
||||
applyStyle(trig, o ? it.triggerOn : it.triggerOff);
|
||||
trig.setAttribute("aria-expanded", o ? "true" : "false");
|
||||
applyStyle(panel, o ? it.panelShown : it.panelHidden);
|
||||
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); });
|
||||
if (it.hoverOpen) {
|
||||
const root = trig.parentElement ?? trig;
|
||||
root.addEventListener("mouseenter", () => set(true));
|
||||
root.addEventListener("mouseleave", () => set(false));
|
||||
}
|
||||
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); });
|
||||
});
|
||||
// No initial set() — the static base state is already correct.
|
||||
}
|
||||
}, [spec]);
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { IR } from "../normalize/ir.js";
|
||||
import type { Section } from "../infer/sections.js";
|
||||
import type { Tokens } from "../infer/tokens.js";
|
||||
import type { AssetGraph } from "../infer/assets.js";
|
||||
import type { FontGraph } from "../infer/fonts.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
|
||||
export const COMPILER_VERSION = "0.1.0";
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
||||
export function buildManifest(args: {
|
||||
ir: IR;
|
||||
sections: Section[];
|
||||
tokens: Tokens;
|
||||
assetGraph: AssetGraph;
|
||||
fontGraph: FontGraph;
|
||||
capture: CaptureResult;
|
||||
componentCount: number;
|
||||
}): Record<string, unknown> {
|
||||
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount } = args;
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
let downloaded = 0, skipped = 0;
|
||||
for (const e of assetGraph.entries) {
|
||||
if (e.type === "css") continue;
|
||||
byType[e.type] = (byType[e.type] ?? 0) + 1;
|
||||
if (e.classification === "downloaded") downloaded++;
|
||||
else skipped++;
|
||||
}
|
||||
|
||||
const tokenCounts: Record<string, number> = {};
|
||||
for (const [k, v] of Object.entries(tokens)) tokenCounts[k] = Object.keys(v).length;
|
||||
|
||||
const scrollHeights: Record<string, number> = {};
|
||||
for (const [vp, d] of Object.entries(ir.doc.perViewport)) scrollHeights[vp] = d.scrollHeight;
|
||||
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
compilerVersion: COMPILER_VERSION,
|
||||
sourceUrl: ir.doc.sourceUrl,
|
||||
capturedAt: capture.capturedAt,
|
||||
viewports: ir.doc.viewports,
|
||||
canonicalViewport: ir.doc.canonicalViewport,
|
||||
doc: {
|
||||
title: ir.doc.title,
|
||||
lang: ir.doc.lang,
|
||||
nodeCount: ir.doc.nodeCount,
|
||||
scrollHeights,
|
||||
},
|
||||
sections: { count: sections.length, ids: sections.map((s) => s.id) },
|
||||
tokens: tokenCounts,
|
||||
assets: { total: downloaded + skipped, downloaded, skipped, byType },
|
||||
fonts: {
|
||||
total: fontGraph.entries.length,
|
||||
resolved: fontGraph.entries.filter((f) => f.status === "resolved").length,
|
||||
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
|
||||
},
|
||||
components: { count: componentCount },
|
||||
// Stage 2: capture-sanity audit — what overlays were dismissed, whether any
|
||||
// still covered the page, video stills materialized, and per-viewport quiescence.
|
||||
capture: {
|
||||
dismissedOverlays: capture.dismissal?.dismissed ?? [],
|
||||
overlaysRemoved: capture.dismissal?.removed ?? 0,
|
||||
overlaysRemaining: capture.dismissal?.overlaysRemaining ?? 0,
|
||||
blockingModal: capture.dismissal?.blocking ?? false,
|
||||
videoStills: capture.dismissal?.videoStills ?? 0,
|
||||
perViewport: capture.perViewport.map((p) => ({
|
||||
viewport: p.viewport, overlaysRemaining: p.overlaysRemaining ?? 0, blocking: p.blocking ?? false, quiescent: p.quiescent ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { MenuCapture } from "../capture/interactions.js";
|
||||
|
||||
/**
|
||||
* M4b — mount-on-open menus (Radix-style portals). The panel is added to the DOM on open
|
||||
* and removed on close, so the IR never sees it and the IR-based disclosure path can't
|
||||
* reproduce it. Instead the panel is captured as a self-contained inline-styled fragment
|
||||
* and reproduced CLIENT-SIDE: `DropdownMenu` renders null on mount and only injects the panel
|
||||
* (under its trigger) on interaction — so the server-rendered base that gates 0–6 grade is
|
||||
* structurally untouched (same safety model as DittoWire). A menu that doesn't reproduce is
|
||||
* pruned by the interaction gate, so a broken menu is never shipped.
|
||||
*/
|
||||
|
||||
const TRANSPARENT_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
export type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
|
||||
|
||||
function capToNode(ir: IR): Map<string, IRNode> {
|
||||
const m = new Map<string, IRNode>();
|
||||
const walk = (n: IRNode): void => {
|
||||
const cap = n.attrs["data-cid-cap"];
|
||||
if (cap !== undefined) m.set(cap, n);
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return m;
|
||||
}
|
||||
|
||||
function textContent(n: IRNode, max = 120): string {
|
||||
let out = "";
|
||||
const walk = (node: IRNode): void => {
|
||||
if (out.length >= max) return;
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) out += " " + c.text;
|
||||
else walk(c);
|
||||
if (out.length >= max) return;
|
||||
}
|
||||
};
|
||||
walk(n);
|
||||
return out.replace(/\s+/g, " ").trim().slice(0, max);
|
||||
}
|
||||
|
||||
function isLikelySkipTrigger(n: IRNode, menu: MenuCapture): boolean {
|
||||
const label = `${textContent(n, 120)} ${n.attrs["aria-label"] ?? ""}`.toLowerCase();
|
||||
const href = (n.attrs.href ?? "").trim();
|
||||
if (/\bskip\s+(?:to\s+)?(?:content|main|navigation)\b/.test(label)) return true;
|
||||
return href.startsWith("#") && menu.gap > 360;
|
||||
}
|
||||
|
||||
/** Rewrite the captured panel HTML so the clone stays self-contained: same-origin links
|
||||
* become app-relative (via the page's linkRewrite), and every image src resolves to a
|
||||
* local asset or a transparent placeholder — never a remote origin (rubric Gate 2). */
|
||||
function rewriteHtml(html: string, assetMap: Map<string, string>, sourceUrl: string, linkRewrite?: (href: string) => string): string {
|
||||
const resolve = (u: string): string => { try { return new URL(u, sourceUrl).href; } catch { return u; } };
|
||||
let out = html.replace(/\bsrc="([^"]*)"/g, (_m, u: string) => {
|
||||
const local = assetMap.get(resolve(u));
|
||||
return `src="${local ?? TRANSPARENT_GIF}"`;
|
||||
});
|
||||
if (linkRewrite) out = out.replace(/\bhref="([^"]*)"/g, (_m, u: string) => `href="${linkRewrite(u).replace(/"/g, """)}"`);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function buildMenuSpecs(
|
||||
ir: IR,
|
||||
menus: MenuCapture[] | undefined,
|
||||
assetMap: Map<string, string>,
|
||||
sourceUrl: string,
|
||||
linkRewrite?: (href: string) => string,
|
||||
include?: (cid: string) => boolean,
|
||||
): RTMenu[] {
|
||||
if (!menus || !menus.length) return [];
|
||||
const map = capToNode(ir);
|
||||
const out: RTMenu[] = [];
|
||||
for (const m of menus) {
|
||||
const trigger = map.get(m.triggerCap);
|
||||
if (!trigger) continue; // trigger pruned from the IR → drop
|
||||
if (isLikelySkipTrigger(trigger, m)) continue;
|
||||
if (include && !include(trigger.id)) continue;
|
||||
out.push({ trigger: trigger.id, hoverOpen: m.hoverOpen, gap: m.gap, align: m.align, html: rewriteHtml(m.html, assetMap, sourceUrl, linkRewrite) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function menusJsx(menus: RTMenu[], indent: number): string {
|
||||
if (!menus.length) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
return `${pad}<DropdownMenu menus={${JSON.stringify(menus)}} />`;
|
||||
}
|
||||
|
||||
export function dropdownMenuImportPath(depth: number): string {
|
||||
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DropdownMenu";
|
||||
}
|
||||
|
||||
/** The fixed DropdownMenu client component, written once per generated app. */
|
||||
export const DROPDOWN_MENU_TSX = `"use client";
|
||||
import { useEffect, useRef } 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 + '"]');
|
||||
|
||||
/** Reproduces mount-on-open dropdown/nav menus: renders nothing and applies NOTHING on mount; only on
|
||||
* 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;
|
||||
for (const m of menus) {
|
||||
const trig = byCid(m.trigger);
|
||||
if (!trig) continue;
|
||||
let panel: HTMLElement | null = null;
|
||||
const place = () => {
|
||||
if (!panel) return;
|
||||
const r = trig.getBoundingClientRect();
|
||||
panel.style.position = "absolute";
|
||||
panel.style.top = (r.bottom + window.scrollY + m.gap) + "px";
|
||||
if (m.align === "right") { panel.style.left = ""; panel.style.right = (document.documentElement.clientWidth - (r.right + window.scrollX)) + "px"; }
|
||||
else { panel.style.right = ""; panel.style.left = (r.left + window.scrollX) + "px"; }
|
||||
panel.style.zIndex = "9999";
|
||||
};
|
||||
const open = () => {
|
||||
if (panel) return;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.innerHTML = m.html;
|
||||
panel = wrap.firstElementChild as HTMLElement | null;
|
||||
if (!panel) return;
|
||||
document.body.appendChild(panel);
|
||||
place();
|
||||
trig.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
const close = () => { if (panel) { 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);
|
||||
} else {
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); });
|
||||
}
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
|
||||
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 });
|
||||
}
|
||||
(window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically
|
||||
}, [menus]);
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { MotionCapture, WaapiAnim, RotatorSpec, RevealSpec, MarqueeSpec } from "../capture/motion.js";
|
||||
|
||||
/**
|
||||
* Stage 5 motion controller. CSS @keyframes motion is reproduced declaratively in
|
||||
* ditto.css (the animation plays on load with no JS). The two families that the
|
||||
* stylesheet can't express are reproduced by one fixed `'use client'` component,
|
||||
* `DittoMotion`, parameterized by captured specs:
|
||||
* - **WAAPI** — re-issues `element.animate(keyframes, timing)` on the cid'd node.
|
||||
* - **rotating text** — cycles the captured text values on an interval.
|
||||
*
|
||||
* Unlike DittoWire (which applies nothing on mount to keep the base frame untouched),
|
||||
* DittoMotion DOES start motion on mount — the clone is meant to REPLAY motion on load.
|
||||
* The validator measures the settled base by cancelling Web Animations and calling
|
||||
* `window.__dittoMotionStop()` (which DittoMotion installs) to restore rotator text, so
|
||||
* gates 0–6 still grade the static frame. The motion gate drives it un-frozen to verify.
|
||||
*/
|
||||
|
||||
export type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
|
||||
export type RTRotator = { cid: string; texts: string[]; intervalMs: number };
|
||||
export type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
|
||||
export type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
|
||||
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
|
||||
|
||||
/** Map every IR node's capture-id → cid (the rendered identity). */
|
||||
function capToCid(ir: IR): Map<string, string> {
|
||||
const m = new Map<string, string>();
|
||||
const walk = (n: IRNode): void => {
|
||||
const cap = n.attrs["data-cid-cap"];
|
||||
if (cap !== undefined) m.set(cap, n.id);
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Resolve cap-keyed motion specs to cids that survived into this IR (optionally
|
||||
* scoped by an include filter, for multi-route body/chrome splitting). Specs whose
|
||||
* element was pruned are dropped (left static). */
|
||||
export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, include?: (cid: string) => boolean): MotionSpec {
|
||||
if (!motion) return { waapi: [], rotators: [], reveals: [], marquees: [] };
|
||||
const map = capToCid(ir);
|
||||
const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid));
|
||||
const waapi: RTWaapi[] = [];
|
||||
for (const w of motion.waapi as WaapiAnim[]) {
|
||||
const cid = map.get(w.cap);
|
||||
if (!ok(cid)) continue;
|
||||
waapi.push({ cid, keyframes: w.keyframes, duration: w.duration, delay: w.delay, easing: w.easing, iterations: w.iterations, direction: w.direction, fill: w.fill });
|
||||
}
|
||||
const rotators: RTRotator[] = [];
|
||||
for (const r of motion.rotators as RotatorSpec[]) {
|
||||
const cid = map.get(r.cap);
|
||||
if (!ok(cid)) continue;
|
||||
rotators.push({ cid, texts: r.texts, intervalMs: r.intervalMs });
|
||||
}
|
||||
const reveals: RTReveal[] = [];
|
||||
for (const rv of (motion.reveals ?? []) as RevealSpec[]) {
|
||||
const cid = map.get(rv.cap);
|
||||
if (!ok(cid)) continue;
|
||||
reveals.push({ cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition });
|
||||
}
|
||||
const marquees: RTMarquee[] = [];
|
||||
for (const m of (motion.marquees ?? []) as MarqueeSpec[]) {
|
||||
const cid = map.get(m.cap);
|
||||
if (!ok(cid)) continue;
|
||||
marquees.push({ cid, pxPerSec: m.pxPerSec, periodPx: m.periodPx });
|
||||
}
|
||||
return { waapi, rotators, reveals, marquees };
|
||||
}
|
||||
|
||||
export function motionHasContent(spec: MotionSpec): boolean {
|
||||
return spec.waapi.length > 0 || spec.rotators.length > 0 || spec.reveals.length > 0 || spec.marquees.length > 0;
|
||||
}
|
||||
|
||||
/** Relative import path from a route page at the given app-segment depth to the shared
|
||||
* DittoMotion (single page / entry route: depth 0 → "./ditto/DittoMotion"). */
|
||||
export function dittoMotionImportPath(depth: number): string {
|
||||
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DittoMotion";
|
||||
}
|
||||
|
||||
/** JSX for the motion controller, rendered at the end of a page fragment. "" when empty. */
|
||||
export function motionWireJsx(spec: MotionSpec, indent: number): string {
|
||||
if (!motionHasContent(spec)) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
return `${pad}<DittoMotion spec={${JSON.stringify(spec)}} />`;
|
||||
}
|
||||
|
||||
/** The fixed DittoMotion client component, written once per generated app. */
|
||||
export const DITTO_MOTION_TSX = `"use client";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
|
||||
type RTRotator = { cid: string; texts: string[]; intervalMs: number };
|
||||
type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
|
||||
type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
|
||||
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
|
||||
|
||||
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
|
||||
|
||||
/** Replays captured motion the stylesheet can't express: WAAPI animations (re-issued via
|
||||
* element.animate), rotating text (interval-cycled), and scroll-triggered reveals (start
|
||||
* hidden, transition in when scrolled into view). Starts on mount. Installs
|
||||
* window.__dittoMotionStop, and honors window.__dittoMotionStopped, so the validator can
|
||||
* restore the fully-settled/revealed base for grading — gates 0–6 measure the static frame.
|
||||
* The stopped FLAG (set by the validator even before this mounts) makes a late mount skip
|
||||
* applying any motion, closing the hydration race that could otherwise leave content hidden. */
|
||||
export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
||||
useEffect(() => {
|
||||
if ((window as any).__dittoMotionStopped) return; // measurement mode — apply nothing
|
||||
const intervals: ReturnType<typeof setInterval>[] = [];
|
||||
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
|
||||
const anims: Animation[] = [];
|
||||
const revealed: Array<() => void> = []; // per-reveal "show now" fns (also the cleanup)
|
||||
let io: IntersectionObserver | null = null;
|
||||
let forceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
for (const w of spec.waapi) {
|
||||
const el = byCid(w.cid);
|
||||
if (!el) continue;
|
||||
try {
|
||||
anims.push(el.animate(w.keyframes, {
|
||||
duration: w.duration || 0, delay: w.delay || 0, easing: w.easing || "linear",
|
||||
iterations: w.iterations < 0 ? Infinity : (w.iterations || 1),
|
||||
direction: (w.direction as PlaybackDirection) || "normal", fill: (w.fill as FillMode) || "none",
|
||||
}));
|
||||
} catch { /* unsupported keyframe shape — leave static */ }
|
||||
}
|
||||
|
||||
// Marquees: rAF-driven continuous tickers, reconstructed as an infinite linear translateX
|
||||
// loop over one duplicated copy (periodPx). Leftward (pxPerSec<0): 0 -> -period; rightward:
|
||||
// -period -> 0. Cancelled by stopAll so the graded frame shows the element's base transform.
|
||||
for (const m of spec.marquees) {
|
||||
const el = byCid(m.cid);
|
||||
if (!el || !m.periodPx || !m.pxPerSec) continue;
|
||||
const left = m.pxPerSec < 0;
|
||||
const a = "translateX(0px)", z = "translateX(-" + m.periodPx + "px)";
|
||||
const durationMs = Math.max(1000, Math.round((m.periodPx / Math.abs(m.pxPerSec)) * 1000));
|
||||
try {
|
||||
anims.push(el.animate([{ transform: left ? a : z }, { transform: left ? z : a }], {
|
||||
duration: durationMs, iterations: Infinity, easing: "linear",
|
||||
}));
|
||||
} catch { /* leave static */ }
|
||||
}
|
||||
|
||||
for (const r of spec.rotators) {
|
||||
const el = byCid(r.cid);
|
||||
if (!el || r.texts.length < 2) continue;
|
||||
const original = el.textContent;
|
||||
const start = r.texts.findIndex((t) => t === (original || "").replace(/\\s+/g, " ").trim());
|
||||
let i = start < 0 ? 0 : start;
|
||||
rotators.push({ el, original });
|
||||
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
|
||||
}
|
||||
|
||||
// Scroll reveals: hide each element (opacity/transform) with the captured transition,
|
||||
// then reveal (clear the inline overrides → transitions to the base CSS) when it scrolls
|
||||
// into view. A force-reveal timer guarantees nothing stays hidden if the observer misses.
|
||||
if (spec.reveals.length) {
|
||||
// Reveal to the full resting state. Setting 1/none (not clearing to base) is correct for
|
||||
// every reveal — the revealed state is always full + un-offset — and is REQUIRED for
|
||||
// scroll-scrub panels whose captured base CSS is a frozen mid-scrub value (opacity 0.63).
|
||||
const show = (el: HTMLElement) => { el.style.opacity = "1"; el.style.transform = "none"; };
|
||||
const byEl = new Map<Element, HTMLElement>();
|
||||
for (const rv of spec.reveals) {
|
||||
const el = byCid(rv.cid);
|
||||
if (!el) continue;
|
||||
el.style.transition = rv.transition;
|
||||
el.style.opacity = rv.opacity;
|
||||
if (rv.transform !== "none") el.style.transform = rv.transform;
|
||||
byEl.set(el, el);
|
||||
revealed.push(() => show(el));
|
||||
}
|
||||
io = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) if (e.isIntersecting) { const el = byEl.get(e.target); if (el) { show(el); io!.unobserve(e.target); } }
|
||||
}, { rootMargin: "0px 0px -8% 0px" });
|
||||
for (const el of byEl.keys()) io.observe(el);
|
||||
forceTimer = setTimeout(() => { for (const f of revealed) f(); }, 4000);
|
||||
}
|
||||
|
||||
const stopAll = () => {
|
||||
(window as any).__dittoMotionStopped = true;
|
||||
for (const id of intervals) clearInterval(id);
|
||||
for (const r of rotators) r.el.textContent = r.original;
|
||||
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
|
||||
if (io) io.disconnect();
|
||||
if (forceTimer) clearTimeout(forceTimer);
|
||||
for (const f of revealed) f(); // reveal everything → base CSS settled frame
|
||||
};
|
||||
// Measurement hook: restore the fully-settled/revealed base for grading.
|
||||
(window as any).__dittoMotionStop = stopAll;
|
||||
return () => {
|
||||
for (const id of intervals) clearInterval(id);
|
||||
if (io) io.disconnect();
|
||||
if (forceTimer) clearTimeout(forceTimer);
|
||||
try { delete (window as any).__dittoMotionStop; } catch { /* ignore */ }
|
||||
};
|
||||
}, [spec]);
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { join } from "node:path";
|
||||
import { backfillLazyBackgrounds, buildIR, writeIR, type IR } from "../normalize/ir.js";
|
||||
import { detectSections, type Section } from "../infer/sections.js";
|
||||
import { extractTokens, tokensToCss, buildTokenResolver, type Tokens } from "../infer/tokens.js";
|
||||
import { buildColorPalette } from "../infer/semanticTokens.js";
|
||||
import { recognizePrimitives, inventoryOf } from "../infer/primitives.js";
|
||||
import { buildAssetGraph, materializeAssets, type AssetGraph } from "../infer/assets.js";
|
||||
import { buildFontGraph, type FontGraph } from "../infer/fonts.js";
|
||||
import { buildRecipeReport, recipeReportToMarkdown, type RecipeReport } from "../infer/recipes.js";
|
||||
import { buildInteractionRecipeReport, interactionRecipeReportToMarkdown, type InteractionRecipeReport } from "../infer/interactionRecipes.js";
|
||||
import { generateApp, type AppFramework } from "./app.js";
|
||||
import { buildCodeQualityReport, codeQualityReportToMarkdown, type CodeQualityReport } from "./codeQuality.js";
|
||||
import { interactionRejectedSet } from "./interactive.js";
|
||||
import { buildManifest } from "./manifest.js";
|
||||
import { buildSeoInventory, seoInventoryToMarkdown, type SeoInventory } from "./seo.js";
|
||||
import { writeJSON, writeText, readJSON, fileExists } from "../util/fsx.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
|
||||
export type GenerateAllResult = {
|
||||
ir: IR;
|
||||
sections: Section[];
|
||||
tokens: Tokens;
|
||||
assetGraph: AssetGraph;
|
||||
fontGraph: FontGraph;
|
||||
recipeReport: RecipeReport;
|
||||
interactionRecipeReport: InteractionRecipeReport;
|
||||
seoInventory: SeoInventory;
|
||||
codeQuality: CodeQualityReport;
|
||||
manifest: Record<string, unknown>;
|
||||
assetsCopied: number;
|
||||
assetsMissing: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure deterministic generation from a frozen capture: IR → infer → generate app
|
||||
* → emit artifacts. Called by the CLI and twice by the determinism gate; given
|
||||
* the same sourceDir it must produce byte-identical output (rubric Gate 6).
|
||||
*/
|
||||
export function generateAll(opts: {
|
||||
sourceDir: string;
|
||||
capture: CaptureResult;
|
||||
viewports: number[]; // band/gate widths (standard breakpoints)
|
||||
sampleViewports?: number[]; // dense set for size inference (defaults to viewports)
|
||||
url: string;
|
||||
outDir: string; // the generated/ dir
|
||||
ignoreRejectedInteractions?: boolean; // validation retest mode: wire everything before pruning
|
||||
}): GenerateAllResult {
|
||||
const { sourceDir, capture, viewports, url, outDir } = opts;
|
||||
const sampleViewports = opts.sampleViewports ?? viewports;
|
||||
const appDir = join(outDir, "app");
|
||||
|
||||
// Stage 5: carry @keyframes only when motion capture ran (motion on by default;
|
||||
// `--no-motion` / the plain static benchmark leave `capture.motion` unset), so
|
||||
// `--no-motion` output is byte-identical to the pre-Stage-5 frozen clone. `!!capture.motion`
|
||||
// is stable across processes (persisted in capture-result.json) ⇒ determinism holds.
|
||||
const ir = buildIR(sourceDir, sampleViewports, { motion: !!capture.motion, bandViewports: viewports });
|
||||
backfillLazyBackgrounds(ir);
|
||||
writeIR(ir, sourceDir);
|
||||
|
||||
const sections = detectSections(ir);
|
||||
const tokens = extractTokens(ir);
|
||||
const assetGraph = buildAssetGraph(capture);
|
||||
const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url);
|
||||
const seoInventory = buildSeoInventory(ir, assetGraph, capture);
|
||||
|
||||
// Stage 3.5: semantic color tokens (load-bearing). The palette's :root supersedes
|
||||
// the decorative color group; ditto.css references var(--token) for colors.
|
||||
const palette = buildColorPalette(ir);
|
||||
const tokensCss = (palette.css ? palette.css + "\n" : "") + tokensToCss(tokens, true);
|
||||
const tokenResolver = buildTokenResolver(tokens);
|
||||
const primitives = recognizePrimitives(ir);
|
||||
const recipeReport = buildRecipeReport(ir, sections, primitives);
|
||||
const interactionRecipeReport = buildInteractionRecipeReport(ir, sections, capture.interaction);
|
||||
// Patterns the interaction gate previously rejected (don't reproduce) → left static.
|
||||
const rejPath = join(sourceDir, "interaction-rejected.json");
|
||||
const rejectedSpecs = !opts.ignoreRejectedInteractions && fileExists(rejPath)
|
||||
? interactionRejectedSet(readJSON<unknown>(rejPath))
|
||||
: undefined;
|
||||
// Stage 4.5: component extraction is opt-in, persisted in the source dir at clone
|
||||
// time so every generateAll for this run (deliverable, determinism gate, prune
|
||||
// regen) makes the SAME choice — keeping output deterministic across processes.
|
||||
const optPath = join(sourceDir, "clone-options.json");
|
||||
const cloneOpts = fileExists(optPath) ? readJSON<{ components?: boolean; humanizeMode?: "tailwind" | "css"; framework?: AppFramework; reflow?: boolean }>(optPath) : {};
|
||||
const components = !!cloneOpts.components;
|
||||
const humanizeMode = cloneOpts.humanizeMode; // undefined → generateApp default ("tailwind")
|
||||
const gen = generateApp({ ir, assetGraph, fontGraph, appDir, sourceDir, sourceUrl: url, seoInventory, colorVar: palette.varForColor, tokenResolver, primitives, recipeReport, interaction: capture.interaction, rejectedSpecs, components, humanizeMode, framework: cloneOpts.framework, motion: capture.motion, reflow: !!cloneOpts.reflow }, tokensCss);
|
||||
const mat = materializeAssets(assetGraph, sourceDir, join(appDir, "public"));
|
||||
|
||||
// Stage 4.5: record promoted components (empty when extraction is off).
|
||||
writeJSON(join(outDir, "extracted-components.json"), gen.components);
|
||||
|
||||
writeJSON(join(outDir, "sections.json"), sections);
|
||||
writeJSON(join(outDir, "tokens.json"), tokens);
|
||||
writeJSON(join(outDir, "assets.json"), assetGraph.entries);
|
||||
writeJSON(join(outDir, "fonts.json"), fontGraph.entries);
|
||||
const inventory = inventoryOf(ir, primitives);
|
||||
writeJSON(join(outDir, "components.json"), inventory);
|
||||
writeJSON(join(outDir, "recipes.json"), recipeReport);
|
||||
writeText(join(outDir, "recipes.md"), recipeReportToMarkdown(recipeReport));
|
||||
writeJSON(join(outDir, "interaction-recipes.json"), interactionRecipeReport);
|
||||
writeText(join(outDir, "interaction-recipes.md"), interactionRecipeReportToMarkdown(interactionRecipeReport));
|
||||
writeJSON(join(outDir, "seo.json"), seoInventory);
|
||||
writeText(join(outDir, "seo.md"), seoInventoryToMarkdown(seoInventory));
|
||||
const codeQuality = buildCodeQualityReport(appDir, recipeReport);
|
||||
writeJSON(join(outDir, "code-quality.json"), codeQuality);
|
||||
writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality));
|
||||
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count });
|
||||
writeJSON(join(outDir, "manifest.json"), manifest);
|
||||
|
||||
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Self-converging sizing refinement.
|
||||
*
|
||||
* The sizing probe is ground truth when it runs against the SOURCE at capture. In this sandbox the
|
||||
* egress proxy can't tunnel the browser to the live site, so we fall back to probing the LOCAL clone
|
||||
* render — but then there's a compounding effect: each element is probed against the OTHER elements'
|
||||
* still-baked widths, so dropping ~120 at once shifts context and the first regen overshoots. The
|
||||
* fix is to iterate render→regen until the output stops changing: each cycle re-probes the layout
|
||||
* the previous drops produced, so the flags and the render agree at the fixed point.
|
||||
*
|
||||
* (With a real source-probe this is a one-pass no-op — the source layout never changes, so the very
|
||||
* first set of flags is already consistent and the loop exits after one render.)
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { buildApp, serveStatic, renderApp } from "../validate/render.js";
|
||||
import { generateAll } from "./pipeline.js";
|
||||
import { readJSON } from "../util/fsx.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
|
||||
/** A COARSE signature: the count of decimal-px dimensions + responsive breakpoints across the
|
||||
* generated app. We converge on this (not an exact content hash) because the clone-probe can leave
|
||||
* a handful of boundary elements oscillating between two near-equivalent layouts forever — the macro
|
||||
* state (how many measurements leaked) is what we're driving to a fixed point. */
|
||||
function coarseSignature(appDir: string): string {
|
||||
let decimals = 0, breakpoints = 0;
|
||||
const reDecimal = /-\[[0-9]+\.[0-9]+px\]/g;
|
||||
const reBp = /\b(sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g;
|
||||
const walk = (dir: string): void => {
|
||||
for (const name of readdirSync(dir).sort()) {
|
||||
const p = join(dir, name);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) { if (name !== "node_modules" && !name.startsWith(".")) walk(p); }
|
||||
else if (name.endsWith(".tsx")) {
|
||||
const txt = readFileSync(p, "utf8");
|
||||
decimals += (txt.match(reDecimal) || []).length;
|
||||
breakpoints += (txt.match(reBp) || []).length;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(appDir);
|
||||
return `${decimals}/${breakpoints}`;
|
||||
}
|
||||
|
||||
export async function refineSizing(
|
||||
runDir: string,
|
||||
harnessDir: string,
|
||||
opts?: { maxIters?: number; log?: (e: Record<string, unknown>) => void },
|
||||
): Promise<{ iters: number; converged: boolean }> {
|
||||
const maxIters = opts?.maxIters ?? 4;
|
||||
const log = opts?.log ?? (() => {});
|
||||
const sourceDir = join(runDir, "source");
|
||||
const generatedDir = join(runDir, "generated");
|
||||
const appDir = join(generatedDir, "app");
|
||||
const renderedDir = join(runDir, "rendered");
|
||||
const input = readJSON<{ url: string; viewports: number[] }>(join(runDir, "input.json"));
|
||||
const capture = readJSON<CaptureResult>(join(sourceDir, "capture", "capture-result.json"));
|
||||
const viewports = input.viewports;
|
||||
|
||||
let lastSig = coarseSignature(appDir);
|
||||
let iters = 0;
|
||||
let converged = false;
|
||||
for (let i = 0; i < maxIters; i++) {
|
||||
// 1) render the current clone locally → writes rendered/dom/dom-*.json with fresh probe flags
|
||||
const build = buildApp(appDir, harnessDir);
|
||||
if (!build.ok || !build.outDir) { log({ event: "refine_build_failed", i }); break; }
|
||||
const server = await serveStatic(build.outDir);
|
||||
try { await renderApp({ url: server.url + "/", viewports, renderedDir }); }
|
||||
finally { await server.close(); }
|
||||
// 2) regen consuming those flags (buildIR overlays them by cid)
|
||||
generateAll({ sourceDir, capture, viewports, sampleViewports: capture.viewports, url: input.url, outDir: generatedDir });
|
||||
iters = i + 1;
|
||||
const sig = coarseSignature(appDir);
|
||||
log({ event: "refine_iter", i, sig, prev: lastSig });
|
||||
if (sig === lastSig) { converged = true; break; } // macro state reproduced ⇒ fixed point
|
||||
lastSig = sig;
|
||||
}
|
||||
return { iters, converged };
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Section splitting (output-quality, fidelity-neutral).
|
||||
*
|
||||
* The page is one deep tree, so inlined it becomes a single 400-line page.tsx. A readable
|
||||
* app should instead be a short composition of named sections — Navbar, HeroSection,
|
||||
* AboutSection, ..., Footer — each in its own file. This module
|
||||
* finds those section roots and names them; the generator (app.ts) emits each section
|
||||
* subtree as its own module and leaves a `<HeroSection />` placeholder in page.tsx.
|
||||
*
|
||||
* Render-identical: a section module renders the exact same subtree (same tags, cids,
|
||||
* classes), so the composed DOM is byte-for-byte what inlining produced — gates align
|
||||
* by data-cid with no change. We do NOT flatten the wrapper chain (those divs are real,
|
||||
* styled nodes); page.tsx keeps the wrappers and plugs section components into them.
|
||||
*/
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { RecipeCandidate, RecipeKind, RecipeReport } from "../infer/recipes.js";
|
||||
import { subtreeSignature } from "../site/sharedLayout.js";
|
||||
|
||||
export type SectionPlan = {
|
||||
/** section-root cid → PascalCase component name. */
|
||||
roots: Map<string, string>;
|
||||
};
|
||||
|
||||
const MIN_SECTION_H = 56;
|
||||
const MAX_SECTIONS = 24;
|
||||
|
||||
function box(n: IRNode, cw: number): { width: number; height: number } | undefined {
|
||||
return n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0];
|
||||
}
|
||||
function yOf(n: IRNode, cw: number): number {
|
||||
return (n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0])?.y ?? 0;
|
||||
}
|
||||
function visible(n: IRNode, cw: number): boolean {
|
||||
return !!(n.visibleByVp[cw] ?? Object.values(n.visibleByVp)[0]);
|
||||
}
|
||||
function elementChildren(n: IRNode): IRNode[] {
|
||||
return n.children.filter((c): c is IRNode => !isTextChild(c));
|
||||
}
|
||||
function significantChildren(n: IRNode, cw: number): IRNode[] {
|
||||
return elementChildren(n).filter((c) => visible(c, cw) && (box(c, cw)?.height ?? 0) >= MIN_SECTION_H);
|
||||
}
|
||||
|
||||
function subtreeHasTag(n: IRNode, tag: string, depth = 4): boolean {
|
||||
if (depth < 0) return false;
|
||||
for (const c of elementChildren(n)) {
|
||||
if (c.tag === tag) return true;
|
||||
if (subtreeHasTag(c, tag, depth - 1)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function collectIds(n: IRNode, out = new Set<string>()): Set<string> {
|
||||
out.add(n.id);
|
||||
for (const c of elementChildren(n)) collectIds(c, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** First heading text (h1–h6) in a subtree, else the first non-trivial text run. */
|
||||
function titleText(n: IRNode, depth = 6): string {
|
||||
const fromHeadings = (node: IRNode, d: number): string => {
|
||||
if (d < 0) return "";
|
||||
for (const c of elementChildren(node)) {
|
||||
if (/^h[1-6]$/.test(c.tag)) { const t = textOf(c); if (t.trim()) return t; }
|
||||
const sub = fromHeadings(c, d - 1); if (sub) return sub;
|
||||
}
|
||||
return "";
|
||||
};
|
||||
const h = fromHeadings(n, depth);
|
||||
if (h) return h;
|
||||
return textOf(n);
|
||||
}
|
||||
function textOf(n: IRNode): string {
|
||||
let s = "";
|
||||
const walk = (x: IRNode): void => {
|
||||
for (const c of x.children) {
|
||||
if (isTextChild(c)) { if (c.text.trim()) s += " " + c.text.trim(); }
|
||||
else walk(c);
|
||||
if (s.length > 60) return;
|
||||
}
|
||||
};
|
||||
walk(n);
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
const STOP = new Set(["the", "a", "an", "of", "and", "to", "in", "for", "with", "your", "our", "is", "are", "be", "at", "on", "we", "you", "that", "this", "from", "by", "or", "it", "as", "new"]);
|
||||
/** Slugify prominent text into ≤3 PascalCase words for a section name. */
|
||||
function slugWords(text: string): string {
|
||||
const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
|
||||
const kept: string[] = [];
|
||||
for (const w of words) {
|
||||
if (kept.length >= 3) break;
|
||||
if (STOP.has(w) && kept.length === 0) continue;
|
||||
if (/^\d+$/.test(w) && kept.length === 0) continue; // drop leading numeric-only noise (e.g. layer ids "0019")
|
||||
if (w.length < 2 && !/\d/.test(w)) continue;
|
||||
kept.push(w);
|
||||
}
|
||||
return kept.map((w) => w[0]!.toUpperCase() + w.slice(1)).join("");
|
||||
}
|
||||
|
||||
/** Ensure a generated name is a valid JS identifier (it's used as an import/export id,
|
||||
* and the kebab filename derives from it — so fixing it here keeps id + path in sync).
|
||||
* A name that doesn't start with a letter/_/$ (e.g. "3dSection") gets an "S" prefix. */
|
||||
function identName(name: string): string {
|
||||
return /^[A-Za-z_$]/.test(name) ? name : "S" + name;
|
||||
}
|
||||
|
||||
function looksLikeNav(n: IRNode, cw: number): boolean {
|
||||
if (n.tag === "nav" || n.tag === "header") return true;
|
||||
if (subtreeHasTag(n, "nav")) return true;
|
||||
const b = box(n, cw);
|
||||
return !!b && b.height <= 140; // a short full-width bar at the top
|
||||
}
|
||||
|
||||
const RECIPE_SECTION_NAME: Partial<Record<RecipeKind, string>> = {
|
||||
"logo-cloud": "LogoCloudSection",
|
||||
"feature-grid": "FeatureGridSection",
|
||||
"product-grid": "ProductGridSection",
|
||||
"gallery-showcase": "GalleryShowcaseSection",
|
||||
"cta-band": "CtaSection",
|
||||
};
|
||||
|
||||
const RECIPE_FALLBACK_SECTION_NAME: Partial<Record<RecipeKind, string>> = {
|
||||
...RECIPE_SECTION_NAME,
|
||||
"card-grid": "CardGridSection",
|
||||
};
|
||||
|
||||
function recipeEligible(r: RecipeCandidate): boolean {
|
||||
if (r.kind === "gallery-showcase") return r.confidence >= 0.82;
|
||||
if (r.kind === "cta-band") return r.confidence >= 0.7;
|
||||
return r.confidence >= 0.82;
|
||||
}
|
||||
|
||||
function recipeRank(kind: RecipeKind): number {
|
||||
switch (kind) {
|
||||
case "gallery-showcase": return 5;
|
||||
case "product-grid": return 4;
|
||||
case "cta-band": return 3;
|
||||
case "feature-grid": return 2;
|
||||
case "logo-cloud": return 1;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function recipeNameForSection(sec: IRNode, recipes?: RecipeReport): string | null {
|
||||
if (!recipes) return null;
|
||||
const ids = collectIds(sec);
|
||||
const matches = recipes.candidates
|
||||
.filter((r) => recipeEligible(r) && RECIPE_SECTION_NAME[r.kind] && ids.has(r.rootCid))
|
||||
.sort((a, b) => recipeRank(b.kind) - recipeRank(a.kind) || b.confidence - a.confidence || a.id.localeCompare(b.id));
|
||||
const best = matches[0];
|
||||
return best ? RECIPE_SECTION_NAME[best.kind] ?? null : null;
|
||||
}
|
||||
|
||||
function indexTree(root: IRNode): { byId: Map<string, IRNode>; parentById: Map<string, IRNode | undefined> } {
|
||||
const byId = new Map<string, IRNode>();
|
||||
const parentById = new Map<string, IRNode | undefined>();
|
||||
const walk = (node: IRNode, parent: IRNode | undefined): void => {
|
||||
byId.set(node.id, node);
|
||||
parentById.set(node.id, parent);
|
||||
for (const c of elementChildren(node)) walk(c, node);
|
||||
};
|
||||
walk(root, undefined);
|
||||
return { byId, parentById };
|
||||
}
|
||||
|
||||
function sourceLooksSectionish(n: IRNode): boolean {
|
||||
return n.tag === "section" || n.tag === "article" || /\b(?:section|wrapper|container|surface|layout|above.?the.?fold|scroll.?container|content|root)\b/i.test(n.srcClass ?? "");
|
||||
}
|
||||
|
||||
function recipeFallbackAnchor(ir: IR, recipe: RecipeCandidate, byId: Map<string, IRNode>, parentById: Map<string, IRNode | undefined>): IRNode | undefined {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
|
||||
const root = byId.get(recipe.rootCid);
|
||||
const rb = root ? box(root, cw) : undefined;
|
||||
const rootTooBroad = !root || root.id === ir.root.id || (!!pageH && !!rb && rb.height > pageH * 0.55);
|
||||
let anchor = rootTooBroad && recipe.itemParentCid ? byId.get(recipe.itemParentCid) : root;
|
||||
if (!anchor) return undefined;
|
||||
const maxH = pageH ? Math.max(1600, pageH * 0.35) : 1600;
|
||||
let best = anchor;
|
||||
let cur: IRNode | undefined = anchor;
|
||||
while (cur) {
|
||||
const parent = parentById.get(cur.id);
|
||||
if (!parent || parent.id === ir.root.id || parent.tag === "body") break;
|
||||
const pb = box(parent, cw);
|
||||
if (!pb || pb.height < MIN_SECTION_H || pb.height > maxH) break;
|
||||
if (pb.width >= cw * 0.45 && sourceLooksSectionish(parent)) best = parent;
|
||||
cur = parent;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function recipeFallbackSections(ir: IR, recipes?: RecipeReport): { sections: IRNode[]; names: Map<string, string> } {
|
||||
const names = new Map<string, string>();
|
||||
if (!recipes) return { sections: [], names };
|
||||
const { byId, parentById } = indexTree(ir.root);
|
||||
const byRoot = new Map<string, { node: IRNode; name: string; rank: number; confidence: number }>();
|
||||
for (const recipe of recipes.candidates) {
|
||||
if (!recipeEligible(recipe)) continue;
|
||||
const name = RECIPE_FALLBACK_SECTION_NAME[recipe.kind];
|
||||
if (!name) continue;
|
||||
const node = recipeFallbackAnchor(ir, recipe, byId, parentById);
|
||||
if (!node || node.id === ir.root.id) continue;
|
||||
const b = box(node, ir.doc.canonicalViewport);
|
||||
if (!b || b.height < MIN_SECTION_H) continue;
|
||||
const rank = recipeRank(recipe.kind);
|
||||
const prev = byRoot.get(node.id);
|
||||
if (!prev || rank > prev.rank || (rank === prev.rank && recipe.confidence > prev.confidence)) {
|
||||
byRoot.set(node.id, { node, name, rank, confidence: recipe.confidence });
|
||||
}
|
||||
}
|
||||
let sections = [...byRoot.values()]
|
||||
.map((v) => v.node)
|
||||
.sort((a, b) => yOf(a, ir.doc.canonicalViewport) - yOf(b, ir.doc.canonicalViewport));
|
||||
sections = sections.filter((node, index) => {
|
||||
const ids = collectIds(node);
|
||||
return !sections.some((other, otherIndex) => otherIndex !== index && ids.has(other.id) && (box(other, ir.doc.canonicalViewport)?.height ?? 0) < (box(node, ir.doc.canonicalViewport)?.height ?? Infinity));
|
||||
});
|
||||
for (const node of sections) {
|
||||
const hit = byRoot.get(node.id);
|
||||
if (hit) names.set(node.id, hit.name);
|
||||
}
|
||||
return { sections, names };
|
||||
}
|
||||
|
||||
export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
// Descend through the wrapper chain (single significant child) to the container
|
||||
// whose children are the actual sections.
|
||||
let node = ir.root;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const sig = significantChildren(node, cw);
|
||||
if (sig.length >= 2) break;
|
||||
if (sig.length === 1) { node = sig[0]!; continue; }
|
||||
const kids = elementChildren(node);
|
||||
if (kids.length === 1) { node = kids[0]!; continue; }
|
||||
break;
|
||||
}
|
||||
// Exclude any child that is part of a REPEATED run (≥3 same-signature siblings): that's
|
||||
// a component cluster (a card/logo grid), which component extraction should turn into a
|
||||
// `.map()` over a data array — not a wall of near-identical "section" files. Only the
|
||||
// distinct, one-off blocks become sections.
|
||||
const distinctOf = (list: IRNode[]): IRNode[] => {
|
||||
const count = new Map<string, number>();
|
||||
for (const s of list) { const sig = subtreeSignature(s); count.set(sig, (count.get(sig) ?? 0) + 1); }
|
||||
return list.filter((s) => (count.get(subtreeSignature(s)) ?? 0) < 3);
|
||||
};
|
||||
|
||||
let candidates = significantChildren(node, cw);
|
||||
let sections = distinctOf(candidates);
|
||||
const fallback = recipeFallbackSections(ir, recipes);
|
||||
// If the container yields too few sections, a dominant child is a wrapper (e.g. <main>)
|
||||
// holding the real sections — expand such oversized children into their own significant
|
||||
// children, keeping the other siblings (e.g. a sibling <footer>). Gated on <3 so a page
|
||||
// that already splits cleanly is untouched.
|
||||
if (sections.length < 3) {
|
||||
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
|
||||
const expanded: IRNode[] = [];
|
||||
for (const c of candidates) {
|
||||
const inner = significantChildren(c, cw);
|
||||
const h = box(c, cw)?.height ?? 0;
|
||||
if (inner.length >= 3 && pageH > 0 && h >= pageH * 0.4) expanded.push(...inner);
|
||||
else expanded.push(c);
|
||||
}
|
||||
candidates = expanded;
|
||||
sections = distinctOf(candidates);
|
||||
}
|
||||
let recipeNameHints = new Map<string, string>();
|
||||
if (sections.length < 3 && fallback.sections.length >= 3) {
|
||||
sections = fallback.sections;
|
||||
recipeNameHints = fallback.names;
|
||||
}
|
||||
const roots = new Map<string, string>();
|
||||
if (sections.length < 3 || sections.length > MAX_SECTIONS) return { roots };
|
||||
|
||||
const used = new Map<string, number>();
|
||||
const dedupe = (base: string): string => {
|
||||
const n = (used.get(base) ?? 0) + 1;
|
||||
used.set(base, n);
|
||||
return n === 1 ? base : `${base}${n}`;
|
||||
};
|
||||
|
||||
let heroAssigned = false;
|
||||
sections.forEach((sec, i) => {
|
||||
const isLast = i === sections.length - 1;
|
||||
const recipeName = recipeNameHints.get(sec.id) ?? recipeNameForSection(sec, recipes);
|
||||
let name: string;
|
||||
if (sec.tag === "footer" || (isLast && looksLikeNav(sec, cw) === false && subtreeHasTag(sec, "a") && (box(sec, cw)?.height ?? 0) < 700)) {
|
||||
name = sec.tag === "footer" ? "Footer" : (titleText(sec) ? slugWords(titleText(sec)) + "Section" : "Footer");
|
||||
} else if (i === 0 && looksLikeNav(sec, cw)) {
|
||||
name = "Navbar";
|
||||
} else if (sec.tag === "header") {
|
||||
name = "Header";
|
||||
} else if (!heroAssigned) {
|
||||
heroAssigned = true;
|
||||
name = "HeroSection";
|
||||
} else if (recipeName) {
|
||||
name = recipeName;
|
||||
} else {
|
||||
const slug = slugWords(titleText(sec));
|
||||
name = slug ? `${slug}Section` : `Section${i + 1}`;
|
||||
}
|
||||
roots.set(sec.id, dedupe(identName(name)));
|
||||
});
|
||||
return { roots };
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import { copyFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import { extname, join } from "node:path";
|
||||
import { ensureDir, fileExists, writeText } from "../util/fsx.js";
|
||||
import type { CaptureResult, SeoResource } from "../capture/capture.js";
|
||||
import type { AssetEntry, AssetGraph } from "../infer/assets.js";
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
|
||||
export type SeoInventory = {
|
||||
sourceUrl: string;
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
canonicalUrl: string;
|
||||
robots: string;
|
||||
referrer: string;
|
||||
themeColor: string;
|
||||
colorScheme: string;
|
||||
openGraph: Array<{ property: string; content: string }>;
|
||||
twitter: Array<{ name: string; content: string }>;
|
||||
icons: Array<{
|
||||
rel: string;
|
||||
href: string;
|
||||
localPath: string | null;
|
||||
type?: string;
|
||||
sizes?: string;
|
||||
media?: string;
|
||||
color?: string;
|
||||
}>;
|
||||
manifest: {
|
||||
href: string;
|
||||
localPath: string | null;
|
||||
assets: Array<{ sourceUrl: string; localPath: string | null; type: string }>;
|
||||
} | null;
|
||||
alternates: Array<{ hrefLang: string; href: string }>;
|
||||
jsonLd: Array<{ id?: string; text: string; types: string[] }>;
|
||||
resources: SeoResource[];
|
||||
metrics: {
|
||||
metaFields: number;
|
||||
openGraphTags: number;
|
||||
twitterTags: number;
|
||||
iconLinks: number;
|
||||
manifestAssets: number;
|
||||
alternates: number;
|
||||
jsonLdBlocks: number;
|
||||
llmsTxt: boolean;
|
||||
llmsFullTxt: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type SeoRouteSummary = {
|
||||
routePath: string;
|
||||
href: string;
|
||||
url: string;
|
||||
title: string;
|
||||
description: string;
|
||||
excerpt: string;
|
||||
};
|
||||
|
||||
type HeadMeta = { name?: string; property?: string; httpEquiv?: string; content: string };
|
||||
|
||||
const clean = (value: string | undefined | null): string => (value ?? "").replace(/\s+/g, " ").trim();
|
||||
const lower = (value: string | undefined): string => (value ?? "").toLowerCase();
|
||||
const relTokens = (rel: string): Set<string> => new Set(rel.toLowerCase().split(/\s+/).filter(Boolean));
|
||||
|
||||
function metaContent(meta: HeadMeta[], key: string, kind: "name" | "property" | "httpEquiv" = "name"): string {
|
||||
const wanted = key.toLowerCase();
|
||||
for (const m of meta) {
|
||||
const actual = kind === "name" ? m.name : kind === "property" ? m.property : m.httpEquiv;
|
||||
if (actual && actual.toLowerCase() === wanted && m.content) return m.content.trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function metaPrefix(meta: HeadMeta[], prefix: string, kind: "name" | "property"): Array<{ key: string; content: string }> {
|
||||
const p = prefix.toLowerCase();
|
||||
const out: Array<{ key: string; content: string }> = [];
|
||||
for (const m of meta) {
|
||||
const key = kind === "name" ? m.name : m.property;
|
||||
const content = clean(m.content);
|
||||
if (key && key.toLowerCase().startsWith(p) && content) out.push({ key, content });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function assetFor(assetGraph: AssetGraph, url: string): AssetEntry | undefined {
|
||||
return assetGraph.byUrl.get(url);
|
||||
}
|
||||
|
||||
function jsonLdTypes(text: string): string[] {
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(text); } catch { return []; }
|
||||
const types = new Set<string>();
|
||||
const visit = (value: unknown): void => {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== "object") return;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const t = obj["@type"];
|
||||
if (typeof t === "string" && t.trim()) types.add(t.trim());
|
||||
else if (Array.isArray(t)) for (const item of t) if (typeof item === "string" && item.trim()) types.add(item.trim());
|
||||
if (Array.isArray(obj["@graph"])) visit(obj["@graph"]);
|
||||
};
|
||||
visit(parsed);
|
||||
return [...types].sort();
|
||||
}
|
||||
|
||||
function collectText(node: IRNode, out: string[]): void {
|
||||
for (const child of node.children) {
|
||||
if (isTextChild(child)) {
|
||||
const text = clean(child.text);
|
||||
if (text) out.push(text);
|
||||
} else {
|
||||
collectText(child, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function routeSummaryFromIr(ir: IR, routePath: string, href: string, url: string): SeoRouteSummary {
|
||||
const parts: string[] = [];
|
||||
collectText(ir.root, parts);
|
||||
const excerpt = parts.join(" ").replace(/\s+/g, " ").slice(0, 700).trim();
|
||||
return {
|
||||
routePath,
|
||||
href,
|
||||
url,
|
||||
title: ir.doc.title || (routePath === "/" ? "Home" : routePath),
|
||||
description: ir.doc.head?.description || "",
|
||||
excerpt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSeoInventory(ir: IR, assetGraph: AssetGraph, capture?: CaptureResult): SeoInventory {
|
||||
const head = ir.doc.head ?? {};
|
||||
const meta = head.meta ?? [];
|
||||
const links = head.links ?? [];
|
||||
const title = ir.doc.title || "";
|
||||
const description = metaContent(meta, "description") || head.description || "";
|
||||
const keywords = metaContent(meta, "keywords") || head.keywords || "";
|
||||
const canonicalUrl = links.find((l) => relTokens(l.rel).has("canonical"))?.href || head.canonical || "";
|
||||
const robots = metaContent(meta, "robots") || head.robots || "";
|
||||
const referrer = metaContent(meta, "referrer") || head.referrer || "";
|
||||
const themeColor = metaContent(meta, "theme-color") || head.themeColor || "";
|
||||
const colorScheme = metaContent(meta, "color-scheme") || head.colorScheme || "";
|
||||
|
||||
const openGraph = metaPrefix(meta, "og:", "property");
|
||||
if (!openGraph.length) {
|
||||
if (head.ogTitle) openGraph.push({ key: "og:title", content: head.ogTitle });
|
||||
if (head.ogDescription) openGraph.push({ key: "og:description", content: head.ogDescription });
|
||||
if (head.ogImage) openGraph.push({ key: "og:image", content: head.ogImage });
|
||||
if (head.ogType) openGraph.push({ key: "og:type", content: head.ogType });
|
||||
if (head.ogSiteName) openGraph.push({ key: "og:site_name", content: head.ogSiteName });
|
||||
}
|
||||
const twitter = metaPrefix(meta, "twitter:", "name");
|
||||
if (!twitter.length && head.twitterCard) twitter.push({ key: "twitter:card", content: head.twitterCard });
|
||||
|
||||
const icons = links.filter((link) => {
|
||||
const rel = link.rel.toLowerCase();
|
||||
return /\b(?:icon|shortcut|apple-touch-icon|mask-icon)\b/.test(rel);
|
||||
}).map((link) => ({
|
||||
rel: link.rel,
|
||||
href: link.href,
|
||||
localPath: assetFor(assetGraph, link.href)?.localPath ?? null,
|
||||
...(link.type ? { type: link.type } : {}),
|
||||
...(link.sizes ? { sizes: link.sizes } : {}),
|
||||
...(link.media ? { media: link.media } : {}),
|
||||
...(link.color ? { color: link.color } : {}),
|
||||
}));
|
||||
|
||||
const manifestLink = links.find((link) => relTokens(link.rel).has("manifest"));
|
||||
const manifestAssets = assetGraph.entries
|
||||
.filter((entry) => entry.via.some((via) => via.startsWith("manifest:")))
|
||||
.map((entry) => ({ sourceUrl: entry.sourceUrl, localPath: entry.localPath, type: entry.type }));
|
||||
const manifest = manifestLink ? {
|
||||
href: manifestLink.href,
|
||||
localPath: assetFor(assetGraph, manifestLink.href)?.localPath ?? null,
|
||||
assets: manifestAssets,
|
||||
} : null;
|
||||
|
||||
const alternates = links.filter((link) => relTokens(link.rel).has("alternate") && link.hrefLang && link.href)
|
||||
.map((link) => ({ hrefLang: link.hrefLang!, href: link.href }));
|
||||
const jsonLd = (head.jsonLd ?? []).map((entry) => ({ ...entry, types: jsonLdTypes(entry.text) }));
|
||||
const resources = [...(capture?.seoResources ?? [])].sort((a, b) => a.url.localeCompare(b.url) || a.kind.localeCompare(b.kind));
|
||||
|
||||
const metaFields = [title, description, keywords, canonicalUrl, robots, referrer, themeColor, colorScheme].filter(Boolean).length;
|
||||
return {
|
||||
sourceUrl: ir.doc.sourceUrl,
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
canonicalUrl,
|
||||
robots,
|
||||
referrer,
|
||||
themeColor,
|
||||
colorScheme,
|
||||
openGraph: openGraph.map((m) => ({ property: m.key, content: m.content })),
|
||||
twitter: twitter.map((m) => ({ name: m.key, content: m.content })),
|
||||
icons,
|
||||
manifest,
|
||||
alternates,
|
||||
jsonLd,
|
||||
resources,
|
||||
metrics: {
|
||||
metaFields,
|
||||
openGraphTags: openGraph.length,
|
||||
twitterTags: twitter.length,
|
||||
iconLinks: icons.length,
|
||||
manifestAssets: manifestAssets.length,
|
||||
alternates: alternates.length,
|
||||
jsonLdBlocks: jsonLd.length,
|
||||
llmsTxt: resources.some((r) => r.kind === "llms" && r.status === 200 && !!r.text),
|
||||
llmsFullTxt: resources.some((r) => r.kind === "llms-full" && r.status === 200 && !!r.text),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function seoInventoryToMarkdown(report: SeoInventory): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# SEO Inventory", "");
|
||||
lines.push(`- source: ${report.sourceUrl}`);
|
||||
lines.push(`- title: ${report.title || "(missing)"}`);
|
||||
lines.push(`- description: ${report.description || "(missing)"}`);
|
||||
lines.push(`- keywords: ${report.keywords || "(missing)"}`);
|
||||
lines.push(`- canonical: ${report.canonicalUrl || "(missing)"}`);
|
||||
lines.push(`- robots: ${report.robots || "(missing)"}`);
|
||||
lines.push(`- referrer: ${report.referrer || "(missing)"}`);
|
||||
lines.push(`- theme-color: ${report.themeColor || "(missing)"}`);
|
||||
lines.push(`- color-scheme: ${report.colorScheme || "(missing)"}`);
|
||||
lines.push("");
|
||||
lines.push("## Coverage", "");
|
||||
lines.push(`- Open Graph tags: ${report.metrics.openGraphTags}`);
|
||||
lines.push(`- Twitter card tags: ${report.metrics.twitterTags}`);
|
||||
lines.push(`- icon links: ${report.metrics.iconLinks}`);
|
||||
lines.push(`- manifest assets: ${report.metrics.manifestAssets}`);
|
||||
lines.push(`- alternates/hreflang: ${report.metrics.alternates}`);
|
||||
lines.push(`- JSON-LD blocks: ${report.metrics.jsonLdBlocks}`);
|
||||
lines.push(`- source llms.txt: ${report.metrics.llmsTxt ? "yes" : "no"}`);
|
||||
lines.push(`- source llms-full.txt: ${report.metrics.llmsFullTxt ? "yes" : "no"}`);
|
||||
if (report.icons.length) {
|
||||
lines.push("", "## Icons", "");
|
||||
for (const icon of report.icons) lines.push(`- ${icon.rel}: ${icon.localPath || icon.href}${icon.sizes ? ` (${icon.sizes})` : ""}`);
|
||||
}
|
||||
if (report.manifest) {
|
||||
lines.push("", "## Manifest", "");
|
||||
lines.push(`- link: ${report.manifest.localPath || report.manifest.href}`);
|
||||
for (const asset of report.manifest.assets) lines.push(`- asset: ${asset.localPath || asset.sourceUrl}`);
|
||||
}
|
||||
if (report.jsonLd.length) {
|
||||
lines.push("", "## Structured Data", "");
|
||||
for (const block of report.jsonLd) lines.push(`- ${block.types.length ? block.types.join(", ") : "JSON-LD"}${block.id ? ` (#${block.id})` : ""}`);
|
||||
}
|
||||
if (report.resources.length) {
|
||||
lines.push("", "## Discovered Files", "");
|
||||
for (const resource of report.resources) lines.push(`- ${resource.kind}: ${resource.status ?? "unreachable"} ${resource.url}`);
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function firstValue(entries: Array<{ property?: string; name?: string; content: string }>, key: string): string {
|
||||
const wanted = key.toLowerCase();
|
||||
const found = entries.find((entry) => (entry.property ?? entry.name ?? "").toLowerCase() === wanted);
|
||||
return found?.content ?? "";
|
||||
}
|
||||
|
||||
function keywordList(keywords: string): string[] | undefined {
|
||||
const parts = keywords.split(",").map((part) => part.trim()).filter(Boolean);
|
||||
return parts.length ? parts : undefined;
|
||||
}
|
||||
|
||||
function iconUrl(icon: SeoInventory["icons"][number]): string {
|
||||
return icon.localPath || icon.href;
|
||||
}
|
||||
|
||||
function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
const metadata: Record<string, unknown> = { title: report.title || "Cloned Page" };
|
||||
if (report.description) metadata.description = report.description;
|
||||
const keywords = keywordList(report.keywords);
|
||||
if (keywords) metadata.keywords = keywords;
|
||||
if (report.robots) metadata.robots = report.robots;
|
||||
if (report.referrer) metadata.referrer = report.referrer;
|
||||
const alternates: Record<string, unknown> = {};
|
||||
if (report.canonicalUrl) alternates.canonical = report.canonicalUrl;
|
||||
if (report.alternates.length) {
|
||||
const languages: Record<string, string> = {};
|
||||
for (const alt of report.alternates) languages[alt.hrefLang] = alt.href;
|
||||
alternates.languages = languages;
|
||||
}
|
||||
if (Object.keys(alternates).length) metadata.alternates = alternates;
|
||||
|
||||
const ogEntries = report.openGraph.map((entry) => ({ property: entry.property, content: entry.content }));
|
||||
const og: Record<string, unknown> = {};
|
||||
const ogTitle = firstValue(ogEntries, "og:title");
|
||||
const ogDescription = firstValue(ogEntries, "og:description");
|
||||
const ogType = firstValue(ogEntries, "og:type");
|
||||
const ogSiteName = firstValue(ogEntries, "og:site_name");
|
||||
const ogUrl = firstValue(ogEntries, "og:url");
|
||||
const ogImages = ogEntries.filter((entry) => entry.property?.toLowerCase() === "og:image").map((entry) => entry.content);
|
||||
if (ogTitle) og.title = ogTitle;
|
||||
if (ogDescription) og.description = ogDescription;
|
||||
if (ogType) og.type = ogType;
|
||||
if (ogSiteName) og.siteName = ogSiteName;
|
||||
if (ogUrl) og.url = ogUrl;
|
||||
if (ogImages.length) og.images = ogImages;
|
||||
if (Object.keys(og).length) metadata.openGraph = og;
|
||||
|
||||
const twEntries = report.twitter.map((entry) => ({ name: entry.name, content: entry.content }));
|
||||
const twitter: Record<string, unknown> = {};
|
||||
const twCard = firstValue(twEntries, "twitter:card");
|
||||
const twTitle = firstValue(twEntries, "twitter:title");
|
||||
const twDescription = firstValue(twEntries, "twitter:description");
|
||||
const twSite = firstValue(twEntries, "twitter:site");
|
||||
const twCreator = firstValue(twEntries, "twitter:creator");
|
||||
const twImages = twEntries.filter((entry) => entry.name?.toLowerCase() === "twitter:image").map((entry) => entry.content);
|
||||
if (twCard) twitter.card = twCard;
|
||||
if (twTitle) twitter.title = twTitle;
|
||||
if (twDescription) twitter.description = twDescription;
|
||||
if (twSite) twitter.site = twSite;
|
||||
if (twCreator) twitter.creator = twCreator;
|
||||
if (twImages.length) twitter.images = twImages;
|
||||
if (Object.keys(twitter).length) metadata.twitter = twitter;
|
||||
|
||||
const icons: Record<string, unknown[]> = {};
|
||||
for (const icon of report.icons) {
|
||||
const rel = icon.rel.toLowerCase();
|
||||
const item: Record<string, unknown> = { url: iconUrl(icon) };
|
||||
if (icon.type) item.type = icon.type;
|
||||
if (icon.sizes) item.sizes = icon.sizes;
|
||||
if (icon.media) item.media = icon.media;
|
||||
if (rel.includes("apple-touch-icon")) (icons.apple ??= []).push(item);
|
||||
else if (rel.includes("shortcut")) (icons.shortcut ??= []).push(item);
|
||||
else if (rel.includes("mask-icon")) (icons.other ??= []).push({ ...item, rel: "mask-icon", ...(icon.color ? { color: icon.color } : {}) });
|
||||
else (icons.icon ??= []).push(item);
|
||||
}
|
||||
if (Object.keys(icons).length) metadata.icons = icons;
|
||||
if (report.manifest) metadata.manifest = report.manifest.localPath || report.manifest.href;
|
||||
return metadata;
|
||||
}
|
||||
|
||||
export function metadataExport(report: SeoInventory): string {
|
||||
return `export const metadata = ${JSON.stringify(metadataObject(report), null, 2)};\n`;
|
||||
}
|
||||
|
||||
export function viewportExport(report: SeoInventory): string {
|
||||
const viewport: Record<string, unknown> = { width: "device-width", initialScale: 1 };
|
||||
if (report.themeColor) viewport.themeColor = report.themeColor;
|
||||
if (report.colorScheme) viewport.colorScheme = report.colorScheme;
|
||||
return `export const viewport = ${JSON.stringify(viewport, null, 2)};\n`;
|
||||
}
|
||||
|
||||
function safeJsonLd(text: string): string {
|
||||
return text.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--");
|
||||
}
|
||||
|
||||
export function jsonLdHeadMarkup(report: SeoInventory, indent = 8): string {
|
||||
if (!report.jsonLd.length) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
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}/>`).join("\n");
|
||||
}
|
||||
|
||||
function resourceText(report: SeoInventory, kind: SeoResource["kind"]): string | null {
|
||||
const resource = report.resources.find((r) => r.kind === kind && r.status === 200 && r.text);
|
||||
return resource?.text ?? null;
|
||||
}
|
||||
|
||||
function plainTextRoute(text: string): string {
|
||||
return `export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
return new Response(${JSON.stringify(text)}, {
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
function originOf(url: string): string {
|
||||
try { return new URL(url).origin; } catch { return "https://example.com"; }
|
||||
}
|
||||
|
||||
function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string {
|
||||
const title = report.title || routes[0]?.title || "Generated Clone";
|
||||
const lines: string[] = [`# ${title}`, ""];
|
||||
if (report.description) lines.push(report.description, "");
|
||||
lines.push("This is a generated ditto.site clone. It preserves captured page content, metadata, route structure, and static assets where available.", "");
|
||||
lines.push("## Routes", "");
|
||||
for (const route of routes) {
|
||||
const label = route.title || route.routePath || route.href;
|
||||
const desc = route.description ? ` - ${route.description}` : "";
|
||||
lines.push(`- [${label}](${route.url})${desc}`);
|
||||
}
|
||||
const contentRoutes = routes.filter((route) => route.excerpt);
|
||||
if (contentRoutes.length) {
|
||||
lines.push("", "## Captured Content", "");
|
||||
for (const route of contentRoutes.slice(0, 8)) {
|
||||
lines.push(`### ${route.title || route.routePath}`);
|
||||
lines.push(route.excerpt);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
|
||||
}
|
||||
|
||||
export function seoRouteFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
|
||||
const origin = originOf(report.sourceUrl);
|
||||
const sitemapUrl = origin + "/sitemap.xml";
|
||||
const robots = `import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: ${JSON.stringify(sitemapUrl)},
|
||||
};
|
||||
}
|
||||
`;
|
||||
const sitemapEntries = (routes.length ? routes : [{
|
||||
routePath: "/",
|
||||
href: "/",
|
||||
url: report.canonicalUrl || report.sourceUrl || origin + "/",
|
||||
title: report.title || "Home",
|
||||
description: report.description,
|
||||
excerpt: "",
|
||||
}]).map((route, index) => ({ url: route.url, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 }));
|
||||
const sitemap = `import type { MetadataRoute } from "next";
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return ${JSON.stringify(sitemapEntries, null, 2)};
|
||||
}
|
||||
`;
|
||||
const sourceLlms = resourceText(report, "llms");
|
||||
const sourceLlmsFull = resourceText(report, "llms-full");
|
||||
const files: Array<[string, string]> = [
|
||||
["robots.ts", robots],
|
||||
["sitemap.ts", sitemap],
|
||||
[join("llms.txt", "route.ts"), plainTextRoute(sourceLlms ?? generatedLlms(report, routes))],
|
||||
];
|
||||
if (sourceLlmsFull) files.push([join("llms-full.txt", "route.ts"), plainTextRoute(sourceLlmsFull)]);
|
||||
return files;
|
||||
}
|
||||
|
||||
function xmlEscape(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
|
||||
const origin = originOf(report.sourceUrl);
|
||||
const sitemapUrl = origin + "/sitemap.xml";
|
||||
const robots = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
`Sitemap: ${sitemapUrl}`,
|
||||
"",
|
||||
].join("\n");
|
||||
const sitemapRoutes = routes.length ? routes : [{
|
||||
routePath: "/",
|
||||
href: "/",
|
||||
url: report.canonicalUrl || report.sourceUrl || origin + "/",
|
||||
title: report.title || "Home",
|
||||
description: report.description,
|
||||
excerpt: "",
|
||||
}];
|
||||
const sitemap = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...sitemapRoutes.map((route, index) => [
|
||||
" <url>",
|
||||
` <loc>${xmlEscape(route.url)}</loc>`,
|
||||
" <changefreq>weekly</changefreq>",
|
||||
` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`,
|
||||
" </url>",
|
||||
].join("\n")),
|
||||
"</urlset>",
|
||||
"",
|
||||
].join("\n");
|
||||
const sourceLlms = resourceText(report, "llms");
|
||||
const sourceLlmsFull = resourceText(report, "llms-full");
|
||||
const files: Array<[string, string]> = [
|
||||
["robots.txt", robots],
|
||||
["sitemap.xml", sitemap],
|
||||
["llms.txt", sourceLlms ?? generatedLlms(report, routes)],
|
||||
];
|
||||
if (sourceLlmsFull) files.push(["llms-full.txt", sourceLlmsFull]);
|
||||
return files;
|
||||
}
|
||||
|
||||
function storedAssetPath(sourceDir: string, entry: AssetEntry): string | null {
|
||||
if (!entry.storedFile) return null;
|
||||
const store = join(sourceDir, "assets-store", entry.storedFile);
|
||||
if (fileExists(store)) return store;
|
||||
const css = join(sourceDir, "capture", "css", entry.storedFile);
|
||||
return fileExists(css) ? css : null;
|
||||
}
|
||||
|
||||
function extFor(entry: AssetEntry, href: string): string {
|
||||
const fromStored = entry.storedFile ? extname(entry.storedFile).toLowerCase() : "";
|
||||
if (fromStored) return fromStored;
|
||||
try { return extname(new URL(href).pathname).toLowerCase(); } catch { return extname(href).toLowerCase(); }
|
||||
}
|
||||
|
||||
function copyIcon(appDir: string, sourceDir: string, entry: AssetEntry | undefined, href: string, destName: string): boolean {
|
||||
if (!entry) return false;
|
||||
const src = storedAssetPath(sourceDir, entry);
|
||||
if (!src) return false;
|
||||
const dest = join(appDir, "src", "app", destName);
|
||||
ensureDir(join(dest, ".."));
|
||||
copyFileSync(src, dest);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function emitSeoAssetFiles(appDir: string, sourceDir: string, assetGraph: AssetGraph, report: SeoInventory): void {
|
||||
const appRoot = join(appDir, "src", "app");
|
||||
if (fileExists(appRoot)) {
|
||||
for (const name of readdirSync(appRoot)) {
|
||||
if (/^(?:favicon\.ico|icon\d*\.(?:ico|jpg|jpeg|png|svg)|apple-icon\d*\.(?:jpg|jpeg|png))$/.test(name)) {
|
||||
rmSync(join(appRoot, name), { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
const firstIcon = report.icons.find((icon) => /\bicon\b/i.test(icon.rel) && !/apple-touch-icon|mask-icon/i.test(icon.rel));
|
||||
const firstIco = report.icons.find((icon) => /\.ico(?:$|[?#])/i.test(icon.href) || lower(icon.type).includes("icon"));
|
||||
if (firstIco) copyIcon(appDir, sourceDir, assetFor(assetGraph, firstIco.href), firstIco.href, "favicon.ico");
|
||||
if (firstIcon) {
|
||||
const entry = assetFor(assetGraph, firstIcon.href);
|
||||
const ext = entry ? extFor(entry, firstIcon.href) : "";
|
||||
if (/^\.(?:ico|jpg|jpeg|png|svg)$/.test(ext)) copyIcon(appDir, sourceDir, entry, firstIcon.href, `icon${ext}`);
|
||||
}
|
||||
const apple = report.icons.find((icon) => /apple-touch-icon/i.test(icon.rel));
|
||||
if (apple) {
|
||||
const entry = assetFor(assetGraph, apple.href);
|
||||
const ext = entry ? extFor(entry, apple.href) : "";
|
||||
if (/^\.(?:jpg|jpeg|png)$/.test(ext)) copyIcon(appDir, sourceDir, entry, apple.href, `apple-icon${ext}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function emitSeoRoutes(appDir: string, report: SeoInventory, routes: SeoRouteSummary[]): void {
|
||||
rmSync(join(appDir, "src", "app", "llms-full.txt"), { recursive: true, force: true });
|
||||
for (const [rel, body] of seoRouteFiles(report, routes)) writeText(join(appDir, "src", "app", rel), body);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user