Initial commit
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
import { join } from "node:path";
|
||||
import { copyFileSync } from "node:fs";
|
||||
import { ensureDir, fileExists } from "../util/fsx.js";
|
||||
import type { CaptureResult, DiscoveredAsset } from "../capture/capture.js";
|
||||
|
||||
/**
|
||||
* Asset graph. Maps every discovered source asset URL to a deterministic local
|
||||
* path under the generated app's public dir, or records why it was skipped
|
||||
* (rubric Gate 2: every reference must be classified, none may 404, and none may
|
||||
* point back to a remote origin unless explicitly external-allowed).
|
||||
*/
|
||||
|
||||
export type AssetClassification = "downloaded" | "skipped";
|
||||
|
||||
export type AssetEntry = {
|
||||
sourceUrl: string;
|
||||
type: string;
|
||||
classification: AssetClassification;
|
||||
localPath: string | null; // public-relative URL, e.g. /assets/cloned/images/ab.png
|
||||
storedFile: string | null;
|
||||
bytes: number;
|
||||
reason: string | null;
|
||||
impact: string | null;
|
||||
via: string[];
|
||||
};
|
||||
|
||||
export type AssetGraph = {
|
||||
entries: AssetEntry[];
|
||||
byUrl: Map<string, AssetEntry>;
|
||||
};
|
||||
|
||||
const TYPE_DIR: Record<string, string> = {
|
||||
image: "images", svg: "svg", video: "videos", font: "fonts", lottie: "lottie",
|
||||
css: "css", manifest: "manifest", other: "other",
|
||||
};
|
||||
|
||||
function publicPathFor(type: string, storedFile: string): string {
|
||||
const dir = TYPE_DIR[type] ?? "other";
|
||||
return `/assets/cloned/${dir}/${storedFile}`;
|
||||
}
|
||||
|
||||
export function buildAssetGraph(capture: CaptureResult): AssetGraph {
|
||||
const entries: AssetEntry[] = [];
|
||||
const byUrl = new Map<string, AssetEntry>();
|
||||
|
||||
for (const a of capture.assets) {
|
||||
const entry = classify(a);
|
||||
entries.push(entry);
|
||||
byUrl.set(a.url, entry);
|
||||
}
|
||||
entries.sort((x, y) => x.sourceUrl.localeCompare(y.sourceUrl));
|
||||
return { entries, byUrl };
|
||||
}
|
||||
|
||||
function classify(a: DiscoveredAsset): AssetEntry {
|
||||
// CSS is consumed by the compiler (font/url extraction), not referenced by the
|
||||
// generated app directly, so it is neither downloaded-as-asset nor a gap.
|
||||
const base: AssetEntry = {
|
||||
sourceUrl: a.url,
|
||||
type: a.type,
|
||||
classification: "skipped",
|
||||
localPath: null,
|
||||
storedFile: null,
|
||||
bytes: a.bytes,
|
||||
reason: null,
|
||||
impact: null,
|
||||
via: a.via.slice().sort(),
|
||||
};
|
||||
|
||||
if (a.storedAs && a.bytes > 0) {
|
||||
return {
|
||||
...base,
|
||||
classification: "downloaded",
|
||||
localPath: publicPathFor(a.type, a.storedAs),
|
||||
storedFile: a.storedAs,
|
||||
};
|
||||
}
|
||||
|
||||
// Not stored — record a deterministic skip reason.
|
||||
let reason = "not_downloaded";
|
||||
if (a.status && a.status >= 400) reason = `http_${a.status}`;
|
||||
else if (a.status === null && a.via.every((v) => v.startsWith("css") || v === "css-url")) reason = "css_referenced_unfetched";
|
||||
return {
|
||||
...base,
|
||||
reason,
|
||||
impact: a.type === "image" || a.type === "video" ? "visual_missing" : "minor",
|
||||
};
|
||||
}
|
||||
|
||||
/** Copy downloaded asset bytes into the generated app's public dir. */
|
||||
export function materializeAssets(graph: AssetGraph, sourceDir: string, appPublicDir: string, seenPublicPaths?: Set<string>): { copied: number; missing: string[] } {
|
||||
const storeDir = join(sourceDir, "assets-store");
|
||||
const cssDir = join(sourceDir, "capture", "css");
|
||||
let copied = 0;
|
||||
const missing: string[] = [];
|
||||
for (const e of graph.entries) {
|
||||
if (e.classification !== "downloaded" || !e.storedFile || !e.localPath) continue;
|
||||
if (e.type === "css") continue; // not served by the app
|
||||
// A <video> is rendered as its poster still (streaming sources are dropped in
|
||||
// generation), so the materialized video file is never referenced — skip
|
||||
// copying it to public/ to save disk and build time. Its first-frame poster is
|
||||
// a separate image asset that is still materialized.
|
||||
if (e.type === "video") continue;
|
||||
if (seenPublicPaths?.has(e.localPath)) continue;
|
||||
const src = join(storeDir, e.storedFile);
|
||||
const from = fileExists(src) ? src : join(cssDir, e.storedFile);
|
||||
if (!fileExists(from)) { missing.push(e.sourceUrl); continue; }
|
||||
const dest = join(appPublicDir, e.localPath.replace(/^\//, ""));
|
||||
ensureDir(join(dest, ".."));
|
||||
copyFileSync(from, dest);
|
||||
seenPublicPaths?.add(e.localPath);
|
||||
copied++;
|
||||
}
|
||||
return { copied, missing };
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
/**
|
||||
* Stage 4.5 — component extraction (repeated subtrees).
|
||||
*
|
||||
* Detects runs of ≥3 contiguous sibling subtrees that share a structural signature
|
||||
* and are strictly shape-aligned (same tags + same element/text child sequence,
|
||||
* recursively). Such a run is promoted to one component rendered over a per-instance
|
||||
* data array; the generator (`generate/app.ts`) emits the skeleton, baking values
|
||||
* that are identical across instances and turning values that vary (text, hrefs,
|
||||
* cids, …) into data fields.
|
||||
*
|
||||
* Fidelity first (the contract): each instance keeps its ORIGINAL cid in the rendered
|
||||
* DOM (carried in the data), so the output is render-identical to inlining — gates
|
||||
* 3/4/5 align by `data-cid` with no remap. Anything that isn't strictly alignable, or
|
||||
* whose between-instance separators aren't insignificant whitespace, is left inline.
|
||||
*/
|
||||
import type { IR, IRNode, IRChild } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import { subtreeSignature } from "../site/sharedLayout.js";
|
||||
import type { RecipeKind, RecipeReport } from "./recipes.js";
|
||||
|
||||
const MIN_INSTANCES = 3;
|
||||
|
||||
export type ComponentCluster = {
|
||||
baseName: string; // role label (Card, NavItem, ListItem, …); emission adds a dedup suffix
|
||||
parentCid: string; // the node whose child run was promoted
|
||||
rootCids: string[]; // instance roots in document order (a contiguous sibling run)
|
||||
insideInteractive: boolean; // is the run nested in an <a>/<button> (affects retagging)
|
||||
recipeKind?: RecipeKind; // high-level layout recipe that owns this repeated run
|
||||
dataModel?: string; // semantic collection prop name from the recipe layer (logos/cards/…)
|
||||
looseRecipe?: "logo-cloud-item" | "variant-card-item"; // recipe-specific emitters for safe mixed-shape runs
|
||||
};
|
||||
|
||||
export type ComponentPlan = {
|
||||
clusters: ComponentCluster[];
|
||||
rootToCluster: Map<string, ComponentCluster>; // every instance root cid → its cluster
|
||||
firstRoot: Set<string>; // cids that are the FIRST instance of a cluster
|
||||
};
|
||||
|
||||
const elementChildren = (n: IRNode): IRNode[] => n.children.filter((c): c is IRNode => !isTextChild(c));
|
||||
const isWsText = (c: IRChild): boolean => isTextChild(c) && c.text.trim() === "";
|
||||
|
||||
/** A subtree worth extracting carries real content — text or media — so we never
|
||||
* promote runs of empty spacer/wrapper boxes (no readability or fidelity gain). */
|
||||
function hasContent(n: IRNode): boolean {
|
||||
if (/^(img|svg|video)$/.test(n.tag)) return true;
|
||||
for (const c of n.children) {
|
||||
if (isTextChild(c)) { if (c.text.trim()) return true; }
|
||||
else if (hasContent(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A bare single element is a meaningful component only when it's a link / control /
|
||||
// list item / media — otherwise it's likely a text-effect fragment (e.g. a word split
|
||||
// into per-letter <span>s) rather than a reusable unit.
|
||||
const MEANINGFUL_LEAF = new Set(["a", "button", "li", "option", "img", "svg", "picture", "video", "tr"]);
|
||||
|
||||
function elementNodeCount(n: IRNode): number {
|
||||
let c = 1;
|
||||
for (const k of elementChildren(n)) c += elementNodeCount(k);
|
||||
return c;
|
||||
}
|
||||
|
||||
/** A run is component-worthy only if each instance is a substantial unit: ≥2 element
|
||||
* nodes, or a semantically meaningful single element. Rejects per-letter/word span
|
||||
* runs (staggered-text effects) and other trivial leaf repetition that would extract
|
||||
* to nonsense like `<Item d={{ f0: "s" }} />`. (Instances are shape-aligned, so the
|
||||
* representative decides for all.) */
|
||||
function meaningful(root: IRNode): boolean {
|
||||
return MEANINGFUL_LEAF.has(root.tag) || elementNodeCount(root) >= 2;
|
||||
}
|
||||
|
||||
/** Strict shape alignment: same tag, same child count, same element/text kind at
|
||||
* every position, recursively. Text CONTENT and attribute VALUES may differ (that's
|
||||
* the per-instance data) — only the SHAPE must be identical so one skeleton renders
|
||||
* every instance faithfully. (subtreeSignature ignores text nodes, so this adds the
|
||||
* text-position check it lacks.) */
|
||||
function alignable(nodes: IRNode[]): boolean {
|
||||
const tag = nodes[0]!.tag;
|
||||
if (!nodes.every((n) => n.tag === tag)) return false;
|
||||
const len = nodes[0]!.children.length;
|
||||
if (!nodes.every((n) => n.children.length === len)) return false;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const isText = isTextChild(nodes[0]!.children[i]!);
|
||||
if (!nodes.every((n) => isTextChild(n.children[i]!) === isText)) return false;
|
||||
if (!isText && !alignable(nodes.map((n) => n.children[i] as IRNode))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whitespace between block-level instances (or under a flex/grid parent) is not
|
||||
* rendered, so dropping it when collapsing the run to a `.map()` is render-safe.
|
||||
* Inline-level instances with whitespace between them DO show a space → not safe. */
|
||||
function separatorsInsignificant(parent: IRNode, instances: IRNode[]): boolean {
|
||||
const pdisp = dispOf(parent);
|
||||
if (/(flex|grid)/.test(pdisp)) return true;
|
||||
return instances.every((n) => /^(block|list-item|table|flow-root|flex|grid)/.test(dispOf(n)));
|
||||
}
|
||||
|
||||
function dispOf(n: IRNode): string {
|
||||
return (n.computedByVp[1280] ?? Object.values(n.computedByVp)[0])?.display ?? "";
|
||||
}
|
||||
|
||||
function hasHeading(n: IRNode): boolean {
|
||||
for (const c of n.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
if (/^h[1-6]$/.test(c.tag)) return true;
|
||||
if (hasHeading(c)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function subtreeHasTag(n: IRNode, re: RegExp): boolean {
|
||||
for (const c of n.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
if (re.test(c.tag)) return true;
|
||||
if (subtreeHasTag(c, re)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function subtreeHasAvatar(n: IRNode, prims?: Map<string, string>): boolean {
|
||||
if (!prims) return false;
|
||||
const walk = (x: IRNode): boolean => {
|
||||
if (prims.get(x.id) === "avatar") return true;
|
||||
for (const c of x.children) if (!isTextChild(c) && walk(c)) return true;
|
||||
return false;
|
||||
};
|
||||
return walk(n);
|
||||
}
|
||||
function directText(n: IRNode): string {
|
||||
let s = "";
|
||||
const walk = (x: IRNode): void => { for (const c of x.children) { if (isTextChild(c)) s += c.text; else walk(c); } };
|
||||
walk(n);
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
/** A readable, deterministic, SEMANTIC component name from the run's role/content —
|
||||
* deliberately avoiding the generic vocabulary (Item/Card/List/Link…). Repeated links
|
||||
* become NavLink/TextLink, repeated cards FeatureCard/MediaCard/ProfileCard by what
|
||||
* they contain, repeated logos Logo, etc. */
|
||||
function baseName(parentTag: string, root: IRNode, prims?: Map<string, string>): string {
|
||||
const prim = prims?.get(root.id);
|
||||
const hasH = hasHeading(root);
|
||||
const hasImg = subtreeHasTag(root, /^(img|svg|picture|video)$/);
|
||||
const hasAvatar = subtreeHasAvatar(root, prims);
|
||||
const text = directText(root);
|
||||
const inNav = parentTag === "nav" || parentTag === "header";
|
||||
|
||||
// Anchor / button runs → link-ish names.
|
||||
if (root.tag === "a" || prim === "link" || prim === "button") {
|
||||
if (inNav) return "NavLink";
|
||||
if (hasImg && hasH) return "CardLink";
|
||||
if (hasImg && !text) return "Logo";
|
||||
if (hasImg) return "MediaLink";
|
||||
return "TextLink";
|
||||
}
|
||||
// Card-ish subtrees (have a heading) → name by media content.
|
||||
if (hasH) {
|
||||
if (hasAvatar) return "ProfileCard";
|
||||
if (hasImg) return "MediaCard";
|
||||
return "FeatureCard";
|
||||
}
|
||||
// Media-only repeats: logos vs media tiles.
|
||||
if (hasImg && !text) return "Logo";
|
||||
if (hasImg) return "MediaTile";
|
||||
// List rows / generic content tiles (still semantic, not "Item").
|
||||
if (root.tag === "li") return inNav ? "NavLink" : "ListRow";
|
||||
if (parentTag === "ul" || parentTag === "ol") return "ListRow";
|
||||
return "Tile";
|
||||
}
|
||||
|
||||
type RecipeComponentHint = {
|
||||
kind: RecipeKind;
|
||||
dataModel?: string;
|
||||
confidence: number;
|
||||
itemRootByCid: Map<string, string>;
|
||||
};
|
||||
|
||||
function nodeIndex(ir: IR): Map<string, IRNode> {
|
||||
const byId = new Map<string, IRNode>();
|
||||
const index = (n: IRNode): void => {
|
||||
byId.set(n.id, n);
|
||||
for (const c of elementChildren(n)) index(c);
|
||||
};
|
||||
index(ir.root);
|
||||
return byId;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function recipeComponentHints(recipes: RecipeReport | undefined, byId: Map<string, IRNode>): RecipeComponentHint[] {
|
||||
if (!recipes) return [];
|
||||
const hints: RecipeComponentHint[] = [];
|
||||
for (const c of recipes.candidates) {
|
||||
if (c.kind === "cta-band" || !c.repeatedItems?.length || !c.dataModel) continue;
|
||||
if (c.confidence < 0.82) continue;
|
||||
const itemRootByCid = new Map<string, string>();
|
||||
for (const item of c.repeatedItems) {
|
||||
const node = byId.get(item.cid);
|
||||
if (!node) continue;
|
||||
for (const id of collectIds(node)) itemRootByCid.set(id, item.cid);
|
||||
}
|
||||
if (itemRootByCid.size) hints.push({ kind: c.kind, dataModel: c.dataModel, confidence: c.confidence, itemRootByCid });
|
||||
}
|
||||
return hints.sort((a, b) => b.confidence - a.confidence);
|
||||
}
|
||||
|
||||
function simpleLogoCloudItem(n: IRNode): boolean {
|
||||
if (n.tag !== "div") return false;
|
||||
const kids = elementChildren(n);
|
||||
if (kids.length !== 1) return false;
|
||||
const only = kids[0]!;
|
||||
if (only.tag === "img") return true;
|
||||
if (only.tag !== "a") return false;
|
||||
const linkKids = elementChildren(only);
|
||||
if (linkKids.length !== 1 || linkKids[0]!.tag !== "div") return false;
|
||||
const innerKids = elementChildren(linkKids[0]!);
|
||||
const images = innerKids.filter((c) => c.tag === "img");
|
||||
if (images.length !== 1) return false;
|
||||
const extras = innerKids.filter((c) => c.tag !== "img");
|
||||
return extras.length <= 1 && extras.every((c) => c.tag === "div");
|
||||
}
|
||||
|
||||
function contiguousChildRun(parent: IRNode, itemCids: string[]): boolean {
|
||||
const childIds = elementChildren(parent).map((c) => c.id);
|
||||
const indexes = itemCids.map((id) => childIds.indexOf(id));
|
||||
if (indexes.some((i) => i < 0)) return false;
|
||||
for (let i = 1; i < indexes.length; i++) if (indexes[i]! <= indexes[i - 1]!) return false;
|
||||
return indexes[indexes.length - 1]! - indexes[0]! + 1 === indexes.length;
|
||||
}
|
||||
|
||||
function looseLogoCloudClusters(recipes: RecipeReport | undefined, byId: Map<string, IRNode>): Map<string, ComponentCluster[]> {
|
||||
const out = new Map<string, ComponentCluster[]>();
|
||||
if (!recipes) return out;
|
||||
for (const c of recipes.candidates) {
|
||||
if (c.kind !== "logo-cloud" || c.confidence < 0.86 || !c.itemParentCid || !c.repeatedItems?.length) continue;
|
||||
const parent = byId.get(c.itemParentCid);
|
||||
const itemCids = c.repeatedItems.map((i) => i.cid);
|
||||
const nodes = itemCids.map((cid) => byId.get(cid));
|
||||
if (!parent || nodes.some((n) => !n)) continue;
|
||||
const items = nodes as IRNode[];
|
||||
if (!contiguousChildRun(parent, itemCids) || !items.every(simpleLogoCloudItem)) continue;
|
||||
const cluster: ComponentCluster = {
|
||||
baseName: "LogoCloudItem",
|
||||
parentCid: parent.id,
|
||||
rootCids: itemCids,
|
||||
insideInteractive: false,
|
||||
recipeKind: "logo-cloud",
|
||||
dataModel: c.dataModel ?? "logos",
|
||||
looseRecipe: "logo-cloud-item",
|
||||
};
|
||||
const prev = out.get(parent.id) ?? [];
|
||||
prev.push(cluster);
|
||||
out.set(parent.id, prev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function looseVariantCardClusters(recipes: RecipeReport | undefined, byId: Map<string, IRNode>): Map<string, ComponentCluster[]> {
|
||||
const out = new Map<string, ComponentCluster[]>();
|
||||
if (!recipes) return out;
|
||||
for (const c of recipes.candidates) {
|
||||
if ((c.kind !== "feature-grid" && c.kind !== "card-grid" && c.kind !== "product-grid") || c.confidence < 0.9 || !c.itemParentCid || !c.repeatedItems?.length) continue;
|
||||
const itemCids = c.repeatedItems.map((i) => i.cid);
|
||||
if (itemCids.length < MIN_INSTANCES || itemCids.length > 12) continue;
|
||||
const parent = byId.get(c.itemParentCid);
|
||||
const nodes = itemCids.map((cid) => byId.get(cid));
|
||||
if (!parent || nodes.some((n) => !n)) continue;
|
||||
const items = nodes as IRNode[];
|
||||
if (!contiguousChildRun(parent, itemCids)) continue;
|
||||
if (alignable(items)) continue; // strict extraction produces cleaner code when it can.
|
||||
if (!items.every((n) => meaningful(n) && hasContent(n))) continue;
|
||||
if (!separatorsInsignificant(parent, items)) continue;
|
||||
const cluster: ComponentCluster = {
|
||||
baseName: c.kind === "feature-grid" ? "FeatureGridItem" : c.kind === "product-grid" ? "ProductCard" : "CardGridItem",
|
||||
parentCid: parent.id,
|
||||
rootCids: itemCids,
|
||||
insideInteractive: false,
|
||||
recipeKind: c.kind,
|
||||
dataModel: c.dataModel ?? (c.kind === "feature-grid" ? "features" : c.kind === "product-grid" ? "products" : "cards"),
|
||||
looseRecipe: "variant-card-item",
|
||||
};
|
||||
const prev = out.get(parent.id) ?? [];
|
||||
prev.push(cluster);
|
||||
out.set(parent.id, prev);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function mergeLooseClusters(...maps: Array<Map<string, ComponentCluster[]>>): Map<string, ComponentCluster[]> {
|
||||
const out = new Map<string, ComponentCluster[]>();
|
||||
for (const map of maps) {
|
||||
for (const [parent, clusters] of map) {
|
||||
const prev = out.get(parent) ?? [];
|
||||
prev.push(...clusters);
|
||||
out.set(parent, prev);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function recipeHintForRun(hints: RecipeComponentHint[], run: IRNode[]): RecipeComponentHint | undefined {
|
||||
for (const hint of hints) {
|
||||
const itemRoots = run.map((n) => hint.itemRootByCid.get(n.id));
|
||||
if (itemRoots.some((id) => !id)) continue;
|
||||
if (new Set(itemRoots).size !== run.length) continue;
|
||||
return hint;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function recipeBaseName(kind: RecipeKind, parentTag: string, root: IRNode, prims?: Map<string, string>): string {
|
||||
if (kind === "logo-cloud") return "Logo";
|
||||
if (kind === "feature-grid") return "FeatureCard";
|
||||
if (kind === "product-grid") return "ProductCard";
|
||||
if (kind === "card-grid") {
|
||||
const inferred = baseName(parentTag, root, prims);
|
||||
return /^(MediaCard|ProfileCard|CardLink)$/.test(inferred) ? inferred : "Card";
|
||||
}
|
||||
return baseName(parentTag, root, prims);
|
||||
}
|
||||
|
||||
export function detectComponents(ir: IR, prims?: Map<string, string>, recipes?: RecipeReport): ComponentPlan {
|
||||
const clusters: ComponentCluster[] = [];
|
||||
const byId = nodeIndex(ir);
|
||||
const recipeHints = recipeComponentHints(recipes, byId);
|
||||
const looseByParent = mergeLooseClusters(looseLogoCloudClusters(recipes, byId), looseVariantCardClusters(recipes, byId));
|
||||
const consumed = new Set<string>(); // cids inside an already-chosen cluster instance
|
||||
const markConsumed = (n: IRNode): void => {
|
||||
consumed.add(n.id);
|
||||
for (const k of elementChildren(n)) markConsumed(k);
|
||||
};
|
||||
|
||||
const walk = (node: IRNode, insideInteractive: boolean): void => {
|
||||
for (const loose of looseByParent.get(node.id) ?? []) {
|
||||
if (loose.rootCids.some((cid) => consumed.has(cid))) continue;
|
||||
clusters.push(loose);
|
||||
for (const cid of loose.rootCids) {
|
||||
const root = byId.get(cid);
|
||||
if (root) markConsumed(root);
|
||||
}
|
||||
}
|
||||
const kids = node.children;
|
||||
let i = 0;
|
||||
while (i < kids.length) {
|
||||
const c = kids[i]!;
|
||||
if (isTextChild(c)) { i++; continue; }
|
||||
if (consumed.has(c.id)) { i++; continue; }
|
||||
// Greedily grow a run of same-signature element siblings (only whitespace text
|
||||
// allowed between them).
|
||||
const sig = subtreeSignature(c);
|
||||
const run: IRNode[] = [c];
|
||||
let j = i + 1;
|
||||
while (j < kids.length) {
|
||||
const d = kids[j]!;
|
||||
if (isTextChild(d)) { if (isWsText(d)) { j++; continue; } else break; }
|
||||
if (subtreeSignature(d) === sig) { run.push(d); j++; } else break;
|
||||
}
|
||||
if (
|
||||
run.length >= MIN_INSTANCES &&
|
||||
alignable(run) &&
|
||||
meaningful(run[0]!) &&
|
||||
run.every(hasContent) &&
|
||||
separatorsInsignificant(node, run)
|
||||
) {
|
||||
const recipeHint = recipeHintForRun(recipeHints, run);
|
||||
clusters.push({
|
||||
baseName: recipeHint ? recipeBaseName(recipeHint.kind, node.tag, c, prims) : baseName(node.tag, c, prims),
|
||||
parentCid: node.id,
|
||||
rootCids: run.map((r) => r.id),
|
||||
insideInteractive,
|
||||
...(recipeHint ? { recipeKind: recipeHint.kind, ...(recipeHint.dataModel ? { dataModel: recipeHint.dataModel } : {}) } : {}),
|
||||
});
|
||||
for (const r of run) markConsumed(r);
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
const childInteractive = insideInteractive || node.tag === "a" || node.tag === "button";
|
||||
for (const k of elementChildren(node)) {
|
||||
if (consumed.has(k.id)) continue; // don't extract within a chosen instance (no nesting)
|
||||
walk(k, childInteractive);
|
||||
}
|
||||
};
|
||||
walk(ir.root, false);
|
||||
|
||||
const rootToCluster = new Map<string, ComponentCluster>();
|
||||
const firstRoot = new Set<string>();
|
||||
for (const cl of clusters) {
|
||||
firstRoot.add(cl.rootCids[0]!);
|
||||
for (const r of cl.rootCids) rootToCluster.set(r, cl);
|
||||
}
|
||||
return { clusters, rootToCluster, firstRoot };
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { FontFace } from "../capture/walker.js";
|
||||
import type { AssetGraph, AssetEntry } from "./assets.js";
|
||||
|
||||
/**
|
||||
* Font graph. Resolves @font-face src URLs to downloaded local font files and
|
||||
* emits deterministic @font-face CSS. Fonts are in V0 scope (rubric Gate 2):
|
||||
* every declaration must resolve to a cloned file or a recorded fallback.
|
||||
*/
|
||||
|
||||
export type FontEntry = {
|
||||
family: string;
|
||||
weight: string;
|
||||
style: string;
|
||||
display: string;
|
||||
unicodeRange: string | null;
|
||||
localPaths: string[]; // resolved cloned font file paths, by format preference
|
||||
status: "resolved" | "fallback";
|
||||
reason: string | null;
|
||||
};
|
||||
|
||||
export type FontGraph = {
|
||||
entries: FontEntry[];
|
||||
css: string; // @font-face blocks ready to inline in globals.css
|
||||
};
|
||||
|
||||
const SYSTEM_FALLBACK = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
|
||||
|
||||
function parseSrcUrls(src: string, sourceUrl: string): Array<{ url: string; format: string | null }> {
|
||||
const out: Array<{ url: string; format: string | null }> = [];
|
||||
const partRe = /url\(\s*(['"]?)([^'")]+)\1\s*\)(?:\s*format\(\s*(['"]?)([^'")]+)\3\s*\))?/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = partRe.exec(src)) !== null) {
|
||||
const raw = m[2]!;
|
||||
if (raw.startsWith("data:")) { out.push({ url: raw, format: m[4] ?? null }); continue; }
|
||||
let abs = raw;
|
||||
try { abs = new URL(raw, sourceUrl).href; } catch { /* keep raw */ }
|
||||
out.push({ url: abs, format: m[4] ?? null });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function basename(url: string): string {
|
||||
try {
|
||||
const p = new URL(url).pathname;
|
||||
return p.slice(p.lastIndexOf("/") + 1).toLowerCase();
|
||||
} catch {
|
||||
return url.slice(url.lastIndexOf("/") + 1).toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
const FORMAT_BY_EXT: Record<string, string> = {
|
||||
woff2: "woff2", woff: "woff", ttf: "truetype", otf: "opentype", eot: "embedded-opentype",
|
||||
};
|
||||
|
||||
export function buildFontGraph(fontFaces: FontFace[], assetGraph: AssetGraph, sourceUrl: string): FontGraph {
|
||||
// Index downloaded fonts by absolute URL and by basename for resilient lookup.
|
||||
const byBasename = new Map<string, AssetEntry>();
|
||||
for (const e of assetGraph.entries) {
|
||||
if (e.type === "font" && e.classification === "downloaded") {
|
||||
byBasename.set(basename(e.sourceUrl), e);
|
||||
}
|
||||
}
|
||||
|
||||
const entries: FontEntry[] = [];
|
||||
const cssBlocks: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const ff of fontFaces) {
|
||||
const weight = (ff.weight || "400").trim();
|
||||
const style = (ff.style || "normal").trim();
|
||||
const display = (ff.display || "swap").trim();
|
||||
const key = `${ff.family}|${weight}|${style}|${ff.unicodeRange ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const srcs = parseSrcUrls(ff.src, sourceUrl);
|
||||
const resolved: Array<{ localPath: string; format: string }> = [];
|
||||
const dataUris: string[] = [];
|
||||
for (const s of srcs) {
|
||||
if (s.url.startsWith("data:")) { dataUris.push(s.url); continue; }
|
||||
const entry = assetGraph.byUrl.get(s.url) ?? byBasename.get(basename(s.url));
|
||||
if (entry?.classification === "downloaded" && entry.localPath) {
|
||||
const ext = (entry.localPath.split(".").pop() || "").toLowerCase();
|
||||
resolved.push({ localPath: entry.localPath, format: s.format || FORMAT_BY_EXT[ext] || "woff2" });
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer woff2 > woff > others for ordering.
|
||||
const order = ["woff2", "woff", "truetype", "opentype", "embedded-opentype"];
|
||||
resolved.sort((a, b) => order.indexOf(a.format) - order.indexOf(b.format));
|
||||
|
||||
if (resolved.length > 0 || dataUris.length > 0) {
|
||||
const srcParts: string[] = [];
|
||||
for (const r of resolved) srcParts.push(`url("${r.localPath}") format("${r.format}")`);
|
||||
for (const d of dataUris) srcParts.push(`url(${d})`);
|
||||
const block = [
|
||||
"@font-face {",
|
||||
` font-family: "${ff.family}";`,
|
||||
` font-style: ${style};`,
|
||||
` font-weight: ${weight};`,
|
||||
` font-display: ${display};`,
|
||||
` src: ${srcParts.join(", ")};`,
|
||||
ff.unicodeRange ? ` unicode-range: ${ff.unicodeRange};` : null,
|
||||
"}",
|
||||
].filter(Boolean).join("\n");
|
||||
cssBlocks.push(block);
|
||||
entries.push({
|
||||
family: ff.family, weight, style, display,
|
||||
unicodeRange: ff.unicodeRange ?? null,
|
||||
localPaths: resolved.map((r) => r.localPath),
|
||||
status: "resolved",
|
||||
reason: null,
|
||||
});
|
||||
} else {
|
||||
entries.push({
|
||||
family: ff.family, weight, style, display,
|
||||
unicodeRange: ff.unicodeRange ?? null,
|
||||
localPaths: [],
|
||||
status: "fallback",
|
||||
reason: "font_file_unavailable",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => `${a.family}${a.weight}${a.style}${a.unicodeRange}`.localeCompare(`${b.family}${b.weight}${b.style}${b.unicodeRange}`));
|
||||
return { entries, css: cssBlocks.join("\n\n") };
|
||||
}
|
||||
|
||||
export { SYSTEM_FALLBACK };
|
||||
@@ -0,0 +1,669 @@
|
||||
import type { InteractionCapture } from "../capture/interactions.js";
|
||||
import type { IR, IRNode, BBox } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { Section } from "./sections.js";
|
||||
|
||||
export type InteractionRecipeKind = "dropdown-menu" | "nav-menu" | "accordion" | "mobile-menu" | "tablist";
|
||||
export type InteractionRecipeSource = "capture-pattern" | "mount-on-open" | "native-details" | "aria" | "structural";
|
||||
export type InteractionMode = "click" | "hover" | "native" | "mixed" | "unknown";
|
||||
export type InteractionEmissionStatus = "report-only" | "semantic-runtime" | "deferred";
|
||||
export type InteractionRecipeRisk = "low" | "medium" | "high";
|
||||
|
||||
export type InteractionPair = {
|
||||
triggerCid: string;
|
||||
panelCid?: string;
|
||||
triggerText?: string;
|
||||
panelTextSample?: string;
|
||||
};
|
||||
|
||||
export type InteractionRecipeCandidate = {
|
||||
id: string;
|
||||
kind: InteractionRecipeKind;
|
||||
confidence: number;
|
||||
risk: InteractionRecipeRisk;
|
||||
source: InteractionRecipeSource;
|
||||
rootCid: string;
|
||||
rootTag: string;
|
||||
sectionId?: string;
|
||||
sectionRole?: string;
|
||||
triggerCid?: string;
|
||||
panelCid?: string;
|
||||
triggerCount: number;
|
||||
panelCount: number;
|
||||
itemCount: number;
|
||||
componentName: "DropdownMenu" | "NavMenu" | "Accordion" | "MobileMenu" | "Tabs";
|
||||
interactionMode: InteractionMode;
|
||||
preservedDom: boolean;
|
||||
triggerPanelPairs: InteractionPair[];
|
||||
accessibility: {
|
||||
nativeDetails: boolean;
|
||||
ariaExpanded: boolean;
|
||||
ariaControls: boolean;
|
||||
ariaHaspopup: boolean;
|
||||
roles: string[];
|
||||
};
|
||||
sourceHints: string[];
|
||||
signals: string[];
|
||||
emissionStatus: InteractionEmissionStatus;
|
||||
fallbackReason: string;
|
||||
};
|
||||
|
||||
export type InteractionRecipeReport = {
|
||||
version: 1;
|
||||
sourceUrl: string;
|
||||
canonicalViewport: number;
|
||||
viewports: number[];
|
||||
summary: {
|
||||
totalCandidates: number;
|
||||
highConfidence: number;
|
||||
byKind: Record<string, number>;
|
||||
bySource: Record<string, number>;
|
||||
semanticRuntime: number;
|
||||
reportOnly: number;
|
||||
deferred: number;
|
||||
};
|
||||
candidates: InteractionRecipeCandidate[];
|
||||
};
|
||||
|
||||
type ParentMap = Map<string, IRNode | undefined>;
|
||||
|
||||
type Context = {
|
||||
ir: IR;
|
||||
cw: number;
|
||||
nodes: IRNode[];
|
||||
byId: Map<string, IRNode>;
|
||||
byHtmlId: Map<string, IRNode>;
|
||||
capToNode: Map<string, IRNode>;
|
||||
parentById: ParentMap;
|
||||
sectionByNodeId: Map<string, Section>;
|
||||
};
|
||||
|
||||
type Draft = Omit<InteractionRecipeCandidate, "id">;
|
||||
|
||||
const SOURCE_HINT = /\b(?:nav|navbar|menu|dropdown|popover|flyout|mega|drawer|mobile|hamburger|accordion|faq|details|summary|tabs?|tablist|dialog|modal|overlay|locale|language|search)\b/i;
|
||||
const MOBILE_PANEL_HINT = /\b(?:mobile-menu|mobile_nav|mobile-nav|drawer|offcanvas|side-menu|menu-drawer|nav-drawer|hamburger|fullscreen-menu)\b/i;
|
||||
const TRIGGER_HINT = /\b(?:menu|hamburger|toggle|open|drawer|nav|dropdown|locale|language|search)\b/i;
|
||||
|
||||
function round(n: number): number {
|
||||
return Math.round(Math.max(0, Math.min(1, n)) * 100) / 100;
|
||||
}
|
||||
|
||||
function riskOf(confidence: number): InteractionRecipeRisk {
|
||||
return confidence >= 0.86 ? "low" : confidence >= 0.74 ? "medium" : "high";
|
||||
}
|
||||
|
||||
function elementChildren(n: IRNode): IRNode[] {
|
||||
return n.children.filter((c): c is IRNode => !isTextChild(c));
|
||||
}
|
||||
|
||||
function textContent(n: IRNode | undefined, max = 240): string {
|
||||
if (!n) return "";
|
||||
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 directText(n: IRNode | undefined, max = 96): string {
|
||||
if (!n) return "";
|
||||
return n.children
|
||||
.filter(isTextChild)
|
||||
.map((c) => c.text)
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
function isLikelySkipTrigger(n: IRNode, gap?: number): 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 !!gap && href.startsWith("#") && gap > 360;
|
||||
}
|
||||
|
||||
function visibleAt(n: IRNode, vp: number): boolean {
|
||||
const b = n.bboxByVp[vp];
|
||||
return !!n.visibleByVp[vp] && !!b && b.width > 1 && b.height > 1;
|
||||
}
|
||||
|
||||
function boxAt(n: IRNode | undefined, vp: number): BBox | undefined {
|
||||
return n?.bboxByVp[vp];
|
||||
}
|
||||
|
||||
function attrText(n: IRNode | undefined): string {
|
||||
if (!n) return "";
|
||||
return [n.tag, n.srcClass ?? "", ...Object.entries(n.attrs).map(([k, v]) => `${k}=${v}`)].join(" ");
|
||||
}
|
||||
|
||||
function sourceHints(root: IRNode | undefined): string[] {
|
||||
if (!root) return [];
|
||||
const hints = new Set<string>();
|
||||
const walk = (n: IRNode): void => {
|
||||
if (hints.size >= 16) return;
|
||||
for (const value of [n.srcClass ?? "", n.attrs.id ?? "", n.attrs.role ?? "", n.attrs["aria-label"] ?? ""]) {
|
||||
for (const token of value.split(/\s+/)) {
|
||||
if (SOURCE_HINT.test(token)) hints.add(token);
|
||||
if (hints.size >= 16) break;
|
||||
}
|
||||
}
|
||||
for (const c of elementChildren(n)) walk(c);
|
||||
};
|
||||
walk(root);
|
||||
return [...hints].sort();
|
||||
}
|
||||
|
||||
function buildContext(ir: IR, sections: Section[]): Context {
|
||||
const nodes: IRNode[] = [];
|
||||
const byId = new Map<string, IRNode>();
|
||||
const byHtmlId = new Map<string, IRNode>();
|
||||
const capToNode = new Map<string, IRNode>();
|
||||
const parentById: ParentMap = new Map();
|
||||
const walk = (n: IRNode, parent: IRNode | undefined): void => {
|
||||
nodes.push(n);
|
||||
byId.set(n.id, n);
|
||||
parentById.set(n.id, parent);
|
||||
if (n.attrs.id) byHtmlId.set(n.attrs.id, n);
|
||||
if (n.attrs["data-cid-cap"] !== undefined) capToNode.set(n.attrs["data-cid-cap"], n);
|
||||
for (const c of elementChildren(n)) walk(c, n);
|
||||
};
|
||||
walk(ir.root, undefined);
|
||||
return {
|
||||
ir,
|
||||
cw: ir.doc.canonicalViewport,
|
||||
nodes,
|
||||
byId,
|
||||
byHtmlId,
|
||||
capToNode,
|
||||
parentById,
|
||||
sectionByNodeId: new Map(sections.map((s) => [s.nodeId, s])),
|
||||
};
|
||||
}
|
||||
|
||||
function nearestSection(ctx: Context, n: IRNode | undefined): Section | undefined {
|
||||
let cur = n;
|
||||
while (cur) {
|
||||
const section = ctx.sectionByNodeId.get(cur.id);
|
||||
if (section) return section;
|
||||
cur = ctx.parentById.get(cur.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function nearestCommonAncestor(ctx: Context, nodes: IRNode[]): IRNode | undefined {
|
||||
if (!nodes.length) return undefined;
|
||||
const chains = nodes.map((n) => {
|
||||
const out: IRNode[] = [];
|
||||
let cur: IRNode | undefined = n;
|
||||
while (cur) { out.push(cur); cur = ctx.parentById.get(cur.id); }
|
||||
return out;
|
||||
});
|
||||
for (const candidate of chains[0] ?? []) {
|
||||
if (chains.every((chain) => chain.some((n) => n.id === candidate.id))) return candidate;
|
||||
}
|
||||
return nodes[0];
|
||||
}
|
||||
|
||||
function isInNav(ctx: Context, n: IRNode | undefined): boolean {
|
||||
let cur = n;
|
||||
let depth = 0;
|
||||
while (cur && depth < 8) {
|
||||
if (cur.tag === "nav" || cur.tag === "header" || cur.attrs.role === "navigation") return true;
|
||||
if (/\b(?:nav|navbar|globalnav|header|menu-bar|menubar)\b/i.test(attrText(cur))) return true;
|
||||
cur = ctx.parentById.get(cur.id);
|
||||
depth++;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLikelyMobilePanel(ctx: Context, n: IRNode | undefined): boolean {
|
||||
if (!n) return false;
|
||||
const t = attrText(n);
|
||||
if (MOBILE_PANEL_HINT.test(t)) return true;
|
||||
const cs = n.computedByVp[ctx.cw];
|
||||
const b = boxAt(n, ctx.cw);
|
||||
const vpH = ctx.ir.doc.perViewport[ctx.cw]?.scrollHeight ? Math.min(ctx.ir.doc.perViewport[ctx.cw]!.scrollHeight, 1200) : 800;
|
||||
if (!cs || !b) return false;
|
||||
const fixedFull = cs.position === "fixed" && b.width >= ctx.cw * 0.75 && b.height >= vpH * 0.65;
|
||||
return fixedFull && /\b(?:menu|nav|drawer|overlay|dialog|modal)\b/i.test(t);
|
||||
}
|
||||
|
||||
function findMobileTrigger(ctx: Context, panel: IRNode): IRNode | undefined {
|
||||
const panelText = textContent(panel, 500).toLowerCase();
|
||||
const candidates = ctx.nodes
|
||||
.filter((n) => visibleAt(n, ctx.cw))
|
||||
.filter((n) => {
|
||||
if (!/^(button|a|div|span)$/.test(n.tag) && n.attrs.role !== "button") return false;
|
||||
const b = boxAt(n, ctx.cw);
|
||||
if (!b || b.y > 180 || b.width < 8 || b.height < 8) return false;
|
||||
const t = `${attrText(n)} ${textContent(n, 80)}`;
|
||||
if (TRIGGER_HINT.test(t) && /\b(?:mobile|menu|hamburger|nav|drawer)\b/i.test(t)) return true;
|
||||
const label = (n.attrs["aria-label"] ?? "").toLowerCase();
|
||||
return label.includes("menu") || label.includes("navigation");
|
||||
})
|
||||
.sort((a, b) => (boxAt(a, ctx.cw)?.y ?? 0) - (boxAt(b, ctx.cw)?.y ?? 0) || (boxAt(a, ctx.cw)?.x ?? 0) - (boxAt(b, ctx.cw)?.x ?? 0));
|
||||
return candidates.find((n) => {
|
||||
const text = textContent(n, 80).toLowerCase();
|
||||
return !text || !panelText.includes(text) || text.length <= 20;
|
||||
}) ?? candidates[0];
|
||||
}
|
||||
|
||||
function rolesOf(nodes: Array<IRNode | undefined>): string[] {
|
||||
return [...new Set(nodes.map((n) => n?.attrs.role).filter((x): x is string => !!x))].sort();
|
||||
}
|
||||
|
||||
function componentNameFor(kind: InteractionRecipeKind): InteractionRecipeCandidate["componentName"] {
|
||||
switch (kind) {
|
||||
case "dropdown-menu": return "DropdownMenu";
|
||||
case "nav-menu": return "NavMenu";
|
||||
case "accordion": return "Accordion";
|
||||
case "mobile-menu": return "MobileMenu";
|
||||
case "tablist": return "Tabs";
|
||||
}
|
||||
}
|
||||
|
||||
function statusFor(kind: InteractionRecipeKind, source: InteractionRecipeSource): { status: InteractionEmissionStatus; reason: string } {
|
||||
if (kind === "accordion" && source === "capture-pattern") {
|
||||
return { status: "semantic-runtime", reason: "captured accordion is emitted through the small Accordion runtime component" };
|
||||
}
|
||||
if ((kind === "dropdown-menu" || kind === "nav-menu") && source === "mount-on-open") {
|
||||
return { status: "semantic-runtime", reason: "captured mount-on-open menu is emitted through the DropdownMenu runtime component" };
|
||||
}
|
||||
if (kind === "mobile-menu") return { status: "report-only", reason: "mobile overlay structure is detected; semantic MobileMenu DOM emission is deferred" };
|
||||
if (kind === "tablist") return { status: "report-only", reason: "tablist relationships are detected; semantic Tabs emission is deferred" };
|
||||
return { status: "report-only", reason: "recognized for inventory; existing measured DOM remains the fallback" };
|
||||
}
|
||||
|
||||
function makeDraft(ctx: Context, init: {
|
||||
kind: InteractionRecipeKind;
|
||||
source: InteractionRecipeSource;
|
||||
confidence: number;
|
||||
root: IRNode;
|
||||
trigger?: IRNode;
|
||||
panel?: IRNode;
|
||||
pairs: InteractionPair[];
|
||||
mode: InteractionMode;
|
||||
preservedDom: boolean;
|
||||
signals: string[];
|
||||
accessibilityNodes?: Array<IRNode | undefined>;
|
||||
}): Draft {
|
||||
const section = nearestSection(ctx, init.root);
|
||||
const status = statusFor(init.kind, init.source);
|
||||
const nodes = init.accessibilityNodes ?? [init.trigger, init.panel, init.root];
|
||||
return {
|
||||
kind: init.kind,
|
||||
confidence: round(init.confidence),
|
||||
risk: riskOf(init.confidence),
|
||||
source: init.source,
|
||||
rootCid: init.root.id,
|
||||
rootTag: init.root.tag,
|
||||
...(section ? { sectionId: section.id, sectionRole: section.role } : {}),
|
||||
...(init.trigger ? { triggerCid: init.trigger.id } : {}),
|
||||
...(init.panel ? { panelCid: init.panel.id } : {}),
|
||||
triggerCount: init.pairs.filter((p) => p.triggerCid).length,
|
||||
panelCount: init.pairs.filter((p) => p.panelCid).length,
|
||||
itemCount: Math.max(1, init.pairs.length),
|
||||
componentName: componentNameFor(init.kind),
|
||||
interactionMode: init.mode,
|
||||
preservedDom: init.preservedDom,
|
||||
triggerPanelPairs: init.pairs,
|
||||
accessibility: {
|
||||
nativeDetails: init.root.tag === "details" || nodes.some((n) => n?.tag === "details"),
|
||||
ariaExpanded: nodes.some((n) => n?.attrs["aria-expanded"] !== undefined),
|
||||
ariaControls: nodes.some((n) => n?.attrs["aria-controls"] !== undefined),
|
||||
ariaHaspopup: nodes.some((n) => n?.attrs["aria-haspopup"] !== undefined),
|
||||
roles: rolesOf(nodes),
|
||||
},
|
||||
sourceHints: sourceHints(init.root),
|
||||
signals: init.signals,
|
||||
emissionStatus: status.status,
|
||||
fallbackReason: status.reason,
|
||||
};
|
||||
}
|
||||
|
||||
function pairOf(trigger?: IRNode, panel?: IRNode): InteractionPair[] {
|
||||
if (!trigger && !panel) return [];
|
||||
return [{
|
||||
...(trigger ? { triggerCid: trigger.id, triggerText: textContent(trigger, 80) || trigger.attrs["aria-label"] || directText(trigger) } : { triggerCid: "" }),
|
||||
...(panel ? { panelCid: panel.id, panelTextSample: textContent(panel, 100) } : {}),
|
||||
}].filter((p) => p.triggerCid || p.panelCid);
|
||||
}
|
||||
|
||||
function fromCapturePatterns(ctx: Context, interaction: InteractionCapture | undefined): Draft[] {
|
||||
const out: Draft[] = [];
|
||||
for (const pattern of interaction?.patterns ?? []) {
|
||||
if (pattern.kind === "accordion") {
|
||||
const triggers = pattern.items.map((i) => ctx.capToNode.get(i.triggerCap)).filter((n): n is IRNode => !!n);
|
||||
const panels = pattern.items.map((i) => ctx.capToNode.get(i.regionCap)).filter((n): n is IRNode => !!n);
|
||||
const root = ctx.capToNode.get(pattern.rootCap) ?? nearestCommonAncestor(ctx, [...triggers, ...panels]) ?? triggers[0] ?? panels[0];
|
||||
if (!root || !triggers.length || !panels.length) continue;
|
||||
out.push(makeDraft(ctx, {
|
||||
kind: "accordion",
|
||||
source: "capture-pattern",
|
||||
confidence: 0.93,
|
||||
root,
|
||||
trigger: triggers[0],
|
||||
panel: panels[0],
|
||||
pairs: pattern.items.map((i) => pairOf(ctx.capToNode.get(i.triggerCap), ctx.capToNode.get(i.regionCap))[0]).filter((p): p is InteractionPair => !!p),
|
||||
mode: "click",
|
||||
preservedDom: true,
|
||||
accessibilityNodes: [...triggers, ...panels, root],
|
||||
signals: [
|
||||
`${pattern.items.length} captured trigger/region pair(s)`,
|
||||
"open and closed styles were driven and stored in interaction.json",
|
||||
pattern.items.some((i) => i.expandedAtBase) ? "source has an expanded base item" : "source base state is collapsed",
|
||||
],
|
||||
}));
|
||||
} else if (pattern.kind === "tabs") {
|
||||
const triggers = pattern.tabs.map((i) => ctx.capToNode.get(i.triggerCap)).filter((n): n is IRNode => !!n);
|
||||
const panels = pattern.tabs.map((i) => ctx.capToNode.get(i.panelCap)).filter((n): n is IRNode => !!n);
|
||||
const root = ctx.capToNode.get(pattern.rootCap) ?? nearestCommonAncestor(ctx, [...triggers, ...panels]) ?? triggers[0] ?? panels[0];
|
||||
if (!root || triggers.length < 2 || panels.length < 2) continue;
|
||||
out.push(makeDraft(ctx, {
|
||||
kind: "tablist",
|
||||
source: "capture-pattern",
|
||||
confidence: 0.9,
|
||||
root,
|
||||
trigger: triggers[0],
|
||||
panel: panels[0],
|
||||
pairs: pattern.tabs.map((i) => pairOf(ctx.capToNode.get(i.triggerCap), ctx.capToNode.get(i.panelCap))[0]).filter((p): p is InteractionPair => !!p),
|
||||
mode: "click",
|
||||
preservedDom: true,
|
||||
accessibilityNodes: [...triggers, ...panels, root],
|
||||
signals: [`${pattern.tabs.length} captured tab/panel pair(s)`, "ARIA tab state was driven during capture"],
|
||||
}));
|
||||
} else if (pattern.kind === "disclosure") {
|
||||
for (const item of pattern.items) {
|
||||
const trigger = ctx.capToNode.get(item.triggerCap);
|
||||
const panel = ctx.capToNode.get(item.panelCap);
|
||||
const root = ctx.capToNode.get(pattern.rootCap) ?? nearestCommonAncestor(ctx, [trigger, panel].filter((n): n is IRNode => !!n)) ?? trigger ?? panel;
|
||||
if (!root || !trigger || !panel) continue;
|
||||
const mobile = isLikelyMobilePanel(ctx, panel);
|
||||
const nav = item.hoverOpen && isInNav(ctx, trigger);
|
||||
const kind: InteractionRecipeKind = mobile ? "mobile-menu" : nav ? "nav-menu" : "dropdown-menu";
|
||||
out.push(makeDraft(ctx, {
|
||||
kind,
|
||||
source: "capture-pattern",
|
||||
confidence: mobile ? 0.86 : nav ? 0.88 : 0.84,
|
||||
root,
|
||||
trigger,
|
||||
panel,
|
||||
pairs: pairOf(trigger, panel),
|
||||
mode: item.hoverOpen ? "hover" : "click",
|
||||
preservedDom: true,
|
||||
accessibilityNodes: [trigger, panel, root],
|
||||
signals: [
|
||||
"captured trigger/panel disclosure pair",
|
||||
item.hoverOpen ? "panel opens on hover" : "panel opens on click",
|
||||
item.isDialog ? "source identifies dialog/modal behavior" : "",
|
||||
mobile ? "panel matches mobile/fullscreen menu geometry or source naming" : "",
|
||||
nav ? "trigger sits in navigation/header context" : "",
|
||||
].filter(Boolean),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromMountMenus(ctx: Context, interaction: InteractionCapture | undefined): Draft[] {
|
||||
const out: Draft[] = [];
|
||||
for (const menu of interaction?.menus ?? []) {
|
||||
const trigger = ctx.capToNode.get(menu.triggerCap);
|
||||
if (!trigger) continue;
|
||||
if (isLikelySkipTrigger(trigger, menu.gap)) continue;
|
||||
const kind: InteractionRecipeKind = menu.hoverOpen && isInNav(ctx, trigger) ? "nav-menu" : "dropdown-menu";
|
||||
out.push(makeDraft(ctx, {
|
||||
kind,
|
||||
source: "mount-on-open",
|
||||
confidence: kind === "nav-menu" ? 0.86 : 0.82,
|
||||
root: trigger,
|
||||
trigger,
|
||||
pairs: [{ triggerCid: trigger.id, triggerText: textContent(trigger, 80) || trigger.attrs["aria-label"] || directText(trigger) }],
|
||||
mode: menu.hoverOpen ? "hover" : "click",
|
||||
preservedDom: false,
|
||||
accessibilityNodes: [trigger],
|
||||
signals: [
|
||||
"panel is mounted only after interaction and captured as an HTML fragment",
|
||||
menu.hoverOpen ? "trigger opened by hover during capture" : "trigger opened by click during capture",
|
||||
`captured panel alignment ${menu.align} with ${menu.gap}px gap`,
|
||||
],
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromNativeDetails(ctx: Context): Draft[] {
|
||||
const out: Draft[] = [];
|
||||
for (const details of ctx.nodes.filter((n) => n.tag === "details")) {
|
||||
const children = elementChildren(details);
|
||||
const summary = children.find((c) => c.tag === "summary");
|
||||
if (!summary) continue;
|
||||
const panel = children.find((c) => c !== summary && textContent(c, 80)) ?? details;
|
||||
out.push(makeDraft(ctx, {
|
||||
kind: "accordion",
|
||||
source: "native-details",
|
||||
confidence: 0.89,
|
||||
root: details,
|
||||
trigger: summary,
|
||||
panel,
|
||||
pairs: pairOf(summary, panel),
|
||||
mode: "native",
|
||||
preservedDom: true,
|
||||
accessibilityNodes: [details, summary, panel],
|
||||
signals: ["native details/summary disclosure is present in source DOM", details.attrs.open !== undefined ? "details is open at base" : "details is closed at base"],
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromAriaPairs(ctx: Context): Draft[] {
|
||||
const out: Draft[] = [];
|
||||
for (const trigger of ctx.nodes) {
|
||||
const controls = trigger.attrs["aria-controls"];
|
||||
if (!controls || trigger.attrs.role === "tab") continue;
|
||||
const panel = ctx.byHtmlId.get(controls);
|
||||
if (!panel) continue;
|
||||
const hasPopup = trigger.attrs["aria-haspopup"] !== undefined;
|
||||
const mobile = isLikelyMobilePanel(ctx, panel);
|
||||
const nav = hasPopup && isInNav(ctx, trigger);
|
||||
const kind: InteractionRecipeKind = mobile ? "mobile-menu" : hasPopup ? (nav ? "nav-menu" : "dropdown-menu") : "accordion";
|
||||
out.push(makeDraft(ctx, {
|
||||
kind,
|
||||
source: "aria",
|
||||
confidence: mobile ? 0.82 : hasPopup ? 0.8 : 0.84,
|
||||
root: nearestCommonAncestor(ctx, [trigger, panel]) ?? trigger,
|
||||
trigger,
|
||||
panel,
|
||||
pairs: pairOf(trigger, panel),
|
||||
mode: hasPopup ? "click" : "unknown",
|
||||
preservedDom: true,
|
||||
accessibilityNodes: [trigger, panel],
|
||||
signals: [
|
||||
"trigger declares aria-controls for a source DOM panel",
|
||||
trigger.attrs["aria-expanded"] !== undefined ? "trigger preserves aria-expanded state" : "",
|
||||
hasPopup ? `trigger declares aria-haspopup=${trigger.attrs["aria-haspopup"] || "true"}` : "",
|
||||
].filter(Boolean),
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromStructuralMobile(ctx: Context): Draft[] {
|
||||
const out: Draft[] = [];
|
||||
for (const panel of ctx.nodes) {
|
||||
if (!isLikelyMobilePanel(ctx, panel)) continue;
|
||||
const trigger = findMobileTrigger(ctx, panel);
|
||||
out.push(makeDraft(ctx, {
|
||||
kind: "mobile-menu",
|
||||
source: "structural",
|
||||
confidence: trigger ? 0.78 : 0.72,
|
||||
root: panel,
|
||||
trigger,
|
||||
panel,
|
||||
pairs: trigger ? pairOf(trigger, panel) : [{ triggerCid: "", panelCid: panel.id, panelTextSample: textContent(panel, 100) }],
|
||||
mode: "click",
|
||||
preservedDom: true,
|
||||
accessibilityNodes: [trigger, panel],
|
||||
signals: [
|
||||
"hidden or fullscreen panel matches mobile menu/drawer source naming",
|
||||
trigger ? "nearby top-of-page menu trigger was found" : "no reliable trigger found yet",
|
||||
],
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromTablists(ctx: Context): Draft[] {
|
||||
const out: Draft[] = [];
|
||||
for (const root of ctx.nodes.filter((n) => n.attrs.role === "tablist")) {
|
||||
const tabs: IRNode[] = [];
|
||||
const walk = (n: IRNode): void => {
|
||||
if (n !== root && n.attrs.role === "tablist") return;
|
||||
if (n.attrs.role === "tab") tabs.push(n);
|
||||
for (const c of elementChildren(n)) walk(c);
|
||||
};
|
||||
walk(root);
|
||||
if (tabs.length < 2) continue;
|
||||
const panels = tabs
|
||||
.map((t) => t.attrs["aria-controls"] ? ctx.byHtmlId.get(t.attrs["aria-controls"]) : undefined)
|
||||
.filter((n): n is IRNode => !!n);
|
||||
const pairNodes = panels.length === tabs.length ? tabs.map((t, i) => pairOf(t, panels[i])[0]!) : tabs.map((t) => pairOf(t)[0]!);
|
||||
out.push(makeDraft(ctx, {
|
||||
kind: "tablist",
|
||||
source: "aria",
|
||||
confidence: panels.length === tabs.length ? 0.88 : 0.8,
|
||||
root,
|
||||
trigger: tabs[0],
|
||||
panel: panels[0],
|
||||
pairs: pairNodes,
|
||||
mode: "click",
|
||||
preservedDom: true,
|
||||
accessibilityNodes: [root, ...tabs, ...panels],
|
||||
signals: [`${tabs.length} role=tab trigger(s) inside role=tablist`, panels.length ? "tabs resolve panels through aria-controls" : "panels are not fully resolved yet"],
|
||||
}));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dedupeAndSort(ctx: Context, drafts: Draft[]): Draft[] {
|
||||
const byKey = new Map<string, Draft>();
|
||||
for (const d of drafts) {
|
||||
const key = [
|
||||
d.kind,
|
||||
d.triggerCid ?? "",
|
||||
d.panelCid ?? "",
|
||||
d.rootCid,
|
||||
d.source === "capture-pattern" || d.source === "mount-on-open" ? d.source : "structural",
|
||||
].join("|");
|
||||
const prev = byKey.get(key);
|
||||
if (!prev || d.confidence > prev.confidence || (d.emissionStatus === "semantic-runtime" && prev.emissionStatus !== "semantic-runtime")) {
|
||||
byKey.set(key, d);
|
||||
}
|
||||
}
|
||||
const draftsSorted = [...byKey.values()].sort((a, b) => {
|
||||
const ab = ctx.byId.get(a.rootCid)?.bboxByVp[ctx.cw];
|
||||
const bb = ctx.byId.get(b.rootCid)?.bboxByVp[ctx.cw];
|
||||
return (ab?.y ?? 0) - (bb?.y ?? 0)
|
||||
|| (ab?.x ?? 0) - (bb?.x ?? 0)
|
||||
|| a.kind.localeCompare(b.kind)
|
||||
|| b.confidence - a.confidence;
|
||||
});
|
||||
return draftsSorted;
|
||||
}
|
||||
|
||||
export function buildInteractionRecipeReport(ir: IR, sections: Section[], interaction?: InteractionCapture): InteractionRecipeReport {
|
||||
const ctx = buildContext(ir, sections);
|
||||
const drafts = dedupeAndSort(ctx, [
|
||||
...fromCapturePatterns(ctx, interaction),
|
||||
...fromMountMenus(ctx, interaction),
|
||||
...fromNativeDetails(ctx),
|
||||
...fromAriaPairs(ctx),
|
||||
...fromStructuralMobile(ctx),
|
||||
...fromTablists(ctx),
|
||||
]);
|
||||
const candidates = drafts.map((d, index) => ({ id: `interaction-${String(index + 1).padStart(3, "0")}`, ...d }));
|
||||
const byKind: Record<string, number> = {};
|
||||
const bySource: Record<string, number> = {};
|
||||
for (const c of candidates) {
|
||||
byKind[c.kind] = (byKind[c.kind] ?? 0) + 1;
|
||||
bySource[c.source] = (bySource[c.source] ?? 0) + 1;
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
sourceUrl: ir.doc.sourceUrl,
|
||||
canonicalViewport: ir.doc.canonicalViewport,
|
||||
viewports: ir.doc.viewports,
|
||||
summary: {
|
||||
totalCandidates: candidates.length,
|
||||
highConfidence: candidates.filter((c) => c.confidence >= 0.82).length,
|
||||
byKind,
|
||||
bySource,
|
||||
semanticRuntime: candidates.filter((c) => c.emissionStatus === "semantic-runtime").length,
|
||||
reportOnly: candidates.filter((c) => c.emissionStatus === "report-only").length,
|
||||
deferred: candidates.filter((c) => c.emissionStatus === "deferred").length,
|
||||
},
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
export function interactionRecipeReportToMarkdown(report: InteractionRecipeReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# Interaction Recipe Report");
|
||||
lines.push("");
|
||||
lines.push(`Source: ${report.sourceUrl}`);
|
||||
lines.push(`Canonical viewport: ${report.canonicalViewport}`);
|
||||
lines.push(`Captured viewports: ${report.viewports.join(", ")}`);
|
||||
lines.push("");
|
||||
lines.push("## Summary");
|
||||
lines.push("");
|
||||
lines.push(`- Candidates: ${report.summary.totalCandidates}`);
|
||||
lines.push(`- High confidence: ${report.summary.highConfidence}`);
|
||||
lines.push(`- Semantic runtime emitted: ${report.summary.semanticRuntime}`);
|
||||
lines.push(`- Report-only: ${report.summary.reportOnly}`);
|
||||
lines.push(`- By kind: ${Object.entries(report.summary.byKind).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}`);
|
||||
lines.push(`- By source: ${Object.entries(report.summary.bySource).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}`);
|
||||
lines.push("");
|
||||
lines.push("## Candidates");
|
||||
lines.push("");
|
||||
if (!report.candidates.length) lines.push("No interaction recipe candidates were detected.");
|
||||
for (const c of report.candidates) {
|
||||
lines.push(`### ${c.id}: ${c.kind}`);
|
||||
lines.push("");
|
||||
lines.push(`- Confidence: ${c.confidence} (${c.risk} risk)`);
|
||||
lines.push(`- Source: ${c.source}; mode: ${c.interactionMode}; preserved DOM: ${c.preservedDom ? "yes" : "no"}`);
|
||||
lines.push(`- Root: ${c.rootTag} \`${c.rootCid}\`${c.sectionId ? `, ${c.sectionId} (${c.sectionRole ?? "section"})` : ""}`);
|
||||
if (c.triggerCid) lines.push(`- Trigger: \`${c.triggerCid}\``);
|
||||
if (c.panelCid) lines.push(`- Panel: \`${c.panelCid}\``);
|
||||
lines.push(`- Component target: ${c.componentName}`);
|
||||
lines.push(`- Counts: ${c.triggerCount} trigger(s), ${c.panelCount} panel(s), ${c.itemCount} item(s)`);
|
||||
const acc = [
|
||||
c.accessibility.nativeDetails ? "native details" : "",
|
||||
c.accessibility.ariaExpanded ? "aria-expanded" : "",
|
||||
c.accessibility.ariaControls ? "aria-controls" : "",
|
||||
c.accessibility.ariaHaspopup ? "aria-haspopup" : "",
|
||||
c.accessibility.roles.length ? `roles ${c.accessibility.roles.join("/")}` : "",
|
||||
].filter(Boolean);
|
||||
if (acc.length) lines.push(`- Accessibility: ${acc.join("; ")}`);
|
||||
if (c.signals.length) lines.push(`- Signals: ${c.signals.join("; ")}`);
|
||||
if (c.sourceHints.length) lines.push(`- Source hints: ${c.sourceHints.slice(0, 12).map((h) => `\`${h}\``).join(", ")}`);
|
||||
lines.push(`- Emission: ${c.emissionStatus}; ${c.fallbackReason}`);
|
||||
if (c.triggerPanelPairs.length) {
|
||||
const sample = c.triggerPanelPairs.slice(0, 4).map((p) => {
|
||||
const t = p.triggerText ? ` "${p.triggerText}"` : "";
|
||||
const panel = p.panelCid ? ` -> \`${p.panelCid}\`` : "";
|
||||
return `\`${p.triggerCid || "unresolved"}\`${t}${panel}`;
|
||||
}).join(", ");
|
||||
lines.push(`- Pair sample: ${sample}${c.triggerPanelPairs.length > 4 ? ", ..." : ""}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trimEnd() + "\n";
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { IR, IRNode, StyleMap } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
|
||||
/**
|
||||
* Typed-primitive recognition (Stage 3.5). Deterministically classifies each node
|
||||
* as a recognized UI primitive — button / link / input / select / textarea / icon /
|
||||
* image / avatar / badge / heading / nav — from tag + ARIA + a few computed-style
|
||||
* signals. The generator stamps the type onto the DOM as `data-component="…"` (an
|
||||
* attribute, so it cannot affect computed styles or structure matching — fidelity-
|
||||
* neutral by construction) and records the inventory in `components.json`.
|
||||
*
|
||||
* Conservative + bounded (an allowlist, like the Stage-4 interaction patterns): a
|
||||
* node we aren't confident about stays untyped. Behavior (open/close, tabs) is NOT
|
||||
* inferred here — that's Stage 4; this is structure/semantics only.
|
||||
*/
|
||||
|
||||
export type PrimitiveType =
|
||||
| "button" | "link" | "input" | "select" | "textarea"
|
||||
| "icon" | "image" | "avatar" | "badge" | "heading" | "nav";
|
||||
|
||||
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]);
|
||||
|
||||
function px(v: string | undefined): number {
|
||||
if (!v) return 0;
|
||||
const m = /(-?\d+(?:\.\d+)?)/.exec(v);
|
||||
return m ? parseFloat(m[1]!) : 0;
|
||||
}
|
||||
function hasBg(cs: StyleMap): boolean {
|
||||
return !!cs.backgroundColor && !TRANSPARENT.has(cs.backgroundColor);
|
||||
}
|
||||
function hasBorder(cs: StyleMap): boolean {
|
||||
return (["borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth"] as const).some((p) => px(cs[p]) > 0);
|
||||
}
|
||||
function hasPadding(cs: StyleMap): boolean {
|
||||
return (["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] as const).some((p) => px(cs[p]) > 0);
|
||||
}
|
||||
function isRound(cs: StyleMap, bb: { width: number; height: number } | undefined): boolean {
|
||||
const r = cs.borderTopLeftRadius || "";
|
||||
if (r.includes("%")) return px(r) >= 40;
|
||||
if (bb) return px(r) >= Math.min(bb.width, bb.height) / 2 - 1 && px(r) > 4;
|
||||
return false;
|
||||
}
|
||||
|
||||
function classify(n: IRNode, cw: number): PrimitiveType | null {
|
||||
const tag = n.tag;
|
||||
const a = n.attrs;
|
||||
const role = a.role;
|
||||
const cs = n.computedByVp[cw];
|
||||
const bb = n.bboxByVp[cw];
|
||||
const text = n.children.filter(isTextChild).map((c) => (c as { text: string }).text).join("").trim();
|
||||
|
||||
// Form controls (tag/ARIA — unambiguous).
|
||||
if (tag === "select" || role === "listbox" || role === "combobox") return "select";
|
||||
if (tag === "textarea") return "textarea";
|
||||
if (tag === "input") {
|
||||
const ty = (a.type || "text").toLowerCase();
|
||||
return ty === "button" || ty === "submit" || ty === "reset" ? "button" : "input";
|
||||
}
|
||||
if (/^h[1-6]$/.test(tag)) return "heading";
|
||||
if (tag === "nav" || role === "navigation") return "nav";
|
||||
if (tag === "button" || role === "button") return "button";
|
||||
|
||||
// SVG: small = icon, larger = image/illustration.
|
||||
if (tag === "svg") return bb && bb.width <= 48 && bb.height <= 48 ? "icon" : "image";
|
||||
|
||||
// Raster images: round + square = avatar.
|
||||
if (tag === "img") return cs && bb && Math.abs(bb.width - bb.height) <= 6 && isRound(cs, bb) ? "avatar" : "image";
|
||||
|
||||
// Anchors: a "button-like" link (filled/outlined pill with padding, not a huge
|
||||
// block) is a button; otherwise a text link.
|
||||
if (tag === "a" && a.href !== undefined) {
|
||||
if (cs && bb && (hasBg(cs) || hasBorder(cs)) && hasPadding(cs) &&
|
||||
/inline-block|inline-flex|flex|inline-grid/.test(cs.display || "") &&
|
||||
bb.width < cw * 0.6 && bb.height <= 80 && text.length > 0 && text.length < 40) {
|
||||
return "button";
|
||||
}
|
||||
return "link";
|
||||
}
|
||||
|
||||
// Badge/pill/tag: small inline-block chip with a background + rounding + short text,
|
||||
// not itself interactive.
|
||||
if (cs && bb && text && text.length <= 24 &&
|
||||
/inline-block|inline-flex/.test(cs.display || "") &&
|
||||
hasBg(cs) && px(cs.borderTopLeftRadius) > 0 &&
|
||||
bb.height > 0 && bb.height <= 32 && bb.width <= 160) {
|
||||
return "badge";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function recognizePrimitives(ir: IR): Map<string, PrimitiveType> {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const out = new Map<string, PrimitiveType>();
|
||||
const walk = (n: IRNode): void => {
|
||||
if (n.visibleByVp[cw]) {
|
||||
const t = classify(n, cw);
|
||||
if (t) out.set(n.id, t);
|
||||
}
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return out;
|
||||
}
|
||||
|
||||
export type PrimitiveInventory = { count: number; byType: Record<string, number>; items: Array<{ cid: string; type: PrimitiveType; tag: string }> };
|
||||
|
||||
export function inventoryOf(ir: IR, prims: Map<string, PrimitiveType>): PrimitiveInventory {
|
||||
const byType: Record<string, number> = {};
|
||||
const items: PrimitiveInventory["items"] = [];
|
||||
const walk = (n: IRNode): void => {
|
||||
const t = prims.get(n.id);
|
||||
if (t) { byType[t] = (byType[t] ?? 0) + 1; items.push({ cid: n.id, type: t, tag: n.tag }); }
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return { count: items.length, byType, items };
|
||||
}
|
||||
@@ -0,0 +1,909 @@
|
||||
import type { IR, IRNode, BBox, StyleMap } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { PrimitiveType } from "./primitives.js";
|
||||
import type { Section } from "./sections.js";
|
||||
import { round } from "../util/canonical.js";
|
||||
|
||||
export type RecipeKind = "logo-cloud" | "feature-grid" | "card-grid" | "product-grid" | "gallery-showcase" | "cta-band";
|
||||
|
||||
export type RecipeRisk = "low" | "medium" | "high";
|
||||
|
||||
export type RecipeResponsiveRegime = {
|
||||
viewport: number;
|
||||
layout: "grid" | "flex" | "stack" | "block" | "absolute" | "mixed";
|
||||
rootBox: { x: number; y: number; width: number; height: number };
|
||||
visibleItems?: number;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
gapX?: number;
|
||||
gapY?: number;
|
||||
};
|
||||
|
||||
export type RecipeRepeatedItem = {
|
||||
cid: string;
|
||||
tag: string;
|
||||
textSample: string;
|
||||
mediaCount: number;
|
||||
headingCount: number;
|
||||
bbox: { x: number; y: number; width: number; height: number };
|
||||
};
|
||||
|
||||
export type RecipeCandidate = {
|
||||
id: string;
|
||||
kind: RecipeKind;
|
||||
confidence: number;
|
||||
risk: RecipeRisk;
|
||||
rootCid: string;
|
||||
rootTag: string;
|
||||
itemParentCid?: string;
|
||||
sectionId?: string;
|
||||
sectionRole?: string;
|
||||
componentName: string;
|
||||
dataModel?: string;
|
||||
itemCount?: number;
|
||||
repeatedItems?: RecipeRepeatedItem[];
|
||||
responsiveRegimes: RecipeResponsiveRegime[];
|
||||
sourceHints: string[];
|
||||
signals: string[];
|
||||
emissionStatus: "report-only";
|
||||
fallbackReason: string;
|
||||
};
|
||||
|
||||
export type RecipeReport = {
|
||||
version: 1;
|
||||
sourceUrl: string;
|
||||
canonicalViewport: number;
|
||||
viewports: number[];
|
||||
sampledViewports: number[];
|
||||
summary: {
|
||||
totalCandidates: number;
|
||||
highConfidence: number;
|
||||
byKind: Record<string, number>;
|
||||
templateReadyKinds: RecipeKind[];
|
||||
};
|
||||
candidates: RecipeCandidate[];
|
||||
};
|
||||
|
||||
type ParentMap = Map<string, IRNode | undefined>;
|
||||
type NodeMap = Map<string, IRNode>;
|
||||
type SectionMap = Map<string, Section>;
|
||||
|
||||
type RecipeContext = {
|
||||
ir: IR;
|
||||
cw: number;
|
||||
viewports: number[];
|
||||
sampledViewports: number[];
|
||||
nodes: IRNode[];
|
||||
byId: NodeMap;
|
||||
parentById: ParentMap;
|
||||
sectionByNodeId: SectionMap;
|
||||
primitives: Map<string, PrimitiveType>;
|
||||
};
|
||||
|
||||
type ItemStats = {
|
||||
node: IRNode;
|
||||
text: string;
|
||||
textLen: number;
|
||||
mediaCount: number;
|
||||
headingCount: number;
|
||||
buttonCount: number;
|
||||
linkCount: number;
|
||||
hasSurface: boolean;
|
||||
bbox?: BBox;
|
||||
};
|
||||
|
||||
type RecipeDraft = Omit<RecipeCandidate, "id">;
|
||||
|
||||
const TEMPLATE_READY: RecipeKind[] = ["logo-cloud", "feature-grid", "card-grid", "product-grid", "cta-band"];
|
||||
const TRANSPARENT = new Set(["", "transparent", "rgba(0, 0, 0, 0)", "rgba(0,0,0,0)"]);
|
||||
const TRUSTED_COPY = /\b(?:trusted by|used by|loved by|teams at|customers|brands|companies|partners|sponsors|backed by|featured in)\b/i;
|
||||
const FEATURE_COPY = /\b(?:feature|features|capabilities|benefits|workflow|workflows|platform|built for|designed for|prototype|collaboration|secure|iterate)\b/i;
|
||||
const CARD_COPY = /\b(?:shop|products?|collections?|categories|resources?|articles?|blog|latest|popular|stories|cards?)\b/i;
|
||||
const PRODUCT_COPY = /\b(?:buy|shop now|add to cart|add to bag|checkout|price|from\s+[$£€]?\d|sale|trade in|learn more\s+buy|now with|supercharged|airpods?|iphone|ipad|mac(?:book)?|apple watch)\b|[$£€]\s?\d/i;
|
||||
const PRODUCT_SOURCE = /\b(?:product|products|shop|store|commerce|collection|collections|catalog|merch|promo|tile|buy|price|cart)\b/i;
|
||||
const GALLERY_SOURCE = /\b(?:media-gallery|gallery|carousel|slider|swiper|splide|showcase|tabpanel|tablist|track)\b/i;
|
||||
const GALLERY_COPY = /\b(?:gallery|stream now|watch now|listen now|play now|featured|entertainment|shows?|movies?|episode|season|sports?)\b/i;
|
||||
const TESTIMONIAL_SOURCE = /\b(?:testimonial|testimonials|quote|quotes|customer|customers|story|stories|isHorizontal)\b/i;
|
||||
const TESTIMONIAL_COPY = /[“”]|\b(?:testimonial|testimonials|trusted by builders|endorsed by|ceo|cto|founder|customer|customers|databricks|trusted enterprise)\b/i;
|
||||
const CTA_COPY = /\b(?:get started|start|try|join|sign up|book|contact|shop now|learn more|download|apply|subscribe)\b/i;
|
||||
const DEFERRED_INTERACTIVE_COLLECTION = /\b(?:rfm-|marquee|carousel|swiper|splide|slider|ticker)\b/i;
|
||||
const RECIPE_HINT = /(?:^|[-_:])(grid|flex|gap|container|max-w|mx-auto|logo|logos|brand|brands|feature|features|card|cards|product|products|promo|commerce|collection|shop|gallery|showcase|carousel|slider|media|cta|hero|footer|nav|trusted|sponsor|partner|customer|cols?|columns?|section)(?:$|[-_:])/i;
|
||||
const BREAKPOINT_HINT = /^(?:max-)?(?:sm|md|lg|xl|2xl):|^(?:min|max)-\[/;
|
||||
|
||||
function clamp(n: number, min = 0, max = 1): number {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
function px(v: string | undefined): number {
|
||||
if (!v) return 0;
|
||||
const m = /-?\d+(?:\.\d+)?/.exec(v);
|
||||
return m ? parseFloat(m[0]) : 0;
|
||||
}
|
||||
|
||||
function elementChildren(n: IRNode): IRNode[] {
|
||||
return n.children.filter((c): c is IRNode => !isTextChild(c));
|
||||
}
|
||||
|
||||
function visibleAt(n: IRNode, vp: number): boolean {
|
||||
const b = n.bboxByVp[vp];
|
||||
return !!n.visibleByVp[vp] && !!b && b.width > 1 && b.height > 1;
|
||||
}
|
||||
|
||||
function visibleElementChildren(n: IRNode, vp: number): IRNode[] {
|
||||
return elementChildren(n).filter((c) => visibleAt(c, vp));
|
||||
}
|
||||
|
||||
function displayOf(n: IRNode, vp: number): string {
|
||||
return n.computedByVp[vp]?.display ?? "";
|
||||
}
|
||||
|
||||
function textContent(n: IRNode, max = 2000): string {
|
||||
let out = "";
|
||||
const walk = (x: IRNode): void => {
|
||||
if (out.length >= max) return;
|
||||
for (const c of x.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 directText(n: IRNode): string {
|
||||
return n.children
|
||||
.filter(isTextChild)
|
||||
.map((c) => c.text)
|
||||
.join(" ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function hasNonTransparentBg(cs: StyleMap | undefined): boolean {
|
||||
return !!cs?.backgroundColor && !TRANSPARENT.has(cs.backgroundColor);
|
||||
}
|
||||
|
||||
function hasBorder(cs: StyleMap | undefined): boolean {
|
||||
if (!cs) return false;
|
||||
return px(cs.borderTopWidth) > 0 || px(cs.borderRightWidth) > 0 || px(cs.borderBottomWidth) > 0 || px(cs.borderLeftWidth) > 0;
|
||||
}
|
||||
|
||||
function hasSurface(n: IRNode, vp: number): boolean {
|
||||
const cs = n.computedByVp[vp];
|
||||
if (!cs) return false;
|
||||
const shadow = cs.boxShadow && cs.boxShadow !== "none";
|
||||
const radius = px(cs.borderTopLeftRadius) >= 4;
|
||||
return hasNonTransparentBg(cs) || hasBorder(cs) || !!shadow || radius;
|
||||
}
|
||||
|
||||
function isMediaNode(n: IRNode, vp: number): boolean {
|
||||
if (/^(img|svg|picture|video|canvas|iframe)$/.test(n.tag)) return true;
|
||||
const bg = n.computedByVp[vp]?.backgroundImage ?? "";
|
||||
return /\burl\(/.test(bg);
|
||||
}
|
||||
|
||||
function countMedia(n: IRNode, vp: number): number {
|
||||
let count = isMediaNode(n, vp) ? 1 : 0;
|
||||
for (const c of elementChildren(n)) count += countMedia(c, vp);
|
||||
return count;
|
||||
}
|
||||
|
||||
function fontWeightNumber(v: string | undefined): number {
|
||||
if (!v) return 400;
|
||||
if (v === "bold") return 700;
|
||||
if (v === "normal") return 400;
|
||||
return px(v) || 400;
|
||||
}
|
||||
|
||||
function isHeadingLike(n: IRNode, vp: number): boolean {
|
||||
if (/^h[1-6]$/.test(n.tag)) return true;
|
||||
const text = directText(n);
|
||||
if (!text || text.length > 96) return false;
|
||||
const cs = n.computedByVp[vp];
|
||||
const fs = px(cs?.fontSize);
|
||||
const fw = fontWeightNumber(cs?.fontWeight);
|
||||
return fs >= 20 || (fs >= 12 && fw >= 600);
|
||||
}
|
||||
|
||||
function countHeadings(n: IRNode, vp: number): number {
|
||||
let count = isHeadingLike(n, vp) ? 1 : 0;
|
||||
for (const c of elementChildren(n)) count += countHeadings(c, vp);
|
||||
return count;
|
||||
}
|
||||
|
||||
function countPrimitive(n: IRNode, primitives: Map<string, PrimitiveType>, types: Set<PrimitiveType>): number {
|
||||
let count = types.has(primitives.get(n.id) as PrimitiveType) ? 1 : 0;
|
||||
for (const c of elementChildren(n)) count += countPrimitive(c, primitives, types);
|
||||
return count;
|
||||
}
|
||||
|
||||
function nodeStats(ctx: RecipeContext, n: IRNode): ItemStats {
|
||||
const text = textContent(n, 500);
|
||||
return {
|
||||
node: n,
|
||||
text,
|
||||
textLen: text.length,
|
||||
mediaCount: countMedia(n, ctx.cw),
|
||||
headingCount: countHeadings(n, ctx.cw),
|
||||
buttonCount: countPrimitive(n, ctx.primitives, new Set(["button"])),
|
||||
linkCount: countPrimitive(n, ctx.primitives, new Set(["link"])),
|
||||
hasSurface: hasSurface(n, ctx.cw),
|
||||
bbox: n.bboxByVp[ctx.cw],
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeContext {
|
||||
const nodes: IRNode[] = [];
|
||||
const byId: NodeMap = new Map();
|
||||
const parentById: ParentMap = new Map();
|
||||
const walk = (n: IRNode, parent: IRNode | undefined): void => {
|
||||
nodes.push(n);
|
||||
byId.set(n.id, n);
|
||||
parentById.set(n.id, parent);
|
||||
for (const c of elementChildren(n)) walk(c, n);
|
||||
};
|
||||
walk(ir.root, undefined);
|
||||
return {
|
||||
ir,
|
||||
cw: ir.doc.canonicalViewport,
|
||||
viewports: [...ir.doc.viewports].sort((a, b) => a - b),
|
||||
sampledViewports: [...(ir.doc.sampleViewports.length ? ir.doc.sampleViewports : ir.doc.viewports)].sort((a, b) => a - b),
|
||||
nodes,
|
||||
byId,
|
||||
parentById,
|
||||
sectionByNodeId: new Map(sections.map((s) => [s.nodeId, s])),
|
||||
primitives,
|
||||
};
|
||||
}
|
||||
|
||||
function nearestSection(ctx: RecipeContext, n: IRNode): Section | undefined {
|
||||
let cur: IRNode | undefined = n;
|
||||
while (cur) {
|
||||
const section = ctx.sectionByNodeId.get(cur.id);
|
||||
if (section) return section;
|
||||
cur = ctx.parentById.get(cur.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function recipeRoot(ctx: RecipeContext, itemParent: IRNode): { root: IRNode; section?: Section } {
|
||||
const section = nearestSection(ctx, itemParent);
|
||||
if (!section || section.nodeId === ctx.ir.root.id) return { root: itemParent };
|
||||
const root = ctx.byId.get(section.nodeId);
|
||||
return root ? { root, section } : { root: itemParent };
|
||||
}
|
||||
|
||||
function collectSourceHints(root: IRNode): string[] {
|
||||
const hints = new Set<string>();
|
||||
const walk = (n: IRNode): void => {
|
||||
if (hints.size >= 18) return;
|
||||
const cls = n.srcClass;
|
||||
if (cls) {
|
||||
for (const tok of cls.split(/\s+/)) {
|
||||
if (!tok) continue;
|
||||
if (RECIPE_HINT.test(tok) || BREAKPOINT_HINT.test(tok)) hints.add(tok);
|
||||
if (hints.size >= 18) break;
|
||||
}
|
||||
}
|
||||
for (const c of elementChildren(n)) walk(c);
|
||||
};
|
||||
walk(root);
|
||||
return [...hints].sort();
|
||||
}
|
||||
|
||||
function subtreeSourceClassMatches(root: IRNode, re: RegExp): boolean {
|
||||
if (root.srcClass && re.test(root.srcClass)) return true;
|
||||
for (const c of elementChildren(root)) if (subtreeSourceClassMatches(c, re)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function subtreeAttrOrSourceMatches(root: IRNode, re: RegExp): boolean {
|
||||
if (root.srcClass && re.test(root.srcClass)) return true;
|
||||
for (const value of Object.values(root.attrs)) if (re.test(value)) return true;
|
||||
for (const c of elementChildren(root)) if (subtreeAttrOrSourceMatches(c, re)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function textSample(s: string, max = 72): string {
|
||||
const t = s.replace(/\s+/g, " ").trim();
|
||||
return t.length <= max ? t : t.slice(0, max - 1).trimEnd() + "…";
|
||||
}
|
||||
|
||||
function repeatedItems(ctx: RecipeContext, stats: ItemStats[]): RecipeRepeatedItem[] {
|
||||
return stats.map((s) => {
|
||||
const b = s.bbox ?? { x: 0, y: 0, width: 0, height: 0 };
|
||||
return {
|
||||
cid: s.node.id,
|
||||
tag: s.node.tag,
|
||||
textSample: textSample(s.text),
|
||||
mediaCount: s.mediaCount,
|
||||
headingCount: s.headingCount,
|
||||
bbox: { x: round(b.x), y: round(b.y), width: round(b.width), height: round(b.height) },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function median(values: number[]): number | undefined {
|
||||
const xs = values.filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
|
||||
if (!xs.length) return undefined;
|
||||
const mid = Math.floor(xs.length / 2);
|
||||
const val = xs.length % 2 ? xs[mid] : ((xs[mid - 1] ?? 0) + (xs[mid] ?? 0)) / 2;
|
||||
return val === undefined ? undefined : round(val);
|
||||
}
|
||||
|
||||
function groupRows(items: Array<{ node: IRNode; box: BBox }>): Array<Array<{ node: IRNode; box: BBox }>> {
|
||||
const sorted = items.slice().sort((a, b) => a.box.y - b.box.y || a.box.x - b.box.x);
|
||||
const rows: Array<Array<{ node: IRNode; box: BBox }>> = [];
|
||||
for (const item of sorted) {
|
||||
const tol = Math.max(8, Math.min(32, item.box.height * 0.28));
|
||||
const row = rows.find((r) => Math.abs((r[0]?.box.y ?? item.box.y) - item.box.y) <= tol);
|
||||
if (row) row.push(item);
|
||||
else rows.push([item]);
|
||||
}
|
||||
for (const row of rows) row.sort((a, b) => a.box.x - b.box.x);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function gapsFor(rows: Array<Array<{ node: IRNode; box: BBox }>>): { gapX?: number; gapY?: number } {
|
||||
const gapXs: number[] = [];
|
||||
for (const row of rows) {
|
||||
for (let i = 1; i < row.length; i++) {
|
||||
const prev = row[i - 1]!;
|
||||
const cur = row[i]!;
|
||||
gapXs.push(cur.box.x - (prev.box.x + prev.box.width));
|
||||
}
|
||||
}
|
||||
const rowYs = rows.map((r) => {
|
||||
const y = Math.min(...r.map((i) => i.box.y));
|
||||
const bottom = Math.max(...r.map((i) => i.box.y + i.box.height));
|
||||
return { y, bottom };
|
||||
}).sort((a, b) => a.y - b.y);
|
||||
const gapYs: number[] = [];
|
||||
for (let i = 1; i < rowYs.length; i++) {
|
||||
const prev = rowYs[i - 1]!;
|
||||
const cur = rowYs[i]!;
|
||||
gapYs.push(cur.y - prev.bottom);
|
||||
}
|
||||
return { gapX: median(gapXs), gapY: median(gapYs) };
|
||||
}
|
||||
|
||||
function regimeAt(ctx: RecipeContext, root: IRNode, itemNodes: IRNode[] | undefined, vp: number): RecipeResponsiveRegime | undefined {
|
||||
const rootBox = root.bboxByVp[vp];
|
||||
if (!rootBox || !visibleAt(root, vp)) return undefined;
|
||||
const display = displayOf(root, vp);
|
||||
const itemBoxes = (itemNodes ?? [])
|
||||
.map((node) => ({ node, box: node.bboxByVp[vp] }))
|
||||
.filter((x): x is { node: IRNode; box: BBox } => !!x.box && visibleAt(x.node, vp));
|
||||
const rows = itemBoxes.length ? groupRows(itemBoxes) : [];
|
||||
const columns = rows.length ? Math.max(...rows.map((r) => r.length)) : undefined;
|
||||
const rowCount = rows.length || undefined;
|
||||
const gaps = rows.length ? gapsFor(rows) : {};
|
||||
const layout = display.includes("grid")
|
||||
? "grid"
|
||||
: display.includes("flex")
|
||||
? "flex"
|
||||
: display.includes("absolute")
|
||||
? "absolute"
|
||||
: columns === 1 && (rowCount ?? 0) > 1
|
||||
? "stack"
|
||||
: display.includes("block")
|
||||
? "block"
|
||||
: "mixed";
|
||||
return {
|
||||
viewport: vp,
|
||||
layout,
|
||||
rootBox: { x: round(rootBox.x), y: round(rootBox.y), width: round(rootBox.width), height: round(rootBox.height) },
|
||||
...(itemNodes ? { visibleItems: itemBoxes.length, columns, rows: rowCount, ...gaps } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function responsiveRegimes(ctx: RecipeContext, root: IRNode, items?: IRNode[]): RecipeResponsiveRegime[] {
|
||||
const out: RecipeResponsiveRegime[] = [];
|
||||
let prevSig = "";
|
||||
for (const vp of ctx.sampledViewports) {
|
||||
const r = regimeAt(ctx, root, items, vp);
|
||||
if (!r) continue;
|
||||
const sig = [r.layout, r.visibleItems ?? "-", r.columns ?? "-", r.rows ?? "-"].join(":");
|
||||
if (sig !== prevSig || ctx.viewports.includes(vp)) {
|
||||
out.push(r);
|
||||
prevSig = sig;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function isLayoutish(ctx: RecipeContext, parent: IRNode, items: IRNode[]): boolean {
|
||||
const display = displayOf(parent, ctx.cw);
|
||||
if (display.includes("grid") || display.includes("flex")) return true;
|
||||
const regime = regimeAt(ctx, parent, items, ctx.cw);
|
||||
return (regime?.columns ?? 0) >= 2 || (regime?.rows ?? 0) >= 2;
|
||||
}
|
||||
|
||||
function hasGridBehavior(ctx: RecipeContext, parent: IRNode, items: IRNode[]): boolean {
|
||||
let maxColumns = 0;
|
||||
for (const vp of ctx.sampledViewports) {
|
||||
const regime = regimeAt(ctx, parent, items, vp);
|
||||
maxColumns = Math.max(maxColumns, regime?.columns ?? 0);
|
||||
}
|
||||
return maxColumns >= 2;
|
||||
}
|
||||
|
||||
function itemAreaOk(stat: ItemStats, minW: number, minH: number): boolean {
|
||||
const b = stat.bbox;
|
||||
return !!b && b.width >= minW && b.height >= minH;
|
||||
}
|
||||
|
||||
function isLikelyLogoItem(stat: ItemStats): boolean {
|
||||
const b = stat.bbox;
|
||||
if (!b || stat.mediaCount < 1 || stat.headingCount > 0 || stat.buttonCount > 0) return false;
|
||||
if (stat.textLen > 40) return false;
|
||||
return b.width <= 360 && b.height <= 180;
|
||||
}
|
||||
|
||||
function isLikelyFeatureItem(stat: ItemStats): boolean {
|
||||
if (!itemAreaOk(stat, 120, 80)) return false;
|
||||
if (stat.textLen < 18 || stat.textLen > 650) return false;
|
||||
if (stat.headingCount < 1) return false;
|
||||
return stat.mediaCount > 0 || stat.hasSurface || stat.buttonCount > 0;
|
||||
}
|
||||
|
||||
function isLikelyCardItem(stat: ItemStats): boolean {
|
||||
if (!itemAreaOk(stat, 140, 120)) return false;
|
||||
if (stat.mediaCount < 1) return false;
|
||||
if (stat.textLen > 900) return false;
|
||||
return stat.textLen >= 8 || stat.buttonCount > 0 || stat.linkCount > 0 || stat.hasSurface;
|
||||
}
|
||||
|
||||
function isLikelyProductItem(stat: ItemStats): boolean {
|
||||
if (!itemAreaOk(stat, 120, 110)) return false;
|
||||
if (stat.mediaCount < 1 || stat.textLen > 700) return false;
|
||||
if (PRODUCT_COPY.test(stat.text)) return true;
|
||||
return stat.headingCount > 0 && (stat.buttonCount > 0 || stat.linkCount > 0);
|
||||
}
|
||||
|
||||
function componentNameFor(kind: RecipeKind): string {
|
||||
switch (kind) {
|
||||
case "logo-cloud": return "LogoCloudSection";
|
||||
case "feature-grid": return "FeatureGridSection";
|
||||
case "card-grid": return "CardGridSection";
|
||||
case "product-grid": return "ProductGridSection";
|
||||
case "gallery-showcase": return "GalleryShowcaseSection";
|
||||
case "cta-band": return "CtaBandSection";
|
||||
}
|
||||
}
|
||||
|
||||
function dataModelFor(kind: RecipeKind): string | undefined {
|
||||
switch (kind) {
|
||||
case "logo-cloud": return "logos";
|
||||
case "feature-grid": return "features";
|
||||
case "card-grid": return "cards";
|
||||
case "product-grid": return "products";
|
||||
case "gallery-showcase": return undefined;
|
||||
case "cta-band": return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function itemSetKey(kind: RecipeKind, stats: ItemStats[]): string {
|
||||
return kind + ":" + stats.map((s) => s.node.id).join(",");
|
||||
}
|
||||
|
||||
function overlapRatio(a: RecipeDraft, b: RecipeDraft): number {
|
||||
const aa = new Set((a.repeatedItems ?? []).map((i) => i.cid));
|
||||
const bb = new Set((b.repeatedItems ?? []).map((i) => i.cid));
|
||||
if (!aa.size || !bb.size) return 0;
|
||||
let hit = 0;
|
||||
for (const id of aa) if (bb.has(id)) hit++;
|
||||
return hit / Math.min(aa.size, bb.size);
|
||||
}
|
||||
|
||||
function isAncestorOrSelf(ctx: RecipeContext, ancestorCid: string, childCid: string): boolean {
|
||||
let cur = ctx.byId.get(childCid);
|
||||
while (cur) {
|
||||
if (cur.id === ancestorCid) return true;
|
||||
cur = ctx.parentById.get(cur.id);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function baseDraft(ctx: RecipeContext, kind: RecipeKind, parent: IRNode, stats: ItemStats[], confidence: number, signals: string[]): RecipeDraft {
|
||||
const { root, section } = recipeRoot(ctx, parent);
|
||||
const rootText = textContent(root, 1000);
|
||||
return {
|
||||
kind,
|
||||
confidence: round(clamp(confidence), 2),
|
||||
risk: confidence >= 0.86 ? "low" : confidence >= 0.74 ? "medium" : "high",
|
||||
rootCid: root.id,
|
||||
rootTag: root.tag,
|
||||
itemParentCid: parent.id,
|
||||
...(section ? { sectionId: section.id, sectionRole: section.role } : {}),
|
||||
componentName: componentNameFor(kind),
|
||||
dataModel: dataModelFor(kind),
|
||||
itemCount: stats.length,
|
||||
repeatedItems: repeatedItems(ctx, stats),
|
||||
responsiveRegimes: responsiveRegimes(ctx, root, stats.map((s) => s.node)),
|
||||
sourceHints: collectSourceHints(root),
|
||||
signals: [...signals, rootText && TRUSTED_COPY.test(rootText) ? "section copy contains social-proof language" : ""].filter(Boolean),
|
||||
emissionStatus: "report-only",
|
||||
fallbackReason: "inventory-only pass; current generator still emits the measured subtree",
|
||||
};
|
||||
}
|
||||
|
||||
function detectLogoClouds(ctx: RecipeContext): RecipeDraft[] {
|
||||
const out: RecipeDraft[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const parent of ctx.nodes) {
|
||||
const kids = visibleElementChildren(parent, ctx.cw);
|
||||
if (kids.length < 3 || kids.length > 40) continue;
|
||||
const stats = kids.map((k) => nodeStats(ctx, k));
|
||||
const logos = stats.filter(isLikelyLogoItem);
|
||||
if (logos.length < 3 || logos.length / kids.length < 0.55) continue;
|
||||
const key = itemSetKey("logo-cloud", logos);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const { root } = recipeRoot(ctx, parent);
|
||||
const rootText = textContent(root, 1200);
|
||||
const layout = isLayoutish(ctx, parent, logos.map((s) => s.node));
|
||||
const avgHeight = logos.reduce((sum, s) => sum + (s.bbox?.height ?? 0), 0) / logos.length;
|
||||
const signals = [
|
||||
`${logos.length} repeated media-light children`,
|
||||
layout ? "item parent behaves like grid/flex/wrapped row" : "item parent has repeated logo geometry",
|
||||
TRUSTED_COPY.test(rootText) ? "nearby copy matches trusted-by/brand language" : "",
|
||||
avgHeight <= 96 ? "logo item boxes stay small" : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.58
|
||||
+ Math.min(0.18, logos.length * 0.025)
|
||||
+ (logos.length / kids.length) * 0.12
|
||||
+ (layout ? 0.08 : 0)
|
||||
+ (TRUSTED_COPY.test(rootText) ? 0.10 : 0)
|
||||
+ (avgHeight <= 96 ? 0.04 : 0);
|
||||
out.push(baseDraft(ctx, "logo-cloud", parent, logos, confidence, signals));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
const out: RecipeDraft[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const parent of ctx.nodes) {
|
||||
if (parent.id === ctx.ir.root.id) continue;
|
||||
if (subtreeSourceClassMatches(parent, DEFERRED_INTERACTIVE_COLLECTION)) continue;
|
||||
const kids = visibleElementChildren(parent, ctx.cw);
|
||||
if (kids.length < 2 || kids.length > 36) continue;
|
||||
const stats = kids.map((k) => nodeStats(ctx, k));
|
||||
const logoRatio = stats.filter(isLikelyLogoItem).length / kids.length;
|
||||
if (logoRatio > 0.65) continue;
|
||||
const layout = hasGridBehavior(ctx, parent, kids);
|
||||
if (!layout) continue;
|
||||
const rootInfo = recipeRoot(ctx, parent);
|
||||
const pageH = ctx.ir.doc.perViewport[ctx.cw]?.scrollHeight ?? 0;
|
||||
const rootBox = rootInfo.root.bboxByVp[ctx.cw];
|
||||
const rootTooBroad = rootInfo.root.id === ctx.ir.root.id || (!!pageH && !!rootBox && rootBox.height > pageH * 0.55);
|
||||
const localText = textContent(parent, 1200);
|
||||
const parentText = rootTooBroad ? localText : textContent(rootInfo.root, 1600);
|
||||
const galleryContext = subtreeAttrOrSourceMatches(parent, GALLERY_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, GALLERY_SOURCE));
|
||||
const testimonialContext = subtreeAttrOrSourceMatches(parent, TESTIMONIAL_SOURCE) || TESTIMONIAL_COPY.test(localText);
|
||||
const featureItems = stats.filter(isLikelyFeatureItem);
|
||||
const cardItems = stats.filter(isLikelyCardItem);
|
||||
const cardLikeItems = stats.filter((s) => isLikelyFeatureItem(s) || isLikelyCardItem(s));
|
||||
if ((galleryContext || testimonialContext) && cardLikeItems.length >= 3 && cardLikeItems.length / kids.length >= 0.45) {
|
||||
const key = itemSetKey("gallery-showcase", cardLikeItems);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
const oneRowRegimes = responsiveRegimes(ctx, recipeRoot(ctx, parent).root, cardLikeItems.map((s) => s.node))
|
||||
.filter((r) => (r.visibleItems ?? 0) >= 3 && (r.rows ?? 0) <= 1).length;
|
||||
const mediaRatio = cardLikeItems.filter((s) => s.mediaCount > 0).length / cardLikeItems.length;
|
||||
const galleryCopy = GALLERY_COPY.test(parentText);
|
||||
const testimonialCopy = TESTIMONIAL_COPY.test(localText);
|
||||
const signals = [
|
||||
`${cardLikeItems.length} repeated ${testimonialContext ? "testimonial/gallery" : "media/gallery"} items`,
|
||||
galleryContext ? "source attributes/classes identify a gallery or carousel track" : "",
|
||||
testimonialContext ? "source text/classes identify a testimonial or horizontal story strip" : "",
|
||||
oneRowRegimes >= 2 ? "items remain in a horizontal gallery row across sampled widths" : "",
|
||||
mediaRatio >= 0.6 ? "most gallery items include media" : "",
|
||||
galleryCopy ? "copy matches media/entertainment gallery language" : "",
|
||||
testimonialCopy ? "copy matches testimonial/social-proof language" : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.62
|
||||
+ Math.min(0.15, cardLikeItems.length * 0.018)
|
||||
+ (layout ? 0.08 : 0)
|
||||
+ (oneRowRegimes >= 2 ? 0.08 : 0)
|
||||
+ (mediaRatio >= 0.6 ? 0.04 : 0)
|
||||
+ (galleryCopy ? 0.04 : 0)
|
||||
+ (testimonialCopy ? 0.05 : 0);
|
||||
out.push(baseDraft(ctx, "gallery-showcase", parent, cardLikeItems, confidence, signals));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const productItems = stats.filter(isLikelyProductItem);
|
||||
const forbiddenProductContext = subtreeAttrOrSourceMatches(parent, /\b(?:footer|directory|nav|menu)\b/i)
|
||||
|| (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, /\b(?:footer|directory|nav|menu)\b/i));
|
||||
const productContext = !forbiddenProductContext &&
|
||||
(PRODUCT_COPY.test(localText) || subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE)
|
||||
|| (!rootTooBroad && (PRODUCT_COPY.test(parentText) || subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))));
|
||||
const productStats = productItems.length >= 2 ? productItems : (productContext ? cardLikeItems : []);
|
||||
if (productStats.length >= 2 && productStats.length / kids.length >= 0.45 && productContext) {
|
||||
const key = itemSetKey("product-grid", productStats);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
const ctaRatio = productStats.filter((s) => s.buttonCount > 0 || s.linkCount > 0).length / productStats.length;
|
||||
const productCopyRatio = productStats.filter((s) => PRODUCT_COPY.test(s.text)).length / productStats.length;
|
||||
const signals = [
|
||||
`${productStats.length} repeated product/promo cards`,
|
||||
layout ? "children form a grid/flex responsive layout" : "children form repeated product geometry",
|
||||
productCopyRatio >= 0.5 ? "most items contain commerce/product copy" : "",
|
||||
ctaRatio >= 0.5 ? "most products include link/button affordances" : "",
|
||||
(subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? "source context is product/promo/commerce-like" : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.58
|
||||
+ Math.min(0.14, productStats.length * 0.025)
|
||||
+ (productStats.length / kids.length) * 0.10
|
||||
+ (layout ? 0.10 : 0)
|
||||
+ (productCopyRatio >= 0.5 ? 0.07 : 0)
|
||||
+ (ctaRatio >= 0.5 ? 0.05 : 0)
|
||||
+ ((subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? 0.06 : 0);
|
||||
out.push(baseDraft(ctx, "product-grid", parent, productStats, confidence, signals));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (featureItems.length >= 3 && featureItems.length / kids.length >= 0.5) {
|
||||
const key = itemSetKey("feature-grid", featureItems);
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
const mediaRatio = featureItems.filter((s) => s.mediaCount > 0).length / featureItems.length;
|
||||
const surfaceRatio = featureItems.filter((s) => s.hasSurface).length / featureItems.length;
|
||||
const signals = [
|
||||
`${featureItems.length} repeated title/body cards`,
|
||||
layout ? "children form a grid/flex responsive layout" : "children form repeated card geometry",
|
||||
mediaRatio >= 0.5 ? "most feature cards include media/icon content" : "",
|
||||
surfaceRatio >= 0.5 ? "most feature cards have card surfaces" : "",
|
||||
FEATURE_COPY.test(parentText) ? "section copy/source context is feature-like" : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.55
|
||||
+ Math.min(0.16, featureItems.length * 0.025)
|
||||
+ (featureItems.length / kids.length) * 0.10
|
||||
+ (layout ? 0.10 : 0)
|
||||
+ (mediaRatio >= 0.5 ? 0.06 : 0)
|
||||
+ (surfaceRatio >= 0.5 ? 0.04 : 0)
|
||||
+ (FEATURE_COPY.test(parentText) ? 0.06 : 0);
|
||||
out.push(baseDraft(ctx, "feature-grid", parent, featureItems, confidence, signals));
|
||||
}
|
||||
}
|
||||
if (cardItems.length >= 2 && cardItems.length / kids.length >= 0.5) {
|
||||
const key = itemSetKey("card-grid", cardItems);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
const ctaRatio = cardItems.filter((s) => s.buttonCount > 0 || s.linkCount > 0).length / cardItems.length;
|
||||
const headingRatio = cardItems.filter((s) => s.headingCount > 0).length / cardItems.length;
|
||||
const signals = [
|
||||
`${cardItems.length} repeated media cards`,
|
||||
layout ? "children form a grid/flex responsive layout" : "children form repeated card geometry",
|
||||
ctaRatio >= 0.5 ? "most cards include link/button affordances" : "",
|
||||
headingRatio >= 0.5 ? "most cards include title-like text" : "",
|
||||
CARD_COPY.test(parentText) ? "section copy/source context is card/product-like" : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.50
|
||||
+ Math.min(0.16, cardItems.length * 0.035)
|
||||
+ (cardItems.length / kids.length) * 0.10
|
||||
+ (layout ? 0.11 : 0)
|
||||
+ (ctaRatio >= 0.5 ? 0.05 : 0)
|
||||
+ (headingRatio >= 0.5 ? 0.04 : 0)
|
||||
+ (CARD_COPY.test(parentText) ? 0.06 : 0);
|
||||
out.push(baseDraft(ctx, "card-grid", parent, cardItems, confidence, signals));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function detectCtaBands(ctx: RecipeContext): RecipeDraft[] {
|
||||
const out: RecipeDraft[] = [];
|
||||
const sectionNodes = [...ctx.sectionByNodeId.values()]
|
||||
.map((s) => ctx.byId.get(s.nodeId))
|
||||
.filter((n): n is IRNode => !!n && n.id !== ctx.ir.root.id);
|
||||
const seenNodes = new Set<string>();
|
||||
const nodes = [...sectionNodes, ...ctx.nodes.filter((n) => n.id !== ctx.ir.root.id)]
|
||||
.filter((n) => {
|
||||
if (seenNodes.has(n.id)) return false;
|
||||
seenNodes.add(n.id);
|
||||
return true;
|
||||
});
|
||||
for (const node of nodes) {
|
||||
if (!visibleAt(node, ctx.cw)) continue;
|
||||
if (/^(header|nav|footer)$/.test(node.tag)) continue;
|
||||
const b = node.bboxByVp[ctx.cw];
|
||||
if (!b || b.width < ctx.cw * 0.45 || b.height < 72 || b.height > 900) continue;
|
||||
const text = textContent(node, 1200);
|
||||
if (text.length < 12 || text.length > 900) continue;
|
||||
const headings = countHeadings(node, ctx.cw);
|
||||
const buttons = countPrimitive(node, ctx.primitives, new Set(["button"]));
|
||||
if (headings < 1 || headings > 2 || buttons < 1 || buttons > 3) continue;
|
||||
const directKids = visibleElementChildren(node, ctx.cw);
|
||||
const repeatedCardKids = directKids.map((k) => nodeStats(ctx, k)).filter((s) => isLikelyFeatureItem(s) || isLikelyCardItem(s));
|
||||
if (repeatedCardKids.length >= 3) continue;
|
||||
const cs = node.computedByVp[ctx.cw];
|
||||
const centered = cs?.textAlign === "center";
|
||||
const ctaCopy = CTA_COPY.test(text);
|
||||
const ctaHint = subtreeSourceClassMatches(node, /\b(?:cta|call-to-action)\b/i);
|
||||
if (!ctaCopy && !ctaHint) continue;
|
||||
const signals = [
|
||||
`${headings} heading-like node(s) and ${buttons} button-like CTA(s)`,
|
||||
centered ? "section text is centered" : "",
|
||||
ctaCopy ? "copy contains CTA language" : "",
|
||||
ctaHint ? "source hints contain CTA naming" : "",
|
||||
hasSurface(node, ctx.cw) ? "bounded visual surface/background" : "",
|
||||
].filter(Boolean);
|
||||
const section = ctx.sectionByNodeId.get(node.id);
|
||||
const confidence = 0.52
|
||||
+ Math.min(0.10, headings * 0.04)
|
||||
+ Math.min(0.12, buttons * 0.04)
|
||||
+ (centered ? 0.06 : 0)
|
||||
+ (ctaCopy ? 0.10 : 0)
|
||||
+ (hasSurface(node, ctx.cw) ? 0.05 : 0);
|
||||
out.push({
|
||||
kind: "cta-band",
|
||||
confidence: round(clamp(confidence), 2),
|
||||
risk: confidence >= 0.86 ? "low" : confidence >= 0.74 ? "medium" : "high",
|
||||
rootCid: node.id,
|
||||
rootTag: node.tag,
|
||||
...(section ? { sectionId: section.id, sectionRole: section.role } : {}),
|
||||
componentName: "CtaBandSection",
|
||||
responsiveRegimes: responsiveRegimes(ctx, node),
|
||||
sourceHints: collectSourceHints(node),
|
||||
signals,
|
||||
emissionStatus: "report-only",
|
||||
fallbackReason: "inventory-only pass; current generator still emits the measured subtree",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function recipeRank(d: RecipeDraft): number {
|
||||
const kindBoost =
|
||||
d.kind === "gallery-showcase" ? 0.08 :
|
||||
d.kind === "product-grid" ? 0.07 :
|
||||
d.kind === "feature-grid" ? 0.03 :
|
||||
d.kind === "card-grid" ? 0.01 :
|
||||
0;
|
||||
return d.confidence + kindBoost + (d.kind === "cta-band" && d.rootTag === "section" ? 0.03 : 0);
|
||||
}
|
||||
|
||||
function nestedItemOverlap(ctx: RecipeContext, a: RecipeDraft, b: RecipeDraft): number {
|
||||
const aa = a.repeatedItems ?? [];
|
||||
const bb = b.repeatedItems ?? [];
|
||||
if (!aa.length || !bb.length) return 0;
|
||||
let hit = 0;
|
||||
for (const itemA of aa) {
|
||||
if (bb.some((itemB) => isAncestorOrSelf(ctx, itemA.cid, itemB.cid) || isAncestorOrSelf(ctx, itemB.cid, itemA.cid))) hit++;
|
||||
}
|
||||
return hit / Math.min(aa.length, bb.length);
|
||||
}
|
||||
|
||||
function suppressDuplicates(ctx: RecipeContext, drafts: RecipeDraft[]): RecipeDraft[] {
|
||||
const gridItemRoots = new Set<string>();
|
||||
for (const d of drafts) {
|
||||
if (d.kind !== "feature-grid" && d.kind !== "card-grid") continue;
|
||||
for (const item of d.repeatedItems ?? []) gridItemRoots.add(item.cid);
|
||||
}
|
||||
drafts = drafts.filter((d) => {
|
||||
if (d.kind !== "cta-band") return true;
|
||||
for (const itemCid of gridItemRoots) {
|
||||
if (isAncestorOrSelf(ctx, itemCid, d.rootCid)) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const keyed = new Map<string, RecipeDraft>();
|
||||
for (const d of drafts) {
|
||||
const key = d.repeatedItems?.length
|
||||
? `${d.kind}:${d.repeatedItems.map((i) => i.cid).join(",")}`
|
||||
: `${d.kind}:${d.rootCid}`;
|
||||
const prev = keyed.get(key);
|
||||
if (!prev || recipeRank(d) > recipeRank(prev)) keyed.set(key, d);
|
||||
}
|
||||
const sorted = [...keyed.values()].sort((a, b) => recipeRank(b) - recipeRank(a));
|
||||
const kept: RecipeDraft[] = [];
|
||||
for (const d of sorted) {
|
||||
const duplicate = kept.some((k) => {
|
||||
if (d.kind === "cta-band" && k.kind === "cta-band") {
|
||||
return isAncestorOrSelf(ctx, d.rootCid, k.rootCid) || isAncestorOrSelf(ctx, k.rootCid, d.rootCid);
|
||||
}
|
||||
const overlap = Math.max(overlapRatio(d, k), nestedItemOverlap(ctx, d, k));
|
||||
if (overlap < 0.8) return false;
|
||||
if (d.kind === k.kind) return true;
|
||||
if ((d.kind === "gallery-showcase" || k.kind === "gallery-showcase") &&
|
||||
(d.kind === "feature-grid" || d.kind === "card-grid" || d.kind === "product-grid" || k.kind === "feature-grid" || k.kind === "card-grid" || k.kind === "product-grid")) return true;
|
||||
if ((d.kind === "product-grid" || k.kind === "product-grid") &&
|
||||
(d.kind === "feature-grid" || d.kind === "card-grid" || k.kind === "feature-grid" || k.kind === "card-grid")) return true;
|
||||
return (d.kind === "feature-grid" && k.kind === "card-grid") || (d.kind === "card-grid" && k.kind === "feature-grid");
|
||||
});
|
||||
if (!duplicate) kept.push(d);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
|
||||
function candidateSort(ctx: RecipeContext, a: RecipeDraft, b: RecipeDraft): number {
|
||||
const ab = ctx.byId.get(a.rootCid)?.bboxByVp[ctx.cw];
|
||||
const bb = ctx.byId.get(b.rootCid)?.bboxByVp[ctx.cw];
|
||||
return (ab?.y ?? 0) - (bb?.y ?? 0)
|
||||
|| (ab?.x ?? 0) - (bb?.x ?? 0)
|
||||
|| a.kind.localeCompare(b.kind)
|
||||
|| b.confidence - a.confidence;
|
||||
}
|
||||
|
||||
export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeReport {
|
||||
const ctx = buildContext(ir, sections, primitives);
|
||||
const drafts = suppressDuplicates(ctx, [
|
||||
...detectLogoClouds(ctx),
|
||||
...detectGrids(ctx),
|
||||
...detectCtaBands(ctx),
|
||||
]).sort((a, b) => candidateSort(ctx, a, b));
|
||||
const candidates = drafts.map((d, index) => ({ id: `recipe-${String(index + 1).padStart(3, "0")}`, ...d }));
|
||||
const byKind: Record<string, number> = {};
|
||||
for (const c of candidates) byKind[c.kind] = (byKind[c.kind] ?? 0) + 1;
|
||||
return {
|
||||
version: 1,
|
||||
sourceUrl: ir.doc.sourceUrl,
|
||||
canonicalViewport: ir.doc.canonicalViewport,
|
||||
viewports: ctx.viewports,
|
||||
sampledViewports: ctx.sampledViewports,
|
||||
summary: {
|
||||
totalCandidates: candidates.length,
|
||||
highConfidence: candidates.filter((c) => c.confidence >= 0.82).length,
|
||||
byKind,
|
||||
templateReadyKinds: TEMPLATE_READY,
|
||||
},
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
function regimesSummary(regimes: RecipeResponsiveRegime[]): string {
|
||||
if (!regimes.length) return "no visible regimes";
|
||||
return regimes.map((r) => {
|
||||
const grid = r.columns ? `${r.columns}c/${r.rows ?? 1}r` : r.layout;
|
||||
const items = r.visibleItems !== undefined ? `, ${r.visibleItems} items` : "";
|
||||
return `${r.viewport}: ${grid}${items}`;
|
||||
}).join("; ");
|
||||
}
|
||||
|
||||
export function recipeReportToMarkdown(report: RecipeReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push("# Layout Recipe Report");
|
||||
lines.push("");
|
||||
lines.push(`Source: ${report.sourceUrl}`);
|
||||
lines.push(`Canonical viewport: ${report.canonicalViewport}`);
|
||||
lines.push(`Captured viewports: ${report.viewports.join(", ")}`);
|
||||
lines.push(`Sampled responsive widths: ${report.sampledViewports.length}`);
|
||||
lines.push("");
|
||||
lines.push("## Summary");
|
||||
lines.push("");
|
||||
lines.push(`- Candidates: ${report.summary.totalCandidates}`);
|
||||
lines.push(`- High confidence: ${report.summary.highConfidence}`);
|
||||
lines.push(`- By kind: ${Object.entries(report.summary.byKind).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}`);
|
||||
lines.push("");
|
||||
lines.push("## Candidates");
|
||||
lines.push("");
|
||||
if (!report.candidates.length) {
|
||||
lines.push("No layout recipe candidates were detected.");
|
||||
}
|
||||
for (const c of report.candidates) {
|
||||
lines.push(`### ${c.id}: ${c.kind}`);
|
||||
lines.push("");
|
||||
lines.push(`- Confidence: ${c.confidence} (${c.risk} risk)`);
|
||||
lines.push(`- Root: ${c.rootTag} \`${c.rootCid}\`${c.sectionId ? `, ${c.sectionId} (${c.sectionRole ?? "section"})` : ""}`);
|
||||
if (c.itemParentCid) lines.push(`- Item parent: \`${c.itemParentCid}\``);
|
||||
lines.push(`- Component target: ${c.componentName}${c.dataModel ? ` with \`${c.dataModel}\`` : ""}`);
|
||||
if (c.itemCount !== undefined) lines.push(`- Items: ${c.itemCount}`);
|
||||
lines.push(`- Responsive regimes: ${regimesSummary(c.responsiveRegimes)}`);
|
||||
if (c.signals.length) lines.push(`- Signals: ${c.signals.join("; ")}`);
|
||||
if (c.sourceHints.length) lines.push(`- Source hints: ${c.sourceHints.slice(0, 12).map((h) => `\`${h}\``).join(", ")}`);
|
||||
lines.push(`- Emission: ${c.emissionStatus}; ${c.fallbackReason}`);
|
||||
if (c.repeatedItems?.length) {
|
||||
const sample = c.repeatedItems.slice(0, 5).map((i) => `\`${i.cid}\`${i.textSample ? ` "${i.textSample}"` : ""}`).join(", ");
|
||||
lines.push(`- Item sample: ${sample}${c.repeatedItems.length > 5 ? ", ..." : ""}`);
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trimEnd() + "\n";
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import { round } from "../util/canonical.js";
|
||||
|
||||
/**
|
||||
* Deterministic section detection. Splits the page into visually coherent
|
||||
* top-level blocks using semantic tags and geometry (we do not rely on class
|
||||
* names — those are intentionally dropped from the IR). Sections are metadata:
|
||||
* stable ids + per-viewport bboxes for the layout/section gate. Role guesses are
|
||||
* advisory and never affect fidelity.
|
||||
*/
|
||||
|
||||
export type Section = {
|
||||
id: string; // section-001
|
||||
nodeId: string;
|
||||
role: string;
|
||||
order: number;
|
||||
bboxByVp: Record<number, { x: number; y: number; width: number; height: number }>;
|
||||
};
|
||||
|
||||
const SEMANTIC = new Set(["header", "nav", "main", "section", "article", "footer", "aside"]);
|
||||
|
||||
type Cand = { node: IRNode; path: string; y: number; width: number; height: number };
|
||||
|
||||
export function detectSections(ir: IR): Section[] {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const vpWidth = cw;
|
||||
|
||||
const candidates: Cand[] = [];
|
||||
const walk = (node: IRNode, path: string): void => {
|
||||
const bbox = node.bboxByVp[cw];
|
||||
const visible = node.visibleByVp[cw];
|
||||
if (bbox && visible) {
|
||||
const isSemantic = SEMANTIC.has(node.tag);
|
||||
const isBigBlock = bbox.width >= vpWidth * 0.55 && bbox.height >= 64;
|
||||
const display = node.computedByVp[cw]?.display ?? "";
|
||||
const blockish = !/^(inline|inline-block|none)$/.test(display);
|
||||
if ((isSemantic || isBigBlock) && blockish) {
|
||||
candidates.push({ node, path, y: bbox.y, width: bbox.width, height: bbox.height });
|
||||
}
|
||||
}
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
walk(c, `${path}/${c.id}`);
|
||||
}
|
||||
};
|
||||
walk(ir.root, ir.root.id);
|
||||
|
||||
// Outermost wins: drop candidates nested inside another candidate.
|
||||
const byShallow = candidates.slice().sort((a, b) => a.path.split("/").length - b.path.split("/").length);
|
||||
let kept: Cand[] = [];
|
||||
for (const c of byShallow) {
|
||||
if (kept.some((k) => c.path.startsWith(k.path + "/"))) continue;
|
||||
kept.push(c);
|
||||
}
|
||||
|
||||
// If the only survivor is a giant wrapper, expand into its section-like children.
|
||||
const pageHeight = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
|
||||
kept = expandOversized(kept, vpWidth, pageHeight, cw);
|
||||
|
||||
// Sort by Y, then assign ids/roles.
|
||||
kept.sort((a, b) => a.y - b.y || b.height - a.height);
|
||||
// Deduplicate near-identical y/height wrappers (keep the first/shallowest).
|
||||
const final: Cand[] = [];
|
||||
for (const c of kept) {
|
||||
const dup = final.find((f) => Math.abs(f.y - c.y) < 4 && Math.abs(f.height - c.height) < 4);
|
||||
if (!dup) final.push(c);
|
||||
}
|
||||
|
||||
return final.map((c, i) => ({
|
||||
id: `section-${String(i + 1).padStart(3, "0")}`,
|
||||
nodeId: c.node.id,
|
||||
role: guessRole(c.node, i, final.length),
|
||||
order: i,
|
||||
bboxByVp: bboxesFor(c.node),
|
||||
}));
|
||||
}
|
||||
|
||||
function expandOversized(cands: Cand[], vpWidth: number, pageHeight: number, cw: number): Cand[] {
|
||||
if (cands.length > 2) return cands;
|
||||
const threshold = Math.max(pageHeight * 0.55, 1200);
|
||||
const out: Cand[] = [];
|
||||
for (const c of cands) {
|
||||
if (c.height < threshold) { out.push(c); continue; }
|
||||
const inner: Cand[] = [];
|
||||
const collect = (node: IRNode, path: string, depth: number): void => {
|
||||
if (depth > 6) return;
|
||||
for (const ch of node.children) {
|
||||
if (isTextChild(ch)) continue;
|
||||
const bbox = ch.bboxByVp[cw];
|
||||
if (bbox && ch.visibleByVp[cw] && bbox.width >= vpWidth * 0.5 && bbox.height >= 64) {
|
||||
inner.push({ node: ch, path: `${path}/${ch.id}`, y: bbox.y, width: bbox.width, height: bbox.height });
|
||||
} else {
|
||||
collect(ch, `${path}/${ch.id}`, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(c.node, c.path, 0);
|
||||
// outermost wins within inner
|
||||
const byShallow = inner.slice().sort((a, b) => a.path.split("/").length - b.path.split("/").length);
|
||||
const keptInner: Cand[] = [];
|
||||
for (const ic of byShallow) {
|
||||
if (keptInner.some((k) => ic.path.startsWith(k.path + "/"))) continue;
|
||||
keptInner.push(ic);
|
||||
}
|
||||
if (keptInner.length >= 3) out.push(...keptInner);
|
||||
else out.push(c);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function guessRole(node: IRNode, index: number, total: number): string {
|
||||
if (node.tag === "header") return "header";
|
||||
if (node.tag === "nav") return "nav";
|
||||
if (node.tag === "footer") return "footer";
|
||||
if (node.tag === "main") return "main";
|
||||
if (index === 0) return "hero";
|
||||
if (index === total - 1) return "footer";
|
||||
return "section";
|
||||
}
|
||||
|
||||
function bboxesFor(node: IRNode): Section["bboxByVp"] {
|
||||
const out: Section["bboxByVp"] = {};
|
||||
for (const [vp, b] of Object.entries(node.bboxByVp)) {
|
||||
out[Number(vp)] = { x: round(b.x), y: round(b.y), width: round(b.width), height: round(b.height) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import type { IR, IRNode, StyleMap } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
|
||||
/**
|
||||
* Semantic color tokens (Stage 3.5). Collects every used color with its *usage
|
||||
* context* (text / background / border / on an interactive element / the page
|
||||
* body), clusters near-identical values within the grader's ±2-per-channel color
|
||||
* tolerance (so tokenizing is fidelity-neutral by construction), and assigns
|
||||
* deterministic *semantic* names by role — `--background`, `--foreground`,
|
||||
* `--primary`/`--accent` (the brand color, by chromatic saturation + interactive
|
||||
* usage), `--border`, `--surface`, `--muted-foreground` — falling back to numbered
|
||||
* `--color-NNN` for the rest.
|
||||
*
|
||||
* The names are opinionated; the *values* are exact, so even a "wrong" role label
|
||||
* cannot hurt fidelity. The fidelity engine rewrites `color`/`background-color`/
|
||||
* `border-*-color` to `var(--token)` via `varForColor`; everything else stays literal.
|
||||
*/
|
||||
|
||||
export type ColorToken = { name: string; value: string };
|
||||
export type ColorPalette = {
|
||||
tokens: ColorToken[]; // ordered for :root emission
|
||||
/** A computed color value → `var(--name)`, or null when it isn't tokenized. */
|
||||
varForColor: (value: string) => string | null;
|
||||
css: string; // ":root { --background: …; … }"
|
||||
};
|
||||
|
||||
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]);
|
||||
|
||||
type RGBA = [number, number, number, number];
|
||||
function parseRgb(v: string): RGBA | null {
|
||||
const m = v.match(/rgba?\(([^)]+)\)/i);
|
||||
if (!m) return null;
|
||||
const p = m[1]!.split(/[,\/]/).map((s) => parseFloat(s.trim()));
|
||||
if (p.length < 3 || p.slice(0, 3).some(Number.isNaN)) return null;
|
||||
return [Math.round(p[0]!), Math.round(p[1]!), Math.round(p[2]!), p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1];
|
||||
}
|
||||
function within2(a: RGBA, b: RGBA): boolean {
|
||||
return Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2 && Math.abs(a[2] - b[2]) <= 2 && Math.abs(a[3] - b[3]) <= 0.04;
|
||||
}
|
||||
/** Saturation + lightness (0–1) from RGB, for neutral-vs-chromatic classification. */
|
||||
function satLight(c: RGBA): { s: number; l: number } {
|
||||
const r = c[0] / 255, g = c[1] / 255, b = c[2] / 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
const d = max - min;
|
||||
const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
|
||||
return { s, l };
|
||||
}
|
||||
|
||||
type Usage = { value: string; rgba: RGBA; count: number; text: number; bg: number; border: number; interactive: number };
|
||||
|
||||
function collectUsage(ir: IR): Map<string, Usage> {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const usage = new Map<string, Usage>();
|
||||
const bump = (v: string | undefined, kind: "text" | "bg" | "border", interactive: boolean): void => {
|
||||
if (!v || TRANSPARENT.has(v)) return;
|
||||
const rgba = parseRgb(v);
|
||||
if (!rgba || rgba[3] === 0) return;
|
||||
let u = usage.get(v);
|
||||
if (!u) { u = { value: v, rgba, count: 0, text: 0, bg: 0, border: 0, interactive: 0 }; usage.set(v, u); }
|
||||
u.count++; u[kind]++; if (interactive) u.interactive++;
|
||||
};
|
||||
const walk = (n: IRNode): void => {
|
||||
const cs = n.computedByVp[cw];
|
||||
if (cs && n.visibleByVp[cw]) {
|
||||
const interactive = n.tag === "a" || n.tag === "button" || n.attrs.role === "button";
|
||||
bump(cs.color, "text", interactive);
|
||||
bump(cs.backgroundColor, "bg", interactive);
|
||||
for (const side of ["borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor"] as const) {
|
||||
const w = cs[side.replace("Color", "Width") as keyof StyleMap];
|
||||
if (w && parseFloat(w) > 0) bump(cs[side], "border", interactive);
|
||||
}
|
||||
}
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return usage;
|
||||
}
|
||||
|
||||
/** Greedy cluster: each color joins an existing canonical within ±2, else starts one.
|
||||
* Canonicals are the most-frequent member, so every member is ≤±2 from it. */
|
||||
function cluster(usages: Usage[]): Array<{ canon: Usage; members: string[] }> {
|
||||
const sorted = [...usages].sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
|
||||
const clusters: Array<{ canon: Usage; members: string[]; agg: Usage }> = [];
|
||||
for (const u of sorted) {
|
||||
const hit = clusters.find((c) => within2(c.canon.rgba, u.rgba));
|
||||
if (hit) {
|
||||
hit.members.push(u.value);
|
||||
hit.agg.count += u.count; hit.agg.text += u.text; hit.agg.bg += u.bg; hit.agg.border += u.border; hit.agg.interactive += u.interactive;
|
||||
} else {
|
||||
clusters.push({ canon: u, members: [u.value], agg: { ...u } });
|
||||
}
|
||||
}
|
||||
return clusters.map((c) => ({ canon: { ...c.agg, value: c.canon.value, rgba: c.canon.rgba }, members: c.members }));
|
||||
}
|
||||
|
||||
/** Single-page palette. */
|
||||
export function buildColorPalette(ir: IR, opts?: { minCount?: number }): ColorPalette {
|
||||
return paletteFrom(collectUsage(ir), ir, opts?.minCount ?? 3);
|
||||
}
|
||||
|
||||
/** Site-wide palette: union color usage across all routes so the whole site shares
|
||||
* one `--primary`/`--foreground`/… ; `roleIr` (the entry) supplies the body bg/fg. */
|
||||
export function buildSiteColorPalette(irs: IR[], roleIr: IR, opts?: { minCount?: number }): ColorPalette {
|
||||
const merged = new Map<string, Usage>();
|
||||
for (const ir of irs) {
|
||||
for (const [k, u] of collectUsage(ir)) {
|
||||
const e = merged.get(k);
|
||||
if (!e) merged.set(k, { ...u });
|
||||
else { e.count += u.count; e.text += u.text; e.bg += u.bg; e.border += u.border; e.interactive += u.interactive; }
|
||||
}
|
||||
}
|
||||
return paletteFrom(merged, roleIr, opts?.minCount ?? 3);
|
||||
}
|
||||
|
||||
function paletteFrom(usage: Map<string, Usage>, roleIr: IR, minCount: number): ColorPalette {
|
||||
const cw = roleIr.doc.canonicalViewport;
|
||||
const pv = roleIr.doc.perViewport[cw];
|
||||
const clusters = cluster([...usage.values()]);
|
||||
|
||||
const valueToName = new Map<string, string>(); // every member value → token name
|
||||
const tokens: ColorToken[] = [];
|
||||
const taken = new Set<string>();
|
||||
const assign = (name: string, c: { canon: Usage; members: string[] }): void => {
|
||||
if (taken.has(name)) return;
|
||||
taken.add(name);
|
||||
tokens.push({ name, value: c.canon.value });
|
||||
for (const m of c.members) valueToName.set(m, name);
|
||||
};
|
||||
const findCluster = (val: string | undefined): { canon: Usage; members: string[] } | undefined => {
|
||||
if (!val) return undefined;
|
||||
const rgba = parseRgb(val);
|
||||
if (!rgba) return undefined;
|
||||
return clusters.find((c) => within2(c.canon.rgba, rgba));
|
||||
};
|
||||
|
||||
// 1) Always-named roles: page background + primary foreground.
|
||||
const bgCluster = findCluster(pv?.bodyBg && !TRANSPARENT.has(pv.bodyBg) ? pv.bodyBg : "rgb(255, 255, 255)");
|
||||
if (bgCluster) assign("--background", bgCluster);
|
||||
const fgCluster = findCluster(pv?.bodyColor);
|
||||
if (fgCluster) assign("--foreground", fgCluster);
|
||||
|
||||
// 2) Primary/accent: most-used chromatic color, weighted toward interactive usage.
|
||||
const remaining = clusters.filter((c) => !c.members.some((m) => valueToName.has(m)));
|
||||
const chromatic = remaining
|
||||
.filter((c) => { const { s } = satLight(c.canon.rgba); return s >= 0.25; })
|
||||
.sort((a, b) => (b.canon.interactive * 3 + b.canon.count) - (a.canon.interactive * 3 + a.canon.count));
|
||||
if (chromatic[0]) assign("--primary", chromatic[0]);
|
||||
if (chromatic[1]) assign("--accent", chromatic[1]);
|
||||
|
||||
// 3) Border: most-used color that appears predominantly as a border.
|
||||
const borderC = remaining
|
||||
.filter((c) => !c.members.some((m) => valueToName.has(m)) && c.canon.border > 0)
|
||||
.sort((a, b) => b.canon.border - a.canon.border)[0];
|
||||
if (borderC && borderC.canon.border >= Math.max(2, minCount - 1)) assign("--border", borderC);
|
||||
|
||||
// 4) Neutrals: a light neutral used as a background → surface; a mid neutral text → muted.
|
||||
for (const c of remaining) {
|
||||
if (c.members.some((m) => valueToName.has(m))) continue;
|
||||
if (c.canon.count < minCount) continue;
|
||||
const { s, l } = satLight(c.canon.rgba);
|
||||
if (s < 0.15 && c.canon.bg >= c.canon.text && l > 0.85 && !taken.has("--surface")) assign("--surface", c);
|
||||
else if (s < 0.2 && c.canon.text >= c.canon.bg && l > 0.35 && l < 0.7 && !taken.has("--muted-foreground")) assign("--muted-foreground", c);
|
||||
}
|
||||
|
||||
// 5) Everything else used >= minCount → numbered.
|
||||
let ci = 1;
|
||||
for (const c of remaining) {
|
||||
if (c.members.some((m) => valueToName.has(m))) continue;
|
||||
if (c.canon.count < minCount) continue;
|
||||
assign(`--color-${String(ci++).padStart(3, "0")}`, c);
|
||||
}
|
||||
|
||||
const varForColor = (value: string): string | null => {
|
||||
const name = valueToName.get(value);
|
||||
if (name) return `var(${name})`;
|
||||
const c = findCluster(value); // tolerate values within ±2 of a tokenized canonical
|
||||
const m = c?.members.find((x) => valueToName.has(x));
|
||||
return m ? `var(${valueToName.get(m)!})` : null;
|
||||
};
|
||||
|
||||
const css = tokens.length
|
||||
? ":root {\n" + tokens.map((t) => ` ${t.name}: ${t.value};`).join("\n") + "\n}\n"
|
||||
: "";
|
||||
|
||||
return { tokens, varForColor, css };
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
|
||||
/**
|
||||
* Usage-based deterministic design-token extraction. Builds computed-style
|
||||
* histograms over the canonical viewport and promotes values used >= 3 times
|
||||
* (page background, primary text color, and primary font family are always
|
||||
* promoted). Tokens are stable and explainable rather than clever.
|
||||
*/
|
||||
|
||||
export type Tokens = {
|
||||
colors: Record<string, string>;
|
||||
fonts: Record<string, string>;
|
||||
fontSizes: Record<string, string>;
|
||||
fontWeights: Record<string, string>;
|
||||
lineHeights: Record<string, string>;
|
||||
spacing: Record<string, string>;
|
||||
radii: Record<string, string>;
|
||||
shadows: Record<string, string>;
|
||||
zIndices: Record<string, string>;
|
||||
breakpoints: Record<string, string>;
|
||||
};
|
||||
|
||||
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)"]);
|
||||
|
||||
function hist(): Map<string, number> { return new Map(); }
|
||||
function bump(m: Map<string, number>, v: string | undefined): void {
|
||||
if (!v) return;
|
||||
m.set(v, (m.get(v) ?? 0) + 1);
|
||||
}
|
||||
function topSorted(m: Map<string, number>, min = 3): Array<[string, number]> {
|
||||
return [...m.entries()]
|
||||
.filter(([, c]) => c >= min)
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
}
|
||||
|
||||
function num(v: string | undefined): number {
|
||||
if (!v) return NaN;
|
||||
const m = /(-?\d+(\.\d+)?)/.exec(v);
|
||||
return m ? parseFloat(m[1]!) : NaN;
|
||||
}
|
||||
|
||||
export function extractTokens(ir: IR): Tokens {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const colors = hist();
|
||||
const fonts = hist();
|
||||
const fontSizes = hist();
|
||||
const fontWeights = hist();
|
||||
const lineHeights = hist();
|
||||
const spacing = hist();
|
||||
const radii = hist();
|
||||
const shadows = hist();
|
||||
const zIndices = hist();
|
||||
|
||||
const visit = (node: IRNode): void => {
|
||||
const cs = node.computedByVp[cw];
|
||||
if (cs && node.visibleByVp[cw]) {
|
||||
if (cs.color && !TRANSPARENT.has(cs.color)) bump(colors, cs.color);
|
||||
if (cs.backgroundColor && !TRANSPARENT.has(cs.backgroundColor)) bump(colors, cs.backgroundColor);
|
||||
bump(fonts, cs.fontFamily);
|
||||
bump(fontSizes, cs.fontSize);
|
||||
bump(fontWeights, cs.fontWeight);
|
||||
if (cs.lineHeight && cs.lineHeight !== "normal") bump(lineHeights, cs.lineHeight);
|
||||
for (const p of ["paddingTop", "paddingBottom", "paddingLeft", "paddingRight", "marginTop", "marginBottom", "gap"] as const) {
|
||||
const v = cs[p];
|
||||
if (v && v !== "0px" && /px$/.test(v)) bump(spacing, v);
|
||||
}
|
||||
for (const p of ["borderTopLeftRadius", "borderTopRightRadius"] as const) {
|
||||
const v = cs[p];
|
||||
if (v && v !== "0px") bump(radii, v);
|
||||
}
|
||||
if (cs.boxShadow && cs.boxShadow !== "none") bump(shadows, cs.boxShadow);
|
||||
if (cs.zIndex && cs.zIndex !== "auto") bump(zIndices, cs.zIndex);
|
||||
}
|
||||
for (const c of node.children) if (!isTextChild(c)) visit(c);
|
||||
};
|
||||
visit(ir.root);
|
||||
|
||||
const pv = ir.doc.perViewport[cw];
|
||||
|
||||
// Colors: always promote page bg + primary text; then the rest by frequency.
|
||||
const colorTokens: Record<string, string> = {};
|
||||
const pageBg = pv?.bodyBg && !TRANSPARENT.has(pv.bodyBg) ? pv.bodyBg : "rgb(255, 255, 255)";
|
||||
colorTokens["--color-bg-page"] = pageBg;
|
||||
const colorRanked = topSorted(colors, 1).map(([v]) => v);
|
||||
const primaryText = pv?.bodyColor && !TRANSPARENT.has(pv.bodyColor) ? pv.bodyColor : colorRanked[0];
|
||||
if (primaryText) colorTokens["--color-text-primary"] = primaryText;
|
||||
let ci = 1;
|
||||
const usedColors = new Set([pageBg, primaryText]);
|
||||
for (const [v] of topSorted(colors, 3)) {
|
||||
if (usedColors.has(v)) continue;
|
||||
usedColors.add(v);
|
||||
colorTokens[`--color-${String(ci++).padStart(3, "0")}`] = v;
|
||||
}
|
||||
|
||||
const fontTokens: Record<string, string> = {};
|
||||
const fontRanked = topSorted(fonts, 1).map(([v]) => v);
|
||||
if (fontRanked[0]) fontTokens["--font-body"] = fontRanked[0];
|
||||
let fi = 1;
|
||||
for (const [v] of topSorted(fonts, 3)) {
|
||||
if (v === fontRanked[0]) continue;
|
||||
fontTokens[`--font-${String(fi++).padStart(3, "0")}`] = v;
|
||||
}
|
||||
|
||||
const numberedByValue = (m: Map<string, number>, prefix: string): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
const promoted = topSorted(m, 3).map(([v]) => v).sort((a, b) => num(a) - num(b));
|
||||
promoted.forEach((v, i) => { out[`${prefix}-${String(i + 1).padStart(3, "0")}`] = v; });
|
||||
return out;
|
||||
};
|
||||
const numberedByFreq = (m: Map<string, number>, prefix: string): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
topSorted(m, 3).forEach(([v], i) => { out[`${prefix}-${String(i + 1).padStart(3, "0")}`] = v; });
|
||||
return out;
|
||||
};
|
||||
|
||||
const breakpoints: Record<string, string> = {};
|
||||
ir.doc.viewports.forEach((vp) => { breakpoints[`--bp-${vp}`] = `${vp}px`; });
|
||||
|
||||
return {
|
||||
colors: colorTokens,
|
||||
fonts: fontTokens,
|
||||
fontSizes: numberedByValue(fontSizes, "--font-size"),
|
||||
fontWeights: numberedByValue(fontWeights, "--font-weight"),
|
||||
lineHeights: numberedByFreq(lineHeights, "--line-height"),
|
||||
spacing: numberedByValue(spacing, "--space"),
|
||||
radii: numberedByValue(radii, "--radius"),
|
||||
shadows: numberedByFreq(shadows, "--shadow"),
|
||||
zIndices: numberedByValue(zIndices, "--z"),
|
||||
breakpoints,
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a (kebab CSS prop, value) to its `var(--token)` reference, or null. Exact
|
||||
* match only — typography/spacing/radius/shadow/z tokens hold the literal computed value,
|
||||
* so referencing them is byte-exact (fidelity-neutral). Colors are handled separately by
|
||||
* the semantic palette (semanticTokens), so they are intentionally NOT resolved here. */
|
||||
export type TokenResolver = (prop: string, value: string) => string | null;
|
||||
export function buildTokenResolver(tokens: Tokens): TokenResolver {
|
||||
const inv = (group: Record<string, string>): Map<string, string> => {
|
||||
const m = new Map<string, string>();
|
||||
for (const [name, val] of Object.entries(group)) if (!m.has(val)) m.set(val, name);
|
||||
return m;
|
||||
};
|
||||
const fonts = inv(tokens.fonts), fontSizes = inv(tokens.fontSizes), fontWeights = inv(tokens.fontWeights),
|
||||
lineHeights = inv(tokens.lineHeights), spacing = inv(tokens.spacing), radii = inv(tokens.radii),
|
||||
shadows = inv(tokens.shadows), zIndices = inv(tokens.zIndices);
|
||||
return (prop, value) => {
|
||||
let m: Map<string, string> | null = null;
|
||||
if (prop === "font-family") m = fonts;
|
||||
else if (prop === "font-size") m = fontSizes;
|
||||
else if (prop === "font-weight") m = fontWeights;
|
||||
else if (prop === "line-height") m = lineHeights;
|
||||
else if (/^(padding|margin)-(top|right|bottom|left)$/.test(prop) || prop === "gap" || prop === "row-gap" || prop === "column-gap") m = spacing;
|
||||
else if (/^border-(top|bottom)-(left|right)-radius$/.test(prop)) m = radii;
|
||||
else if (prop === "box-shadow") m = shadows;
|
||||
else if (prop === "z-index") m = zIndices;
|
||||
if (!m) return null;
|
||||
const name = m.get(value);
|
||||
return name ? `var(${name})` : null;
|
||||
};
|
||||
}
|
||||
|
||||
export function tokensToCss(tokens: Tokens, skipColors = false): string {
|
||||
const lines: string[] = [":root {"];
|
||||
const groups: Array<[string, Record<string, string>]> = [
|
||||
...(skipColors ? [] : [["colors", tokens.colors] as [string, Record<string, string>]]),
|
||||
["fonts", tokens.fonts], ["fontSizes", tokens.fontSizes],
|
||||
["fontWeights", tokens.fontWeights], ["lineHeights", tokens.lineHeights],
|
||||
["spacing", tokens.spacing], ["radii", tokens.radii], ["shadows", tokens.shadows],
|
||||
["zIndices", tokens.zIndices], ["breakpoints", tokens.breakpoints],
|
||||
];
|
||||
for (const [label, group] of groups) {
|
||||
const keys = Object.keys(group);
|
||||
if (keys.length === 0) continue;
|
||||
lines.push(` /* ${label} */`);
|
||||
for (const k of keys) lines.push(` ${k}: ${group[k]};`);
|
||||
}
|
||||
lines.push("}");
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
Reference in New Issue
Block a user