Initial commit

This commit is contained in:
Samraaj Bath
2026-06-29 15:11:48 -07:00
commit 66dfdcc58d
404 changed files with 45970 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
// Local capture bridge: Chromium → (this HTTP proxy, TLS-terminated locally) → Node
// CONNECT-tunnel through the agent proxy → origin. Exists because Chromium 141's TLS
// ClientHello is closed by the egress proxy during the handshake, while Node/curl/openssl
// ClientHellos are accepted. Chromium talks plaintext-after-TLS to us (we present a static
// self-signed cert; capture runs with ignoreHTTPSErrors so host/CA mismatch is fine), and
// WE re-originate every request over a Node TLS socket the proxy is happy with.
import net from "node:net";
import http from "node:http";
import tls from "node:tls";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
const DIR = dirname(fileURLToPath(import.meta.url));
const KEY = fs.readFileSync(join(DIR, "key.pem"));
const CERT = fs.readFileSync(join(DIR, "cert.pem"));
const CA = fs.readFileSync("/root/.ccr/ca-bundle.crt");
const LISTEN_PORT = Number(process.env.BRIDGE_PORT || 8899);
// The real egress proxy we tunnel through (the one Chromium can't TLS-handshake with).
const upstream = new URL(process.env.UPSTREAM_PROXY || "http://127.0.0.1:37671");
const UP_HOST = upstream.hostname;
const UP_PORT = Number(upstream.port || 80);
/** Open a socket to `host:port` by CONNECT-tunnelling through the agent proxy, then (for
* 443) upgrading to TLS that the proxy accepts. Calls back with the ready socket. */
function tunnel(host, port, cb) {
const raw = net.connect(UP_PORT, UP_HOST);
let settled = false;
const fail = (e) => { if (!settled) { settled = true; cb(e); } raw.destroy(); };
raw.once("error", fail);
raw.on("connect", () => {
raw.write(`CONNECT ${host}:${port} HTTP/1.1\r\nHost: ${host}:${port}\r\n\r\n`);
});
let buf = Buffer.alloc(0);
const onData = (d) => {
buf = Buffer.concat([buf, d]);
const i = buf.indexOf("\r\n\r\n");
if (i < 0) return;
raw.removeListener("data", onData);
const status = buf.slice(0, buf.indexOf("\r\n")).toString();
if (!/ 200 /.test(status)) return fail(new Error(`upstream CONNECT ${host}:${port}${status}`));
raw.removeListener("error", fail);
if (port === 443) {
const t = tls.connect({ socket: raw, servername: host, ca: CA, ALPNProtocols: ["http/1.1"] }, () => {
if (!settled) { settled = true; cb(null, t); }
});
t.once("error", (e) => { if (!settled) { settled = true; cb(e); } });
} else {
if (!settled) { settled = true; cb(null, raw); }
}
};
raw.on("data", onData);
}
// An internal HTTP server that PARSES the plaintext requests Chromium sends us (after we
// TLS-terminate its tunnel) and re-issues them to the origin over a fresh upstream tunnel.
const origin = http.createServer((creq, cres) => {
const host = creq.socket.__host;
const opts = {
method: creq.method,
path: creq.url,
headers: { ...creq.headers, host: creq.headers.host || host, connection: "close" },
createConnection: (_o, cb) => tunnel(host, 443, cb),
};
const preq = http.request(opts, (pres) => {
cres.writeHead(pres.statusCode || 502, pres.headers);
pres.pipe(cres);
});
preq.once("error", (e) => { if (!cres.headersSent) cres.writeHead(502); cres.end("bridge upstream error: " + e.message); });
creq.pipe(preq);
});
origin.on("clientError", (_e, sock) => sock.destroy());
const proxy = http.createServer((req, res) => {
// Plain-HTTP proxied request (rare for these sites): forward host:80 through the tunnel.
const u = new URL(req.url);
const opts = { method: req.method, path: u.pathname + u.search, headers: { ...req.headers, connection: "close" },
createConnection: (_o, cb) => tunnel(u.hostname, Number(u.port || 80), cb) };
const preq = http.request(opts, (pres) => { res.writeHead(pres.statusCode || 502, pres.headers); pres.pipe(res); });
preq.once("error", (e) => { if (!res.headersSent) res.writeHead(502); res.end("bridge error: " + e.message); });
req.pipe(preq);
});
proxy.on("connect", (req, clientSocket) => {
const [host] = req.url.split(":");
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
// We are the TLS *server* for Chromium's tunnel; present our static cert.
const tlsSock = new tls.TLSSocket(clientSocket, { isServer: true, key: KEY, cert: CERT });
tlsSock.__host = host;
tlsSock.on("error", () => tlsSock.destroy());
// Hand the decrypted byte stream to the internal HTTP parser.
origin.emit("connection", tlsSock);
});
proxy.on("clientError", (_e, sock) => sock.destroy());
proxy.listen(LISTEN_PORT, "127.0.0.1", () => {
console.log(`bridge listening on http://127.0.0.1:${LISTEN_PORT} → upstream ${UP_HOST}:${UP_PORT}`);
});
+2798
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "clone-build-harness",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "next build"
},
"dependencies": {
"next": "14.2.21",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.4",
"@tailwindcss/postcss": "^4.3.1",
"@types/node": "22.10.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"tailwindcss": "^4.3.1",
"typescript": "5.7.3",
"vite": "^5.4.11"
}
}
+1
View File
@@ -0,0 +1 @@
export default { plugins: { "@tailwindcss/postcss": {} } };
+17
View File
@@ -0,0 +1,17 @@
{
"name": "clone-build-harness",
"version": "0.1.0",
"private": true,
"scripts": { "build": "next build" },
"dependencies": {
"next": "14.2.21",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"typescript": "5.7.3",
"@types/node": "22.10.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5"
}
}
+50
View File
@@ -0,0 +1,50 @@
# ditto.site Compiler
This workspace contains the deterministic compiler used by ditto.site. It captures
a source URL, builds a render IR, infers assets/fonts/tokens/sections/recipes/SEO,
generates a Next.js App Router app by default or a Vite React app on request, and
validates the result with deterministic gates.
The full architecture overview lives in [../README.md](../README.md). This file
keeps only compiler-local commands and notes.
## Commands
```bash
cd compiler
npm install
npx playwright install chromium
npm run clone -- https://example.com/
npm run clone -- https://example.com/ --mode=multi --styling=tailwind
npm run clone -- https://example.com/ --mode=single --framework=vite
npm run clone-site -- https://example.com/
npm run validate-site -- ../runs/site-example.com/<timestamp>
npm run clone -- https://example.com/ --mode=multi --concurrency=5
npm run clone -- https://example.com/ --mode=multi --validate --validate-concurrency=3 --viewport-concurrency=2
npm run validate-site -- ../runs/site-example.com/<timestamp> --validate-concurrency=3 --viewport-concurrency=2
npm run bench -- --tier=easy
npm run bench-site
npm run quality -- ../runs/example.com/<timestamp>
npm run audit -- ../runs/example.com/<timestamp>
npm test
npm run typecheck
```
Root-level scripts forward to these commands, so `npm run clone -- <url>` works
from the repository root too.
Multi-page generation defaults to the fast no-validation path. Use `--validate`
when the clone command itself should run the full build/render/gates QA pass, or
run `validate-site` separately. `--concurrency` controls source route capture;
`--validate-concurrency` controls how many routes validation grades at once; and
`--viewport-concurrency` controls how many clone viewports each route renders at
once.
## Generated App Shape
Default Next generated apps use `src/app/ditto.css` and optional helpers under
`src/app/ditto/`. Vite generated apps use `src/ditto.css` and optional helpers
under `src/ditto/`, with multi-route pages under `src/routes/`. Validation builds
keep `data-cid` attributes for source/clone alignment; delivered apps strip those
validation ids and keep only required `data-ditto-id` anchors.
+26
View File
@@ -0,0 +1,26 @@
[
{ "id": "easy-001", "url": "https://www.michaelcole.me/", "tier": "easy", "notes": "portfolio" },
{ "id": "easy-002", "url": "https://f.inc/", "tier": "easy", "notes": "company/landing" },
{ "id": "easy-003", "url": "https://www.arkli.io/", "tier": "easy", "notes": "SaaS/landing" },
{ "id": "easy-004", "url": "https://gist-quiz.com/", "tier": "easy", "notes": "app landing" },
{ "id": "easy-005", "url": "https://brittanychiang.com/", "tier": "easy", "notes": "portfolio" },
{ "id": "easy-006", "url": "https://leerob.io/", "tier": "easy", "notes": "personal" },
{ "id": "easy-007", "url": "https://paco.me/", "tier": "easy", "notes": "personal" },
{ "id": "easy-008", "url": "https://rauno.me/", "tier": "easy", "notes": "portfolio/personal" },
{ "id": "easy-009", "url": "https://sive.rs/", "tier": "easy", "notes": "personal/content" },
{ "id": "easy-010", "url": "https://daringfireball.net/", "tier": "easy", "notes": "content-heavy" },
{ "id": "easy-011", "url": "https://www.julian.com/", "tier": "easy", "notes": "personal" },
{ "id": "easy-012", "url": "https://ianlunn.co.uk/", "tier": "easy", "notes": "portfolio" },
{ "id": "easy-013", "url": "https://adamwathan.me/", "tier": "easy", "notes": "personal/portfolio" },
{ "id": "easy-014", "url": "https://www.joshwcomeau.com/", "tier": "easy", "notes": "personal/content" },
{ "id": "easy-015", "url": "https://emilkowal.ski/", "tier": "easy", "notes": "portfolio/personal" },
{ "id": "easy-016", "url": "https://bradfrost.com/", "tier": "easy", "notes": "content/personal" },
{ "id": "easy-017", "url": "https://maggieappleton.com/", "tier": "easy", "notes": "portfolio/content" },
{ "id": "easy-018", "url": "https://www.robinwieruch.de/", "tier": "easy", "notes": "content/personal" },
{ "id": "easy-019", "url": "https://www.karlsutt.com/", "tier": "easy", "notes": "portfolio" },
{ "id": "easy-020", "url": "https://www.maxbo.me/", "tier": "easy", "notes": "portfolio/personal" },
{ "id": "easy-021", "url": "https://www.olaolu.dev/", "tier": "easy", "notes": "portfolio" },
{ "id": "easy-022", "url": "https://www.taniarascia.com/", "tier": "easy", "notes": "content/personal" },
{ "id": "easy-023", "url": "https://kentcdodds.com/", "tier": "easy", "notes": "content/personal" },
{ "id": "easy-024", "url": "https://www.swyx.io/", "tier": "easy", "notes": "content/personal" }
]
+194
View File
@@ -0,0 +1,194 @@
[
{
"id": "hard-001",
"url": "https://www.paulgraham.com/",
"tier": "hard",
"notes": "1996 nested-table/font typography"
},
{
"id": "hard-002",
"url": "https://news.ycombinator.com/",
"tier": "hard",
"notes": "dense table layout"
},
{
"id": "hard-003",
"url": "https://en.wikipedia.org/wiki/Cascading_Style_Sheets",
"tier": "hard",
"notes": "heavy nested DOM article"
},
{
"id": "hard-004",
"url": "https://tailwindcss.com/",
"tier": "hard",
"notes": "dense component showcase"
},
{
"id": "hard-005",
"url": "https://developer.mozilla.org/en-US/",
"tier": "hard",
"notes": "dense docs"
},
{
"id": "hard-006",
"url": "https://stripe.com/",
"tier": "hard",
"notes": "dense, gradients, complex"
},
{
"id": "hard-007",
"url": "https://github.com/",
"tier": "hard",
"notes": "dense landing"
},
{
"id": "hard-008",
"url": "https://www.apple.com/",
"tier": "hard",
"notes": "complex typography, scroll anim"
},
{
"id": "hard-009",
"url": "https://www.engadget.com/",
"tier": "hard",
"notes": "tech news, dense"
},
{
"id": "hard-010",
"url": "https://www.theregister.com/",
"tier": "hard",
"notes": "tech news, dense"
},
{
"id": "hard-011",
"url": "https://react.dev/",
"tier": "hard",
"notes": "docs, dense"
},
{
"id": "hard-012",
"url": "https://www.netlify.com/",
"tier": "hard",
"notes": "dense marketing"
},
{
"id": "hard-013",
"url": "https://www.smashingmagazine.com/",
"tier": "hard",
"notes": "dense editorial"
},
{
"id": "hard-014",
"url": "https://css-tricks.com/",
"tier": "hard",
"notes": "dense content"
},
{
"id": "hard-015",
"url": "https://www.sitepoint.com/",
"tier": "hard",
"notes": "editorial, dense"
},
{
"id": "hard-016",
"url": "https://dev.to/",
"tier": "hard",
"notes": "dense content feed"
},
{
"id": "hard-017",
"url": "https://www.producthunt.com/",
"tier": "hard",
"notes": "dense product feed"
},
{
"id": "hard-018",
"url": "https://www.awwwards.com/",
"tier": "hard",
"notes": "design gallery"
},
{
"id": "hard-019",
"url": "https://medium.com/",
"tier": "hard",
"notes": "editorial typography"
},
{
"id": "hard-020",
"url": "https://hackernoon.com/",
"tier": "hard",
"notes": "editorial feed, dense"
},
{
"id": "hard-021",
"url": "https://www.gov.uk/",
"tier": "hard",
"notes": "dense govt, static"
},
{
"id": "hard-022",
"url": "https://blog.cloudflare.com/",
"tier": "hard",
"notes": "dense technical blog"
},
{
"id": "hard-023",
"url": "https://www.nasa.gov/",
"tier": "hard",
"notes": "dense, many media assets"
},
{
"id": "hard-024",
"url": "https://www.mozilla.org/en-US/",
"tier": "hard",
"notes": "dense marketing"
},
{
"id": "hard-025",
"url": "https://web.dev/",
"tier": "hard",
"notes": "dense docs/articles"
},
{
"id": "hard-026",
"url": "https://www.nike.com/",
"tier": "hard",
"notes": "ecommerce; dense, popups"
},
{
"id": "hard-027",
"url": "https://www.zara.com/",
"tier": "hard",
"notes": "ecommerce; minimalist complex grid"
},
{
"id": "hard-028",
"url": "https://www.ssense.com/",
"tier": "hard",
"notes": "ecommerce; editorial luxury, dense"
},
{
"id": "hard-029",
"url": "https://www.gymshark.com/",
"tier": "hard",
"notes": "ecommerce; Shopify DTC, popups"
},
{
"id": "hard-030",
"url": "https://www.sephora.com/",
"tier": "hard",
"notes": "ecommerce; very dense, popups"
},
{
"id": "hard-031",
"url": "https://www.etsy.com/",
"tier": "hard",
"notes": "ecommerce; dense product grid"
},
{
"id": "hard-032",
"url": "https://www.uniqlo.com/us/en/",
"tier": "hard",
"notes": "ecommerce; dense, complex"
}
]
+25
View File
@@ -0,0 +1,25 @@
[
{ "id": "medium-002", "url": "https://origami.chat/", "tier": "medium", "notes": "SaaS" },
{ "id": "medium-004", "url": "https://www.tryskylink.com/", "tier": "medium", "notes": "SaaS" },
{ "id": "medium-005", "url": "https://linear.app/", "tier": "medium", "notes": "SaaS marketing" },
{ "id": "medium-006", "url": "https://www.raycast.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-007", "url": "https://supabase.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-008", "url": "https://retool.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-009", "url": "https://www.warp.dev/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-010", "url": "https://plaid.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-011", "url": "https://www.loom.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-012", "url": "https://webflow.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-013", "url": "https://www.notion.com/product", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-014", "url": "https://slack.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-015", "url": "https://www.figma.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-016", "url": "https://www.attio.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-017", "url": "https://www.airtable.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-018", "url": "https://www.deel.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-019", "url": "https://mercury.com/", "tier": "medium", "notes": "SaaS/fintech" },
{ "id": "medium-020", "url": "https://asana.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-021", "url": "https://replit.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-022", "url": "https://vercel.com/", "tier": "medium", "notes": "SaaS/product" },
{ "id": "medium-023", "url": "https://www.shopify.com/", "tier": "medium", "notes": "SaaS/ecommerce" },
{ "id": "medium-024", "url": "https://www.allbirds.com/", "tier": "medium", "notes": "ecommerce" },
{ "id": "medium-025", "url": "https://www.glossier.com/", "tier": "medium", "notes": "ecommerce" }
]
+8
View File
@@ -0,0 +1,8 @@
[
{ "id": "motion-001", "url": "https://rauno.me/", "tier": "motion", "notes": "personal site, ~7 CSS @keyframes animations + many transitions" },
{ "id": "motion-002", "url": "https://www.sive.rs/", "tier": "motion", "notes": "minimal content site, light motion (regression baseline)" },
{ "id": "motion-003", "url": "https://emilkowal.ski/", "tier": "motion", "notes": "animation-specialist site; entrance motion (some WAAPI finishes before capture)" },
{ "id": "motion-004", "url": "https://leerob.io/", "tier": "motion", "notes": "personal site, subtle CSS transitions/animations" },
{ "id": "motion-005", "url": "https://www.joshwcomeau.com/", "tier": "motion", "notes": "playful CSS animations (hero canvas is out of scope, frozen)" },
{ "id": "motion-006", "url": "https://framer.com/", "tier": "motion", "notes": "Framer Motion (WAAPI) + 16 infinite CSS keyframes — declarative-motion showcase" }
]
+30
View File
@@ -0,0 +1,30 @@
[
{
"id": "site-overreacted",
"url": "https://overreacted.io/",
"notes": "flat root-level blog collection (/:slug); home acts as the listing. Tests root-collection collapse + structural confirmation (home vs post)."
},
{
"id": "site-brew",
"url": "https://brew.sh/",
"notes": "tiny site: home + blog listing + one post. Pure multi-page singletons + shared chrome baseline."
},
{
"id": "site-jamstack",
"url": "https://jamstack.org/",
"maxRoutes": 12,
"notes": "directory site: large /generators, /headless-cms, /glossary collections collapsed to listing + representative."
},
{
"id": "site-gatsby",
"url": "https://www.gatsbyjs.com/",
"notes": "marketing + /docs collection + a /contributing pair kept whole; route-cap exercise."
},
{
"id": "site-11ty",
"url": "https://www.11ty.dev/",
"maxRoutes": 12,
"maxCollectionInstances": 500,
"notes": "docs+blog multi-collection incl. nested /docs/:id/:id and an 882-instance authors directory (listing capped via maxCollectionInstances)."
}
]
+27
View File
@@ -0,0 +1,27 @@
[
{ "id": "stage2-001", "url": "https://www.figma.com/", "tier": "stage2", "notes": "cookie consent (OneTrust), product hero, animation" },
{ "id": "stage2-002", "url": "https://www.duolingo.com/", "tier": "stage2", "notes": "cookie banner, heavy mascot animation" },
{ "id": "stage2-003", "url": "https://monday.com/", "tier": "stage2", "notes": "cookie consent, colorful marketing, video" },
{ "id": "stage2-004", "url": "https://www.intercom.com/", "tier": "stage2", "notes": "cookie banner, marketing, scroll reveals" },
{ "id": "stage2-005", "url": "https://www.grammarly.com/", "tier": "stage2", "notes": "cookie consent, marketing animation" },
{ "id": "stage2-006", "url": "https://www.allbirds.com/", "tier": "stage2", "notes": "DTC: newsletter modal, cookie banner" },
{ "id": "stage2-007", "url": "https://www.glossier.com/", "tier": "stage2", "notes": "DTC: email-capture popup" },
{ "id": "stage2-008", "url": "https://www.warbyparker.com/", "tier": "stage2", "notes": "DTC: popups, hero media" },
{ "id": "stage2-009", "url": "https://casper.com/", "tier": "stage2", "notes": "DTC: newsletter modal, sticky offers" },
{ "id": "stage2-010", "url": "https://www.brooklinen.com/", "tier": "stage2", "notes": "DTC: email popup, promo banner" },
{ "id": "stage2-011", "url": "https://ruggable.com/", "tier": "stage2", "notes": "DTC: spin-to-win / email popup" },
{ "id": "stage2-012", "url": "https://bombas.com/", "tier": "stage2", "notes": "DTC: email-capture modal" },
{ "id": "stage2-013", "url": "https://webflow.com/", "tier": "stage2", "notes": "hero video, scroll animation" },
{ "id": "stage2-014", "url": "https://www.squarespace.com/", "tier": "stage2", "notes": "autoplay hero video" },
{ "id": "stage2-015", "url": "https://www.wix.com/", "tier": "stage2", "notes": "popups, hero video, heavy JS" },
{ "id": "stage2-016", "url": "https://www.descript.com/", "tier": "stage2", "notes": "hero/product video, animation" },
{ "id": "stage2-017", "url": "https://www.clay.com/", "tier": "stage2", "notes": "hero video, scroll reveals" },
{ "id": "stage2-018", "url": "https://runwayml.com/", "tier": "stage2", "notes": "video-heavy, dynamic media" },
{ "id": "stage2-019", "url": "https://www.framer.com/", "tier": "stage2", "notes": "heavy entrance/scroll animation, video" },
{ "id": "stage2-020", "url": "https://vercel.com/", "tier": "stage2", "notes": "scroll-reveal animation, gradients" },
{ "id": "stage2-021", "url": "https://mailchimp.com/", "tier": "stage2", "notes": "illustrative animation, consent" },
{ "id": "stage2-022", "url": "https://resend.com/", "tier": "stage2", "notes": "animation, dark gradients" },
{ "id": "stage2-023", "url": "https://linear.app/", "tier": "stage2", "notes": "entrance animation, hero video" },
{ "id": "stage2-024", "url": "https://www.everlane.com/", "tier": "stage2", "notes": "DTC: newsletter popup, stable product grid" },
{ "id": "stage2-025", "url": "https://posthog.com/", "tier": "stage2", "notes": "cookie banner, quirky marketing animation, stable" }
]
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="12" fill="#123456"/>
<path d="M18 39h28v6H18zM18 19h28v6H18zM18 29h20v6H18z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 196 B

+72
View File
@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Carousel fixture</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
main { max-width: 760px; margin: 0 auto; padding: 48px 24px; }
h1 { font-size: 30px; }
/* A Swiper-like single-slide carousel: a clipped viewport, a flex track moved
by translateX, prev/next buttons, and pagination bullets. */
.swiper { position: relative; overflow: hidden; border-radius: 12px; margin-top: 20px; }
.swiper-wrapper { display: flex; transition: transform .35s ease; will-change: transform; }
.swiper-slide { flex: 0 0 100%; min-width: 100%; height: 280px; display: flex; align-items: center; justify-content: center; font-size: 40px; color: #fff; }
.s0 { background: #4f46e5; } .s1 { background: #0891b2; } .s2 { background: #16a34a; } .s3 { background: #db2777; } .s4 { background: #ea580c; }
.swiper-button-prev, .swiper-button-next {
position: absolute; top: 50%; transform: translateY(-50%); z-index: 2;
width: 44px; height: 44px; border-radius: 50%; border: 0; cursor: pointer;
background: rgba(255,255,255,.85); color: #1a1a1a; font-size: 20px;
}
.swiper-button-prev { left: 12px; } .swiper-button-next { right: 12px; }
.swiper-pagination { display: flex; gap: 8px; justify-content: center; margin-top: 16px; }
.swiper-pagination-bullet { width: 10px; height: 10px; border-radius: 50%; border: 0; padding: 0; cursor: pointer; background: #d1d5db; }
.swiper-pagination-bullet-active { background: #4f46e5; }
</style>
</head>
<body>
<main>
<h1>Carousel</h1>
<p>A single-slide, transform-positioned carousel with prev/next and pagination.</p>
<div class="swiper" aria-roledescription="carousel" aria-label="Featured">
<div class="swiper-wrapper">
<div class="swiper-slide s0" aria-roledescription="slide" aria-label="1 of 5">Slide 1</div>
<div class="swiper-slide s1" aria-roledescription="slide" aria-label="2 of 5">Slide 2</div>
<div class="swiper-slide s2" aria-roledescription="slide" aria-label="3 of 5">Slide 3</div>
<div class="swiper-slide s3" aria-roledescription="slide" aria-label="4 of 5">Slide 4</div>
<div class="swiper-slide s4" aria-roledescription="slide" aria-label="5 of 5">Slide 5</div>
</div>
<button class="swiper-button-prev" aria-label="Previous slide"></button>
<button class="swiper-button-next" aria-label="Next slide"></button>
<div class="swiper-pagination">
<button class="swiper-pagination-bullet swiper-pagination-bullet-active" aria-label="Go to slide 1"></button>
<button class="swiper-pagination-bullet" aria-label="Go to slide 2"></button>
<button class="swiper-pagination-bullet" aria-label="Go to slide 3"></button>
<button class="swiper-pagination-bullet" aria-label="Go to slide 4"></button>
<button class="swiper-pagination-bullet" aria-label="Go to slide 5"></button>
</div>
</div>
</main>
<script>
document.querySelectorAll(".swiper").forEach((root) => {
const wrap = root.querySelector(".swiper-wrapper");
const slides = [...root.querySelectorAll(".swiper-slide")];
const bullets = [...root.querySelectorAll(".swiper-pagination-bullet")];
let i = 0;
const go = (n) => {
i = Math.max(0, Math.min(slides.length - 1, n));
wrap.style.transform = "translateX(-" + (i * 100) + "%)";
bullets.forEach((b, k) => b.classList.toggle("swiper-pagination-bullet-active", k === i));
};
root.querySelector(".swiper-button-next").addEventListener("click", () => go(i + 1));
root.querySelector(".swiper-button-prev").addEventListener("click", () => go(i - 1));
bullets.forEach((b, k) => b.addEventListener("click", () => go(k)));
});
</script>
</body>
</html>
+91
View File
@@ -0,0 +1,91 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Component extraction fixture</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
header { border-bottom: 1px solid #e5e5e5; }
nav { max-width: 960px; margin: 0 auto; padding: 16px 24px; display: flex; gap: 24px; }
nav a { color: #374151; text-decoration: none; font-size: 15px; font-weight: 500; }
main { max-width: 960px; margin: 0 auto; padding: 48px 24px; }
h1 { font-size: 34px; margin: 0 0 8px; }
.intro { color: #6b7280; font-size: 17px; margin: 0 0 40px; }
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; }
.card { border: 1px solid #e5e7eb; border-radius: 12px; overflow: hidden; }
.card-link { display: block; padding: 20px; color: inherit; text-decoration: none; }
.badge { display: inline-block; background: #eef2ff; color: #4f46e5; font-size: 12px; font-weight: 600; padding: 3px 10px; border-radius: 999px; text-transform: uppercase; letter-spacing: .04em; }
.card-title { font-size: 19px; margin: 14px 0 6px; line-height: 1.3; }
.card-body { font-size: 14px; color: #6b7280; line-height: 1.6; margin: 0 0 14px; }
.card-cta { font-size: 14px; font-weight: 600; color: #4f46e5; }
footer { max-width: 960px; margin: 0 auto; padding: 32px 24px; color: #9ca3af; font-size: 13px; border-top: 1px solid #e5e5e5; }
</style>
</head>
<body>
<header>
<nav aria-label="Primary">
<a href="https://example.com/">Home</a>
<a href="https://example.com/features">Features</a>
<a href="https://example.com/pricing">Pricing</a>
<a href="https://example.com/docs">Docs</a>
</nav>
</header>
<main>
<h1>From the blog</h1>
<p class="intro">Notes, guides, and release updates from the team.</p>
<div class="grid">
<article class="card">
<a class="card-link" href="https://example.com/post/alpha">
<span class="badge">News</span>
<h3 class="card-title">Announcing the alpha release</h3>
<p class="card-body">A first look at everything that shipped in our earliest public build.</p>
<span class="card-cta">Read more &rarr;</span>
</a>
</article>
<article class="card">
<a class="card-link" href="https://example.com/post/grid-guide">
<span class="badge">Guide</span>
<h3 class="card-title">A practical guide to CSS grid</h3>
<p class="card-body">Lay out responsive card collections without fighting the box model.</p>
<span class="card-cta">Read more &rarr;</span>
</a>
</article>
<article class="card">
<a class="card-link" href="https://example.com/post/perf">
<span class="badge">Engineering</span>
<h3 class="card-title">Shipping faster pages in 2026</h3>
<p class="card-body">The performance budget we hold every release to, and why it matters.</p>
<span class="card-cta">Read more &rarr;</span>
</a>
</article>
<article class="card">
<a class="card-link" href="https://example.com/post/changelog">
<span class="badge">News</span>
<h3 class="card-title">What changed this month</h3>
<p class="card-body">A roundup of the fixes and features that landed across the platform.</p>
<span class="card-cta">Read more &rarr;</span>
</a>
</article>
<article class="card">
<a class="card-link" href="https://example.com/post/design">
<span class="badge">Design</span>
<h3 class="card-title">Rethinking our color system</h3>
<p class="card-body">How we moved to semantic tokens and what we learned along the way.</p>
<span class="card-cta">Read more &rarr;</span>
</a>
</article>
<article class="card">
<a class="card-link" href="https://example.com/post/community">
<span class="badge">Community</span>
<h3 class="card-title">Highlights from the meetup</h3>
<p class="card-body">Talks, demos, and conversations from our first in-person gathering.</p>
<span class="card-cta">Read more &rarr;</span>
</a>
</article>
</div>
</main>
<footer>&copy; 2026 Example, Inc. All rights reserved.</footer>
</body>
</html>
+96
View File
@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Disclosure fixture — dropdown, mega-menu, modal</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
main { max-width: 820px; margin: 0 auto; padding: 48px 24px; }
h1 { font-size: 30px; } h2 { font-size: 20px; margin-top: 40px; }
nav { display: flex; gap: 8px; }
.menu-root { position: relative; }
.menu-trigger, .modal-trigger {
appearance: none; border: 1px solid #d1d5db; background: #fff; border-radius: 8px;
padding: 10px 16px; font-size: 15px; cursor: pointer; color: #1a1a1a;
}
.menu-trigger[aria-expanded="true"] { background: #eef2ff; border-color: #4f46e5; color: #4f46e5; }
.menu-panel {
position: absolute; top: calc(100% + 6px); left: 0; z-index: 10;
min-width: 200px; background: #fff; border: 1px solid #e5e7eb; border-radius: 10px;
box-shadow: 0 10px 30px rgba(0,0,0,.12); padding: 8px; list-style: none; margin: 0;
}
.menu-panel[hidden] { display: none; }
.menu-panel li > a { display: block; padding: 8px 12px; border-radius: 6px; color: #1a1a1a; text-decoration: none; }
.mega { min-width: 520px; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.modal-backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 50;
display: flex; align-items: center; justify-content: center;
}
.modal-backdrop[hidden] { display: none; }
.modal-dialog { background: #fff; border-radius: 14px; padding: 28px; max-width: 440px; width: 90%; }
.modal-dialog h3 { margin: 0 0 8px; font-size: 22px; }
.modal-close { float: right; border: 0; background: transparent; font-size: 22px; cursor: pointer; }
</style>
</head>
<body>
<main>
<h1>Disclosure patterns</h1>
<h2>Dropdown menu</h2>
<nav>
<div class="menu-root">
<button class="menu-trigger" id="mt1" aria-haspopup="menu" aria-expanded="false" aria-controls="m1">Products ▾</button>
<ul class="menu-panel" id="m1" role="menu" aria-labelledby="mt1" hidden>
<li role="none"><a role="menuitem" href="#search">Search</a></li>
<li role="none"><a role="menuitem" href="#recommend">Recommend</a></li>
<li role="none"><a role="menuitem" href="#analytics">Analytics</a></li>
</ul>
</div>
<div class="menu-root">
<button class="menu-trigger" id="mt2" aria-haspopup="menu" aria-expanded="false" aria-controls="m2">Solutions ▾</button>
<ul class="menu-panel mega" id="m2" role="menu" aria-labelledby="mt2" hidden>
<li role="none"><a role="menuitem" href="#ecommerce">Ecommerce — fast product discovery</a></li>
<li role="none"><a role="menuitem" href="#media">Media — content recommendations</a></li>
<li role="none"><a role="menuitem" href="#saas">SaaS — in-app search</a></li>
<li role="none"><a role="menuitem" href="#marketplaces">Marketplaces — ranking</a></li>
</ul>
</div>
</nav>
<h2>Modal dialog</h2>
<button class="modal-trigger" id="open-dlg" aria-haspopup="dialog" aria-controls="dlg">Open dialog</button>
<div class="modal-backdrop" id="dlg-backdrop" hidden>
<div class="modal-dialog" id="dlg" role="dialog" aria-modal="true" aria-labelledby="dlg-title">
<button class="modal-close" id="dlg-close" aria-label="Close dialog">×</button>
<h3 id="dlg-title">Subscribe</h3>
<p>Get product updates in your inbox. We send at most one email a month.</p>
</div>
</div>
</main>
<script>
// Dropdown menus: toggle on click, open on hover, close on outside click / Escape.
document.querySelectorAll(".menu-root").forEach((root) => {
const trigger = root.querySelector(".menu-trigger");
const panel = document.getElementById(trigger.getAttribute("aria-controls"));
const set = (open) => { trigger.setAttribute("aria-expanded", open ? "true" : "false"); panel.hidden = !open; };
trigger.addEventListener("click", () => set(trigger.getAttribute("aria-expanded") !== "true"));
root.addEventListener("mouseenter", () => set(true));
root.addEventListener("mouseleave", () => set(false));
});
document.addEventListener("keydown", (e) => { if (e.key === "Escape") document.querySelectorAll(".menu-trigger").forEach((t) => { t.setAttribute("aria-expanded", "false"); document.getElementById(t.getAttribute("aria-controls")).hidden = true; }); });
// Modal: open from trigger, close from close button / backdrop / Escape.
const backdrop = document.getElementById("dlg-backdrop");
const openModal = (open) => { backdrop.hidden = !open; };
document.getElementById("open-dlg").addEventListener("click", () => openModal(true));
document.getElementById("dlg-close").addEventListener("click", () => openModal(false));
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) openModal(false); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") openModal(false); });
</script>
</body>
</html>
+5
View File
@@ -0,0 +1,5 @@
<svg viewBox="0 0 48 48" version="1.2" baseProfile="tiny" xmlns="http://www.w3.org/2000/svg" id="Calque_1">
<rect fill="#82a3f7" height="48" width="48"></rect>
<path d="M36,25.7c-4.8,0-5-11-5-11h-3.1s-.4,11-3.4,11-3.4-11-3.4-11h-3.1s-.1,11-5,11h-.7v7h.7c6.7,0,6-11.3,6.1-12.1h.6c.1.9-.4,12.1,4.5,12.1s4.4-11.3,4.5-12.1h.6c.1.9-.6,12.1,6.1,12.1h.7v-7h-.4Z"></path>
</svg>

After

Width:  |  Height:  |  Size: 377 B

+27
View File
@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hover transition fixture</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; padding: 60px; }
/* a button whose hover EASES (transition), not snaps */
.btn {
display: inline-block; padding: 12px 24px; border-radius: 8px;
background: rgb(238, 238, 238); color: rgb(51, 51, 51);
transition: background-color 0.25s ease, color 0.25s ease, transform 0.2s ease;
cursor: pointer;
}
.btn:hover { background: rgb(43, 80, 214); color: rgb(255, 255, 255); transform: translateY(-2px); }
/* a link with no transition: its hover should NOT gain a transition rule */
.plain { display: inline-block; margin-left: 24px; color: rgb(43, 80, 214); }
.plain:hover { color: rgb(20, 30, 90); }
</style>
</head>
<body>
<a class="btn" href="/go">Hover me (eased)</a>
<a class="plain" href="/plain">Plain hover (snaps)</a>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Interactive patterns fixture</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
main { max-width: 760px; margin: 0 auto; padding: 48px 24px; }
h1 { font-size: 32px; }
h2 { font-size: 22px; margin-top: 48px; }
/* Tabs */
.tabs { margin-top: 16px; }
[role="tablist"] { display: flex; gap: 4px; border-bottom: 2px solid #e5e5e5; }
[role="tab"] {
appearance: none; border: 0; background: transparent; cursor: pointer;
padding: 10px 18px; font-size: 15px; color: #6b7280;
border-bottom: 3px solid transparent; margin-bottom: -2px;
}
[role="tab"][aria-selected="true"] { color: #4f46e5; border-bottom-color: #4f46e5; font-weight: 600; }
[role="tabpanel"] { padding: 20px 4px; font-size: 15px; line-height: 1.6; }
[role="tabpanel"][hidden] { display: none; }
/* Accordion */
.accordion { margin-top: 16px; border: 1px solid #e5e5e5; border-radius: 8px; overflow: hidden; }
.accordion-item + .accordion-item { border-top: 1px solid #e5e5e5; }
.accordion-trigger {
appearance: none; width: 100%; text-align: left; border: 0; background: #fafafa;
padding: 16px 18px; font-size: 16px; cursor: pointer; color: #1a1a1a;
display: flex; justify-content: space-between; align-items: center;
}
.accordion-trigger[aria-expanded="true"] { background: #eef2ff; color: #4f46e5; }
.accordion-trigger .chev { transition: transform .15s; }
.accordion-trigger[aria-expanded="true"] .chev { transform: rotate(180deg); }
.accordion-panel { padding: 4px 18px 20px; font-size: 15px; line-height: 1.6; color: #4b5563; }
.accordion-panel[hidden] { display: none; }
</style>
</head>
<body>
<main>
<h1>Interactive patterns</h1>
<p>A fixture exercising the recognized Stage-4 patterns with panels kept in the DOM.</p>
<h2>Tabs</h2>
<div class="tabs">
<div role="tablist" aria-label="Fruit">
<button role="tab" id="t1" aria-controls="p1" aria-selected="true">Apples</button>
<button role="tab" id="t2" aria-controls="p2" aria-selected="false" tabindex="-1">Oranges</button>
<button role="tab" id="t3" aria-controls="p3" aria-selected="false" tabindex="-1">Pears</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1"><p>Apples are crisp and come in many varieties such as Fuji and Gala.</p></div>
<div role="tabpanel" id="p2" aria-labelledby="t2" hidden><p>Oranges are citrus fruits rich in vitamin C and very juicy.</p></div>
<div role="tabpanel" id="p3" aria-labelledby="t3" hidden><p>Pears have a soft, sweet flesh and a distinctive shape.</p></div>
</div>
<h2>Accordion</h2>
<div class="accordion">
<div class="accordion-item">
<button class="accordion-trigger" id="a1" aria-controls="s1" aria-expanded="true"><span>What is your return policy?</span><span class="chev"></span></button>
<div class="accordion-panel" id="s1" role="region" aria-labelledby="a1"><p>You can return any item within 30 days of purchase for a full refund.</p></div>
</div>
<div class="accordion-item">
<button class="accordion-trigger" id="a2" aria-controls="s2" aria-expanded="false"><span>Do you ship internationally?</span><span class="chev"></span></button>
<div class="accordion-panel" id="s2" role="region" aria-labelledby="a2" hidden><p>Yes, we ship to over 50 countries with tracked delivery.</p></div>
</div>
<div class="accordion-item">
<button class="accordion-trigger" id="a3" aria-controls="s3" aria-expanded="false"><span>How do I track my order?</span><span class="chev"></span></button>
<div class="accordion-panel" id="s3" role="region" aria-labelledby="a3" hidden><p>A tracking link is emailed to you as soon as your order ships.</p></div>
</div>
</div>
</main>
<script>
// Minimal ARIA tabs: keep panels mounted, toggle [hidden] + aria-selected.
document.querySelectorAll('[role="tablist"]').forEach((list) => {
const tabs = [...list.querySelectorAll('[role="tab"]')];
tabs.forEach((tab) => tab.addEventListener('click', () => {
tabs.forEach((t) => {
const sel = t === tab;
t.setAttribute('aria-selected', sel ? 'true' : 'false');
t.tabIndex = sel ? 0 : -1;
const panel = document.getElementById(t.getAttribute('aria-controls'));
if (panel) panel.hidden = !sel;
});
}));
});
// Minimal accordion: toggle [hidden] + aria-expanded.
document.querySelectorAll('.accordion-trigger').forEach((btn) => {
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', open ? 'false' : 'true');
const panel = document.getElementById(btn.getAttribute('aria-controls'));
if (panel) panel.hidden = open;
});
});
</script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Layout + Links fixture</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; }
body { font-family: system-ui, sans-serif; color: #101010; }
/* full-bleed sections: authored fluid (width:100%), must stay fluid in the clone */
.bleed { width: 100%; background: #eef2fd; padding: 16px 0; }
.bleed.alt { background: #f6f6f6; }
/* centered, capped container: fluid up to 960px, capped beyond */
.container { max-width: 960px; margin: 0 auto; padding: 0 24px; }
nav { display: flex; gap: 16px; align-items: center; }
nav a { color: #2b50d6; text-decoration: none; }
h1 { font-size: 40px; line-height: 1.2; margin: 24px 0; }
/* a genuinely fixed-width element: must NOT be made fluid */
.fixed-box { width: 220px; height: 80px; background: #ccd; }
/* an absolute bar pinned to both edges (sticky-nav shape): width is redundant
with left:0;right:0 and must stay fluid */
.topbar { position: fixed; top: 0; left: 0; right: 0; height: 36px; background: rgb(20, 24, 40); color: #fff; z-index: 100; }
</style>
</head>
<body>
<div class="topbar">pinned bar</div>
<header class="bleed">
<nav class="container">
<a href="/">Home</a>
<a href="/pricing">Pricing</a>
<a href="/enterprise">Enterprise</a>
<a href="https://example.com/external">External</a>
<a href="#features">Features</a>
</nav>
</header>
<main>
<!-- a full-bleed replaced element (svg): its width:100% must NOT become width:auto,
which would collapse it to its intrinsic viewBox width at every viewport -->
<svg class="bleed-svg" viewBox="0 0 100 12" preserveAspectRatio="none" style="display:block;width:100%;height:12px;background:rgb(200,60,60)"><rect width="100" height="12" fill="rgb(200,60,60)"/></svg>
<section class="bleed alt">
<div class="container">
<h1 id="features">A full-width hero band</h1>
<p>The band background should reach both window edges at any width.</p>
<div class="fixed-box">fixed 220px</div>
</div>
</section>
<section class="bleed">
<div class="container">
<p>Second full-bleed band.</p>
</div>
</section>
</main>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
# Source LLMS Full
This longer source file should be preserved when the fixture exposes it.
+3
View File
@@ -0,0 +1,3 @@
# Source LLMS
This text comes from the source fixture and should be preserved by the generated app.
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

+3
View File
@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<path d="M8 8h48v48H8z" fill="#000"/>
</svg>

After

Width:  |  Height:  |  Size: 108 B

+65
View File
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mount-on-open menu fixture</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, sans-serif; }
header { display: flex; align-items: center; gap: 24px; height: 56px; padding: 0 24px; border-bottom: 1px solid #eee; }
.brand { font-weight: 700; }
nav { display: flex; gap: 8px; }
.trigger { background: none; border: 0; font: inherit; padding: 8px 12px; border-radius: 6px; cursor: pointer; color: #333; }
.trigger[aria-expanded="true"] { background: #eef2fd; color: #2b50d6; }
/* the panel is mounted into <body> on open (Radix-style portal) and removed on close */
.menu-panel { position: absolute; background: #fff; border: 1px solid #e2e2e2; border-radius: 12px; box-shadow: 0 12px 32px rgba(0,0,0,0.12); padding: 16px; width: 320px; display: grid; gap: 8px; }
.menu-panel a { display: block; padding: 10px 12px; border-radius: 8px; color: #222; text-decoration: none; }
.menu-panel a:hover { background: #f4f6fe; }
.menu-panel .label { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: .04em; padding: 4px 12px; }
main { padding: 48px 24px; }
</style>
</head>
<body>
<header>
<span class="brand">Acme</span>
<nav>
<button class="trigger" id="t-product" aria-haspopup="menu" aria-expanded="false" aria-controls="panel-product">Product</button>
<a class="trigger" href="/pricing">Pricing</a>
</nav>
</header>
<main>
<h1>Mount-on-open menu</h1>
<p>The Product menu mounts its panel into &lt;body&gt; on open and removes it on close.</p>
</main>
<script>
const t = document.getElementById('t-product');
let panel = null;
function open() {
if (panel) return;
panel = document.createElement('div');
panel.className = 'menu-panel';
panel.id = 'panel-product';
panel.setAttribute('role', 'menu');
const r = t.getBoundingClientRect();
panel.style.left = (r.left + window.scrollX) + 'px';
panel.style.top = (r.bottom + window.scrollY + 8) + 'px';
panel.innerHTML = '<div class="label">Build</div>' +
'<a href="/features">Features</a>' +
'<a href="/integrations">Integrations</a>' +
'<a href="/changelog">Changelog</a>' +
'<div class="label">Learn</div>' +
'<a href="/docs">Documentation</a>' +
'<a href="/guides">Guides</a>';
document.body.appendChild(panel);
t.setAttribute('aria-expanded', 'true');
}
function close() {
if (panel) { panel.remove(); panel = null; }
t.setAttribute('aria-expanded', 'false');
}
t.addEventListener('click', () => (t.getAttribute('aria-expanded') === 'true' ? close() : open()));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
</script>
</body>
</html>
+79
View File
@@ -0,0 +1,79 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Motion fixture — CSS keyframes</title>
<style>
:root { --bg: #0b0b10; --fg: #f5f5f7; --muted: #9aa0aa; --accent: #6d6aff; }
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: var(--bg); color: var(--fg);
font-family: -apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 920px; margin: 0 auto; padding: 64px 24px 96px; }
/* 1) Finite entrance: fade + slide up, ends at rest (fill forwards). */
@keyframes fadeUp {
from { opacity: 0; transform: translateY(28px); }
to { opacity: 1; transform: translateY(0); }
}
.hero h1 {
font-size: 56px; font-weight: 700; line-height: 1.05; letter-spacing: -0.02em; margin: 0 0 16px;
animation: fadeUp 700ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.hero p {
font-size: 20px; line-height: 1.5; color: var(--muted); margin: 0; max-width: 560px;
animation: fadeUp 700ms cubic-bezier(0.22, 1, 0.36, 1) 120ms both;
}
/* 2) Infinite spinner. */
@keyframes spin { to { transform: rotate(360deg); } }
.spinner {
width: 48px; height: 48px; margin: 56px 0 0; border-radius: 9999px;
border: 4px solid rgba(255,255,255,0.15); border-top-color: var(--accent);
animation: spin 1s linear infinite;
}
/* 3) Infinite pulse (opacity + scale). */
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(1.08); }
}
.badge {
display: inline-block; margin-top: 40px; padding: 8px 16px; border-radius: 9999px;
background: var(--accent); color: #fff; font-size: 14px; font-weight: 600;
animation: pulse 1.8s ease-in-out infinite;
}
.grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; margin-top: 64px; }
.card {
background: #15151d; border: 1px solid #23232e; border-radius: 14px; padding: 20px;
animation: fadeUp 600ms ease-out both;
}
.card h3 { margin: 0 0 8px; font-size: 18px; font-weight: 600; }
.card p { margin: 0; font-size: 15px; line-height: 1.5; color: var(--muted); }
.footer { margin-top: 80px; color: var(--muted); font-size: 14px; }
</style>
</head>
<body>
<main class="wrap">
<section class="hero">
<h1>Motion that replays</h1>
<p>A deterministic fixture: heading and subtext animate in, the loader spins forever, and the badge pulses.</p>
<div class="spinner" aria-label="Loading"></div>
<span class="badge">Live</span>
</section>
<section class="grid">
<div class="card"><h3>Entrance</h3><p>Cards fade and slide up on load via a CSS keyframes animation.</p></div>
<div class="card"><h3>Looping</h3><p>The spinner uses an infinite linear rotation that never settles.</p></div>
<div class="card"><h3>Pulsing</h3><p>The badge eases between two opacity and scale states forever.</p></div>
</section>
<p class="footer">Reconstructed deterministically — the clone should replay each of these.</p>
</main>
</body>
</html>
+64
View File
@@ -0,0 +1,64 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Motion fixture 2 — WAAPI + rotating text</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; }
body {
background: #ffffff; color: #111418;
font-family: -apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.wrap { max-width: 880px; margin: 0 auto; padding: 80px 24px; }
h1 { font-size: 48px; line-height: 1.1; font-weight: 800; margin: 0 0 24px; letter-spacing: -0.02em; }
.rot { color: #5b5bff; }
p { font-size: 18px; line-height: 1.6; color: #5a626e; margin: 0 0 16px; max-width: 600px; }
.orbit { width: 56px; height: 56px; margin-top: 56px; border-radius: 12px; background: #5b5bff; }
.fade { margin-top: 48px; padding: 24px; border: 1px solid #e6e8ec; border-radius: 14px; }
.fade h3 { margin: 0 0 8px; font-size: 18px; }
.fade p { margin: 0; }
</style>
</head>
<body>
<main class="wrap">
<h1>Build <span class="rot" id="rot">faster</span></h1>
<p>The highlighted word rotates on an interval. The square orbits via the Web Animations API, and the card below fades in via WAAPI on load.</p>
<div class="orbit" id="orbit" aria-hidden="true"></div>
<div class="fade" id="fade">
<h3>WAAPI entrance</h3>
<p>This card is animated with element.animate(), not CSS keyframes.</p>
</div>
<ul style="margin-top:40px;padding:0;list-style:none;display:grid;gap:12px">
<li style="padding:14px 16px;border:1px solid #e6e8ec;border-radius:10px">Deterministic reconstruction from observed evidence.</li>
<li style="padding:14px 16px;border:1px solid #e6e8ec;border-radius:10px">Recognized-pattern allowlist, fixed templates, no synthesis.</li>
<li style="padding:14px 16px;border:1px solid #e6e8ec;border-radius:10px">Gate-verified motion: reproduce, or freeze honestly.</li>
</ul>
<footer style="margin-top:56px;color:#9aa0aa;font-size:14px">Motion replays on load; the gates grade the settled frame.</footer>
</main>
<script>
// Rotating text on an interval (the shopify/notion pattern).
(function () {
var el = document.getElementById('rot');
var words = ['faster', 'cleaner', 'smarter', 'together'];
var i = 0;
setInterval(function () { i = (i + 1) % words.length; el.textContent = words[i]; }, 1400);
})();
// WAAPI: infinite orbit (translate loop) — persistent, observable at capture time.
document.getElementById('orbit').animate(
[
{ transform: 'translateX(0) rotate(0deg)' },
{ transform: 'translateX(120px) rotate(180deg)' },
{ transform: 'translateX(0) rotate(360deg)' }
],
{ duration: 2400, iterations: Infinity, easing: 'ease-in-out' }
);
// WAAPI: finite fade+rise entrance on the card.
document.getElementById('fade').animate(
[ { opacity: 0, transform: 'translateY(24px)' }, { opacity: 1, transform: 'translateY(0)' } ],
{ duration: 600, easing: 'ease-out', fill: 'both' }
);
</script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Motion fixture 3 — scroll-triggered reveals</title>
<style>
* { box-sizing: border-box; }
html, body { margin: 0; }
body { background: #0c0d12; color: #eef0f4; font-family: -apple-system, system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif; }
.wrap { max-width: 760px; margin: 0 auto; padding: 64px 24px 160px; }
h1 { font-size: 44px; line-height: 1.1; font-weight: 800; margin: 0 0 12px; letter-spacing: -0.02em; }
.lede { font-size: 18px; color: #9aa2ad; margin: 0 0 48px; }
/* Scroll-reveal: start hidden + lifted, transition in when .in is added on intersection. */
.reveal { opacity: 0; transform: translateY(36px); transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22,1,0.36,1); }
.reveal.in { opacity: 1; transform: translateY(0); }
.card { margin: 28px 0; padding: 28px; background: #15171f; border: 1px solid #242732; border-radius: 16px; }
.card h2 { margin: 0 0 8px; font-size: 22px; font-weight: 700; }
.card p { margin: 0; color: #9aa2ad; line-height: 1.6; }
.spacer { height: 40vh; }
</style>
</head>
<body>
<main class="wrap">
<h1>Scroll to reveal</h1>
<p class="lede">Each card below starts hidden and animates in when it scrolls into view.</p>
<div class="spacer"></div>
<section class="card reveal"><h2>First reveal</h2><p>Fades and slides up as it enters the viewport, driven by IntersectionObserver toggling a class with a CSS transition.</p></section>
<section class="card reveal"><h2>Second reveal</h2><p>The same entrance, staggered down the page so it is comfortably below the initial fold.</p></section>
<section class="card reveal"><h2>Third reveal</h2><p>A deterministic reproduction target: hidden at load, revealed on scroll, settling to full opacity.</p></section>
<section class="card reveal"><h2>Fourth reveal</h2><p>The clone should hide these on load and reveal them on scroll, then the gate verifies it.</p></section>
<section class="card reveal"><h2>Fifth reveal</h2><p>Content is never lost: a force-reveal timer guarantees everything appears even without scrolling.</p></section>
</main>
<script>
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add('in'); io.unobserve(e.target); } });
}, { rootMargin: '0px 0px -10% 0px' });
document.querySelectorAll('.reveal').forEach(function (el) { io.observe(el); });
</script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Non-ARIA interactive patterns</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; margin: 0; color: #1a1a1a; background: #fff; }
main { max-width: 760px; margin: 0 auto; padding: 48px 24px; }
h1 { font-size: 30px; } h2 { font-size: 20px; margin-top: 40px; }
/* A custom dropdown with NO aria — just a div with a click handler. */
.cmenu { position: relative; display: inline-block; }
.cbtn { cursor: pointer; user-select: none; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 16px; font-size: 15px; background: #fff; }
.cbtn.is-open { background: #eef2ff; border-color: #4f46e5; color: #4f46e5; }
.cpanel { position: absolute; top: calc(100% + 6px); left: 0; min-width: 200px; background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; box-shadow: 0 10px 30px rgba(0,0,0,.12); padding: 8px; }
.cpanel { display: none; }
.cpanel.is-open { display: block; }
.cpanel a { display: block; padding: 8px 12px; border-radius: 6px; color: #1a1a1a; text-decoration: none; }
/* A custom modal with NO aria — div trigger + div overlay. */
.open-x { cursor: pointer; display: inline-block; border: 1px solid #d1d5db; border-radius: 8px; padding: 10px 16px; font-size: 15px; }
.ovl { position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 50; display: none; align-items: center; justify-content: center; }
.ovl.is-open { display: flex; }
.box { background: #fff; border-radius: 14px; padding: 28px; max-width: 440px; width: 90%; }
.x { float: right; cursor: pointer; font-size: 22px; }
</style>
</head>
<body>
<main>
<h1>Non-ARIA patterns</h1>
<p>A custom dropdown and modal built from plain &lt;div&gt;s with click handlers and no ARIA at all — the kind the recognizer can only find via real event listeners + drive-and-diff.</p>
<h2>Custom dropdown</h2>
<div class="cmenu">
<div class="cbtn" id="cbtn">Resources ▾</div>
<div class="cpanel" id="cpanel">
<a href="#docs">Documentation</a>
<a href="#guides">Guides</a>
<a href="#api">API reference</a>
</div>
</div>
<h2>Custom modal</h2>
<div class="open-x" id="openx">Contact us</div>
<div class="ovl" id="ovl">
<div class="box">
<span class="x" id="closex">×</span>
<h3>Contact us</h3>
<p>Reach the team at hello@example.com — we usually reply within a day.</p>
</div>
</div>
</main>
<script>
const cbtn = document.getElementById("cbtn"), cpanel = document.getElementById("cpanel");
cbtn.addEventListener("click", () => {
const open = cpanel.classList.toggle("is-open");
cbtn.classList.toggle("is-open", open);
});
const ovl = document.getElementById("ovl");
document.getElementById("openx").addEventListener("click", () => ovl.classList.add("is-open"));
document.getElementById("closex").addEventListener("click", () => ovl.classList.remove("is-open"));
ovl.addEventListener("click", (e) => { if (e.target === ovl) ovl.classList.remove("is-open"); });
document.addEventListener("keydown", (e) => { if (e.key === "Escape") { ovl.classList.remove("is-open"); cpanel.classList.remove("is-open"); cbtn.classList.remove("is-open"); } });
</script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Numeric Heading Repro</title>
<style>*{margin:0;box-sizing:border-box}body{font-family:system-ui}section,footer{display:block;min-height:240px;padding:48px}h1{font-size:40px}h2{font-size:32px}h3{font-size:24px}</style></head>
<body>
<section><h1>Build faster than ever</h1><p>Lead paragraph for the hero block goes here.</p></section>
<section><h2>0019 Iterate Faster</h2><p>This section heading starts with a numeric layer id.</p><a href="/x">Learn more</a></section>
<section><h3>Pricing plans</h3><ul><li>Free</li><li>Pro</li><li>Team</li></ul></section>
<section><h2>Frequently asked questions</h2><p>Answers to common questions.</p><span>more</span></section>
<footer><p>&copy; 2026 Example</p></footer>
</body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 340 B

+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Polish fixture — iframe + inline-block headings</title>
<style>
* { box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 0; color: #111; background: #fff; }
main { max-width: 760px; margin: 0 auto; padding: 56px 24px 96px; }
section { margin: 0 0 64px; }
.label { font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: #888; margin: 0 0 16px; }
/* Control: plain block heading */
h1.plain { font-size: 56px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; max-width: 520px; margin: 0; }
/* The linear/notion pattern: each word an inline-block, wraps across lines */
h1.words { font-size: 56px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; max-width: 520px; margin: 0; }
h1.words .w { display: inline-block; }
/* Same, plus zero-width inline spacer spans between words (staggered-text rigs) */
h1.spacer { font-size: 56px; line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; max-width: 520px; margin: 0; }
h1.spacer .w { display: inline-block; }
h1.spacer .sp { display: inline-block; width: 0.28em; }
/* iframe embed: a sized box that should be preserved as a placeholder */
.embed { width: 100%; max-width: 560px; height: 315px; border: 1px solid #ddd; border-radius: 12px; display: block; }
.map { width: 320px; height: 200px; border: 0; margin-top: 24px; }
.grid { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 24px; }
.chip { display: inline-block; background: #f1f5f9; border-radius: 8px; padding: 10px 16px; font-size: 15px; }
footer { border-top: 1px solid #eee; padding: 24px; text-align: center; color: #999; font-size: 13px; }
</style>
</head>
<body>
<main>
<section>
<p class="label">Control · plain heading</p>
<h1 class="plain">Build better products with a faster planning tool</h1>
</section>
<section>
<p class="label">Words · inline-block per word</p>
<h1 class="words"><span class="w">Build</span> <span class="w">better</span> <span class="w">products</span> <span class="w">with</span> <span class="w">a</span> <span class="w">faster</span> <span class="w">planning</span> <span class="w">tool</span></h1>
</section>
<section>
<p class="label">Words + zero-width spacers</p>
<h1 class="spacer"><span class="w">Build</span><span class="sp"></span><span class="w">better</span><span class="sp"></span><span class="w">products</span><span class="sp"></span><span class="w">with</span><span class="sp"></span><span class="w">a</span><span class="sp"></span><span class="w">faster</span><span class="sp"></span><span class="w">planning</span><span class="sp"></span><span class="w">tool</span></h1>
</section>
<section>
<p class="label">iframe · video embed placeholder</p>
<iframe class="embed" src="https://www.youtube.com/embed/dQw4w9WgXcQ" title="Embedded video" allowfullscreen></iframe>
<iframe class="map" src="https://www.openstreetmap.org/export/embed.html" title="Map"></iframe>
</section>
<section>
<p class="label">flex chips (inline-block, control for layout)</p>
<div class="grid">
<span class="chip">Planning</span>
<span class="chip">Issues</span>
<span class="chip">Roadmaps</span>
<span class="chip">Insights</span>
</div>
</section>
</main>
<footer>Polish fixture · deterministic local test</footer>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: /sitemap.xml
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SEO Rich Fixture</title>
<meta name="description" content="A local fixture with rich SEO metadata for generator tests.">
<meta name="keywords" content="clone, seo, fixture">
<meta name="robots" content="index,follow">
<meta name="referrer" content="strict-origin-when-cross-origin">
<meta name="theme-color" content="#123456">
<meta name="color-scheme" content="light dark">
<link rel="canonical" href="/seo-rich.html">
<link rel="alternate" hreflang="en" href="/seo-rich.html">
<link rel="alternate" hreflang="es" href="/es/seo-rich.html">
<link rel="icon" type="image/png" sizes="32x32" href="/seo-icon.png">
<link rel="shortcut icon" href="/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="mask-icon" href="/mask.svg" color="#123456">
<link rel="manifest" href="/site.webmanifest">
<link rel="sitemap" type="application/xml" href="/sitemap.xml">
<meta property="og:title" content="SEO Rich Fixture OG">
<meta property="og:description" content="Open Graph description from the source page.">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Fixture Site">
<meta property="og:image" content="/og-image.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="SEO Rich Fixture Twitter">
<meta name="twitter:description" content="Twitter card description from the source page.">
<meta name="twitter:image" content="/twitter-image.png">
<script type="application/ld+json" id="fixture-jsonld">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "SEO Rich Fixture",
"url": "https://fixtures.example/seo-rich.html"
}
</script>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: -apple-system, Segoe UI, Roboto, sans-serif; color: #172033; background: #f7fafc; }
main { max-width: 880px; margin: 0 auto; padding: 64px 24px; }
h1 { font-size: 42px; margin: 0 0 12px; letter-spacing: 0; }
p { font-size: 18px; line-height: 1.6; margin: 0 0 18px; color: #48566f; }
.panel { margin-top: 32px; padding: 24px; border: 1px solid #d9e2ef; border-radius: 8px; background: #fff; }
</style>
</head>
<body>
<main>
<h1>SEO Rich Fixture</h1>
<p>This page exercises generator-level SEO inventory, metadata emission, icons, manifests, structured data, and llms.txt preservation.</p>
<section class="panel">
<h2>Captured content</h2>
<p>The generated llms fallback can summarize useful page and route content when the source does not expose its own llms.txt.</p>
</section>
</main>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
{
"name": "SEO Rich Fixture",
"short_name": "SEOFixture",
"icons": [
{
"src": "/manifest-icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/brand.svg",
"sizes": "any",
"type": "image/svg+xml"
}
],
"theme_color": "#123456",
"background_color": "#ffffff",
"display": "standalone"
}
+75
View File
@@ -0,0 +1,75 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acme — Blog</title>
<style>
*{box-sizing:border-box} body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;color:#1a1a1a;background:#fff}
header{border-bottom:1px solid #e5e7eb}
nav{max-width:960px;margin:0 auto;padding:16px 24px;display:flex;gap:20px;align-items:center}
nav .brand{font-weight:700;margin-right:auto}
nav a{color:#374151;text-decoration:none;font-size:15px;font-weight:500}
main{max-width:760px;margin:0 auto;padding:48px 24px}
h1{font-size:32px;margin:0 0 28px}
.posts{list-style:none;margin:0;padding:0}
.posts li{padding:20px 0;border-top:1px solid #eee}
.posts a{color:inherit;text-decoration:none;display:block}
.posts .date{font-size:13px;color:#9ca3af}
.posts h2{font-size:20px;margin:6px 0 4px}
.posts p{font-size:14px;color:#6b7280;line-height:1.6;margin:0}
footer{max-width:960px;margin:0 auto;padding:32px 24px;color:#9ca3af;font-size:13px;border-top:1px solid #e5e5e5}
</style>
</head>
<body>
<header>
<nav aria-label="Primary">
<span class="brand">Acme</span>
<a href="/">Home</a>
<a href="blog.html">Blog</a>
<a href="faq.html">FAQ</a>
</nav>
</header>
<main>
<h1>From the blog</h1>
<ul class="posts">
<li>
<a href="blog.html">
<span class="date">Jun 2, 2026</span>
<h2>Announcing the alpha release</h2>
<p>A first look at everything that shipped in our earliest public build.</p>
</a>
</li>
<li>
<a href="blog.html">
<span class="date">May 21, 2026</span>
<h2>A practical guide to CSS grid</h2>
<p>Lay out responsive collections without fighting the box model.</p>
</a>
</li>
<li>
<a href="blog.html">
<span class="date">May 9, 2026</span>
<h2>Shipping faster pages in 2026</h2>
<p>The performance budget we hold every release to, and why it matters.</p>
</a>
</li>
<li>
<a href="blog.html">
<span class="date">Apr 27, 2026</span>
<h2>Rethinking our color system</h2>
<p>How we moved to semantic tokens and what we learned along the way.</p>
</a>
</li>
<li>
<a href="blog.html">
<span class="date">Apr 14, 2026</span>
<h2>Highlights from the meetup</h2>
<p>Talks, demos, and conversations from our first in-person gathering.</p>
</a>
</li>
</ul>
</main>
<footer>&copy; 2026 Acme, Inc. All rights reserved.</footer>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acme — FAQ</title>
<style>
*{box-sizing:border-box} body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;color:#1a1a1a;background:#fff}
header{border-bottom:1px solid #e5e7eb}
nav{max-width:960px;margin:0 auto;padding:16px 24px;display:flex;gap:20px;align-items:center}
nav .brand{font-weight:700;margin-right:auto}
nav a{color:#374151;text-decoration:none;font-size:15px;font-weight:500}
main{max-width:760px;margin:0 auto;padding:48px 24px}
h1{font-size:32px;margin:0 0 24px}
.acc{border:1px solid #e5e7eb;border-radius:8px;overflow:hidden}
.acc-item+.acc-item{border-top:1px solid #e5e7eb}
.acc-trigger{appearance:none;width:100%;text-align:left;border:0;background:#fafafa;padding:16px 18px;font-size:16px;cursor:pointer;color:#1a1a1a}
.acc-trigger[aria-expanded=true]{background:#eef2ff;color:#4f46e5}
.acc-panel{padding:4px 18px 20px;font-size:15px;line-height:1.6;color:#4b5563}
.acc-panel[hidden]{display:none}
footer{max-width:960px;margin:0 auto;padding:32px 24px;color:#9ca3af;font-size:13px;border-top:1px solid #e5e5e5}
</style>
</head>
<body>
<header>
<nav aria-label="Primary">
<span class="brand">Acme</span>
<a href="/">Home</a>
<a href="blog.html">Blog</a>
<a href="faq.html">FAQ</a>
</nav>
</header>
<main>
<h1>Frequently asked questions</h1>
<div class="acc">
<div class="acc-item">
<button class="acc-trigger" id="a1" aria-controls="s1" aria-expanded="true">How do I get started?</button>
<div class="acc-panel" id="s1" role="region" aria-labelledby="a1"><p>Sign up for the Starter plan and follow the onboarding guide.</p></div>
</div>
<div class="acc-item">
<button class="acc-trigger" id="a2" aria-controls="s2" aria-expanded="false">Can I upgrade later?</button>
<div class="acc-panel" id="s2" role="region" aria-labelledby="a2" hidden><p>Yes — upgrade to Pro anytime from your account settings.</p></div>
</div>
<div class="acc-item">
<button class="acc-trigger" id="a3" aria-controls="s3" aria-expanded="false">Do you offer refunds?</button>
<div class="acc-panel" id="s3" role="region" aria-labelledby="a3" hidden><p>We offer a 30-day money-back guarantee on all paid plans.</p></div>
</div>
</div>
</main>
<footer>&copy; 2026 Acme, Inc. All rights reserved.</footer>
<script>
document.querySelectorAll('.acc-trigger').forEach((btn)=>{btn.addEventListener('click',()=>{const open=btn.getAttribute('aria-expanded')==='true';btn.setAttribute('aria-expanded',open?'false':'true');const p=document.getElementById(btn.getAttribute('aria-controls'));if(p)p.hidden=open;});});
</script>
</body>
</html>
+71
View File
@@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Acme — Home</title>
<style>
*{box-sizing:border-box} body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;margin:0;color:#1a1a1a;background:#fff}
header{border-bottom:1px solid #e5e7eb}
nav{max-width:960px;margin:0 auto;padding:16px 24px;display:flex;gap:20px;align-items:center}
nav .brand{font-weight:700;margin-right:auto}
nav a{color:#374151;text-decoration:none;font-size:15px;font-weight:500}
main{max-width:960px;margin:0 auto;padding:48px 24px}
h1{font-size:34px;margin:0 0 8px}
.intro{color:#6b7280;font-size:17px;margin:0 0 40px}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:24px}
.feature{border:1px solid #e5e7eb;border-radius:12px;padding:22px}
.feature .ico{width:36px;height:36px;border-radius:8px;background:#eef2ff}
.feature h3{font-size:18px;margin:14px 0 6px}
.feature p{font-size:14px;color:#6b7280;line-height:1.6;margin:0}
footer{max-width:960px;margin:0 auto;padding:32px 24px;color:#9ca3af;font-size:13px;border-top:1px solid #e5e5e5}
</style>
</head>
<body>
<header>
<nav aria-label="Primary">
<span class="brand">Acme</span>
<a href="/">Home</a>
<a href="blog.html">Blog</a>
<a href="faq.html">FAQ</a>
</nav>
</header>
<main>
<h1>Build faster with Acme</h1>
<p class="intro">Everything your team needs to ship, in one place.</p>
<div class="grid">
<div class="feature">
<div class="ico"></div>
<h3>Fast by default</h3>
<p>Sensible defaults and zero-config builds get you to production quickly.</p>
</div>
<div class="feature">
<div class="ico"></div>
<h3>Collaborative</h3>
<p>Share previews and gather feedback without leaving your editor.</p>
</div>
<div class="feature">
<div class="ico"></div>
<h3>Secure</h3>
<p>SSO, audit logs, and granular permissions for every workspace.</p>
</div>
<div class="feature">
<div class="ico"></div>
<h3>Observable</h3>
<p>Built-in metrics and tracing so you always know what shipped.</p>
</div>
<div class="feature">
<div class="ico"></div>
<h3>Extensible</h3>
<p>A plugin API and webhooks connect Acme to the rest of your stack.</p>
</div>
<div class="feature">
<div class="ico"></div>
<h3>Supported</h3>
<p>Priority support and a responsive community keep you unblocked.</p>
</div>
</div>
</main>
<footer>&copy; 2026 Acme, Inc. All rights reserved.</footer>
</body>
</html>
+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>/seo-rich.html</loc></url>
</urlset>
Binary file not shown.

After

Width:  |  Height:  |  Size: 374 B

+670
View File
@@ -0,0 +1,670 @@
{
"name": "clone-static",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clone-static",
"version": "0.1.0",
"dependencies": {
"pixelmatch": "5.3.0",
"playwright": "1.56.1",
"pngjs": "7.0.0"
},
"bin": {
"clone-static": "src/cli.ts"
},
"devDependencies": {
"@types/node": "22.10.5",
"@types/pixelmatch": "5.2.6",
"@types/pngjs": "6.0.5",
"tsx": "4.22.4",
"typescript": "5.7.3"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.10.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz",
"integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.20.0"
}
},
"node_modules/@types/pixelmatch": {
"version": "5.2.6",
"resolved": "https://registry.npmjs.org/@types/pixelmatch/-/pixelmatch-5.2.6.tgz",
"integrity": "sha512-wC83uexE5KGuUODn6zkm9gMzTwdY5L0chiK+VrKcDfEjzxh1uadlWTvOmAbCpnM9zx/Ww3f8uKlYQVnO/TrqVg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pngjs": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/@types/pngjs/-/pngjs-6.0.5.tgz",
"integrity": "sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/esbuild": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.1",
"@esbuild/android-arm": "0.28.1",
"@esbuild/android-arm64": "0.28.1",
"@esbuild/android-x64": "0.28.1",
"@esbuild/darwin-arm64": "0.28.1",
"@esbuild/darwin-x64": "0.28.1",
"@esbuild/freebsd-arm64": "0.28.1",
"@esbuild/freebsd-x64": "0.28.1",
"@esbuild/linux-arm": "0.28.1",
"@esbuild/linux-arm64": "0.28.1",
"@esbuild/linux-ia32": "0.28.1",
"@esbuild/linux-loong64": "0.28.1",
"@esbuild/linux-mips64el": "0.28.1",
"@esbuild/linux-ppc64": "0.28.1",
"@esbuild/linux-riscv64": "0.28.1",
"@esbuild/linux-s390x": "0.28.1",
"@esbuild/linux-x64": "0.28.1",
"@esbuild/netbsd-arm64": "0.28.1",
"@esbuild/netbsd-x64": "0.28.1",
"@esbuild/openbsd-arm64": "0.28.1",
"@esbuild/openbsd-x64": "0.28.1",
"@esbuild/openharmony-arm64": "0.28.1",
"@esbuild/sunos-x64": "0.28.1",
"@esbuild/win32-arm64": "0.28.1",
"@esbuild/win32-ia32": "0.28.1",
"@esbuild/win32-x64": "0.28.1"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pixelmatch": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz",
"integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==",
"license": "ISC",
"dependencies": {
"pngjs": "^6.0.0"
},
"bin": {
"pixelmatch": "bin/pixelmatch"
}
},
"node_modules/pixelmatch/node_modules/pngjs": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz",
"integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==",
"license": "MIT",
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/playwright": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.1.tgz",
"integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.56.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.56.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.1.tgz",
"integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/pngjs": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
"integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==",
"license": "MIT",
"engines": {
"node": ">=14.19.0"
}
},
"node_modules/tsx": {
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tsx/node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"license": "MIT"
}
}
}
+38
View File
@@ -0,0 +1,38 @@
{
"name": "clone-static",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"clone-static": "./src/cli.ts"
},
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"scripts": {
"clone": "tsx src/cli.ts",
"clone-site": "tsx src/site/cloneSite.ts",
"validate-site": "tsx src/site/validateSite.ts",
"bench": "tsx src/runner/benchmark.ts",
"bench-site": "tsx src/runner/siteBenchmark.ts",
"validate-one": "tsx src/runner/validateOne.ts",
"quality": "tsx src/runner/qualityScore.ts",
"audit": "tsx src/runner/codeAudit.ts",
"regen": "tsx src/runner/regen.ts",
"test": "node --import tsx --test test/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"playwright": "1.56.1",
"pixelmatch": "5.3.0",
"pngjs": "7.0.0"
},
"devDependencies": {
"tsx": "4.22.4",
"typescript": "5.7.3",
"@types/node": "22.10.5",
"@types/pixelmatch": "5.2.6",
"@types/pngjs": "6.0.5"
}
}
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Classify width-varying nodes in a DENSE capture by their sizing law, using the full
sample curve. Run after a dense clone: python3 scripts/analyze-dense.py <run/.clone> """
import json, sys, statistics, glob, os
run = sys.argv[1] if len(sys.argv) > 1 else "output-dense/sample/.clone"
caps = {}
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
vp = int(os.path.basename(f)[4:-5])
caps[vp] = json.load(open(f))["root"]
VPS = sorted(caps)
print("sample widths:", VPS)
def flat(n, a):
if isinstance(n, dict):
a.append(n)
for c in (n.get("children") or []): flat(c, a)
F = {vp: [] for vp in VPS}
for vp in VPS: flat(caps[vp], F[vp])
def wp(n, p, m):
m[id(n)] = p
for c in (n.get("children") or []): wp(c, n, m)
PM = {vp: {} for vp in VPS}
for vp in VPS: wp(caps[vp], None, PM[vp])
def pf(v):
try: return float(str(v).replace("px", ""))
except: return 0.0
n = min(len(F[vp]) for vp in VPS)
cats = {"fills_container": 0, "prop_container": 0, "prop_viewport": 0, "clamped_maxw": 0,
"shrink_recoverable": 0, "real_breakpoint": 0, "fixed": 0, "unknown": 0}
for i in range(n):
nd = {vp: F[vp][i] for vp in VPS}
if not all(nd[vp].get("visible") for vp in VPS): continue
w = {vp: (nd[vp].get("bbox") or {}).get("width", 0) for vp in VPS}
if max(w.values()) - min(w.values()) <= 2: continue # constant → no band
# container content width per vp
cw = {}
for vp in VPS:
p = PM[vp].get(id(nd[vp])); pb = (p or {}).get("bbox"); pcs = (p or {}).get("computed") or {}
base = pb["width"] if pb else vp
cw[vp] = base - pf(pcs.get("paddingLeft")) - pf(pcs.get("paddingRight"))
rc = [w[vp] / cw[vp] for vp in VPS if cw[vp] > 0]
rv = [w[vp] / vp for vp in VPS]
def cv(xs):
m = statistics.mean(xs); return statistics.pstdev(xs) / m if m else 9
wl = [w[vp] for vp in VPS]
if rc and cv(rc) < 0.02 and abs(statistics.mean(rc) - 1) < 0.02: cats["fills_container"] += 1
elif rc and cv(rc) < 0.03: cats["prop_container"] += 1
elif cv(rv) < 0.03: cats["prop_viewport"] += 1
elif wl[-1] == wl[-2] and wl[0] < wl[-1]: # plateau at the widest → clamp or recoverable shrink
# constant above a knee, smaller below
cats["clamped_maxw"] += 1
elif wl[-1] < wl[-2]: # still shrinking even at the widest → natural beyond range
cats["shrink_recoverable"] += 1
else:
# piecewise? count distinct plateaus
cats["unknown"] += 1
print("\nwidth-varying nodes by inferred sizing law (dense):")
for k, v in cats.items(): print(f" {k:20} {v}")
print(f" TOTAL varying: {sum(cats.values())}")
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""For every node whose width VARIES across viewports (=> would emit a width band),
classify the underlying law: hairline / parent-fill / fraction-of-parent / step.
Tells us how many bands each recovery law would eliminate."""
import json, glob, os, sys, collections
run = sys.argv[1] if len(sys.argv) > 1 else "compiler/output/sample/.clone"
caps = {}
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
caps[int(os.path.basename(f)[4:-5])] = json.load(open(f))["root"]
VPS = sorted(caps)
# flatten each viewport in pre-order, aligned by index
F = {vp: [] for vp in VPS}
PARENT = []
def flat(n, a, parent_idx, store_parent):
idx = len(a); a.append(n)
if store_parent: PARENT.append(parent_idx)
for c in (n.get("children") or []):
flat(c, a, idx, store_parent)
for vp in VPS:
flat(caps[vp], F[vp], -1, vp == VPS[0])
N = len(F[VPS[0]])
def pf(v):
try: return float(str(v).replace("px",""))
except: return 0.0
def cs(i, vp): return F[vp][i].get("computed") or {}
def bb(i, vp): return F[vp][i].get("bbox") or {}
def vis(i, vp):
if not F[vp][i].get("visible"): return False
if (cs(i,vp).get("display")=="none"): return False
return True
def width(i, vp): return bb(i, vp).get("width", 0)
def parent_content_w(i, vp):
p = PARENT[i]
if p < 0: return bb(i,vp).get("width",0)
pc = cs(p, vp)
return bb(p,vp).get("width",0) - pf(pc.get("paddingLeft")) - pf(pc.get("paddingRight")) - pf(pc.get("borderLeftWidth")) - pf(pc.get("borderRightWidth"))
FRACTIONS = {1/2:"1/2",1/3:"1/3",2/3:"2/3",1/4:"1/4",3/4:"3/4",1/5:"1/5",2/5:"2/5",3/5:"3/5",4/5:"4/5",1/6:"1/6",5/6:"5/6"}
cat = collections.Counter()
band_cat = collections.Counter()
examples = collections.defaultdict(list)
for i in range(N):
ws = {vp: width(i,vp) for vp in VPS if vis(i,vp)}
if len(ws) < 2: continue
wv = list(ws.values())
spread = max(wv) - min(wv)
if spread <= 1.0: continue # constant => no band
# how many band variants would this node emit? (# distinct rounded widths across the 4 band vps) - 1
distinct = len(set(round(w) for w in wv))
bands = distinct - 1
if bands < 1: continue
# --- classify ---
if max(wv) < 2.0:
c = "hairline(<2px)"
else:
ratios = []
ok_parent = True
for vp, w in ws.items():
pcw = parent_content_w(i, vp)
if pcw <= 0: ok_parent = False; break
ratios.append(w / pcw)
if not ok_parent:
c = "no-parent"
elif max(ratios) - min(ratios) < 0.03 and abs(sum(ratios)/len(ratios) - 1.0) < 0.03:
c = "parent-fill(100%)"
else:
avg = sum(ratios)/len(ratios)
# nearest simple fraction
best = min(FRACTIONS, key=lambda f: abs(f-avg))
if max(ratios)-min(ratios) < 0.03 and abs(best-avg) < 0.015:
c = f"fraction~{FRACTIONS[best]}"
elif max(ratios)-min(ratios) < 0.04:
c = "const-%(other)"
else:
c = "step/other"
cat[c] += 1
band_cat[c] += bands
if len(examples[c]) < 4:
examples[c].append((round(min(wv),1), round(max(wv),1), [round(width(i,vp)) for vp in VPS]))
print(f"nodes with width-bands: {sum(cat.values())} total band-variants: {sum(band_cat.values())}")
print(f"{'category':22} {'nodes':>6} {'bands':>6}")
for c,_ in band_cat.most_common():
print(f"{c:22} {cat[c]:>6} {band_cat[c]:>6} eg {examples[c][:3]}")
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Replicate the (updated) reconstructFlexRow and report success + bail reasons + band impact."""
import json, glob, os, sys, collections
run = sys.argv[1] if len(sys.argv) > 1 else "compiler/output-dense/sample/.clone"
caps = {}
for f in sorted(glob.glob(os.path.join(run, "source/capture/dom-*.json"))):
caps[int(os.path.basename(f)[4:-5])] = json.load(open(f))["root"]
VPS = sorted(caps); BAND_VPS = [v for v in (375, 768, 1280, 1920) if v in VPS]
acc = []
def walkidx(n):
n["_i"] = len(acc); acc.append(n); n["_kids"] = []
for c in (n.get("children") or []):
n["_kids"].append(len(acc)); walkidx(c)
walkidx(caps[VPS[0]])
N = len(acc)
# per-vp flat lists aligned by index
F = {vp: [] for vp in VPS}
def flat(n, a):
a.append(n)
for c in (n.get("children") or []): flat(c, a)
for vp in VPS: flat(caps[vp], F[vp])
def pf(v):
try: return float(str(v).replace("px", ""))
except: return 0.0
def CS(i, vp): return F[vp][i].get("computed") or {}
def BB(i, vp): return F[vp][i].get("bbox") or {}
def VIS(i, vp): return F[vp][i].get("visible")
reasons = collections.Counter(); succ = 0; succ_items = 0; bands_removed = 0
for i in range(N):
c0 = CS(i, VPS[0]); disp = c0.get("display", "")
if "flex" not in disp: continue
if c0.get("flexDirection", "row") not in ("row", "row-reverse"): reasons["not_row"] += 1; continue
if (c0.get("flexWrap") or "nowrap") != "nowrap": reasons["wrap"] += 1; continue
info = []; bail = None
for k in acc[i]["_kids"]:
if not acc[k].get("tag"): continue
ck = CS(k, VPS[0]); pos = ck.get("position", "static")
if pos in ("absolute", "fixed"): continue
if (ck.get("float") or "none") != "none": continue
if ck.get("marginLeft") == "auto" or ck.get("marginRight") == "auto": bail = "auto_margin"; break
if (ck.get("boxSizing") or "border-box") == "content-box": bail = "content_box"; break
if pf(ck.get("flexGrow")) > 0: bail = "grow"; break
basis = ck.get("flexBasis") or "auto"
if basis != "auto" and not basis.endswith("px"): bail = "basis_pct"; break
info.append((k, pf(ck.get("flexShrink")), pf(basis) if basis.endswith("px") else None,
pf(ck.get("minWidth")) if (ck.get("minWidth") or "").endswith("px") else 0,
pf(ck.get("maxWidth")) if (ck.get("maxWidth") or "").endswith("px") else float("inf")))
if bail: reasons[bail] += 1; continue
if not info: continue
used = {}; margin = {}; cw = {}; gap = {}
for vp in VPS:
pcs = CS(i, vp); pb = BB(i, vp)
cw[vp] = pb.get("width", 0) - pf(pcs.get("paddingLeft")) - pf(pcs.get("paddingRight"))
gap[vp] = pf(pcs.get("columnGap") if pcs.get("columnGap") not in (None, "normal") else pcs.get("gap"))
u = []; m = []
for (k, sh, bp, mn, mx) in info:
ck = CS(k, vp); hidden = (not VIS(k, vp)) or ck.get("display") == "none"
u.append(None if hidden else BB(k, vp).get("width", 0)); m.append(0 if hidden else pf(ck.get("marginLeft")) + pf(ck.get("marginRight")))
used[vp] = u; margin[vp] = m
def slack(vp):
vis = [j for j in range(len(info)) if used[vp][j] is not None]
return cw[vp] - sum(used[vp][j] for j in vis) - (gap[vp] * max(0, len(vis) - 1) + sum(margin[vp][j] for j in vis))
bases = []; ok = True
for j, (k, sh, bp, mn, mx) in enumerate(info):
if bp is not None: bases.append(bp); continue
base = None
for v in range(len(VPS) - 1, -1, -1):
vp = VPS[v]
if used[vp][j] is None: continue
if slack(vp) >= -0.5: base = used[vp][j]; break
if base is None: ok = False; break
bases.append(base)
if not ok: reasons["no_base"] += 1; continue
bad = False
for vp in VPS:
vis = [j for j in range(len(info)) if used[vp][j] is not None]
if not vis: continue
hyp = [min(max(bases[j], info[j][3]), info[j][4]) for j in vis]
gaps = gap[vp] * max(0, len(vis) - 1) + sum(margin[vp][j] for j in vis)
free = cw[vp] - sum(hyp) - gaps
sim = list(hyp)
if free < -0.5:
ts = sum(info[vis[k]][1] * hyp[k] for k in range(len(vis)))
if ts > 0:
for k in range(len(vis)): sim[k] = max(hyp[k] - abs(free) * (info[vis[k]][1] * hyp[k]) / ts, info[vis[k]][3])
for k in range(len(vis)):
if abs(sim[k] - used[vp][vis[k]]) > 1: bad = True
if bad: reasons["sim_mismatch"] += 1; continue
varies = [j for j in range(len(info)) if len([used[vp][j] for vp in VPS if used[vp][j] is not None]) >= 2 and max(x for vp in VPS if (x:=used[vp][j]) is not None) - min(x for vp in VPS if (x:=used[vp][j]) is not None) > 8]
if not varies: reasons["no_variation"] += 1; continue
succ += 1; succ_items += len(info)
# estimate band variants removed: for each varying item, count distinct band-vp widths != base-vp
for j in varies:
bandw = set(round(used[vp][j]) for vp in BAND_VPS if used[vp][j] is not None)
bands_removed += max(0, len(bandw) - 1)
print(f"SUCCESS lines: {succ} items: {succ_items} est. width-band variants removed: ~{bands_removed}")
for k, v in reasons.most_common(): print(f" bail {k:16} {v}")
+43
View File
@@ -0,0 +1,43 @@
// Run a generated app in Next DEV mode (unminified React) and print hydration errors.
import { spawn } from "node:child_process";
import { cpSync, rmSync, existsSync } from "node:fs";
import { chromium } from "playwright";
const appDir = process.argv[2];
const harness = new URL("../.harness", import.meta.url).pathname;
const PORT = 3987;
for (const s of ["src", "public", "next.config.mjs", "tsconfig.json", "next-env.d.ts", ".next", "out"]) {
const p = `${harness}/${s}`;
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
}
cpSync(`${appDir}/src`, `${harness}/src`, { recursive: true });
if (existsSync(`${appDir}/public`)) cpSync(`${appDir}/public`, `${harness}/public`, { recursive: true });
for (const f of ["next.config.mjs", "tsconfig.json", "next-env.d.ts"]) {
if (existsSync(`${appDir}/${f}`)) cpSync(`${appDir}/${f}`, `${harness}/${f}`);
}
const dev = spawn("./node_modules/.bin/next", ["dev", "-p", String(PORT)], {
cwd: harness, env: { ...process.env, NEXT_TELEMETRY_DISABLED: "1" },
});
let ready = false;
dev.stdout.on("data", (d) => { if (/Ready in|Local:|started server/i.test(d.toString())) ready = true; });
dev.stderr.on("data", () => {});
const t0 = Date.now();
while (!ready && Date.now() - t0 < 90000) await new Promise((r) => setTimeout(r, 500));
await new Promise((r) => setTimeout(r, 4000));
const browser = await chromium.launch();
const page = await browser.newPage();
const msgs = [];
page.on("console", (m) => { msgs.push(m.text()); });
page.on("pageerror", (e) => msgs.push("PAGEERROR: " + e.message));
try { await page.goto(`http://localhost:${PORT}/`, { waitUntil: "networkidle", timeout: 60000 }); } catch (e) { msgs.push("goto: " + e.message); }
await new Promise((r) => setTimeout(r, 3000));
console.log("=== ALL CONSOLE MESSAGES (" + msgs.length + ") ===");
for (const e of msgs.slice(0, 12)) console.log("\n---\n" + e.slice(0, 1500));
await browser.close();
dev.kill("SIGKILL");
process.exit(0);
+50
View File
@@ -0,0 +1,50 @@
// Generate examples/<tier>/RESULTS.md from the latest run per site.
import { readFileSync, readdirSync, existsSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const ROOT = new URL("../..", import.meta.url).pathname;
const RUNS = join(ROOT, "runs");
const tier = process.argv[2] || "stage2";
const sites = JSON.parse(readFileSync(join(ROOT, "compiler", "benchmarks", `${tier}.json`), "utf8"));
function siteId(url) {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
const path = u.pathname.replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
return (host + (path ? "-" + path : "")).replace(/[^a-zA-Z0-9.-]/g, "-").slice(0, 80);
}
function latest(host) {
const dir = join(RUNS, host);
if (!existsSync(dir)) return null;
const runs = readdirSync(dir).filter((d) => /^\d/.test(d)).sort();
for (let i = runs.length - 1; i >= 0; i--) {
if (existsSync(join(dir, runs[i], "validation", "report.json"))) return join(dir, runs[i]);
}
return null;
}
let g06 = 0, s2 = 0, sum = 0, n = 0;
const rows = [];
for (const s of sites) {
const run = latest(siteId(s.url));
if (!run) { rows.push(`| ${s.id} | ${s.url} | — | — | no run |`); continue; }
const r = JSON.parse(readFileSync(join(run, "validation", "report.json"), "utf8"));
n++; sum += r.scorecard.total; if (r.gates0to6Pass) g06++; if (r.stage2Pass) s2++;
const fails = Object.entries(r.gates).filter(([, gg]) => !gg.pass).map(([k]) => k).join(",");
const host = new URL(s.url).hostname.replace(/^www\./, "") + new URL(s.url).pathname.replace(/\/$/, "");
rows.push(`| ${s.id} | ${host} | ${r.scorecard.total} | ${r.gates0to6Pass ? "PASS" : "FAIL"} | ${r.stage2Pass ? "PASS" : "FAIL"} | ${fails || ""} |`);
}
const avg = Math.round((sum / n) * 10) / 10;
const md = `# Stage 2 — results (capture-state correctness)
**${g06}/${n} pass gates 0-6; ${s2}/${n} pass the stricter stage-2 bar** (gates 0-6 + non-degenerate capture + perceptually-close render), average ${avg}.
Stage 2 = popup/video/animation pages where the captured frame must be the settled, unobstructed state. Stage-2 gates: **pollution** (degenerate/wall/blocking-modal) and **perceptual** (tier-thresholded screenshot diff).
| id | site | score | gates0-6 | stage2 | failing |
|----|------|------:|:--------:|:------:|---------|
${rows.join("\n")}
Documented residuals (limitations, not defects): wix (heavy-JS site-builder — custom-element carousel positioned by script we don't run); warbyparker/squarespace (dynamic/autoplay hero — perceptual-only, frame-to-frame non-deterministic).
`;
writeFileSync(join(ROOT, "examples", tier, "RESULTS.md"), md);
console.log(`wrote examples/${tier}/RESULTS.md — gates0-6 ${g06}/${n}, stage2 ${s2}/${n}, avg ${avg}`);
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Human-tell diagnostic: count patterns a human would (almost) never hand-write.
Separate from codeAudit.ts — this is the "would a human write this?" lens, focused on
specific robotic constructs, scanned over the .tsx/.css of each tree. Prints a column per tree.
Usage: human-tells.py <label=dir> ...
"""
import re, sys, os
def read_tree(d):
tsx, css = [], []
for root, dirs, files in os.walk(d):
dirs[:] = [x for x in dirs if x not in ("node_modules", ".next", "out") and not x.startswith(".")]
for f in files:
p = os.path.join(root, f)
try:
s = open(p, encoding="utf8").read()
except Exception:
continue
if f.endswith((".tsx", ".jsx", ".ts")):
tsx.append(s)
elif f.endswith(".css"):
css.append(s)
return "\n".join(tsx), "\n".join(css)
def count(pat, s):
return len(re.findall(pat, s))
TELLS = {
# noise / redundancy — almost never hand-written
"noop relative inset (top-0 right-0 bottom-0 left-0)": (lambda tsx, css: count(r'\btop-0 right-0 bottom-0 left-0\b', tsx), True),
"redundant gap+gap-x+gap-y (same n)": (lambda tsx, css: len([m for m in re.finditer(r'\bgap-(\d+(?:\.\d+)?) gap-y-(\d+(?:\.\d+)?) gap-x-(\d+(?:\.\d+)?)', tsx) if m.group(1)==m.group(2)==m.group(3)]), True),
"4-side longhand pad (pt-x pr-y pb-x pl-y)": (lambda tsx, css: count(r'\bpt-\S+ pr-\S+ pb-\S+ pl-\S+', tsx), True),
"per-side border arbitrary [border-*]": (lambda tsx, css: count(r'\[border-(?:top|right|bottom|left)-(?:style|color|width):', tsx+css), True),
"baked runtime id (radix/_R_/:r)": (lambda tsx, css: count(r'id=\"(?:radix-|[^\"]*_R_|:r)', tsx), True),
"[text-align:inherit] override": (lambda tsx, css: count(r'\[text-align:inherit\]', tsx), True),
"data-cid shipped (jsx)": (lambda tsx, css: count(r'data-cid', tsx), True),
"data-cid selectors in ditto.css": (lambda tsx, css: count(r'data-cid', css), True),
# font-size arbitrary that maps to a named tailwind size (should be text-xs..text-9xl)
"font-size arbitrary mappable to named": (lambda tsx, css: len([m for m in re.finditer(r'text-\[(\d+(?:\.\d+)?)(px|rem)\]', tsx) if round((float(m.group(1))*(16 if m.group(2)=='rem' else 1)))/16 in NAMED_REM]), True),
# decimal arbitrary in JSX classes (sub-pixel frozen measurement)
"decimal arbitrary [N.NNpx/rem] (jsx)": (lambda tsx, css: len([m for m in re.finditer(r'\[(-?\d+\.?\d*)(px|rem)\]', tsx) if abs((v:=float(m.group(1))*(16 if m.group(2)=='rem' else 1))-round(v))>0.02]), True),
# decimal px in ditto.css (uncounted by codeAudit)
"decimal px in ditto.css (Npx)": (lambda tsx, css: len([m for m in re.finditer(r'(-?\d+\.\d+)px', css) if abs((v:=float(m.group(1)))-round(v))>0.02]), True),
"pseudo-element rules in ditto.css": (lambda tsx, css: count(r'::(?:before|after)\b', css), True),
}
# named tailwind text sizes in rem
NAMED_REM = {0.75, 0.875, 1.0, 1.125, 1.25, 1.5, 1.875, 2.25, 3.0, 3.75, 4.5, 6.0, 8.0}
def main():
trees = []
for a in sys.argv[1:]:
label, d = a.split("=", 1)
trees.append((label, read_tree(d)))
w0 = max(len(k) for k in TELLS)
cw = max(11, *(len(l) for l, _ in trees))
print("\n" + "metric".ljust(w0) + " " + "".join(l.ljust(cw) for l, _ in trees))
print("-" * (w0 + 2 + cw * len(trees)))
for k, (fn, _) in TELLS.items():
cells = "".join(str(fn(tsx, css)).ljust(cw) for _, (tsx, css) in trees)
print(k.ljust(w0) + " " + cells)
print()
main()
+129
View File
@@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Build before/after (source vs clone) screenshot composites for a tier.
For each site: a single PNG with a desktop row (1280px) and a mobile row (375px),
each showing SOURCE on the left and CLONE on the right, with labels.
"""
import json, os, sys, glob, re
from PIL import Image, ImageDraw, ImageFont
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
RUNS = os.path.join(ROOT, "runs")
TIER = sys.argv[1] if len(sys.argv) > 1 else "easy"
OUT = os.path.join(RUNS, f"compare-{TIER}")
os.makedirs(OUT, exist_ok=True)
def font(sz):
for p in ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
if os.path.exists(p):
return ImageFont.truetype(p, sz)
return ImageFont.load_default()
F_TITLE = font(30)
F_LABEL = font(22)
def host_of(url):
# Mirror siteIdFromUrl() in cli.ts: host (minus www) + slugified path.
from urllib.parse import urlparse
u = urlparse(url)
host = re.sub(r"^www\.", "", u.hostname or "")
path = re.sub(r"^-|-$", "", re.sub(r"[^a-zA-Z0-9]+", "-", u.path.rstrip("/")))
sid = host + (("-" + path) if path else "")
return re.sub(r"[^a-zA-Z0-9.-]", "-", sid)[:80]
def latest_run(host):
dirs = sorted(glob.glob(os.path.join(RUNS, host, "*")), reverse=True)
for d in dirs:
if (os.path.exists(os.path.join(d, "source", "screenshots")) and
os.path.exists(os.path.join(d, "rendered", "screenshots"))):
return d
return None
def load(path, scale, max_h):
if not os.path.exists(path):
return None
im = Image.open(path).convert("RGB")
w = int(im.width * scale)
h = int(im.height * scale)
im = im.resize((w, h), Image.LANCZOS)
if im.height > max_h: # cap very tall pages
im = im.crop((0, 0, im.width, max_h))
return im
PAD = 16
HEADER = 46 # per-row label strip
TITLE = 56 # site title strip
GAP = 10 # gap between source and clone
BG = (250, 250, 250)
INK = (20, 20, 20)
RULE = (210, 210, 210)
def row(src_path, clone_path, scale, max_h, label):
"""One labeled row: [SOURCE | CLONE] side by side, top-aligned."""
s = load(src_path, scale, max_h)
c = load(clone_path, scale, max_h)
if s is None and c is None:
return None
sw = s.width if s else (c.width if c else 0)
cw = c.width if c else sw
sh = s.height if s else 0
ch = c.height if c else 0
body_h = max(sh, ch)
W = sw + GAP + cw
H = HEADER + body_h
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
d.text((4, 10), label, font=F_LABEL, fill=INK)
d.text((4, HEADER - 4), "SOURCE", font=F_LABEL, fill=(90, 90, 90))
d.text((sw + GAP + 4, HEADER - 4), "CLONE", font=F_LABEL, fill=(90, 90, 90))
if s: img.paste(s, (0, HEADER))
if c: img.paste(c, (sw + GAP, HEADER))
# divider line between the pair
d.line([(sw + GAP // 2, HEADER), (sw + GAP // 2, H)], fill=RULE, width=2)
return img
def stack(title, rows):
rows = [r for r in rows if r is not None]
if not rows:
return None
W = max(r.width for r in rows) + PAD * 2
H = TITLE + sum(r.height for r in rows) + PAD * (len(rows) + 1)
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
d.text((PAD, 14), title, font=F_TITLE, fill=INK)
d.line([(0, TITLE - 4), (W, TITLE - 4)], fill=RULE, width=2)
y = TITLE + PAD
for r in rows:
img.paste(r, (PAD, y))
y += r.height + PAD
return img
def main():
sites = json.load(open(os.path.join(ROOT, "compiler", "benchmarks", f"{TIER}.json")))
made = []
for site in sites:
host = host_of(site["url"])
run = latest_run(host)
if not run:
print(f"SKIP {site['id']} {host}: no run with screenshots")
continue
ss = os.path.join(run, "source", "screenshots")
rs = os.path.join(run, "rendered", "screenshots")
title = f"{site['id']}{host}"
desktop = row(os.path.join(ss, "1280.png"), os.path.join(rs, "1280.png"),
0.5, 1600, "Desktop · 1280px viewport (shown at 50%)")
mobile = row(os.path.join(ss, "375.png"), os.path.join(rs, "375.png"),
1.0, 1800, "Mobile · 375px viewport (100%)")
img = stack(title, [desktop, mobile])
if img is None:
print(f"SKIP {site['id']}: no images")
continue
out = os.path.join(OUT, f"{site['id']}-{host}.png")
img.save(out, optimize=True)
made.append(out)
print(f"OK {out} ({img.width}x{img.height})")
print(f"\n{len(made)} composites -> {OUT}")
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""Build before/after (source vs clone) composites for the multi-page `sites` benchmark.
For each site: a stacked PNG of a few key routes (entry + listings/representatives),
each a labeled SOURCE|CLONE row at the desktop (1280px) viewport, titled with the
site's per-route pass summary. Mirrors make_compare.py but reads the site run layout
(runs/site-<host>/<ts>/routes/<key>/{source,rendered}/screenshots/<vp>.png).
"""
import json, os, sys, glob, re
from PIL import Image, ImageDraw, ImageFont
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
RUNS = os.path.join(ROOT, "runs")
OUT = os.path.join(RUNS, "compare-sites")
os.makedirs(OUT, exist_ok=True)
MAX_ROUTES = int(os.environ.get("MAX_ROUTES", "4"))
def font(sz):
for p in ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
if os.path.exists(p):
return ImageFont.truetype(p, sz)
return ImageFont.load_default()
F_TITLE, F_LABEL = font(30), font(20)
PAD, HEADER, TITLE, GAP = 16, 42, 60, 10
BG, INK, RULE = (250, 250, 250), (20, 20, 20), (210, 210, 210)
def host_of(url):
from urllib.parse import urlparse
u = urlparse(url)
host = re.sub(r"^www\.", "", u.hostname or "")
path = re.sub(r"^-|-$", "", re.sub(r"[^a-zA-Z0-9]+", "-", u.path.rstrip("/")))
sid = host + (("-" + path) if path else "")
return "site-" + re.sub(r"[^a-zA-Z0-9.-]", "-", sid)[:80]
def sanitize_seg(s):
out = re.sub(r"-+", "-", re.sub(r"[^A-Za-z0-9._-]", "-", s)).strip("-")
if out == "" or re.match(r"^[_(@.]", out):
out = "r-" + re.sub(r"^[_(@.]+", "", out)
return out or "r"
def route_key(path):
if path == "/":
return "home"
segs = [sanitize_seg(s) for s in path.split("/") if s]
return "__".join(segs) or "home"
def latest_run(host):
for d in sorted(glob.glob(os.path.join(RUNS, host, "*")), reverse=True):
if os.path.exists(os.path.join(d, "site-manifest.json")):
return d
return None
def load(path, scale, max_h):
if not os.path.exists(path):
return None
im = Image.open(path).convert("RGB")
im = im.resize((int(im.width * scale), int(im.height * scale)), Image.LANCZOS)
if im.height > max_h:
im = im.crop((0, 0, im.width, max_h))
return im
def row(src_path, clone_path, scale, max_h, label):
s, c = load(src_path, scale, max_h), load(clone_path, scale, max_h)
if s is None and c is None:
return None
sw = s.width if s else (c.width if c else 0)
cw = c.width if c else sw
body_h = max(s.height if s else 0, c.height if c else 0)
img = Image.new("RGB", (sw + GAP + cw, HEADER + body_h), BG)
d = ImageDraw.Draw(img)
d.text((4, 8), label, font=F_LABEL, fill=INK)
d.text((4, HEADER - 6), "SOURCE", font=F_LABEL, fill=(90, 90, 90))
d.text((sw + GAP + 4, HEADER - 6), "CLONE", font=F_LABEL, fill=(90, 90, 90))
if s: img.paste(s, (0, HEADER))
if c: img.paste(c, (sw + GAP, HEADER))
d.line([(sw + GAP // 2, HEADER), (sw + GAP // 2, img.height)], fill=RULE, width=2)
return img
def stack(title, rows):
rows = [r for r in rows if r is not None]
if not rows:
return None
W = max(r.width for r in rows) + PAD * 2
H = TITLE + sum(r.height for r in rows) + PAD * (len(rows) + 1)
img = Image.new("RGB", (W, H), BG)
d = ImageDraw.Draw(img)
d.text((PAD, 16), title, font=F_TITLE, fill=INK)
d.line([(0, TITLE - 4), (W, TITLE - 4)], fill=RULE, width=2)
y = TITLE + PAD
for r in rows:
img.paste(r, (PAD, y))
y += r.height + PAD
return img
def main():
sites = json.load(open(os.path.join(ROOT, "compiler", "benchmarks", "sites.json")))
made = []
for site in sites:
host = host_of(site["url"])
run = latest_run(host)
if not run:
print(f"SKIP {site['id']}: no run"); continue
man = json.load(open(os.path.join(run, "site-manifest.json")))
report = None
rp = os.path.join(run, "validation", "site-report.json")
if os.path.exists(rp):
report = json.load(open(rp))
summ = ""
if report:
summ = f"{report['routesGates0to6']}/{report['routesTotal']} routes pass gates 06"
routes = man.get("routes", [])[:MAX_ROUTES]
rows = []
for r in routes:
key = route_key(r["routePath"])
ss = os.path.join(run, "routes", key, "source", "screenshots", "1280.png")
rs = os.path.join(run, "routes", key, "rendered", "screenshots", "1280.png")
rows.append(row(ss, rs, 0.5, 1400, f"{r.get('role','page')} · {r['href']}"))
img = stack(f"{site['id']} ({host}){summ}", rows)
if img is None:
print(f"SKIP {site['id']}: no screenshots"); continue
out = os.path.join(OUT, f"{site['id']}.png")
img.save(out, optimize=True)
made.append(out)
print(f"OK {out} ({img.width}x{img.height})")
print(f"\n{len(made)} composites -> {OUT}")
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
// Probe the recent-highlights container width vs its parent across desktop widths — confirm it
// FILLS (no gutter/snap) instead of freezing at a baked w-310.
import { chromium } from "playwright";
const url = process.argv[2] || "http://127.0.0.1:8139";
const widths = [1280, 1400, 1536, 1700, 1920];
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
// the grid that holds the highlight cards: a grid with grid-cols and >=3 <a> children
const ok = await page.evaluate(() => {
const g = [...document.querySelectorAll("div")].find(
(d) => /grid/.test(getComputedStyle(d).display) && d.querySelectorAll(":scope > div > article, :scope > div > a, :scope > a").length >= 3
&& d.closest("section")?.textContent?.includes("Recent highlights")
);
if (!g) return false;
g.setAttribute("data-hl", "1");
return true;
});
if (!ok) { console.log("recent-highlights grid not found"); await browser.close(); process.exit(0); }
console.log(`\n=== ${url} (recent-highlights) ===`);
for (const w of widths) {
await page.setViewportSize({ width: w, height: 1000 });
await page.waitForTimeout(200);
const d = await page.evaluate(() => {
const g = document.querySelector("[data-hl]");
const p = g.parentElement;
const gw = g.getBoundingClientRect().width;
const pw = p.getBoundingClientRect().width;
return { gw: Math.round(gw), pw: Math.round(pw), gutter: Math.round(pw - gw) };
});
console.log(`w=${String(w).padEnd(5)} grid=${d.gw} parent=${d.pw} gutter=${d.gutter}px ${d.gutter > 4 ? "**GUTTER**" : "fills"}`);
}
await browser.close();
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Reproduce the compiler environment from a clean checkout.
# - installs compiler deps
# - installs the Chromium build Playwright needs
# - installs the shared build harness deps (Next/Vite/React) used to build & render
# every generated app during validation
# Safe to re-run; each step is idempotent.
set -euo pipefail
cd "$(dirname "$0")/.."
echo "[setup] installing compiler deps"
npm install
echo "[setup] installing Playwright chromium"
npx playwright install chromium
echo "[setup] installing build harness deps"
( cd .harness && npm install )
echo "[setup] done. Try: npm run clone -- https://example.com/"
+51
View File
@@ -0,0 +1,51 @@
// Hypothesis test: does adding -webkit-line-clamp:5 to quote cards make their
// heights uniform? Inject the clamp at runtime and re-measure heights.
import { chromium } from "playwright";
const url = process.argv[2] || "http://127.0.0.1:8139";
const widths = [1024, 1100, 1200, 1280, 1440];
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto(url, { waitUntil: "networkidle", timeout: 60000 });
const gridSel = await page.evaluate(() => {
const grids = [...document.querySelectorAll("div")].filter(
(d) => getComputedStyle(d).display === "grid" && d.querySelector("blockquote")
);
if (!grids[0]) return null;
grids[0].setAttribute("data-probe-grid", "1");
return true;
});
if (!gridSel) { console.log("no grid"); await browser.close(); process.exit(0); }
const measure = async (label) => {
console.log(`\n--- ${label} ---`);
for (const w of widths) {
await page.setViewportSize({ width: w, height: 1000 });
await page.waitForTimeout(200);
const d = await page.evaluate(() => {
const grid = document.querySelector('[data-probe-grid]');
const vis = [...grid.children].filter((c) => getComputedStyle(c).display !== "none");
const H = vis.map((c) => Math.round((c.querySelector(":scope > div") || c).getBoundingClientRect().height * 10) / 10);
return { cols: getComputedStyle(grid).gridTemplateColumns.split(" ").length, H, uniq: [...new Set(H)].length };
});
console.log(`w=${String(w).padEnd(5)} cols=${d.cols} H=${JSON.stringify(d.H)} ${d.uniq === 1 ? "UNIFORM" : "**NONUNIFORM " + d.uniq + "**"}`);
}
};
await measure("BEFORE (current clone)");
// (0) h-full ONLY (no line-clamp) — does filling the definite grid cell alone equalize within rows?
await page.addStyleTag({
content: `[data-probe-grid] > div > div { height:100% !important; }
[data-probe-grid] figure { height:100% !important; }`,
});
await measure("AFTER-0 (card/figure h-full ONLY, no line-clamp)");
// (1) add line-clamp:5 on top.
await page.addStyleTag({
content: `[data-probe-grid] blockquote p { display:-webkit-box !important; -webkit-box-orient:vertical !important; -webkit-line-clamp:5 !important; overflow:hidden !important; }`,
});
await measure("AFTER-1 (h-full + line-clamp:5)");
await browser.close();
+19
View File
@@ -0,0 +1,19 @@
// Dev-only: replay selectRoutes on a site's discovered crawl paths (from crawl.json) to
// inspect/verify the route plan WITHOUT re-capturing. Usage: tsx scripts/test-select.ts [maxRoutes]
import { selectRoutes } from "../src/crawl/routeTemplates.js";
import { readFileSync } from "node:fs";
const sites: [string, string][] = [
["cropin", "output/cropin"],
];
const maxRoutes = Number(process.argv[2] ?? 25);
for (const [site, dir] of sites) {
let c: { entryPath?: string; depthByPath?: Record<string, number> };
try { c = JSON.parse(readFileSync(`${dir}/.clone/crawl.json`, "utf8")); } catch { console.log(`\n### ${site} (no crawl.json)`); continue; }
const paths = Object.keys(c.depthByPath ?? {});
const plan = selectRoutes({ entryPath: c.entryPath ?? "/", paths, maxRoutes });
console.log(`\n### ${site} (discovered ${paths.length}, maxRoutes ${maxRoutes}) ###`);
console.log(" collections:", plan.collections.map((x) => `${x.template}(${x.instanceCount})`).join(" ") || "none");
console.log(" selected:");
for (const r of plan.selected) console.log(` ${r.role.padEnd(14)} ${r.path}`);
}
+45
View File
@@ -0,0 +1,45 @@
// Triage the latest run per stage-2 site: pass state + the key metric per failure.
import { readFileSync, readdirSync, existsSync } from "node:fs";
import { join } from "node:path";
const ROOT = new URL("../..", import.meta.url).pathname;
const RUNS = join(ROOT, "runs");
const tier = process.argv[2] || "stage2";
const sites = JSON.parse(readFileSync(join(ROOT, "compiler", "benchmarks", `${tier}.json`), "utf8"));
function siteId(url) {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
const path = u.pathname.replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
return (host + (path ? "-" + path : "")).replace(/[^a-zA-Z0-9.-]/g, "-").slice(0, 80);
}
function latest(host) {
const dir = join(RUNS, host);
if (!existsSync(dir)) return null;
const runs = readdirSync(dir).filter((d) => /^\d/.test(d)).sort();
for (let i = runs.length - 1; i >= 0; i--) {
const r = join(dir, runs[i], "validation", "report.json");
if (existsSync(r)) return join(dir, runs[i]);
}
return null;
}
let pass = 0, g06 = 0;
const lines = [];
for (const s of sites) {
const run = latest(siteId(s.url));
if (!run) { lines.push(`${s.id} NO RUN`); continue; }
const r = JSON.parse(readFileSync(join(run, "validation", "report.json"), "utf8"));
if (r.gates0to6Pass) g06++;
if (r.stage2Pass) pass++;
const fails = Object.entries(r.gates).filter(([, g]) => !g.pass).map(([k]) => k);
let detail = "";
if (fails.includes("perceptual")) detail += ` perc=${JSON.stringify(r.gates.perceptual.metrics.perViewport)}`;
if (fails.includes("layout")) { const pv = r.gates.layout.metrics.perViewport; detail += " layout=" + Object.entries(pv).filter(([, m]) => (m.heightDeltaPct > 0.05 || m.leafMedianDelta > 8 || m.sectionsBboxOkPct < 0.9)).map(([vp, m]) => `${vp}:h${m.heightDeltaPct},leaf${m.leafMedianDelta}`).join(","); }
if (fails.includes("pollution")) detail += " poll=" + JSON.stringify(r.gates.pollution.issues);
if (fails.includes("style")) detail += " style=" + JSON.stringify(r.gates.style.metrics.topFailingProps);
if (fails.includes("build")) detail += " build=" + (r.gates.build.metrics.runtimeErrorSample || []).length + "errs";
lines.push(`${r.stage2Pass ? "✅" : (r.gates0to6Pass ? "🟡" : "❌")} ${s.id} ${r.scorecard.total} [${fails.join(",") || "PASS"}]${detail}`);
}
console.log(lines.join("\n"));
console.log(`\n${tier}: gates0-6 ${g06}/${sites.length}, stage2 ${pass}/${sites.length}`);
+283
View File
@@ -0,0 +1,283 @@
/**
* Source-vs-clone Tailwind diff for the cloner (Stage 4/5 instrument).
*
* For a Tailwind-built source, the live `class` attribute is the AUTHORED fluid intent —
* the exact utility our generator should have inferred. This tool rebuilds the IR (which now carries
* the source `class` as `srcClass`) and our emitted classes (`buildTailwind().classOf`) IN-PROCESS,
* both keyed by node id, so the per-node diff is perfectly aligned with zero live-probe flakiness.
*
* It then NORMALIZES away faithful, unavoidable differences (colors, design-system spacing tokens,
* data-*, no-preflight arbitrary transforms) and focuses the diff on the LAYOUT/SIZING utility
* families — where the class name itself is the signal — to surface the real misses: places we baked
* per-viewport px where the source expressed ONE fluid rule. Misses are clustered by axis + source
* utility and ranked by frequency, so one law fixes many nodes.
*
* npx tsx scripts/tw-diff.ts [runDir=output/sample/.clone] [--axis=width] [--samples=N] [--json=path]
*
* Run from compiler/.
*/
import { join, resolve } from "node:path";
import { writeFileSync } from "node:fs";
import { buildIR, isTextChild, type IR, type IRNode } from "../src/normalize/ir.js";
import { buildTailwind } from "../src/generate/tailwind.js";
import { buildColorPalette } from "../src/infer/semanticTokens.js";
import { buildAssetGraph, type AssetGraph } from "../src/infer/assets.js";
import { readJSON } from "../src/util/fsx.js";
import type { CaptureResult } from "../src/capture/capture.js";
function buildAssetMap(assetGraph: AssetGraph): Map<string, string> {
const m = new Map<string, string>();
for (const e of assetGraph.entries) {
if (e.classification === "downloaded" && e.localPath && e.type !== "css") m.set(e.sourceUrl, e.localPath);
}
return m;
}
// ---- token parsing -------------------------------------------------------
type Tok = { raw: string; variant: string; core: string };
/** Split a className string into tokens, separating the responsive/state variant prefix
* (`md:`, `lg:`, `max-md:`, `hover:`, `2xl:` …) from the core utility. Arbitrary values can
* themselves contain `:` inside `[...]`, so only split on `:` that are OUTSIDE brackets. */
function parseClass(cls: string): Tok[] {
const out: Tok[] = [];
for (const raw of cls.split(/\s+/).filter(Boolean)) {
// find the last variant-colon that's outside brackets/parens
let depth = 0, lastColon = -1;
for (let i = 0; i < raw.length; i++) {
const ch = raw[i]!;
if (ch === "[" || ch === "(") depth++;
else if (ch === "]" || ch === ")") depth--;
else if (ch === ":" && depth === 0) lastColon = i;
}
const variant = lastColon >= 0 ? raw.slice(0, lastColon) : "";
const core = lastColon >= 0 ? raw.slice(lastColon + 1) : raw;
out.push({ raw, variant, core });
}
return out;
}
// ---- sizing-axis classification -----------------------------------------
type Axis = "width" | "height" | "grid-cols" | "grid-rows" | "flex" | "aspect" | "line-clamp";
/** Does an arbitrary value `[…]` (already unwrapped) denote a BAKED fixed length (px/rem)? */
function isBakedLen(v: string): boolean {
return /^-?[\d.]+(px|rem|em)$/.test(v);
}
/** Does an arbitrary value denote a FLUID length (%, vw/vh, fr, auto/min/max-content, calc with %/vw)? */
function isFluidLen(v: string): boolean {
if (/^-?[\d.]+(%|vw|vh|svh|lvh|dvh|svw|lvw|dvw|fr)$/.test(v)) return true;
if (/^(auto|min-content|max-content|fit-content|stretch)$/.test(v)) return true;
if (/(%|vw|vh|fr|min-content|max-content|fit-content)/.test(v) && /^(calc|min|max|clamp|repeat|fit-content)\(/.test(v)) return true;
return false;
}
/** Spacing-scale numbers (`w-24`, `h-2.5`, `w-93`) are FIXED px. Fractions (`w-1/2`) are fluid. */
function spacingIsFixed(rest: string): boolean {
return /^-?[\d.]+$/.test(rest); // pure number → fixed scale step; a fraction has a '/'
}
type AxisVerdict = { fluid?: string; baked?: string };
/** Classify a single core utility onto an axis with a fluid|baked verdict.
* Returns null for utilities that are not on a sizing axis. */
function classify(core: string): { axis: Axis; v: AxisVerdict } | null {
// grid templates
let m = core.match(/^grid-(cols|rows)-(.+)$/);
if (m) {
const axis: Axis = m[1] === "cols" ? "grid-cols" : "grid-rows";
const val = m[2]!;
if (/^\d+$/.test(val)) return { axis, v: { fluid: `grid-${m[1]}-N` } }; // fixed-count fluid grid
if (val === "none" || val === "subgrid") return { axis, v: { fluid: val } };
if (val.startsWith("[")) {
const inner = val.slice(1, -1);
// baked iff it contains an explicit px/rem track and NO fr/%/min/max/auto
const hasFixed = /[\d.]+(px|rem)/.test(inner);
const hasFluid = /(fr|%|min-content|max-content|auto|minmax|repeat)/.test(inner);
if (hasFixed && !hasFluid) return { axis, v: { baked: `grid-${m[1]}-[px]` } };
if (hasFixed && hasFluid) return { axis, v: { baked: `grid-${m[1]}-[mixed-px]` } }; // partly baked
return { axis, v: { fluid: `grid-${m[1]}-[fr]` } };
}
return { axis, v: { fluid: `grid-${m[1]}-${val}` } };
}
// aspect-ratio
m = core.match(/^aspect-(.+)$/);
if (m) {
const val = m[1]!;
if (val === "square" || val === "video" || val === "auto") return { axis: "aspect", v: { fluid: `aspect-${val}` } };
if (val.startsWith("[")) {
const inner = val.slice(1, -1);
// clean ratio like [16/9] is fluid intent; [auto_42_/_42] baked-from-px
if (/^\d+\s*\/\s*\d+$/.test(inner.replace(/_/g, " "))) return { axis: "aspect", v: { fluid: "aspect-[ratio]" } };
return { axis: "aspect", v: { baked: "aspect-[baked]" } };
}
return { axis: "aspect", v: { fluid: `aspect-${val}` } };
}
// line-clamp
m = core.match(/^line-clamp-(.+)$/);
if (m) return { axis: "line-clamp", v: { fluid: /^\d+$/.test(m[1]!) ? "line-clamp-N" : `line-clamp-${m[1]}` } };
// flex shorthand / grow / shrink / basis (all width-flow on the main axis)
if (/^(flex-1|flex-auto|flex-initial|grow|grow-0|shrink|shrink-0)$/.test(core)) return { axis: "flex", v: { fluid: core } };
m = core.match(/^basis-(.+)$/);
if (m) {
const val = m[1]!;
if (val === "full" || val === "auto" || /\d+\/\d+/.test(val)) return { axis: "flex", v: { fluid: `basis-${val.includes("/") ? "frac" : val}` } };
if (val.startsWith("[")) { const inner = val.slice(1, -1); return isBakedLen(inner) ? { axis: "flex", v: { baked: "basis-[px]" } } : { axis: "flex", v: { fluid: "basis-[fluid]" } }; }
return { axis: "flex", v: { baked: "basis-px" } }; // basis-24 etc
}
// width / height (incl. min-/max-)
m = core.match(/^(min-|max-)?([wh])-(.+)$/);
if (m) {
const axis: Axis = m[2] === "w" ? "width" : "height";
const val = m[3]!;
const fam = `${m[1] ?? ""}${m[2]}`; // w, min-w, max-w, h, …
if (/^(full|screen|auto|fit|min|max|svh|lvh|dvh|svw|lvw|dvw|prose)$/.test(val)) return { axis, v: { fluid: `${fam}-${val}` } };
if (/^\d+\/\d+$/.test(val)) return { axis, v: { fluid: `${fam}-frac` } }; // w-1/2
if (/^(screen-|3xl|2xl|xl|lg|md|sm|xs|7xl|6xl|5xl|4xl)/.test(val) && fam === "max-w") return { axis, v: { fluid: `max-w-${val}` } }; // named cap
if (val.startsWith("[")) {
const inner = val.slice(1, -1);
if (inner.startsWith("var(")) return { axis, v: { fluid: `${fam}-[var]` } }; // token-driven → fluid-ish
if (isFluidLen(inner)) return { axis, v: { fluid: `${fam}-[fluid]` } };
if (isBakedLen(inner)) return { axis, v: { baked: `${fam}-[px]` } };
return { axis, v: {} };
}
if (spacingIsFixed(val)) return { axis, v: { baked: `${fam}-num` } }; // w-24, h-93 → fixed scale
return { axis, v: {} };
}
return null;
}
type NodeAxisInfo = Record<Axis, { srcFluid: Set<string>; srcBaked: Set<string>; cloFluid: Set<string>; cloBaked: Set<string> }>;
function emptyAxisInfo(): NodeAxisInfo {
const mk = () => ({ srcFluid: new Set<string>(), srcBaked: new Set<string>(), cloFluid: new Set<string>(), cloBaked: new Set<string>() });
return { width: mk(), height: mk(), "grid-cols": mk(), "grid-rows": mk(), flex: mk(), aspect: mk(), "line-clamp": mk() };
}
function collectAxis(cls: string, info: NodeAxisInfo, side: "src" | "clo"): void {
for (const t of parseClass(cls)) {
const c = classify(t.core);
if (!c) continue;
const bucket = info[c.axis];
if (c.v.fluid) (side === "src" ? bucket.srcFluid : bucket.cloFluid).add(c.v.fluid);
if (c.v.baked) (side === "src" ? bucket.srcBaked : bucket.cloBaked).add(c.v.baked);
}
}
// ---- main ----------------------------------------------------------------
const argv = process.argv.slice(2);
const runDir = resolve(argv.find((a) => !a.startsWith("--")) ?? "output/sample/.clone");
const axisFilter = (argv.find((a) => a.startsWith("--axis="))?.split("=")[1] ?? "") as Axis | "";
const sampleN = parseInt(argv.find((a) => a.startsWith("--samples="))?.split("=")[1] ?? "20", 10);
const jsonOut = argv.find((a) => a.startsWith("--json="))?.split("=")[1];
const inspectIds = (argv.find((a) => a.startsWith("--ids="))?.split("=")[1] ?? "").split(",").filter(Boolean);
const sourceDir = join(runDir, "source");
const input = readJSON<{ url: string; viewports: number[] }>(join(runDir, "input.json"));
const capture = readJSON<CaptureResult>(join(sourceDir, "capture", "capture-result.json"));
const cloneOpts = readJSON<{ reflow?: boolean }>(join(sourceDir, "clone-options.json"));
const ir: IR = buildIR(sourceDir, capture.viewports, { motion: !!capture.motion, bandViewports: input.viewports });
const assetGraph = buildAssetGraph(capture);
const palette = buildColorPalette(ir);
const tw = buildTailwind(ir, buildAssetMap(assetGraph), palette.varForColor, { interaction: capture.interaction, reflow: !!cloneOpts.reflow });
const classOf = tw.classOf;
// --ids inspect mode: dump src/clone class + per-vp height geometry for specific nodes, then exit.
if (inspectIds.length) {
const idx = new Map<string, IRNode>();
const idxWalk = (n: IRNode): void => { idx.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) idxWalk(c); };
idxWalk(ir.root);
for (const id of inspectIds) {
const n = idx.get(id);
if (!n) { console.log(`${id}: NOT FOUND`); continue; }
const vps = ir.doc.sampleViewports;
console.log(`\n${id} <${n.tag}>`);
console.log(` src: ${n.srcClass ?? "(none)"}`);
console.log(` clone: ${classOf.get(id) ?? "(none)"}`);
console.log(` bboxH: ${vps.map((vp) => n.bboxByVp[vp]?.height ?? "·").join("/")}`);
}
process.exit(0);
}
// Per-axis miss tally + per (axis, srcUtil) cluster + sample rows.
type MissRow = { id: string; tag: string; text: string; axis: Axis; srcUtil: string; cloBaked: string; source: string; clone: string };
const misses: MissRow[] = [];
const clusterCount = new Map<string, number>(); // "axis · srcUtil" → count
const axisMiss = new Map<Axis, number>();
let nNodes = 0, nSrc = 0, nClone = 0, nBoth = 0;
function textOf(n: IRNode): string {
for (const c of n.children) if (isTextChild(c) && c.text.trim()) return c.text.trim().slice(0, 40);
return "";
}
function walk(n: IRNode): void {
nNodes++;
const src = n.srcClass ?? "";
const clo = classOf.get(n.id) ?? "";
if (src) nSrc++;
if (clo) nClone++;
if (src && clo) {
nBoth++;
const info = emptyAxisInfo();
collectAxis(src, info, "src");
collectAxis(clo, info, "clo");
for (const axis of Object.keys(info) as Axis[]) {
const b = info[axis];
// MISS = source expressed a fluid rule on this axis, clone baked px (or token absent) and
// produced NO fluid utility of its own on that axis.
const cloneHasFluid = b.cloFluid.size > 0;
if (b.srcFluid.size > 0 && !cloneHasFluid && (b.cloBaked.size > 0 || (b.srcBaked.size === 0 && axisExpectedInClone(axis, clo)))) {
const srcUtil = [...b.srcFluid].sort().join("+");
const cloBaked = [...b.cloBaked].sort().join("+") || "(absent)";
misses.push({ id: n.id, tag: n.tag, text: textOf(n), axis, srcUtil, cloBaked, source: src, clone: clo });
const key = `${axis} · ${srcUtil}`;
clusterCount.set(key, (clusterCount.get(key) ?? 0) + 1);
axisMiss.set(axis, (axisMiss.get(axis) ?? 0) + 1);
}
}
}
for (const c of n.children) if (!isTextChild(c)) walk(c);
}
/** For line-clamp/aspect a missing clone utility is itself the miss; for width/height/grid we only
* count a miss when the clone actually baked something (handled by cloBaked.size>0). */
function axisExpectedInClone(axis: Axis, _clone: string): boolean {
return axis === "line-clamp" || axis === "aspect";
}
walk(ir.root);
// ---- report --------------------------------------------------------------
console.log(`\n=== tw-diff: ${runDir} ===`);
console.log(`nodes=${nNodes} withSrcClass=${nSrc} withCloneClass=${nClone} both=${nBoth}`);
console.log(`\n--- real misses by axis (source fluid, clone baked/absent) ---`);
const totalMiss = misses.length;
for (const [axis, n] of [...axisMiss.entries()].sort((a, b) => b[1] - a[1])) {
console.log(` ${axis.padEnd(11)} ${n}`);
}
console.log(` ${"TOTAL".padEnd(11)} ${totalMiss}`);
console.log(`\n--- top miss clusters (axis · source-util → count) ---`);
for (const [key, n] of [...clusterCount.entries()].sort((a, b) => b[1] - a[1]).slice(0, 20)) {
console.log(` ${String(n).padStart(4)} ${key}`);
}
const shown = axisFilter ? misses.filter((m) => m.axis === axisFilter) : misses;
console.log(`\n--- sample miss rows${axisFilter ? ` [axis=${axisFilter}]` : ""} (${Math.min(sampleN, shown.length)} of ${shown.length}) ---`);
for (const m of shown.slice(0, sampleN)) {
console.log(`\n ${m.id} <${m.tag}> ${m.text ? `${m.text}` : ""} [${m.axis}: src ${m.srcUtil} → clone ${m.cloBaked}]`);
console.log(` src: ${m.source}`);
console.log(` clone: ${m.clone}`);
}
if (jsonOut) {
writeFileSync(resolve(jsonOut), JSON.stringify({ stats: { nNodes, nSrc, nClone, nBoth, totalMiss }, axisMiss: Object.fromEntries(axisMiss), clusters: Object.fromEntries([...clusterCount.entries()].sort((a, b) => b[1] - a[1])), misses }, null, 2));
console.log(`\nwrote ${jsonOut}`);
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Breakpoint discovery using the browser as the layout oracle.
*
* We capture at a few fixed widths and force the responsive bands onto Tailwind's default
* breakpoints — which is wrong for any site whose real cut points differ, and can spawn extra bands
* when our sample widths happen to straddle a breakpoint that isn't really there. Instead, ASK the
* page: sweep the viewport width and find the exact widths where the layout actually changes.
*
* Media queries cause DISCRETE jumps in structural properties (display, flex-direction, flex-wrap,
* grid track count, position, float, text-align, visibility) — those flip only at a breakpoint,
* whereas widths/heights scale smoothly. So we hash that discrete signature across a coarse width
* scan, then binary-search each interval where the hash changed down to 1px. The result is the
* site's REAL breakpoint set, to drive both the capture sample widths and the emitted band edges.
*/
import type { Page } from "playwright";
/** In-page: a hash of every element's discrete (media-query-toggled) layout properties. Pure
* structural — excludes widths/heights/font-size, which vary continuously and would mask the steps. */
const DISCRETE_SIGNATURE = (): string => {
let s = "";
const walk = (el: Element): void => {
const cs = getComputedStyle(el);
const cols = (cs.gridTemplateColumns || "none") === "none" ? 0 : cs.gridTemplateColumns.split(" ").filter(Boolean).length;
const painted = (el as HTMLElement).offsetParent !== null || cs.position === "fixed" ? 1 : 0;
s += `${cs.display}|${cs.flexDirection}|${cs.flexWrap}|${cols}|${cs.position}|${cs.float}|${cs.textAlign}|${cs.visibility}|${painted};`;
for (let i = 0; i < el.children.length; i++) walk(el.children[i]!);
};
walk(document.body);
let h = 0;
for (let i = 0; i < s.length; i++) h = (Math.imul(h, 31) + s.charCodeAt(i)) | 0;
return `${s.length}:${h}`;
};
export async function discoverBreakpoints(
page: Page,
opts?: { min?: number; max?: number; coarseStep?: number; height?: number },
): Promise<number[]> {
const min = opts?.min ?? 320;
const max = opts?.max ?? 1920;
const step = opts?.coarseStep ?? 16;
const height = opts?.height ?? 1200;
const sig = async (w: number): Promise<string> => {
await page.setViewportSize({ width: w, height });
return page.evaluate(DISCRETE_SIGNATURE);
};
// Coarse scan: record where the signature changes between adjacent samples.
const coarse: { w: number; sig: string }[] = [];
for (let w = min; w <= max; w += step) coarse.push({ w, sig: await sig(w) });
// Binary-search each changed interval down to 1px — the edge where the NEW layout begins.
const edges: number[] = [];
for (let i = 1; i < coarse.length; i++) {
if (coarse[i]!.sig === coarse[i - 1]!.sig) continue;
let lo = coarse[i - 1]!.w, hi = coarse[i]!.w; const loSig = coarse[i - 1]!.sig;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if ((await sig(mid)) === loSig) lo = mid; else hi = mid;
}
edges.push(hi);
}
return edges;
}
+996
View File
@@ -0,0 +1,996 @@
import { chromium, type Browser, type BrowserContext } from "playwright";
import { join } from "node:path";
import { collectPage, type PageSnapshot, type FontFace } from "./walker.js";
import { tagElements, captureInteractions, type InteractionCapture } from "./interactions.js";
import { captureMotion, probeReveals, type MotionCapture } from "./motion.js";
import { discoverBreakpoints } from "./breakpoints.js";
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
import { sha1_12, round } from "../util/canonical.js";
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const;
// The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing
// law (constant / proportional / clamped / flex-distributed) far better than 4 — enough to drop a
// baked px for a relative construct with confidence (and to surface a shrunk item's natural size,
// which needs widths beyond 1920). REQUIRED_VIEWPORTS ⊂ this; only those 4 carry responsive bands.
export const SAMPLE_VIEWPORTS = [375, 480, 640, 768, 1024, 1280, 1536, 1920, 2560] as const;
const VIEWPORT_HEIGHTS: Record<number, number> = {
375: 812,
480: 854,
640: 960,
768: 1024,
1024: 768,
1280: 800,
1536: 864,
1920: 1080,
2560: 1440,
};
const DESKTOP_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
// Defines esbuild/tsx runtime helpers in the page so serialized evaluate
// callbacks that reference them don't throw ReferenceError.
const ESBUILD_SHIM =
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
export type DiscoveredAsset = {
url: string;
type: string; // image|svg|video|font|lottie|css|manifest|other
contentType: string | null;
status: number | null;
storedAs: string | null; // sha1-named file in assets-store, or null if not downloaded
bytes: number; // size of stored file
via: string[]; // discovery sources
};
export type SeoResource = {
kind: "robots" | "sitemap" | "llms" | "llms-full";
url: string;
status: number | null;
contentType: string | null;
text?: string;
};
export type CaptureResult = {
sourceUrl: string;
capturedAt: string;
viewports: number[];
// Browser-as-oracle: the widths where the SOURCE layout actually restructures (display /
// flex-direction / wrap / grid-track-count / position / visibility flips), found by sweeping the
// viewport and binary-searching each discrete-signature change. Ground truth for the real
// responsive band edges — distinct from REQUIRED_VIEWPORTS (our fixed capture widths). Absent if
// discovery was disabled or the sweep failed. See breakpoints.ts.
breakpoints?: number[];
perViewport: Array<{
viewport: number;
height: number;
scrollHeight: number;
nodeCount: number;
truncated: boolean;
overlaysRemaining?: number;
blocking?: boolean;
quiescent?: boolean;
}>;
assets: DiscoveredAsset[];
seoResources?: SeoResource[];
fontFaces: FontFace[];
cssTexts: string[]; // sha1 names of stored css files
// Stage 2: overlay/popup dismissal audit (union of actions across viewports).
dismissal?: { dismissed: string[]; overlaysRemaining: number; removed: number; videoStills: number; blocking: boolean };
// Stage 4: optional interaction capture (hover/focus + recognized patterns).
interaction?: InteractionCapture;
// Stage 5: optional motion capture (WAAPI animations + rotating text). CSS @keyframes
// motion is reconstructed from the IR, so it isn't re-captured here.
motion?: MotionCapture;
};
function viewportHeight(width: number): number {
return VIEWPORT_HEIGHTS[width] ?? Math.round(width * 0.66);
}
function extFromUrl(url: string): string {
try {
const p = new URL(url).pathname;
const dot = p.lastIndexOf(".");
if (dot >= 0 && dot > p.lastIndexOf("/")) {
const ext = p.slice(dot + 1).toLowerCase().slice(0, 5);
if (/^[a-z0-9]+$/.test(ext)) return ext;
}
} catch { /* ignore */ }
return "";
}
const CONTENT_TYPE_EXT: Record<string, string> = {
"image/webp": "webp", "image/png": "png", "image/jpeg": "jpg", "image/jpg": "jpg",
"image/avif": "avif", "image/gif": "gif", "image/svg+xml": "svg",
"image/x-icon": "ico", "image/vnd.microsoft.icon": "ico", "image/bmp": "bmp",
"video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov", "video/ogg": "ogv",
"font/woff2": "woff2", "font/woff": "woff", "font/ttf": "ttf", "font/otf": "otf",
"application/font-woff2": "woff2", "application/font-woff": "woff",
"application/x-font-ttf": "ttf", "application/vnd.ms-fontobject": "eot",
"text/css": "css", "application/json": "json", "application/manifest+json": "webmanifest",
};
function extFromContentType(contentType: string | null): string {
if (!contentType) return "";
const ct = contentType.split(";")[0]!.trim().toLowerCase();
return CONTENT_TYPE_EXT[ct] ?? "";
}
function classifyAsset(url: string, contentType: string | null): string | null {
const u = url.toLowerCase().split("?")[0]!;
const ct = (contentType || "").toLowerCase();
if (u.endsWith(".svg") || ct === "image/svg+xml") return "svg";
if (/\.(jpg|jpeg|png|webp|avif|gif|ico|bmp)$/.test(u) || ct.startsWith("image/")) return "image";
if (/\.(mp4|mov|webm|m4v|ogv)$/.test(u) || ct.startsWith("video/")) return "video";
if (/\.(woff2|woff|ttf|otf|eot)$/.test(u) || ct.startsWith("font/") ||
ct.includes("font-woff") || ct.includes("x-font")) return "font";
if (u.endsWith(".json") && (u.includes("lottie") || u.includes("animation"))) return "lottie";
if (u.endsWith(".webmanifest") || /(?:^|\/)manifest\.json$/.test(u) || ct.includes("manifest+json")) return "manifest";
if (u.endsWith(".css") || ct.startsWith("text/css")) return "css";
return null;
}
function isCss(url: string, contentType: string | null): boolean {
return classifyAsset(url, contentType) === "css";
}
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> {
// Scroll through the page to trigger lazy-loaded images/backgrounds, then return
// to the top so document-coordinate bboxes are measured from a settled layout.
await page.evaluate(async (step: number) => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const maxScroll = () => document.documentElement.scrollHeight - window.innerHeight;
let y = 0;
let guard = 0;
while (y < maxScroll() && guard < 100) {
y += step;
window.scrollTo(0, y);
await sleep(60);
guard++;
}
window.scrollTo(0, 0);
await sleep(120);
}, Math.max(Math.round(vpHeight * 0.8), 300));
}
async function settle(page: import("playwright").Page, maxMs = 2500): Promise<void> {
try {
await page.waitForLoadState("networkidle", { timeout: Math.max(maxMs / 2, 800) });
} catch { /* ignore */ }
try {
// document.fonts.ready can never resolve when a font request hangs (heavy SaaS
// pages), and page.evaluate has no default timeout — so bound it explicitly.
await Promise.race([
page.evaluate(() => (document as Document).fonts?.ready as unknown as Promise<void>),
new Promise<void>((r) => setTimeout(r, 6000)),
]);
} catch { /* ignore */ }
await page.waitForTimeout(250);
}
export type DismissResult = { dismissed: string[]; overlaysRemaining: number; removed: number; blocking: boolean };
/**
* Stage 2 — overlay/popup dismissal, phase 1: click the accept/close affordance.
* Cookie-consent walls, newsletter modals, region/age/app-install interstitials
* cover the real page; the capture must see the state a returning user sees.
* Deterministic + replayable: click the same known/accept controls in DOM order.
* Removal of a stuck overlay happens later (finalizeOverlays) AFTER a settle, so a
* just-clicked dialog has time to close and unlock scrolling before we judge it.
*/
async function clickDismiss(page: import("playwright").Page): Promise<string[]> {
try {
return await Promise.race([
page.evaluate(() => {
const dismissed: string[] = [];
const vis = (el: Element): boolean => {
const cs = getComputedStyle(el);
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) return false;
const r = (el as HTMLElement).getBoundingClientRect();
return r.width > 0 && r.height > 0;
};
const click = (el: Element): void => { try { (el as HTMLElement).click(); } catch { /* ignore */ } };
// 1) Known consent-framework / generic close affordances, in priority order.
const KNOWN = [
"#onetrust-accept-btn-handler", "#accept-recommended-btn-handler", ".onetrust-close-btn-handler",
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", "#CybotCookiebotDialogBodyButtonAccept",
"#truste-consent-button", ".osano-cm-accept-all", ".osano-cm-dialog__close",
"[data-testid='uc-accept-all-button']", "[data-testid='uc-deny-all-button']",
"#didomi-notice-agree-button", ".didomi-continue-without-agreeing",
".qc-cmp2-summary-buttons button[mode='primary']", "button[aria-label='Consent']",
".cc-allow", ".cookie-consent-accept", "#hs-eu-confirmation-button", "#gdpr-consent-tool-wrapper button",
];
for (const sel of KNOWN) {
try {
for (const el of Array.from(document.querySelectorAll(sel))) {
if (vis(el)) { click(el); dismissed.push(sel); break; }
}
} catch { /* invalid selector in this browser */ }
}
// 2) Scoped text-button pass: only inside overlay-ish containers (dialogs,
// or id/class naming cookie/consent/modal/popup/newsletter) so we never
// click an ordinary page button.
const ACCEPT = new Set([
"accept", "accept all", "accept all cookies", "accept cookies", "accept & close",
"i accept", "i agree", "agree", "agree and continue", "allow all", "allow cookies",
"allow all cookies", "got it", "ok", "okay", "continue", "no thanks", "no, thanks",
"dismiss", "close", "got it!", "understood", "yes, i agree",
]);
const containerSel =
"[role='dialog'],[aria-modal='true'],[id*='cookie' i],[class*='cookie' i],[id*='consent' i]," +
"[class*='consent' i],[class*='gdpr' i],[id*='gdpr' i],[class*='modal' i],[class*='popup' i]," +
"[class*='newsletter' i],[class*='interstitial' i]";
let containers: Element[] = [];
try { containers = Array.from(document.querySelectorAll(containerSel)); } catch { /* ignore */ }
for (const c of containers) {
if (!vis(c)) continue;
const btns = Array.from(c.querySelectorAll("button,[role='button'],a,input[type='button'],input[type='submit']"));
for (const b of btns) {
const t = (b.textContent || (b as HTMLInputElement).value || "").replace(/\s+/g, " ").trim().toLowerCase();
if (t && ACCEPT.has(t) && vis(b)) { click(b); dismissed.push("text:" + t); break; }
}
}
return dismissed;
}),
new Promise<string[]>((res) => setTimeout(() => res([]), 6000)),
]);
} catch {
return [];
}
}
/**
* Stage 2 — overlay/popup dismissal, phase 2 (run after a settle). Detect any
* full-viewport, high-z, fixed/sticky overlay still present. A fixed nav is wide
* but short; a sticky sidebar is tall but narrow — both excluded; a consent wall /
* modal backdrop covers most of both axes. If the page is *still scroll-locked*
* (the modal is genuinely blocking) remove the overlay — but ONLY when its id/class
* looks like a consent/modal layer, never a header/nav/main/footer, so legitimate
* sticky chrome is never stripped. Reports `blocking` = a scroll-locking overlay we
* could not clear (the pollution gate keys off this, not mere overlay presence).
*/
async function finalizeOverlays(page: import("playwright").Page): Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }> {
try {
return await Promise.race([
page.evaluate(() => {
const vw = window.innerWidth, vh = window.innerHeight;
const bigOverlays = (): HTMLElement[] => {
const out: HTMLElement[] = [];
for (const el of Array.from(document.body.querySelectorAll("*"))) {
const cs = getComputedStyle(el);
if (cs.position !== "fixed" && cs.position !== "sticky") continue;
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) continue;
const r = (el as HTMLElement).getBoundingClientRect();
const z = parseInt(cs.zIndex || "0", 10) || 0;
const area = (r.width * r.height) / (vw * vh);
if (area >= 0.5 && z >= 100 && r.width >= vw * 0.7 && r.height >= vh * 0.5) out.push(el as HTMLElement);
}
return out.filter((el) => !out.some((o) => o !== el && o.contains(el)));
};
const isLocked = (): boolean => {
const b = document.body, h = document.documentElement;
return getComputedStyle(b).overflow === "hidden" || getComputedStyle(h).overflow === "hidden" ||
getComputedStyle(b).position === "fixed";
};
// A scroll-locked page behind a full-viewport overlay IS a blocking modal by
// definition (legit pages don't scroll-lock). So remove ANY such overlay that
// isn't page chrome — many modals/drawers carry no consent/modal keyword and
// an icon-only close, so a keyword/aria allowlist misses them (ruggable's
// z-[1001] drawer). PROTECTED guards real chrome (header/nav/footer) only.
const PROTECTED = /header|navbar|nav-|site-nav|topbar|masthead|footer/i;
const sig = (el: HTMLElement): string => `${el.id} ${el.className}`.toString();
const removedLabels: string[] = [];
let removed = 0;
let remaining = bigOverlays();
if (remaining.length && isLocked()) {
for (const el of remaining) {
const s = sig(el);
const z = parseInt(getComputedStyle(el).zIndex || "0", 10) || 0;
// Scroll-locked + full-viewport ⇒ blocking modal; remove unless it's page
// chrome. Always remove iframes (cross-origin close, unclickable) and the
// max-z-index popup trick.
const removable = !PROTECTED.test(s) || el.getAttribute("aria-modal") === "true" ||
el.tagName === "IFRAME" || z >= 2_000_000_000;
if (removable) { el.remove(); removed++; removedLabels.push((el.id || el.className || el.tagName).toString().slice(0, 40)); }
}
if (removed) { document.body.style.overflow = "visible"; document.documentElement.style.overflow = "visible"; document.body.style.position = "static"; }
remaining = bigOverlays();
}
return { overlaysRemaining: remaining.length, removed, blocking: remaining.length > 0 && isLocked(), removedLabels };
}),
new Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }>((res) => setTimeout(() => res({ overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }), 6000)),
]);
} catch {
return { overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] };
}
}
/**
* Stage 2 — animation settling. Wait until layout stops moving (entrance/scroll
* reveals finished) before measuring, so geometry isn't sampled mid-transition
* ("sizes off due to animations in progress"). Samples large-box geometry across
* frames; resolves when stable for several windows or the bound elapses.
*/
async function waitForQuiescence(page: import("playwright").Page, maxMs = 4000): Promise<boolean> {
try {
return await Promise.race([
page.evaluate(async (budget: number) => {
const sample = (): string => {
const els = Array.from(document.body.querySelectorAll("*")).filter((e) => {
const r = (e as HTMLElement).getBoundingClientRect();
return r.width > 80 && r.height > 40;
}).slice(0, 240);
return els.map((e) => {
const r = (e as HTMLElement).getBoundingClientRect();
return `${Math.round(r.x)},${Math.round(r.y)},${Math.round(r.width)},${Math.round(r.height)}`;
}).join("|");
};
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const deadline = Date.now() + budget;
let prev = sample();
let stable = 0;
while (Date.now() < deadline && stable < 3) {
await sleep(130);
const cur = sample();
if (cur === prev) stable++; else { stable = 0; prev = cur; }
}
return stable >= 3;
}, maxMs),
new Promise<boolean>((res) => setTimeout(() => res(false), maxMs + 1500)),
]);
} catch {
return false;
}
}
/**
* Stage 2 — dynamic-media first frame. A streamed `<video>` has no deterministic
* frame and its request aborts at snapshot time, so the element would render blank.
* For videos lacking a `poster`, materialize a representative still and point the
* element's poster at it (via a synthetic URL whose bytes the normal asset pipeline
* rewrites to a local file). Two acquisition paths:
* 1. canvas — draw the decoded frame; exact, but THROWS for cross-origin/tainted
* videos (common: CDN-hosted hero videos with no CORS header).
* 2. element screenshot (the fallback) — rasterize the element's painted region
* via the DevTools protocol; works regardless of CORS and even when the video
* decoded late, because it reads composited pixels rather than the media buffer.
* This closes the "poster-less video renders blank" gap (e.g. descript's hero).
*
* Returns canvas stills (bytes ready) + the videos that still need a node-side
* element screenshot (the page can't screenshot itself), each tagged with a stable
* selector. Videos with no usable surface (no poster obtainable) fall back to the
* transparent placeholder, same as before.
*/
type VideoStillPlan = { stills: Array<{ url: string; dataUrl: string }>; shots: Array<{ url: string; sel: string }> };
async function captureVideoStills(page: import("playwright").Page): Promise<VideoStillPlan> {
try {
const plan = await Promise.race([
page.evaluate(async () => {
const stills: Array<{ url: string; dataUrl: string }> = [];
const shots: Array<{ url: string; sel: string }> = [];
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const hash = (s: string): string => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h.toString(36); };
const vids = Array.from(document.querySelectorAll("video"));
let i = 0;
for (const v of vids) {
if (v.getAttribute("poster")) continue; // real poster already discovered
const r = v.getBoundingClientRect();
if (r.width < 16 || r.height < 16) continue; // not a visible media surface
const idx = i++;
v.setAttribute("data-clone-vid", String(idx));
const key = v.currentSrc || (v.querySelector("source") as HTMLSourceElement | null)?.src || String(idx);
const url = `https://clone-still.local/${idx}-${hash(key)}.jpg`;
try { v.pause(); } catch { /* ignore */ }
try { v.currentTime = 0; } catch { /* ignore */ }
await sleep(100);
let done = false;
const w = v.videoWidth, h = v.videoHeight;
if (w && h && v.readyState >= 2) {
try {
const canvas = document.createElement("canvas");
canvas.width = w; canvas.height = h;
const ctx = canvas.getContext("2d");
if (ctx) {
ctx.drawImage(v, 0, 0, w, h);
const dataUrl = canvas.toDataURL("image/jpeg", 0.82); // throws if tainted
if (dataUrl.startsWith("data:image/jpeg")) { stills.push({ url, dataUrl }); done = true; }
}
} catch { /* tainted/cross-origin — fall through to the element screenshot */ }
}
if (done) {
v.setAttribute("poster", url);
} else {
// Element screenshots capture composited pixels. For background hero
// videos with text/CTAs overlaid, that would bake the foreground into
// the poster and then render the live DOM on top again.
let occluded = false;
for (const el of Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,p,span,a,button,li"))) {
if (v.contains(el) || (el.textContent || "").trim().length < 3) continue;
const cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none" || parseFloat(cs.opacity || "1") < 0.05) continue;
const er = el.getBoundingClientRect();
if (er.width < 2 || er.height < 2) continue;
const ix = Math.min(er.right, r.right) - Math.max(er.left, r.left);
const iy = Math.min(er.bottom, r.bottom) - Math.max(er.top, r.top);
if (ix > 0 && iy > 0 && ix * iy > 0.05 * er.width * er.height) { occluded = true; break; }
}
if (!occluded) {
v.setAttribute("poster", url);
shots.push({ url, sel: `video[data-clone-vid="${idx}"]` });
}
}
}
return { stills, shots };
}),
new Promise<VideoStillPlan>((res) => setTimeout(() => res({ stills: [], shots: [] }), 12000)),
]);
return plan;
} catch {
return { stills: [], shots: [] };
}
}
/**
* Full-page screenshot with robustness for heavy/animated pages. The default 30s
* timeout is exceeded by tall SaaS pages (Playwright also waits for web fonts);
* use a long timeout, freeze animations (also improves determinism), and retry.
* As a last resort take a viewport-only shot so the file exists (the capture gate
* checks presence; a partial image still beats none).
*/
async function captureScreenshot(
page: import("playwright").Page,
path: string,
vw: number,
log: (e: Record<string, unknown>) => void,
): Promise<void> {
for (let attempt = 0; attempt < 2; attempt++) {
try {
await page.screenshot({ path, fullPage: true, timeout: 90_000, animations: "disabled" });
return;
} catch (e) {
if (attempt === 1) {
try {
await page.screenshot({ path, fullPage: false, timeout: 30_000, animations: "disabled" });
log({ event: "screenshot_fallback_viewport", viewport: vw });
return;
} catch (e2) {
log({ event: "screenshot_error", viewport: vw, error: String(e2) });
}
}
}
}
}
export async function captureSite(opts: {
url: string;
outDir: string; // source/ directory
viewports?: number[];
interactions?: boolean; // Stage 4: opt-in interaction capture (hover/focus + patterns)
motion?: boolean; // Stage 5: opt-in motion capture (WAAPI + rotating text)
breakpoints?: boolean; // discover the source's real responsive band edges (default on; read-only sweep)
screenshots?: boolean; // write per-viewport full-page PNGs (default on). ONLY the validator reads these
// (generation never touches pixels), and full-page shots of tall pages are the
// dominant capture cost — so a production clone that won't be perceptually graded
// can skip them. The cheap poster-less-video element stills are always kept
// (generation needs a first frame), this only gates the per-viewport page shots.
log?: (event: Record<string, unknown>) => void;
}): Promise<CaptureResult> {
const viewports = opts.viewports ?? [...REQUIRED_VIEWPORTS];
const log = opts.log ?? (() => {});
const captureDir = join(opts.outDir, "capture");
const screenshotsDir = join(opts.outDir, "screenshots");
const cssDir = join(captureDir, "css");
const storeDir = join(opts.outDir, "assets-store");
ensureDir(captureDir);
ensureDir(screenshotsDir);
// url -> discovered asset (merged across viewports)
const assetMap = new Map<string, DiscoveredAsset>();
const cssStored = new Set<string>();
const fontFaceMap = new Map<string, FontFace>();
const seoResourceUrls = new Map<string, SeoResource["kind"]>();
const sourceOrigin = (() => {
try {
const u = new URL(opts.url);
return u.protocol === "http:" || u.protocol === "https:" ? u.origin : "";
} catch {
return "";
}
})();
const addSeoResource = (url: string, kind: SeoResource["kind"], base = opts.url): void => {
try {
const abs = new URL(url, base).href;
if (!/^https?:\/\//i.test(abs)) return;
if (!seoResourceUrls.has(abs)) seoResourceUrls.set(abs, kind);
} catch { /* ignore malformed resource hints */ }
};
if (sourceOrigin) {
addSeoResource(sourceOrigin + "/robots.txt", "robots");
addSeoResource(sourceOrigin + "/sitemap.xml", "sitemap");
addSeoResource(sourceOrigin + "/sitemap_index.xml", "sitemap");
addSeoResource(sourceOrigin + "/llms.txt", "llms");
addSeoResource(sourceOrigin + "/llms-full.txt", "llms-full");
}
const recordAsset = (url: string, type: string, contentType: string | null, status: number | null, via: string): DiscoveredAsset => {
let a = assetMap.get(url);
if (!a) {
a = { url, type, contentType, status, storedAs: null, bytes: 0, via: [] };
assetMap.set(url, a);
}
if (!a.via.includes(via)) a.via.push(via);
if (contentType && !a.contentType) a.contentType = contentType;
if (status != null && a.status == null) a.status = status;
// SVG/specific types win over generic
if (type && (a.type === "other" || (a.type === "image" && type === "svg"))) a.type = type;
return a;
};
const storeBytes = (url: string, type: string, bytes: Buffer): void => {
if (!bytes || bytes.length === 0) return;
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
if (a.storedAs) return;
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
(type === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" :
type === "video" ? "mp4" : type === "lottie" ? "json" : "png");
const name = `${sha1_12(url)}.${ext}`;
if (type === "css") {
writeBytes(join(cssDir, name), bytes);
cssStored.add(name);
} else {
writeBytes(join(storeDir, name), bytes);
}
a.storedAs = name;
a.bytes = bytes.length;
};
const parseManifestForAssets = (text: string, baseUrl: string): void => {
let parsed: unknown;
try { parsed = JSON.parse(text); } catch { return; }
if (!parsed || typeof parsed !== "object") return;
const push = (raw: unknown, via: string): void => {
if (typeof raw !== "string" || !raw.trim() || raw.trim().startsWith("data:")) return;
let abs = raw;
try { abs = new URL(raw, baseUrl).href; } catch { /* keep raw */ }
const t = classifyAsset(abs, null) ?? "image";
recordAsset(abs, t, null, null, via);
};
const iconList = (parsed as { icons?: unknown }).icons;
if (Array.isArray(iconList)) {
for (const icon of iconList) if (icon && typeof icon === "object") push((icon as { src?: unknown }).src, "manifest:icons");
}
const screenshots = (parsed as { screenshots?: unknown }).screenshots;
if (Array.isArray(screenshots)) {
for (const shot of screenshots) if (shot && typeof shot === "object") push((shot as { src?: unknown }).src, "manifest:screenshots");
}
const shortcuts = (parsed as { shortcuts?: unknown }).shortcuts;
if (Array.isArray(shortcuts)) {
for (const shortcut of shortcuts) {
const icons = shortcut && typeof shortcut === "object" ? (shortcut as { icons?: unknown }).icons : undefined;
if (Array.isArray(icons)) for (const icon of icons) if (icon && typeof icon === "object") push((icon as { src?: unknown }).src, "manifest:shortcuts");
}
}
};
// Honor the standard HTTPS_PROXY env so capture works behind an egress proxy
// (sandboxed/CI environments). The proxy re-terminates TLS, so the per-context
// `ignoreHTTPSErrors` below covers its CA. Capture is not part of the determinism
// gate, so this never affects generated output.
// Honor HTTPS_PROXY for real remote captures behind an egress proxy (sandboxed/CI),
// but ONLY for non-loopback http(s) targets — file:// and localhost fixtures (tests,
// the validator's static server) must be fetched directly. Playwright otherwise routes
// even loopback through the proxy (its default `<-loopback>`), which would replace a
// local fixture with the proxy's error page. Capture is not part of the determinism
// gate, so this never affects generated output.
let proxyServer = process.env.HTTPS_PROXY || process.env.https_proxy || "";
try {
const u = new URL(opts.url);
// Skip the proxy ONLY for a localhost http(s) target (test fixtures, the validator's
// static server) — Playwright otherwise routes loopback through the proxy and replaces
// the page with the proxy error page. file:// keeps the proxy on so any REMOTE assets
// the local page references fast-fail through the proxy instead of hanging on a blocked
// direct connection; external targets obviously keep it.
const isHttp = u.protocol === "http:" || u.protocol === "https:";
const isLoopback = /^(localhost|127\.0\.0\.1|\[?::1\]?|0\.0\.0\.0)$/.test(u.hostname);
if (isHttp && isLoopback) proxyServer = "";
} catch { /* keep proxy */ }
const browser: Browser = await chromium.launch({
headless: true,
args: ["--disable-blink-features=AutomationControlled", "--disable-dev-shm-usage"],
...(proxyServer ? { proxy: { server: proxyServer } } : {}),
});
const perViewport: CaptureResult["perViewport"] = [];
let interaction: InteractionCapture | undefined;
let motion: MotionCapture | undefined;
let discoveredBreakpoints: number[] | undefined;
const captureSeoResources: SeoResource[] = [];
const cssTextsForParsing: Array<{ baseUrl: string; text: string }> = [];
const dismissUnion = { dismissed: [] as string[], overlaysRemaining: 0, removed: 0, videoStills: 0, blocking: false };
// Carry cookies/localStorage across the per-viewport contexts so the SAME page
// is captured at every width: A/B-test buckets and consent state are usually
// session-persisted, so without this each fresh context can load a different
// variant (grammarly served a different hero per viewport) and the cross-
// viewport IR alignment then can't reconcile them. Sharing the session also
// means a banner dismissed at the first width stays dismissed at the rest.
const canonical = viewports.includes(1280) ? 1280 : viewports[Math.floor(viewports.length / 2)]!;
try {
// Single context + single navigation; every viewport is captured by RESIZING
// the same loaded page (no reload). This guarantees identical content across
// widths — eliminating both A/B-per-load variance (grammarly served a different
// hero on each fresh load) and session-reuse degeneration (warbyparker returned
// a 13-node shell when reloaded with a carried session). The IR's cross-viewport
// alignment then operates on one logical DOM that CSS merely reflows.
const context: BrowserContext = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: canonical, height: viewportHeight(canonical) },
deviceScaleFactor: 1,
userAgent: DESKTOP_UA,
javaScriptEnabled: true,
});
const page = await context.newPage();
// tsx/esbuild wraps functions with a __name() helper for stack traces; that
// helper does not exist in the browser when we serialize page.evaluate
// callbacks. Shim it (as a raw string so it isn't itself transformed).
await page.addInitScript(ESBUILD_SHIM);
const bodyPromises: Promise<void>[] = [];
page.on("response", (resp) => {
try {
const url = resp.url();
if (url.startsWith("data:") || url.startsWith("blob:")) return;
const ct = resp.headers()["content-type"] || null;
const type = classifyAsset(url, ct);
if (!type) return;
const status = resp.status();
recordAsset(url, type, ct, status, "network");
const existing = assetMap.get(url);
if (existing?.storedAs) return;
if (status >= 400) return;
bodyPromises.push(
(async () => {
try {
const buf = await resp.body();
storeBytes(url, type, buf);
if (type === "css") cssTextsForParsing.push({ baseUrl: url, text: buf.toString("utf8") });
if (type === "manifest") parseManifestForAssets(buf.toString("utf8"), url);
} catch { /* body unavailable */ }
})(),
);
} catch { /* ignore */ }
});
// Single navigation at the canonical width; every viewport below is a resize.
log({ event: "goto", url: opts.url });
let navigated = false;
let navErr: unknown = null;
for (let attempt = 0; attempt < 3 && !navigated; attempt++) {
try {
await page.goto(opts.url, { waitUntil: attempt === 0 ? "load" : "domcontentloaded", timeout: 45000 });
navigated = true;
} catch (e) {
navErr = e;
await page.waitForTimeout(1000);
}
}
if (!navigated) throw navErr;
await settle(page);
for (const vw of viewports) {
const vh = viewportHeight(vw);
await page.setViewportSize({ width: vw, height: vh });
await settle(page, 1500); // let the resize reflow + any width-triggered content settle
// Stage 2: dismiss cookie/consent/newsletter/region overlays. Run TWICE —
// once after initial load (cookie/consent walls appear immediately), and
// again after the scroll pass (newsletter/email-capture modals are usually
// timer- or scroll-triggered and only mount a few seconds in). Each pass
// clicks the accept/close control, settles (so a just-closed dialog unlocks
// scrolling), THEN removes only a genuinely-stuck consent/modal layer.
let overlaysRemaining = 0;
let blocking = false;
const applyDismiss = async (phase: string): Promise<void> => {
const clicked = await clickDismiss(page);
if (clicked.length) await settle(page, 1000);
const fin = await finalizeOverlays(page);
for (const d of clicked) if (!dismissUnion.dismissed.includes(d)) dismissUnion.dismissed.push(d);
for (const d of fin.removedLabels) { const k = "removed:" + d; if (!dismissUnion.dismissed.includes(k)) dismissUnion.dismissed.push(k); }
dismissUnion.removed += fin.removed;
overlaysRemaining = fin.overlaysRemaining;
blocking = blocking || fin.blocking;
if (clicked.length || fin.removed) { await settle(page, 1000); log({ event: "dismissed", viewport: vw, phase, count: clicked.length, removed: fin.removed, remaining: fin.overlaysRemaining, blocking: fin.blocking }); }
};
await applyDismiss("load");
// Stage 5 (scroll reveals): at the FIRST viewport, before any scroll, tag elements
// and snapshot the pre-reveal hidden state — scroll reveals fire on the first
// autoScroll and stay revealed, so this is the only moment their hidden state is
// observable. Idempotent + motion-gated; the settled snapshot is unchanged.
if (opts.motion && vw === viewports[0]) { await tagElements(page); await probeReveals(page); }
await autoScroll(page, vh);
await settle(page, 1500);
await applyDismiss("post-scroll");
dismissUnion.overlaysRemaining = Math.max(dismissUnion.overlaysRemaining, overlaysRemaining);
dismissUnion.blocking = dismissUnion.blocking || blocking;
// Stage 2: wait for entrance/scroll animations to settle so geometry isn't
// sampled mid-transition.
const quiescent = await waitForQuiescence(page);
// Reset window + all inner scrollable containers to scroll 0 so captured
// positions match the generated app's default (un-scrolled) render. Inner
// scroll state is runtime JS state and otherwise non-deterministic.
await page.evaluate(() => {
window.scrollTo(0, 0);
for (const el of Array.from(document.querySelectorAll("*"))) {
if (el.scrollLeft) el.scrollLeft = 0;
if (el.scrollTop) el.scrollTop = 0;
}
});
await page.waitForTimeout(150);
// Stage 2: dynamic-media first frame. Materialize a still for poster-less
// videos so they don't render blank — canvas where the frame is readable, else
// an element screenshot (the page cannot screenshot itself). Done once at the
// canonical viewport: it's the highest-fidelity width, the poster attr persists
// across the later resizes, and the IR reads attrs from the canonical snapshot.
if (vw === canonical) {
const plan = await captureVideoStills(page);
for (const s of plan.stills) {
const comma = s.dataUrl.indexOf(",");
if (comma < 0) continue;
try {
const buf = Buffer.from(s.dataUrl.slice(comma + 1), "base64");
recordAsset(s.url, "image", "image/jpeg", 200, "video-still");
storeBytes(s.url, "image", buf);
dismissUnion.videoStills++;
} catch { /* ignore */ }
}
for (const s of plan.shots) {
try {
const buf = await page.locator(s.sel).first().screenshot({ type: "jpeg", quality: 82, timeout: 5000, animations: "disabled" });
recordAsset(s.url, "image", "image/jpeg", 200, "video-still");
storeBytes(s.url, "image", buf);
dismissUnion.videoStills++;
} catch { /* element not shootable (off-screen/detached) — poster falls back to placeholder */ }
}
// Element screenshots scroll the target into view; restore scroll 0 so the
// canonical DOM walk + screenshot match the generated app's default render.
if (plan.shots.length) {
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(80);
}
}
// Stage 4/5: stamp capture-ids before the canonical snapshot so the IR carries
// them (whitelisted), enabling interaction deltas + motion specs to map to cids.
if ((opts.interactions || opts.motion) && vw === canonical) await tagElements(page);
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
const snapshot: PageSnapshot = await Promise.race([
page.evaluate(collectPage),
new Promise<never>((_, rej) => setTimeout(() => rej(new Error(`collectPage timeout vp${vw}`)), 60_000)),
]);
// Merge discovered references from the snapshot (DOM + accessible CSS).
for (const da of snapshot.domAssets) {
const t = classifyAsset(da.url, null) ?? (da.kind === "manifest" ? "manifest" : da.kind === "video" ? "video" : da.kind === "svg" ? "svg" : "image");
recordAsset(da.url, t, null, null, da.via);
}
for (const link of snapshot.doc.head?.links ?? []) {
const rel = (link.rel || "").toLowerCase();
const href = link.href || "";
if (!href) continue;
if (/\bsitemap\b/.test(rel)) addSeoResource(href, "sitemap", snapshot.doc.url);
if (/llms-full\.txt(?:$|[?#])/i.test(href) || /\bllms-full\b/.test(rel)) addSeoResource(href, "llms-full", snapshot.doc.url);
else if (/llms\.txt(?:$|[?#])/i.test(href) || /\bllms\b/.test(rel)) addSeoResource(href, "llms", snapshot.doc.url);
}
for (const u of snapshot.cssUrls) {
const t = classifyAsset(u, null) ?? "other";
recordAsset(u, t, null, null, "css-url");
}
for (const ff of snapshot.fontFaces) {
const key = `${ff.family}|${ff.weight ?? ""}|${ff.style ?? ""}|${ff.src}`;
if (!fontFaceMap.has(key)) fontFaceMap.set(key, ff);
}
// Cap body collection: resp.body() has no timeout, so a streaming/long-poll
// response (common on heavy SaaS pages) would hang allSettled forever. By
// now bodies have been downloading throughout navigation+scroll; stragglers
// are dropped (the post-pass fallback fetch re-fetches anything missing).
await Promise.race([
Promise.allSettled(bodyPromises),
new Promise((r) => setTimeout(r, 20_000)),
]);
// Persist DOM snapshot, and (unless skipped for a production clone) the full-page screenshot.
writeJSONCompact(join(captureDir, `dom-${vw}.json`), snapshot);
if (opts.screenshots !== false) await captureScreenshot(page, join(screenshotsDir, `${vw}.png`), vw, log);
// Stage 4: drive recognized affordances at the canonical viewport (opt-in).
if (opts.interactions && vw === canonical) {
try { interaction = await captureInteractions(page, { log }); }
catch (e) { log({ event: "interactions_error", error: String(e).slice(0, 200) }); }
}
// Stage 5: capture motion (WAAPI + rotating text) at the canonical viewport.
if (opts.motion && vw === canonical) {
try { motion = await captureMotion(page, { log }); }
catch (e) { log({ event: "motion_error", error: String(e).slice(0, 200) }); }
}
perViewport.push({
viewport: vw,
height: vh,
scrollHeight: round(snapshot.doc.scrollHeight),
nodeCount: snapshot.doc.nodeCount,
truncated: snapshot.doc.truncated,
overlaysRemaining,
blocking,
quiescent,
});
log({ event: "captured", viewport: vw, nodes: snapshot.doc.nodeCount, scrollHeight: snapshot.doc.scrollHeight });
}
// Browser-as-oracle: discover the source's real responsive band edges by sweeping the viewport
// and binary-searching each width where the discrete (media-query-toggled) layout signature
// changes. Read-only and bounded; runs once here — overlays are dismissed and the DOM settled, so
// the signature is the real page, not a consent wall. Never fatal: a failed sweep just omits the
// field. The context closes right after, so the swept viewport size needs no restore.
if (opts.breakpoints ?? true) {
try {
const edges = await Promise.race([
discoverBreakpoints(page, { min: 320, max: 1920 }),
new Promise<number[]>((_, rej) => setTimeout(() => rej(new Error("breakpoint sweep timeout")), 60_000)),
]);
discoveredBreakpoints = edges;
log({ event: "breakpoints_discovered", edges });
} catch (e) {
log({ event: "breakpoints_error", error: String(e).slice(0, 200) });
}
}
await context.close();
// Parse @font-face + url() from cross-origin CSS texts we fetched at the
// network layer (the in-page walker can only read same-origin sheets).
for (const { baseUrl, text } of cssTextsForParsing) {
parseCssForFonts(text, baseUrl, fontFaceMap, (u) => {
const t = classifyAsset(u, null) ?? "other";
recordAsset(u, t, null, null, "css-text");
});
}
// Fallback: download any referenced-but-not-yet-stored asset using the
// browser context (shares TLS handling). Fonts and CSS-referenced images are
// commonly missed by the response listener when served from cache.
const fallbackCtx = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: DESKTOP_UA });
for (const a of assetMap.values()) {
if (a.storedAs) continue;
if (a.url.startsWith("data:")) continue;
if (!["image", "svg", "video", "font", "lottie", "css", "manifest"].includes(a.type)) continue;
try {
const resp = await fallbackCtx.request.get(a.url, { timeout: 30000 });
a.status = resp.status();
if (resp.ok()) {
const buf = await resp.body();
a.contentType = a.contentType ?? (resp.headers()["content-type"] || null);
storeBytes(a.url, a.type, buf);
if (a.type === "css") parseCssForFonts(buf.toString("utf8"), a.url, fontFaceMap, (u) => {
const t = classifyAsset(u, null) ?? "other";
recordAsset(u, t, null, null, "css-text");
});
if (a.type === "manifest") parseManifestForAssets(buf.toString("utf8"), a.url);
}
} catch { /* unreachable/signed — left as skipped */ }
}
const seoResources: SeoResource[] = [];
const fetchedSeo = new Set<string>();
const fetchSeoResource = async (url: string, kind: SeoResource["kind"]): Promise<void> => {
if (fetchedSeo.has(url)) return;
fetchedSeo.add(url);
try {
const resp = await fallbackCtx.request.get(url, { timeout: 15000 });
const contentType = resp.headers()["content-type"] || null;
const resource: SeoResource = { kind, url, status: resp.status(), contentType };
if (resp.ok() && (kind === "llms" || kind === "llms-full" || kind === "robots")) {
const text = await resp.text();
resource.text = text;
if (kind === "robots") {
for (const line of text.split(/\r?\n/)) {
const m = /^sitemap:\s*(\S+)/i.exec(line.trim());
if (m) addSeoResource(m[1]!, "sitemap", url);
}
}
}
seoResources.push(resource);
} catch {
seoResources.push({ kind, url, status: null, contentType: null });
}
};
for (let pass = 0; pass < 2; pass++) {
const entries = [...seoResourceUrls.entries()].sort((a, b) => a[0].localeCompare(b[0]));
for (const [url, kind] of entries) await fetchSeoResource(url, kind);
}
await fallbackCtx.close();
if (seoResources.length) {
(captureSeoResources as SeoResource[]).push(...seoResources.sort((a, b) => a.url.localeCompare(b.url) || a.kind.localeCompare(b.kind)));
}
} finally {
await browser.close();
}
const assets = [...assetMap.values()].sort((a, b) => a.url.localeCompare(b.url));
const fontFaces = [...fontFaceMap.values()].sort((a, b) =>
`${a.family}${a.weight}${a.style}`.localeCompare(`${b.family}${b.weight}${b.style}`),
);
const result: CaptureResult = {
sourceUrl: opts.url,
capturedAt: new Date().toISOString(),
viewports,
breakpoints: discoveredBreakpoints,
perViewport,
assets,
...(captureSeoResources.length ? { seoResources: captureSeoResources } : {}),
fontFaces,
cssTexts: [...cssStored].sort(),
dismissal: dismissUnion,
interaction,
motion,
};
if (interaction) writeJSON(join(opts.outDir, "interaction.json"), interaction);
if (motion) writeJSON(join(opts.outDir, "motion.json"), motion);
writeJSON(join(opts.outDir, "assets-discovered.json"), assets);
writeJSON(join(opts.outDir, "fonts-discovered.json"), fontFaces);
writeJSON(join(captureDir, "capture-result.json"), result);
return result;
}
function parseCssForFonts(
cssText: string,
baseUrl: string,
fontFaceMap: Map<string, FontFace>,
onUrl: (url: string) => void,
): void {
const abs = (u: string): string => {
try { return new URL(u, baseUrl).href; } catch { return u; }
};
// Extract @font-face blocks. src urls are rewritten to absolute so they can be
// resolved/downloaded regardless of where the CSS file lived.
const faceRe = /@font-face\s*\{([^}]*)\}/gi;
let m: RegExpExecArray | null;
while ((m = faceRe.exec(cssText)) !== null) {
const block = m[1]!;
const family = /font-family\s*:\s*([^;]+)/i.exec(block)?.[1]?.trim().replace(/^['"]|['"]$/g, "");
let src = /src\s*:\s*([^;]+)/i.exec(block)?.[1]?.trim();
if (!family || !src) continue;
src = src.replace(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi, (_full, _q, u) =>
u.startsWith("data:") ? `url(${u})` : `url(${abs(u)})`);
const weight = /font-weight\s*:\s*([^;]+)/i.exec(block)?.[1]?.trim();
const style = /font-style\s*:\s*([^;]+)/i.exec(block)?.[1]?.trim();
const display = /font-display\s*:\s*([^;]+)/i.exec(block)?.[1]?.trim();
const unicodeRange = /unicode-range\s*:\s*([^;]+)/i.exec(block)?.[1]?.trim();
const key = `${family}|${weight ?? ""}|${style ?? ""}|${src}`;
if (!fontFaceMap.has(key)) {
fontFaceMap.set(key, { family, src, weight, style, display, unicodeRange });
}
}
// Extract all url() references for asset discovery.
const urlRe = /url\(\s*(['"]?)([^'")]+)\1\s*\)/gi;
while ((m = urlRe.exec(cssText)) !== null) {
const raw = m[2];
if (raw && !raw.startsWith("data:")) onUrl(abs(raw));
}
}
File diff suppressed because it is too large Load Diff
+350
View File
@@ -0,0 +1,350 @@
import type { Page } from "playwright";
/**
* Stage 5 motion capture. Runs at the canonical viewport AFTER `tagElements` (so every
* element carries a `data-cid-cap` the IR threads through to a cid). Two families that
* the declarative CSS path (per-node `animation-name` + captured `@keyframes`, handled
* entirely in the IR/generator) does NOT cover:
*
* - **WAAPI** (`element.animate(...)`, e.g. Framer Motion): only ever visible via
* `document.getAnimations()`. Captured here as keyframes + timing so a fixed client
* controller can replay it. (Entrance WAAPI that already finished by capture time is
* not observable — persistent/looping WAAPI is; that's the deterministic subset.)
* - **Rotating text**: a JS interval swapping an element's text (shopify "category
* creator → global empire", notion "Ship → Create"). Observed with a MutationObserver
* over a short window; captured as the text cycle + cadence for a fixed controller.
*
* CSS @keyframes/transition motion is intentionally NOT re-captured here — it is fully
* reconstructable from the static evidence already in the IR, which is the most
* deterministic path.
*/
export type WaapiAnim = {
cap: string; // data-cid-cap of the animated element (→ cid at generation)
keyframes: Array<Record<string, string | number>>; // effect.getKeyframes()
duration: number; // ms
delay: number; // ms
easing: string;
iterations: number; // -1 encodes Infinity (JSON-safe)
direction: string;
fill: string;
};
export type RotatorSpec = {
cap: string; // data-cid-cap of the element whose text cycles
texts: string[]; // the observed cycle of trimmed text values (in order)
intervalMs: number; // approximate cadence between swaps
};
export type RevealSpec = {
cap: string; // data-cid-cap of a scroll-revealed element
opacity: string; // its hidden (pre-reveal) opacity, e.g. "0"
transform: string; // its hidden transform (slide/scale offset), or "none"
transition: string; // the transition to animate the reveal with
};
export type MarqueeSpec = {
cap: string; // data-cid-cap of the continuously-translating track
axis: "x"; // horizontal marquee (the common case; vertical reserved)
pxPerSec: number; // signed scroll velocity (negative = leftward)
periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2)
};
export type MotionCapture = {
waapi: WaapiAnim[];
rotators: RotatorSpec[];
reveals: RevealSpec[]; // scroll-triggered entrance reveals (start hidden, reveal on view)
marquees: MarqueeSpec[]; // rAF-driven continuous tickers (Framer Motion etc.) — not in getAnimations()
cssAnimated: number; // elements with a computed animation-name (informational)
};
/**
* Stage 5 (scroll reveals) — pre-scroll probe. Scroll-triggered reveals start hidden
* (opacity:0 / transform offset) and animate in when scrolled into view; by the time the
* settled snapshot is taken (after `autoScroll` has walked the page) they are already
* revealed, so their hidden state must be sampled BEFORE the first scroll. Records, on
* `window.__cloneReveal`, the pre-scroll opacity/transform/transition of every tagged
* element that is hidden-but-boxed with a real transition — the candidate reveal set
* `captureMotion` later confirms (kept only if the element ends up visible). Idempotent;
* call once at the first viewport, after settle, before `autoScroll`.
*/
export async function probeReveals(page: Page): Promise<void> {
try {
await page.evaluate(() => {
const out: Record<string, { opacity: string; transform: string; transition: string }> = {};
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
let cs: CSSStyleDeclaration; try { cs = getComputedStyle(el); } catch { continue; }
const op = parseFloat(cs.opacity || "1");
if (op > 0.05) continue; // only currently-hidden elements are reveal candidates
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue; // must occupy real space (not a 0-box hidden node)
// must have a transition on opacity/transform/all (so the reveal animates, not snaps)
const tp = (cs.transitionProperty || "").toLowerCase();
const td = cs.transitionDuration || "0s";
const animates = /opacity|transform|all/.test(tp) && !/^0s(,\s*0s)*$/.test(td);
if (!animates) continue;
out[cap] = {
opacity: cs.opacity || "0",
transform: cs.transform && cs.transform !== "none" ? cs.transform : "none",
transition: cs.transition && cs.transition !== "all 0s ease 0s" ? cs.transition : `opacity ${td}, transform ${td}`,
};
}
(window as unknown as { __cloneReveal?: unknown }).__cloneReveal = out;
});
} catch { /* ignore */ }
}
/**
* Stage 5 (marquees) — rAF-driven continuous tickers. Framer Motion (and similar)
* drive infinite horizontal scrollers by mutating an element's `transform` every frame
* via requestAnimationFrame, so they are invisible to `getAnimations()` (the WAAPI path)
* and have no CSS `@keyframes` (the declarative path). They are also paused while
* off-screen, so the velocity is only observable after scrolling the track into view.
* Detect a track by a translateX that changes steadily in one direction once visible,
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
* scroll to top); does not touch the captured snapshot/IR.
*/
async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
try {
return await page.evaluate(async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const txOf = (el: Element): number => { try { return new DOMMatrixReadOnly(getComputedStyle(el).transform).m41; } catch { return 0; } };
const inClip = (el: Element): boolean => {
let p = el.parentElement, depth = 0;
while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; }
return false;
};
// Candidates: tagged, already translated (paused tickers keep their offset), with
// duplicated content (≥2 children) inside an overflow-clip viewport — the marquee shape.
const cand: Element[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
if (Math.abs(txOf(el)) <= 0.5) continue;
if (el.children.length < 2) continue;
if (!inClip(el)) continue;
cand.push(el);
if (cand.length >= 16) break;
}
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
const seen = new Set<string>();
for (const el of cand) {
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
await sleep(450); // wake the off-screen-paused ticker + let the scroll settle
const xs: number[] = [];
for (let i = 0; i < 6; i++) { xs.push(txOf(el)); await sleep(120); }
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
if (dxs.length < 3) continue;
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier).
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!;
const pxPerSec = Math.round((med / 120) * 1000);
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset)
// direction must be consistent in all but (at most) the one wrap step.
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue;
const periodPx = Math.round(el.scrollWidth / 2);
if (periodPx < 40) continue;
seen.add(cap);
out.push({ cap, axis: "x", pxPerSec, periodPx });
}
try { window.scrollTo(0, 0); } catch { /* ignore */ }
return out;
});
} catch { return []; }
}
/**
* Stage 5 (scroll-scrub reveals) — scroll-LINKED entrance animations. Some sections
* (framer's "Agents" sticky-pinned panels) drive opacity/transform as a continuous
* function of scroll position rather than a one-way IntersectionObserver reveal, so they
* start only PARTIALLY hidden (opacity ~0.2, a translate offset) and `probeReveals`
* (which requires opacity ≤ 0.05 pre-scroll) never sees them — and the settled snapshot
* catches them frozen MID-scrub (opacity 0.63), which the clone then bakes as a dimmed,
* offset panel. Detect them by sampling each panel's opacity/transform across a full
* scroll pass: a panel that is dimmed/slid at some scroll position but reaches full
* opacity at another is a scroll reveal. Reconstruct as a normal reveal (hide at the
* most-dimmed state, transition to full on view) — the deterministic, gate-reconciled
* path (the validator force-reveals before grading, so gates measure the revealed frame).
*/
async function detectScrubReveals(page: Page): Promise<RevealSpec[]> {
try {
return await page.evaluate(async () => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const txOf = (cs: CSSStyleDeclaration): number => { try { return new DOMMatrixReadOnly(cs.transform).m41; } catch { return 0; } };
// Panel-like candidates: tagged, sized, content-bearing, in normal flow.
const cands: HTMLElement[] = [];
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]")) as HTMLElement[]) {
const r = el.getBoundingClientRect();
if (r.width < 120 || r.height < 60) continue;
const pos = getComputedStyle(el).position;
if (pos === "fixed" || pos === "sticky") continue;
if (!el.querySelector("img, p, h1, h2, h3, span")) continue; // has real content
cands.push(el);
if (cands.length >= 1200) break;
}
const samples = new Map<HTMLElement, Array<{ op: number; tx: number }>>();
const maxY = Math.max(0, document.documentElement.scrollHeight - window.innerHeight);
const steps = 14;
for (let i = 0; i <= steps; i++) {
window.scrollTo(0, Math.round((maxY * i) / steps));
await sleep(120);
for (const el of cands) {
const cs = getComputedStyle(el);
let a = samples.get(el); if (!a) { a = []; samples.set(el, a); }
a.push({ op: parseFloat(cs.opacity || "1"), tx: txOf(cs) });
}
}
window.scrollTo(0, 0);
const found: Array<{ el: HTMLElement; cap: string; opacity: string; transform: string; transition: string }> = [];
for (const [el, a] of samples) {
const ops = a.map((s) => s.op), absTx = a.map((s) => Math.abs(s.tx));
const maxOp = Math.max(...ops), minOp = Math.min(...ops);
const maxTx = Math.max(...absTx), minTx = Math.min(...absTx);
const opReveal = maxOp > 0.9 && minOp < 0.85; // fades in across scroll
const txReveal = maxOp > 0.9 && maxTx > 6 && minTx < 2; // slides in across scroll
if (!opReveal && !txReveal) continue;
// hidden = the most-dimmed sample (lowest opacity, tie-break largest offset).
let hidden = a[0]!;
for (const s of a) if (s.op < hidden.op - 0.001 || (Math.abs(s.op - hidden.op) <= 0.001 && Math.abs(s.tx) > Math.abs(hidden.tx))) hidden = s;
const cap = el.getAttribute("data-cid-cap"); if (!cap) continue;
found.push({
el, cap,
opacity: String(Math.max(0, Math.min(hidden.op, 0.5))),
transform: Math.abs(hidden.tx) > 2 ? `translateX(${Math.round(hidden.tx)}px)` : "none",
transition: "opacity 0.6s ease, transform 0.6s ease",
});
}
// Keep only the OUTERMOST scrub per nesting (a panel and its scrubbed children both match).
const outer = found.filter((f) => !found.some((o) => o !== f && o.el.contains(f.el)));
return outer.slice(0, 40).map(({ cap, opacity, transform, transition }) => ({ cap, opacity, transform, transition }));
});
} catch { return []; }
}
export async function captureMotion(page: Page, opts?: { observeMs?: number; log?: (e: Record<string, unknown>) => void }): Promise<MotionCapture> {
const log = opts?.log ?? (() => {});
const observeMs = opts?.observeMs ?? 2600;
try {
const result = await Promise.race([
page.evaluate(async (budget: number) => {
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
// ---- WAAPI: pure script-driven animations (exclude CSS animations/transitions,
// which the declarative path reproduces). Capture persistent/looping ones — the
// deterministic subset still alive at capture time. ----
const waapi: Array<{
cap: string; keyframes: Array<Record<string, string | number>>;
duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string;
}> = [];
try {
const anims = (document as Document).getAnimations ? document.getAnimations() : [];
const seen = new Set<string>();
for (const a of anims) {
const ctor = a.constructor.name;
if (ctor === "CSSAnimation" || ctor === "CSSTransition") continue; // declarative path owns these
const eff = a.effect as KeyframeEffect | null;
const target = eff?.target as Element | undefined;
const cap = target?.getAttribute?.("data-cid-cap");
if (!cap || seen.has(cap)) continue;
let kfs: Array<Record<string, string | number>> = [];
try { kfs = (eff!.getKeyframes() as unknown as Array<Record<string, string | number>>); } catch { kfs = []; }
if (!kfs.length) continue;
const t = eff!.getTiming();
const iters = t.iterations === Infinity ? -1 : (typeof t.iterations === "number" ? t.iterations : 1);
seen.add(cap);
waapi.push({
cap,
keyframes: kfs,
duration: typeof t.duration === "number" ? t.duration : 0,
delay: t.delay ?? 0,
easing: String(t.easing ?? "linear"),
iterations: iters,
direction: String(t.direction ?? "normal"),
fill: String(t.fill ?? "none"),
});
}
} catch { /* ignore */ }
// ---- Rotating text: watch for elements whose text content cycles. Record the
// smallest text-bearing element that changes (so we cycle the word, not a whole
// section), the ordered set of values it shows, and the cadence. ----
const changes = new Map<string, { texts: string[]; times: number[] }>();
const norm = (s: string) => s.replace(/\s+/g, " ").trim();
const obs = new MutationObserver((records) => {
for (const rec of records) {
// climb to the nearest element with a data-cid-cap
let el: Node | null = rec.target;
while (el && !(el as Element).getAttribute?.("data-cid-cap")) el = el.parentNode;
const cap = el && (el as Element).getAttribute?.("data-cid-cap");
if (!cap) continue;
const elem = el as Element;
// only track small text-bearing elements (a rotating word/phrase, not a big block)
if (elem.querySelector("[data-cid-cap]")) continue; // has element children → not a leaf word
const txt = norm(elem.textContent || "");
if (!txt || txt.length > 80) continue;
const e = changes.get(cap) ?? { texts: [], times: [] };
if (e.texts[e.texts.length - 1] !== txt) { e.texts.push(txt); e.times.push(performance.now()); }
changes.set(cap, e);
}
});
obs.observe(document.body, { subtree: true, childList: true, characterData: true });
await sleep(budget);
obs.disconnect();
const rotators: Array<{ cap: string; texts: string[]; intervalMs: number }> = [];
for (const [cap, e] of changes) {
// a genuine rotator shows ≥2 distinct values
const distinct = Array.from(new Set(e.texts));
if (distinct.length < 2) continue;
let interval = 0;
if (e.times.length >= 2) {
const gaps: number[] = [];
for (let i = 1; i < e.times.length; i++) gaps.push(e.times[i]! - e.times[i - 1]!);
gaps.sort((a, b) => a - b);
interval = Math.round(gaps[Math.floor(gaps.length / 2)]!);
}
rotators.push({ cap, texts: distinct.slice(0, 12), intervalMs: interval || 2000 });
}
// ---- Scroll reveals: confirm the pre-scroll candidates (probeReveals) that are
// NOW visible — those genuinely revealed on scroll (vs. elements that stayed hidden).
const reveals: Array<{ cap: string; opacity: string; transform: string; transition: string }> = [];
try {
const probed = (window as unknown as { __cloneReveal?: Record<string, { opacity: string; transform: string; transition: string }> }).__cloneReveal || {};
for (const cap of Object.keys(probed)) {
const el = document.querySelector(`[data-cid-cap="${cap}"]`);
if (!el) continue;
const cs = getComputedStyle(el);
if (parseFloat(cs.opacity || "1") <= 0.05) continue; // still hidden → not a reveal, just hidden
const r = (el as HTMLElement).getBoundingClientRect();
if (r.width < 8 || r.height < 8) continue;
reveals.push({ cap, ...probed[cap]! });
}
} catch { /* ignore */ }
let cssAnimated = 0;
for (const el of Array.from(document.querySelectorAll("*"))) {
try { const an = getComputedStyle(el).animationName; if (an && an !== "none") cssAnimated++; } catch { /* ignore */ }
}
return { waapi, rotators, reveals, cssAnimated };
}, observeMs),
new Promise<Omit<MotionCapture, "marquees">>((res) => setTimeout(() => res({ waapi: [], rotators: [], reveals: [], cssAnimated: 0 }), observeMs + 4000)),
]);
// Scroll-scrub reveals: scroll-linked panels probeReveals can't see (they start only
// partially hidden). Merge into reveals, preferring the probe-confirmed entry when a cap
// appears in both.
const scrubReveals = await detectScrubReveals(page);
const reveals = [...result.reveals];
const haveCap = new Set(reveals.map((r) => r.cap));
for (const s of scrubReveals) if (!haveCap.has(s.cap)) { reveals.push(s); haveCap.add(s.cap); }
// Marquees run as a separate pass (scrolls each track into view to wake the paused
// rAF ticker; kept after the rotator MutationObserver window so it can't add false text rotators).
const marquees = await detectMarquees(page);
log({ event: "motion_captured", waapi: result.waapi.length, rotators: result.rotators.length, reveals: reveals.length, marquees: marquees.length, cssAnimated: result.cssAnimated });
return { ...result, reveals, marquees };
} catch (e) {
log({ event: "motion_error", error: String(e).slice(0, 200) });
return { waapi: [], rotators: [], reveals: [], marquees: [], cssAnimated: 0 };
}
}
+608
View File
@@ -0,0 +1,608 @@
/**
* In-page capture walker. This function is serialized and executed inside the
* browser via page.evaluate, so it must be fully self-contained (no imports, no
* outer references). It returns a serializable snapshot of the rendered page:
* the visible DOM tree with computed styles, bounding boxes (in document
* coordinates), pseudo-element styles, plus discovered CSS/font/asset references.
*
* Design notes:
* - We serialize ALL elements (including display:none) so the tree structure is
* identical across viewports and nodes can be aligned by pre-order index.
* - Inline <svg> is captured as raw outerHTML and not recursed — reconstructing
* SVG node-by-node is lossy; replaying the markup is exact.
* - script/style/link/meta/noscript/template are skipped (we generate our own
* CSS and never reproduce JS).
*/
export type RawBBox = { x: number; y: number; width: number; height: number };
export type RawStyle = Record<string, string>;
/** Sizing-intent probe results: does the browser re-derive this
* element's width/height when we drop the authored value? Ground truth for "is this dimension
* load-bearing". For out-of-flow (absolute/fixed) elements, `insetDrop` instead records, per side,
* whether setting that inset to `auto` leaves the box exactly in place — i.e. it's a filled-in used
* value (the browser resolved `bottom`/`right` from the containing-block size) that we should NOT
* bake, vs an authored anchor that pins the box. */
export type RawSizing = {
wAuto: boolean; wFill: boolean; hAuto: boolean;
// Does `height:100%` re-derive this box (within 0.5px) at this viewport? The HEIGHT analogue of
// wFill: ground truth that the element FILLS a definite-height containing block, so the faithful
// emission is `h-full` rather than a baked per-vp px band. Distinguished from hAuto (content-sized):
// a node with hFill && !hAuto genuinely fills a definite parent (the source's `h-full`), whereas
// hAuto means it's content-sized (drop to auto). Present only for in-flow probed elements.
hFill?: boolean;
// Intrinsic-size anchors: the element's min-content and max-content widths,
// measured live. They are the anchors a fluid width LAW is fit from — a load-bearing width that
// VARIES across viewports but rides between these bounds is a fluid rule (`clamp()`/`%`/`flex`),
// not four baked px. Present only for in-flow probed elements (px, rounded).
wMin?: number; wMax?: number;
insetDrop?: { top: boolean; right: boolean; bottom: boolean; left: boolean };
};
export type RawNode = {
tag: string;
attrs: Record<string, string>;
computed: RawStyle;
bbox: RawBBox;
visible: boolean;
sizing?: RawSizing;
before?: RawStyle;
after?: RawStyle;
rawHTML?: string; // set for inline <svg>
children: RawChild[];
};
export type RawChild = RawNode | { text: string };
export type FontFace = {
family: string;
src: string; // raw src descriptor (may contain multiple url())
weight?: string;
style?: string;
display?: string;
unicodeRange?: string;
stretch?: string;
};
export type PageSnapshot = {
doc: {
url: string;
title: string;
head: {
description: string; canonical: string; ogTitle: string; ogDescription: string;
ogImage: string; ogType: string; ogSiteName: string; twitterCard: string; themeColor: string;
keywords?: string; robots?: string; referrer?: string; colorScheme?: string;
meta?: Array<{ name?: string; property?: string; httpEquiv?: string; content: string }>;
links?: Array<{
rel: string; href: string; as?: string; type?: string; sizes?: string; media?: string;
color?: string; hrefLang?: string; title?: string; crossOrigin?: string; referrerPolicy?: string;
}>;
jsonLd?: Array<{ id?: string; text: string }>;
};
lang: string;
charset: string;
viewportWidth: number;
viewportHeight: number;
scrollWidth: number;
scrollHeight: number;
htmlBg: string;
bodyBg: string;
bodyColor: string;
bodyFont: string;
metaViewport: string;
nodeCount: number;
truncated: boolean;
};
root: RawNode;
cssVars: Record<string, string>;
fontFaces: FontFace[];
cssUrls: string[];
domAssets: Array<{ kind: string; url: string; via: string }>;
keyframes: string[]; // raw @keyframes blocks from accessible sheets
};
export function collectPage(): PageSnapshot {
// NOTE: This function is serialized and run in the browser via page.evaluate,
// so every constant/helper it uses must be declared INSIDE it (no module-scope
// closure is available in the page).
// Curated computed-style property set. Comprehensive enough to replay layout,
// box model, typography, visuals, layering, and safe transitions/animations.
const PROPS: string[] = [
"display", "position", "top", "right", "bottom", "left", "float", "clear",
"zIndex", "boxSizing", "visibility", "opacity", "isolation",
"width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
"marginTop", "marginRight", "marginBottom", "marginLeft",
"paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
"borderTopStyle", "borderRightStyle", "borderBottomStyle", "borderLeftStyle",
"borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor",
"borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius",
"flexDirection", "flexWrap", "justifyContent", "alignItems", "alignContent",
"alignSelf", "flexGrow", "flexShrink", "flexBasis", "order",
"gap", "rowGap", "columnGap",
"gridTemplateColumns", "gridTemplateRows", "gridTemplateAreas", "gridAutoFlow", "gridAutoRows",
"gridAutoColumns", "justifyItems", "placeItems", "placeContent",
"gridColumnStart", "gridColumnEnd", "gridRowStart", "gridRowEnd",
"overflow", "overflowX", "overflowY",
"objectFit", "objectPosition", "aspectRatio", "verticalAlign",
"color", "fontFamily", "fontSize", "fontWeight", "fontStyle", "lineHeight",
"letterSpacing", "wordSpacing", "textAlign", "textTransform",
"textDecorationLine", "textDecorationColor", "textDecorationStyle",
"whiteSpace", "wordBreak", "overflowWrap", "textOverflow", "textIndent",
"textShadow", "fontVariantCaps", "fontFeatureSettings",
// Line clamping (`display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:N`):
// the mechanism that keeps cards equal height regardless of text length. Without it the engine
// sees only the RESULTING px height and bakes/drops it per card — uneven.
"webkitLineClamp", "webkitBoxOrient",
"listStyleType", "listStylePosition", "writingMode", "direction",
"backgroundColor", "backgroundImage", "backgroundSize", "backgroundPosition",
"backgroundRepeat", "backgroundClip", "backgroundOrigin", "backgroundAttachment",
"backgroundBlendMode",
"boxShadow", "filter", "backdropFilter", "mixBlendMode",
"transform", "transformOrigin", "transformStyle", "perspective",
// Individual transform properties (CSS Transforms Level 2) — modern sites centre/offset with
// `translate: -50% -50%` etc. INSTEAD of `transform`. getComputedStyle reports them separately
// (transform stays "none"), so without capturing them a translate-centred box loses its shift.
// Emitted as raw `[translate:...]` properties.
"translate", "rotate", "scale",
"clipPath", "maskImage",
"webkitTextStroke", "webkitTextFillColor", "webkitBackgroundClip",
"transition", "animationName", "animationDuration", "animationTimingFunction",
"animationDelay", "animationIterationCount", "animationDirection", "animationFillMode",
"cursor", "pointerEvents", "userSelect", "tableLayout", "borderCollapse", "borderSpacing",
];
const PSEUDO_PROPS: string[] = [
"content", "display", "position", "top", "right", "bottom", "left", "zIndex",
"width", "height", "marginTop", "marginRight", "marginBottom", "marginLeft",
"paddingTop", "paddingRight", "paddingBottom", "paddingLeft",
"borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth",
"borderTopStyle", "borderRightStyle", "borderBottomStyle", "borderLeftStyle",
"borderTopColor", "borderRightColor", "borderBottomColor", "borderLeftColor",
"borderTopLeftRadius", "borderTopRightRadius", "borderBottomRightRadius", "borderBottomLeftRadius",
"color", "fontFamily", "fontSize", "fontWeight", "lineHeight", "letterSpacing",
"textAlign", "textTransform",
"backgroundColor", "backgroundImage", "backgroundSize", "backgroundPosition", "backgroundRepeat",
"boxShadow", "opacity", "transform", "transformOrigin", "translate", "rotate", "scale", "filter",
"overflow", "objectFit",
];
const SKIP_TAGS = new Set([
"script", "style", "link", "meta", "noscript", "template", "base", "title", "head",
]);
const MAX_NODES = 12000;
const round2 = (n: number): number => Math.round((n || 0) * 100) / 100;
const scrollX = window.scrollX;
const scrollY = window.scrollY;
// Resolve `line-height: normal` to a concrete px value by probing the actual
// line-box height for each (font-family, font-size, font-weight, font-style).
// getComputedStyle reports the keyword "normal", which renders font-metric-
// dependently and drifts sub-pixel over many lines (e.g. dense text tables);
// emitting the resolved px makes the clone deterministic and exact.
const lhCache = new Map<string, string>();
const lhProbe = document.createElement("div");
lhProbe.style.cssText = "position:absolute;visibility:hidden;left:-99999px;top:-99999px;padding:0;border:0;margin:0;white-space:nowrap;line-height:normal;";
lhProbe.textContent = "Mgy";
document.body.appendChild(lhProbe);
const resolveNormalLineHeight = (ff: string, fs: string, fw: string, fst: string): string => {
const key = `${ff}|${fs}|${fw}|${fst}`;
const cached = lhCache.get(key);
if (cached !== undefined) return cached;
lhProbe.style.fontFamily = ff;
lhProbe.style.fontSize = fs;
lhProbe.style.fontWeight = fw;
lhProbe.style.fontStyle = fst;
const h = lhProbe.getBoundingClientRect().height;
const v = h > 0 ? `${Math.round(h * 100) / 100}px` : "normal";
lhCache.set(key, v);
return v;
};
let nodeCount = 0;
let truncated = false;
const grabStyle = (cs: CSSStyleDeclaration, props: string[]): RawStyle => {
const out: RawStyle = {};
for (const p of props) {
// CSSStyleDeclaration is indexable by camelCase property names.
const v = (cs as unknown as Record<string, string>)[p];
if (v !== undefined && v !== null && v !== "") out[p] = v;
}
return out;
};
const isVisible = (el: Element, cs: CSSStyleDeclaration, bbox: RawBBox): boolean => {
if (cs.display === "none") return false;
if (cs.visibility === "hidden" || cs.visibility === "collapse") return false;
if (parseFloat(cs.opacity || "1") === 0) return false;
if (bbox.width === 0 && bbox.height === 0) {
// zero-size but might still matter (e.g. absolutely positioned icon); treat
// as not visible for matching purposes.
return false;
}
return true;
};
const serializeElement = (el: Element): RawNode | null => {
if (nodeCount >= MAX_NODES) { truncated = true; return null; }
const tag = el.tagName.toLowerCase();
nodeCount++;
const cs = window.getComputedStyle(el);
const r = el.getBoundingClientRect();
const bbox: RawBBox = {
x: round2(r.x + scrollX),
y: round2(r.y + scrollY),
width: round2(r.width),
height: round2(r.height),
};
const attrs: Record<string, string> = {};
for (const a of Array.from(el.attributes)) {
// Drop inline event handlers and inline style (we replay computed style).
const name = a.name;
if (name.startsWith("on")) continue;
attrs[name] = a.value;
}
const resolveLH = (style: RawStyle): void => {
if (style.lineHeight === "normal" && style.fontSize) {
style.lineHeight = resolveNormalLineHeight(
style.fontFamily || cs.fontFamily,
style.fontSize,
style.fontWeight || "400",
style.fontStyle || "normal",
);
}
};
const computed = grabStyle(cs, PROPS);
resolveLH(computed);
// Sizing-intent probe: does the browser re-derive this box when we drop its
// width/height? Ground truth for whether the generator can omit the baked dimension. Run AFTER
// the canonical cs/bbox are recorded and fully restored after, so the capture is unchanged — it
// only ADDS three booleans. Skipped for out-of-flow, replaced, hidden, and animated elements.
let sizing: RawSizing | undefined;
const probePos = cs.position || "static";
if (
(probePos === "static" || probePos === "relative") && r.width > 1 && cs.display !== "none" &&
(cs.animationName || "none") === "none" && (el as HTMLElement).style &&
!/^(img|svg|video|canvas|picture|iframe|input|textarea|select|hr|object|embed|br)$/.test(tag)
) {
const h = el as HTMLElement;
const sw = h.style.getPropertyValue("width"), swp = h.style.getPropertyPriority("width");
const sh = h.style.getPropertyValue("height"), shp = h.style.getPropertyPriority("height");
const restore = () => {
h.style.removeProperty("width"); if (sw) h.style.setProperty("width", sw, swp);
h.style.removeProperty("height"); if (sh) h.style.setProperty("height", sh, shp);
};
try {
h.style.setProperty("width", "auto", "important");
const wa = el.getBoundingClientRect().width;
h.style.setProperty("width", "100%", "important");
const wf = el.getBoundingClientRect().width;
// Intrinsic-size anchors (probe 3): min-content (longest unbreakable run) and max-content
// (no-wrap natural width). A load-bearing width that varies across viewports but stays
// between these is a fluid law; these are the anchors css.ts fits it from.
h.style.setProperty("width", "min-content", "important");
const wmin = el.getBoundingClientRect().width;
h.style.setProperty("width", "max-content", "important");
const wmax = el.getBoundingClientRect().width;
h.style.removeProperty("width"); if (sw) h.style.setProperty("width", sw, swp);
h.style.setProperty("height", "auto", "important");
const ha = el.getBoundingClientRect().height;
h.style.setProperty("height", "100%", "important");
const hf = el.getBoundingClientRect().height;
// Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly,
// so a drop can't accumulate a visible shift across many elements (favours fidelity).
sizing = {
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto: Math.abs(ha - r.height) <= 0.5,
hFill: Math.abs(hf - r.height) <= 0.5,
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
};
} finally {
restore();
}
} else if (
(probePos === "absolute" || probePos === "fixed") && cs.display !== "none" &&
(cs.animationName || "none") === "none" && (el as HTMLElement).style
) {
// Inset-anchor probe: for an out-of-flow box, per side, does setting that
// inset to `auto` leave the box exactly in place? If so the inset was a filled-in USED value
// (the browser resolved it from the containing-block size — `bottom` = page height, `right` =
// viewport width) that bakes a huge per-viewport number and a band; if the box MOVES, the inset
// is the authored anchor and must stay. Out-of-flow, so dropping never cascades the in-flow page.
const h = el as HTMLElement;
const r0 = el.getBoundingClientRect();
const drop = { top: false, right: false, bottom: false, left: false };
for (const side of ["top", "right", "bottom", "left"] as const) {
const cur = cs[side as "top"];
if (cur === "auto" || cur == null || cur === "") { drop[side] = true; continue; } // nothing to emit
const sv = h.style.getPropertyValue(side), svp = h.style.getPropertyPriority(side);
try {
h.style.setProperty(side, "auto", "important");
const r1 = el.getBoundingClientRect();
drop[side] = Math.abs(r1.left - r0.left) <= 0.5 && Math.abs(r1.top - r0.top) <= 0.5 &&
Math.abs(r1.width - r0.width) <= 0.5 && Math.abs(r1.height - r0.height) <= 0.5;
} finally {
h.style.removeProperty(side); if (sv) h.style.setProperty(side, sv, svp);
}
}
sizing = { wAuto: false, wFill: false, hAuto: false, insetDrop: drop };
}
const node: RawNode = {
tag,
attrs,
computed,
bbox,
visible: isVisible(el, cs, bbox),
...(sizing ? { sizing } : {}),
children: [],
};
// Pseudo-elements
try {
const before = window.getComputedStyle(el, "::before");
if (before.content && before.content !== "none" && before.content !== "normal") {
node.before = grabStyle(before, PSEUDO_PROPS);
resolveLH(node.before);
}
const after = window.getComputedStyle(el, "::after");
if (after.content && after.content !== "none" && after.content !== "normal") {
node.after = grabStyle(after, PSEUDO_PROPS);
resolveLH(node.after);
}
} catch { /* ignore */ }
// Inline SVG → raw markup, no recursion.
if (tag === "svg") {
node.rawHTML = el.outerHTML;
return node;
}
// Whitespace inside pre/pre-wrap/break-spaces is significant (e.g. multi-line
// code blocks with highlighted spans separated by newlines).
const preserveWs = /^(pre|pre-wrap|break-spaces)/.test(cs.whiteSpace || "");
// Recurse children (elements + text nodes), preserving order.
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) {
const t = child.textContent || "";
if (preserveWs && t.length > 0) {
node.children.push({ text: t });
} else if (t.trim().length > 0) {
node.children.push({ text: t });
} else if (t.length > 0 && node.children.length > 0) {
// Preserve a single significant space between inline elements.
node.children.push({ text: " " });
}
continue;
}
if (child.nodeType !== Node.ELEMENT_NODE) continue;
const childEl = child as Element;
if (SKIP_TAGS.has(childEl.tagName.toLowerCase())) continue;
const sn = serializeElement(childEl);
if (sn) node.children.push(sn);
}
return node;
};
// ---- Stylesheet introspection (font-faces, css url(), keyframes, vars) ----
const fontFaces: FontFace[] = [];
const cssUrlSet = new Set<string>();
const keyframes: string[] = [];
const cssVars: Record<string, string> = {};
const absUrl = (u: string): string => {
try { return new URL(u, document.baseURI).href; } catch { return u; }
};
const harvestUrlsFromText = (text: string): void => {
const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const raw = m[2];
if (!raw || raw.startsWith("data:")) continue;
cssUrlSet.add(absUrl(raw));
}
};
const readRules = (rules: CSSRuleList): void => {
for (const rule of Array.from(rules)) {
const type = rule.constructor.name;
if (type === "CSSFontFaceRule") {
const r = rule as CSSFontFaceRule;
const s = r.style;
const family = (s.getPropertyValue("font-family") || "").trim().replace(/^['"]|['"]$/g, "");
const src = (s.getPropertyValue("src") || "").trim();
if (family && src) {
fontFaces.push({
family,
src,
weight: s.getPropertyValue("font-weight") || undefined,
style: s.getPropertyValue("font-style") || undefined,
display: s.getPropertyValue("font-display") || undefined,
unicodeRange: s.getPropertyValue("unicode-range") || undefined,
stretch: s.getPropertyValue("font-stretch") || undefined,
});
harvestUrlsFromText(src);
}
} else if (type === "CSSKeyframesRule") {
keyframes.push((rule as CSSKeyframesRule).cssText);
} else if (type === "CSSStyleRule") {
const r = rule as CSSStyleRule;
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText);
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
} else if (type === "CSSImportRule") {
const imp = rule as CSSImportRule;
try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules); } catch { /* cross-origin */ }
}
}
};
for (const sheet of Array.from(document.styleSheets)) {
try {
readRules(sheet.cssRules);
} catch {
// Cross-origin sheet — record its href so the capture layer can fetch the
// raw text out-of-band and parse font-faces/urls from it.
if (sheet.href) cssUrlSet.add(absUrl(sheet.href));
}
}
// CSS custom properties declared on :root.
try {
const rootCs = window.getComputedStyle(document.documentElement);
for (let i = 0; i < rootCs.length; i++) {
const prop = rootCs.item(i);
if (prop.startsWith("--")) {
const val = rootCs.getPropertyValue(prop).trim();
if (val) cssVars[prop] = val;
}
}
} catch { /* ignore */ }
// ---- DOM asset references ----
const domAssets: Array<{ kind: string; url: string; via: string }> = [];
const pushAsset = (kind: string, url: string | null, via: string): void => {
if (!url) return;
const trimmed = url.trim();
if (!trimmed || trimmed.startsWith("data:")) return;
domAssets.push({ kind, url: absUrl(trimmed), via });
};
// Lazy loaders often keep a transparent data: placeholder in src/srcset and park
// the real URL in data-* until the element scrolls near the viewport. Harvest all
// known carriers; pushAsset skips placeholders, so this is safe for normal images.
const harvestSrcset = (kind: string, srcset: string | null, via: string): void => {
if (!srcset) return;
for (const part of srcset.split(",")) {
const u = part.trim().split(/\s+/)[0];
if (u) pushAsset(kind, u, via);
}
};
for (const img of Array.from(document.querySelectorAll("img"))) {
pushAsset("image", img.getAttribute("src"), "img[src]");
pushAsset("image", img.getAttribute("data-lazy-src"), "img[data-lazy-src]");
pushAsset("image", img.getAttribute("data-src"), "img[data-src]");
pushAsset("image", img.getAttribute("data-original"), "img[data-original]");
pushAsset("image", img.getAttribute("data-ll-src"), "img[data-ll-src]");
harvestSrcset("image", img.getAttribute("srcset"), "img[srcset]");
harvestSrcset("image", img.getAttribute("data-lazy-srcset"), "img[data-lazy-srcset]");
harvestSrcset("image", img.getAttribute("data-srcset"), "img[data-srcset]");
}
for (const source of Array.from(document.querySelectorAll("source"))) {
pushAsset("media", source.getAttribute("src"), "source[src]");
pushAsset("media", source.getAttribute("data-src"), "source[data-src]");
harvestSrcset("image", source.getAttribute("srcset"), "source[srcset]");
harvestSrcset("image", source.getAttribute("data-lazy-srcset"), "source[data-lazy-srcset]");
harvestSrcset("image", source.getAttribute("data-srcset"), "source[data-srcset]");
}
for (const video of Array.from(document.querySelectorAll("video"))) {
pushAsset("video", video.getAttribute("src"), "video[src]");
pushAsset("image", video.getAttribute("poster"), "video[poster]");
}
for (const use of Array.from(document.querySelectorAll("use"))) {
const href = use.getAttribute("href") || use.getAttribute("xlink:href");
if (href && !href.startsWith("#")) pushAsset("svg", href, "use[href]");
}
const body = document.body;
const root = serializeElement(body)!;
lhProbe.remove();
const htmlCs = window.getComputedStyle(document.documentElement);
const bodyCs = window.getComputedStyle(body);
const metaContent = (sel: string): string => (document.querySelector(sel) as HTMLMetaElement | null)?.content || "";
const linkHref = (sel: string): string => (document.querySelector(sel) as HTMLLinkElement | null)?.href || "";
const attr = (el: Element, name: string): string | undefined => {
const v = el.getAttribute(name);
return v && v.trim() ? v.trim() : undefined;
};
const headMeta = Array.from(document.head.querySelectorAll("meta")).map((m) => ({
...(attr(m, "name") ? { name: attr(m, "name") } : {}),
...(attr(m, "property") ? { property: attr(m, "property") } : {}),
...(attr(m, "http-equiv") ? { httpEquiv: attr(m, "http-equiv") } : {}),
content: (m.getAttribute("content") || "").trim(),
})).filter((m) => m.content || m.name || m.property || m.httpEquiv);
const headLinks = Array.from(document.head.querySelectorAll("link[href]")).map((l) => {
const link = l as HTMLLinkElement;
return {
rel: (link.getAttribute("rel") || "").trim(),
href: link.href || absUrl(link.getAttribute("href") || ""),
...(attr(link, "as") ? { as: attr(link, "as") } : {}),
...(attr(link, "type") ? { type: attr(link, "type") } : {}),
...(attr(link, "sizes") ? { sizes: attr(link, "sizes") } : {}),
...(attr(link, "media") ? { media: attr(link, "media") } : {}),
...(attr(link, "color") ? { color: attr(link, "color") } : {}),
...(attr(link, "hreflang") ? { hrefLang: attr(link, "hreflang") } : {}),
...(attr(link, "title") ? { title: attr(link, "title") } : {}),
...(attr(link, "crossorigin") ? { crossOrigin: attr(link, "crossorigin") } : {}),
...(attr(link, "referrerpolicy") ? { referrerPolicy: attr(link, "referrerpolicy") } : {}),
};
}).filter((l) => l.rel || l.href);
const jsonLd = Array.from(document.querySelectorAll("script[type]")).filter((s) => {
const type = (s.getAttribute("type") || "").toLowerCase().split(";")[0]!.trim();
return type === "application/ld+json";
}).map((s) => ({
...(attr(s, "id") ? { id: attr(s, "id") } : {}),
text: (s.textContent || "").trim(),
})).filter((s) => s.text);
for (const link of headLinks) {
const rel = link.rel.toLowerCase();
if (/\b(?:icon|shortcut icon|apple-touch-icon|mask-icon)\b/.test(rel)) pushAsset("image", link.href, `head link[rel="${link.rel}"]`);
if (/\bmanifest\b/.test(rel)) pushAsset("manifest", link.href, `head link[rel="${link.rel}"]`);
}
return {
doc: {
url: location.href,
title: document.title || "",
// SEO head metadata (for the generated app's <metadata>, robots, llms.txt).
head: {
description: metaContent('meta[name="description"]'),
canonical: linkHref('link[rel="canonical"]'),
ogTitle: metaContent('meta[property="og:title"]'),
ogDescription: metaContent('meta[property="og:description"]'),
ogImage: metaContent('meta[property="og:image"]'),
ogType: metaContent('meta[property="og:type"]'),
ogSiteName: metaContent('meta[property="og:site_name"]'),
twitterCard: metaContent('meta[name="twitter:card"]'),
themeColor: metaContent('meta[name="theme-color"]'),
keywords: metaContent('meta[name="keywords"]'),
robots: metaContent('meta[name="robots"]'),
referrer: metaContent('meta[name="referrer"]'),
colorScheme: metaContent('meta[name="color-scheme"]'),
meta: headMeta,
links: headLinks,
jsonLd,
},
lang: document.documentElement.getAttribute("lang") || "",
charset: document.characterSet || "UTF-8",
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
scrollWidth: round2(document.documentElement.scrollWidth),
scrollHeight: round2(document.documentElement.scrollHeight),
htmlBg: htmlCs.backgroundColor,
bodyBg: bodyCs.backgroundColor,
bodyColor: bodyCs.color,
bodyFont: bodyCs.fontFamily,
metaViewport: (document.querySelector('meta[name="viewport"]') as HTMLMetaElement | null)?.content || "",
nodeCount,
truncated,
},
root,
cssVars,
fontFaces,
cssUrls: Array.from(cssUrlSet).sort(),
domAssets,
keyframes,
};
}
+633
View File
@@ -0,0 +1,633 @@
#!/usr/bin/env -S npx tsx
import { basename, dirname, join, resolve, sep } from "node:path";
import { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { captureSite, REQUIRED_VIEWPORTS, SAMPLE_VIEWPORTS, type CaptureResult } from "./capture/capture.js";
import { fileURLToPath } from "node:url";
import { generateAll } from "./generate/pipeline.js";
import { refineSizing } from "./generate/refineSizing.js";
import { writeJSON, writeText, ensureDir, readJSON, fileExists } from "./util/fsx.js";
import type { AppFramework } from "./generate/app.js";
export type CloneOptions = {
url: string;
runsDir?: string;
viewports?: number[];
reuseSource?: string; // path to an existing source/ dir to skip capture
interactions?: boolean; // Stage 4 — capture & reproduce hover/focus states (opt-in)
components?: boolean; // Stage 4.5 — extract repeated subtrees into components (opt-in)
motion?: boolean; // Stage 5 — capture & replay motion (CSS @keyframes / WAAPI / rotating text)
dense?: boolean; // capture intermediate/wide widths (SAMPLE_VIEWPORTS) to strengthen size inference
humanizeMode?: "tailwind" | "css"; // styling output — Tailwind utilities (default) or semantic CSS
framework?: AppFramework; // output framework — Next.js App Router (default) or Vite React
outDir?: string; // write a clean <outDir>/<siteName>/{app,.clone} layout instead of runs/<id>/<ts>
refineSizing?: boolean; // iterate render→regen until the clone-probe converges (proxy mode)
reflow?: boolean; // Reflow trade: flow all heights (cleaner code,
// content re-positions between breakpoints; backstopped by the perceptual gate).
// ON by default at the CLI (--no-reflow to disable); persisted per-run in clone-options.json.
screenshots?: boolean; // capture per-viewport screenshots (default on); --no-screenshots skips them for a
// faster production clone (validation-only artifact — generation ignores pixels).
log?: (event: Record<string, unknown>) => void;
};
export type CloneResult = {
runDir: string;
sourceDir: string;
appDir: string;
sourceUrl: string;
};
export function siteIdFromUrl(url: string): string {
try {
const u = new URL(url);
const host = u.hostname.replace(/^www\./, "");
const path = u.pathname.replace(/\/+$/, "").replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-|-$/g, "");
return (host + (path ? "-" + path : "")).replace(/[^a-zA-Z0-9.-]/g, "-").slice(0, 80);
} catch {
return "site-" + url.replace(/[^a-zA-Z0-9]+/g, "-").slice(0, 40);
}
}
// Common multi-part public suffixes — not exhaustive, covers the frequent ones; anything
// else is treated as a single-chunk TLD.
const MULTIPART_TLDS = new Set([
"co.uk", "org.uk", "ac.uk", "gov.uk", "me.uk", "com.au", "net.au", "org.au", "co.nz",
"co.za", "co.jp", "or.jp", "ne.jp", "com.br", "com.mx", "com.ar", "com.sg", "com.hk",
"co.in", "co.kr", "com.tr", "com.cn",
]);
/** A short, human folder name for a site: the registrable domain's main label, minus
* `www.` and the TLD — `www.ion.design` → `ion`, `blog.example.co.uk` → `example`,
* `blog.example.co.uk` → `example`. Falls back to the full slug for non-host URLs
* (e.g. file://) so it's always a valid, non-empty directory name. */
export function siteName(url: string): string {
let host = "";
try { host = new URL(url).hostname; } catch { /* not a URL */ }
host = host.replace(/^www\./i, "");
const labels = host.split(".").filter(Boolean);
let name = "";
if (labels.length <= 1) name = labels[0] ?? "";
else {
const tldParts = MULTIPART_TLDS.has(labels.slice(-2).join(".")) ? 2 : 1;
name = labels[labels.length - 1 - tldParts] ?? labels[0]!;
}
name = name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "");
return name || siteIdFromUrl(url);
}
function timestamp(): string {
const d = new Date();
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${d.getUTCFullYear()}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}-${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}`;
}
/** `--out` layout: a clean, predictable `<outDir>/<siteName>/` folder holding the
* deliverable app at `app/` and the reusable working artifacts (capture/IR/validation)
* under `.clone/` — so a later `clone-site --out` can expand into the full site without
* re-capturing page 1. Single + multi clones of the same site share this folder. */
export function namedOutDirs(outDir: string, url: string): { namedDir: string; runDir: string; appDir: string } {
const namedDir = join(resolve(outDir), siteName(url));
return { namedDir, runDir: join(namedDir, ".clone"), appDir: join(namedDir, "app") };
}
/** Publish the freshly-generated app to the deliverable `app/` dir (replacing any prior). */
export function exportApp(generatedAppDir: string, appOutDir: string): { removed: number; kept: number } {
rmSync(appOutDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
cpSync(generatedAppDir, appOutDir, { recursive: true });
rmSync(join(appOutDir, ".next"), { recursive: true, force: true });
rmSync(join(appOutDir, "out"), { recursive: true, force: true });
rmSync(join(appOutDir, "node_modules"), { recursive: true, force: true });
return stripDeliveryDataCids(appOutDir);
}
/** Strip the validation-only `data-cid` plumbing from the SHIPPED app. The compiler keeps
* `data-cid` on every node in `.clone/generated` so the fidelity grader can align the clone
* to the source. The deliverable should not expose those probe ids. Runtime/CSS references
* that still need a DOM anchor are rewritten to semantic `data-ditto-id` values; extracted
* component `cids` arrays become typed `ditto-meta` anchor objects. */
export function stripDeliveryDataCids(appDir: string): { removed: number; kept: number } {
const srcDir = join(appDir, "src");
const files: string[] = [];
const walk = (d: string): void => {
for (const name of readdirSync(d)) {
const p = join(d, name);
if (statSync(p).isDirectory()) walk(p);
else files.push(p);
}
};
if (existsSync(srcDir)) walk(srcDir);
const walkHtml = (d: string): void => {
for (const name of readdirSync(d)) {
if (name === "src" || name === "public" || name === "node_modules" || name === ".next" || name === "out" || name === "dist") continue;
const p = join(d, name);
if (statSync(p).isDirectory()) walkHtml(p);
else if (name.endsWith(".html")) files.push(p);
}
};
if (existsSync(appDir)) walkHtml(appDir);
if (!files.length) return { removed: 0, kept: 0 };
const isCodeFile = (f: string): boolean => /\.(tsx|jsx|ts)$/.test(f);
const isJsxFile = (f: string): boolean => /\.(tsx|jsx)$/.test(f);
const isHtmlFile = (f: string): boolean => /\.html$/.test(f);
const exactCidStringRe = /"([A-Za-z]*n\d+)"/g;
const hintByCid = collectDeliveryCidHints(files);
const anchors = new Map<string, { name: string; kind: string }>();
const usedAnchors = new Set<string>();
const counters = new Map<string, number>();
const anchorFor = (cid: string, kind: string): string => {
const existing = anchors.get(cid);
if (existing) return existing.name;
const hint = hintByCid.get(cid);
const numbered = (base: string): string => {
const clean = slug(base) || kind;
let out = clean;
let n = 2;
while (usedAnchors.has(out)) out = `${clean}-${n++}`;
usedAnchors.add(out);
return out;
};
const base = hint ? `${kind}-${hint}` : `${kind}-${(counters.get(kind) ?? 0) + 1}`;
counters.set(kind, (counters.get(kind) ?? 0) + 1);
const name = numbered(base);
anchors.set(cid, { name, kind });
return name;
};
// Runtime refs first, so an element used by both runtime and CSS gets a meaningful
// anchor (`motion-*`, `menu-trigger-*`) instead of a generic stylesheet name.
for (const f of files) {
if (!isCodeFile(f) && !f.endsWith(".css") && !isHtmlFile(f)) continue;
const text = readFileSync(f, "utf8");
if (isCodeFile(f)) {
for (const line of text.split("\n")) {
if (line.includes("<DittoMotion")) {
for (const m of line.matchAll(exactCidStringRe)) anchorFor(m[1]!, "motion");
} else if (line.includes("<DittoWire") || line.includes("<Accordion")) {
for (const m of line.matchAll(exactCidStringRe)) anchorFor(m[1]!, "interaction");
} else if (line.includes("<DropdownMenu")) {
for (const m of line.matchAll(/"trigger":\s*"([^"]+)"/g)) anchorFor(m[1]!, "menu-trigger");
}
}
for (const m of text.matchAll(/"(?:trigger|region|panel|track|next|prev)":\s*"([A-Za-z]*n\d+)"/g)) anchorFor(m[1]!, "interaction");
}
if (f.endsWith(".css")) for (const m of text.matchAll(/\[data-cid="([^"]+)"\]/g)) anchorFor(m[1]!, "style");
}
let removed = 0, kept = 0;
const cidsFile = files.find((f) => basename(f) === "_cids.ts");
let componentsWithMetaAnchors = new Set<string>();
let metaAnchorIndexes = new Map<string, Set<number>>();
if (cidsFile) {
const text = readFileSync(cidsFile, "utf8");
const meta = rewriteCidsModuleToMeta(text, (cid) => anchors.get(cid)?.name ?? null, (hasAnchor) => {
if (hasAnchor) kept++; else removed++;
});
componentsWithMetaAnchors = meta.componentsWithAnchors;
metaAnchorIndexes = meta.anchorIndexesByComponent;
const metaPath = join(dirname(cidsFile), "ditto-meta.ts");
if (componentsWithMetaAnchors.size) writeFileSync(metaPath, pruneCloneMetaModule(meta.text, componentsWithMetaAnchors));
else rmSync(metaPath, { force: true });
rmSync(cidsFile, { force: true });
}
for (const f of files) {
if (!existsSync(f) || (!isCodeFile(f) && !f.endsWith(".css") && !isHtmlFile(f))) continue;
const text = readFileSync(f, "utf8");
let next = text;
if (f.endsWith(".css")) {
next = next.replace(/\[data-cid="([^"]+)"\]/g, (_full, cid: string) => `[data-ditto-id="${anchors.get(cid)?.name ?? anchorFor(cid, "style")}"]`);
}
if (isHtmlFile(f)) {
next = next.replace(/\sdata-cid="([^"]+)"/g, (_full, cid: string) => {
const anchor = anchors.get(cid)?.name;
if (anchor) { kept++; return ` data-ditto-id="${anchor}"`; }
removed++; return "";
});
}
if (isCodeFile(f)) {
next = rewriteDeliveryImportsAndMeta(next, componentsWithMetaAnchors);
next = rewriteRuntimeAnchorQueries(next, basename(f));
if (/\bDittoMotion\b/.test(next)) next = next.replace(/"cid":/g, '"anchor":');
next = next.replace(/\sdata-cid="([^"]+)"/g, (_full, cid: string) => {
const anchor = anchors.get(cid)?.name;
if (anchor) { kept++; return ` data-ditto-id="${anchor}"`; }
removed++; return "";
});
if (isJsxFile(f)) {
next = rewriteComponentMetaAttrs(next, componentsWithMetaAnchors, metaAnchorIndexes);
next = rewriteSvgDittoIdProps(next, (cid) => anchors.get(cid)?.name ?? null, (hasAnchor) => {
if (hasAnchor) kept++; else removed++;
});
}
next = next.replace(exactCidStringRe, (full, cid: string) => {
const anchor = anchors.get(cid)?.name;
return anchor ? JSON.stringify(anchor) : full;
});
}
if (next !== text) writeFileSync(f, next);
}
pruneUnusedSvgDittoIds(files);
return { removed, kept };
}
function collectDeliveryCidHints(files: string[]): Map<string, string> {
const hints = new Map<string, string>();
for (const f of files) {
if (!/\.(tsx|jsx)$/.test(f)) continue;
const text = readFileSync(f, "utf8");
const tagRe = /<([A-Za-z][\w:-]*)\b[^>]*\sdata-cid="([^"]+)"[^>]*>/g;
for (const m of text.matchAll(tagRe)) {
const tag = m[0]!;
const tagName = m[1]!;
const cid = m[2]!;
const attr = (name: string): string => {
const a = new RegExp(`(?:^|\\s)${name}=(?:"([^"]*)"|'([^']*)')`).exec(tag);
return a?.[1] ?? a?.[2] ?? "";
};
const hint = attr("id") || attr("aria-label") || attr("data-component") || tagName;
if (!hints.has(cid)) hints.set(cid, slug(hint));
}
}
return hints;
}
function slug(value: string): string {
return value
.toLowerCase()
.replace(/&[a-z0-9#]+;/g, " ")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 56);
}
function rewriteCidsModuleToMeta(
text: string,
anchorOf: (cid: string) => string | null,
count: (hasAnchor: boolean) => void,
): { text: string; componentsWithAnchors: Set<string>; anchorIndexesByComponent: Map<string, Set<number>> } {
const componentsWithAnchors = new Set<string>();
const anchorIndexesByComponent = new Map<string, Set<number>>();
const rows = text.replace(/export const ([A-Za-z_$][\w$]*)_cids(\d*): string\[\]\[\] = ([\s\S]*?);/g, (_full, name: string, suffix: string, body: string) => {
let hasAnchor = false;
const indexSet = anchorIndexesByComponent.get(name) ?? new Set<number>();
anchorIndexesByComponent.set(name, indexSet);
let sourceRows: string[][] = [];
try { sourceRows = JSON.parse(body) as string[][]; } catch { sourceRows = []; }
const metaBody = sourceRows.map((row) => {
const entries: string[] = [];
row.forEach((cid, idx) => {
const anchor = anchorOf(cid);
count(!!anchor);
if (!anchor) return;
hasAnchor = true;
indexSet.add(idx);
entries.push(`${idx}: { anchor: ${JSON.stringify(anchor)} }`);
});
return `{ ${entries.join(", ")} }`;
}).join(",\n ");
if (hasAnchor) componentsWithAnchors.add(name);
return `export const ${name}_meta${suffix}: DittoNodeMetaMap[] = [\n ${metaBody}\n];`;
});
return {
text: `// Per-instance Ditto metadata. Validation-only node ids stay in .clone/generated.\nexport type DittoNodeMeta = { anchor?: string };\nexport type DittoNodeMetaMap = Record<number, DittoNodeMeta | undefined>;\n\n${rows.replace(/^\/\/.*\n\n?/, "")}`,
componentsWithAnchors,
anchorIndexesByComponent,
};
}
function pruneCloneMetaModule(text: string, componentsWithAnchors: Set<string>): string {
return text.replace(/export const ([A-Za-z_$][\w$]*)_meta(\d*): DittoNodeMetaMap\[] = [\s\S]*?;\n?/g, (full, name: string) => (
componentsWithAnchors.has(name) ? full : ""
));
}
function metaVarComponent(varName: string): string | null {
const m = /^([A-Za-z_$][\w$]*)_meta\d*$/.exec(varName);
return m?.[1] ?? null;
}
function keepMetaVar(varName: string, componentsWithAnchors: Set<string>): boolean {
const comp = metaVarComponent(varName);
return !!comp && componentsWithAnchors.has(comp);
}
function rewriteDeliveryImportsAndMeta(text: string, componentsWithAnchors: Set<string>): string {
let next = text
.replace(/(["'])((?:\.\.?\/)+)_cids\1/g, (_full, q: string, rel: string) => `${q}${rel}ditto-meta${q}`)
.replace(/\b([A-Za-z_$][\w$]*)_cids(\d*)\b/g, "$1_meta$2")
.replace(/\bcids=/g, "meta=")
.replace(/\bcids\b/g, "meta")
.replace(/\bmeta:\s*string\[\]/g, "meta: DittoNodeMetaMap");
next = next.replace(/^import \{ ([^}]+) \} from ["']((?:\.\.?\/)+ditto-meta)["'];\n?/gm, (_full, specs: string, rel: string) => {
const kept = specs.split(",").map((s) => s.trim()).filter((s) => keepMetaVar(s, componentsWithAnchors));
return kept.length ? `import { ${kept.join(", ")} } from "${rel}";\n` : "";
});
next = next.replace(/\smeta=\{([A-Za-z_$][\w$]*_meta\d*)\[i\]\}/g, (full, varName: string) => (
keepMetaVar(varName, componentsWithAnchors) ? full : ""
));
return next;
}
function rewriteComponentMetaAttrs(text: string, componentsWithAnchors: Set<string>, anchorIndexesByComponent: Map<string, Set<number>>): string {
let next = text.replace(/\sdata-(?:cid|clone-id|ditto-id)=\{meta\[(\d+)\]\}/g, (_full, idx: string) => ` data-ditto-id={meta[${idx}]?.anchor}`);
const comp = /export default function ([A-Za-z_$][\w$]*)\(/.exec(next)?.[1];
if (comp && !componentsWithAnchors.has(comp)) {
next = next
.replace(/\sdata-ditto-id=\{meta\[\d+\]\?\.anchor\}/g, "")
.replace(/\{ d, meta, styles \}/g, "{ d, styles }")
.replace(/\{ d, meta \}/g, "{ d }")
.replace(/;\s*meta:\s*(?:Clone|Ditto)NodeMeta(?:Map|\[\])/g, "")
.replace(/import type \{ DittoNodeMetaMap \} from ["'][.]{2}\/ditto-meta["'];\n?/g, "");
} else if (comp) {
const keepIndexes = anchorIndexesByComponent.get(comp) ?? new Set<number>();
next = next.replace(/\sdata-ditto-id=\{meta\[(\d+)\]\?\.anchor\}/g, (full, idx: string) => (
keepIndexes.has(Number(idx)) ? full : ""
));
}
if (next.includes("DittoNodeMetaMap") && !/import type \{ DittoNodeMetaMap \} from ["'][.]{2}\/ditto-meta["'];/.test(next)) {
next = `import type { DittoNodeMetaMap } from "../ditto-meta";\n${next}`;
}
return next;
}
function rewriteSvgDittoIdProps(text: string, anchorOf: (cid: string) => string | null, count: (hasAnchor: boolean) => void): string {
let next = text
.replace(/\(\{ cid \}: \{ cid\?: string \}\)/g, "({ dittoId }: { dittoId?: string })")
.replace(/\sdata-(?:cid|clone-id|ditto-id)=\{cid\}/g, " data-ditto-id={dittoId}");
next = next.replace(/\scid=\{\s*"([^"]+)"\s*\}/g, (_full, cid: string) => {
const anchor = anchorOf(cid);
if (anchor) {
count(true);
return ` dittoId={${JSON.stringify(anchor)}}`;
}
if (!/^[A-Za-z]*n\d+$/.test(cid)) return ` dittoId={${JSON.stringify(cid)}}`;
count(false);
return "";
});
next = next.replace(/\scid=\{([^}]+)\}/g, (_full, expr: string) => ` dittoId={${expr}}`);
return next;
}
function pruneUnusedSvgDittoIds(files: string[]): void {
const hasDittoIdUse = files.some((f) => {
if (!existsSync(f) || !/\.(tsx|jsx)$/.test(f) || f.includes(`${sep}svgs${sep}`)) return false;
return /\sdittoId=\{/.test(readFileSync(f, "utf8"));
});
if (hasDittoIdUse) return;
for (const f of files) {
if (!existsSync(f) || !f.includes(`${sep}svgs${sep}`) || !/\.(tsx|jsx)$/.test(f)) continue;
const text = readFileSync(f, "utf8");
const next = text
.replace(/export default function ([A-Za-z_$][\w$]*)\(\{ dittoId \}: \{ dittoId\?: string \}\)/g, "export default function $1()")
.replace(/\sdata-ditto-id=\{dittoId\}/g, "");
if (next !== text) writeFileSync(f, next);
}
}
function rewriteRuntimeAnchorQueries(text: string, fileName: string): string {
if (!/^(?:DittoMotion|DittoWire|DropdownMenu|Accordion)\.tsx$/.test(fileName)) return text;
let next = text
.replace(/\bbyCid\b/g, "byDittoId")
.replace(/const byDittoId = \(cid: string\): HTMLElement \| null => document\.querySelector\('\[data-cid="' \+ cid \+ '"\]'\);/g,
`const byDittoId = (id: string): HTMLElement | null => document.querySelector('[data-ditto-id="' + id + '"]');`)
.replace(/data-cid/g, "data-ditto-id");
if (fileName === "DittoMotion.tsx") {
next = next
.replace(/\bcid: string/g, "anchor: string")
.replace(/\.cid\b/g, ".anchor");
} else if (fileName === "DittoWire.tsx") {
next = next
.replace(/cid → style/g, "anchor → style")
.replace(/for \(const cid in d\) applyStyle\(byDittoId\(cid\), d\[cid\]\);/g, "for (const anchor in d) applyStyle(byDittoId(anchor), d[anchor]);");
}
return next;
}
/** Run the deterministic compiler end-to-end. Capture is skippable via reuseSource. */
export async function runClone(opts: CloneOptions): Promise<CloneResult> {
// The size-inference SAMPLE set. Defaults to the standard 4 (band) widths; pass `--dense` to
// also capture intermediate/wide widths (SAMPLE_VIEWPORTS) — the IR feeds those to width
// inference while still emitting bands only at the standard breakpoints. NOTE: dense capture
// makes the inference STRICTER (verified at more widths → fewer false relatives), which is more
// correct but currently nets slightly MORE baked px until container-level natural-size recovery
// lands; left opt-in for now. A --viewports override sets both sets explicitly.
const captureViewports = opts.viewports ?? (opts.dense ? [...SAMPLE_VIEWPORTS] : [...REQUIRED_VIEWPORTS]);
const viewports = opts.viewports ?? [...REQUIRED_VIEWPORTS];
const log = opts.log ?? (() => {});
const runsDir = opts.runsDir ?? resolve(process.cwd(), "..", "runs");
const siteId = siteIdFromUrl(opts.url);
// --out: predictable <outDir>/<siteName>/.clone working dir + app/ deliverable.
const out = opts.outDir ? namedOutDirs(opts.outDir, opts.url) : null;
const runDir = out ? out.runDir : join(runsDir, siteId, timestamp());
const sourceDir = join(runDir, "source");
const generatedDir = join(runDir, "generated");
const appDir = join(generatedDir, "app");
ensureDir(runDir);
ensureDir(join(runDir, "logs"));
const logEvents: Record<string, unknown>[] = [];
const logBoth = (e: Record<string, unknown>) => { logEvents.push(e); log(e); };
writeJSON(join(runDir, "input.json"), { url: opts.url, siteId, viewports, sampleViewports: captureViewports, startedAt: new Date().toISOString() });
// 1. Capture (or reuse)
let capture: CaptureResult;
if (opts.reuseSource && fileExists(join(opts.reuseSource, "capture", "capture-result.json"))) {
logBoth({ event: "capture_reuse", from: opts.reuseSource });
capture = readJSON<CaptureResult>(join(opts.reuseSource, "capture", "capture-result.json"));
// Copy reuseSource contents would be ideal; instead, point sourceDir at it via re-read.
// For simplicity the IR is built from reuseSource and other artifacts written into runDir.
copySourceRef(opts.reuseSource, sourceDir);
} else {
capture = await captureSite({ url: opts.url, outDir: sourceDir, viewports: captureViewports, interactions: opts.interactions, motion: opts.motion, screenshots: opts.screenshots, log: logBoth });
}
// Stage 4.5: persist the component-extraction choice in the source dir so every
// generateAll for this run (deliverable + determinism/prune regens, possibly in a
// separate validate process) reads the same flag and stays deterministic.
writeJSON(join(sourceDir, "clone-options.json"), { components: !!opts.components, ...(opts.humanizeMode ? { humanizeMode: opts.humanizeMode } : {}), ...(opts.framework ? { framework: opts.framework } : {}), reflow: !!opts.reflow });
// 2-5. Normalize → infer → generate → emit (shared deterministic pipeline)
const gen = generateAll({ sourceDir, capture, viewports, sampleViewports: captureViewports, url: opts.url, outDir: generatedDir });
logBoth({ event: "ir_built", nodes: gen.ir.doc.nodeCount });
logBoth({ event: "inferred", sections: gen.sections.length, assets: gen.assetGraph.entries.length, fonts: gen.fontGraph.entries.length });
logBoth({ event: "generated", assetsCopied: gen.assetsCopied, assetsMissing: gen.assetsMissing.length });
// When the source capture lacks native probe flags (this sandbox can't reach the
// live site through the egress proxy), optionally iterate render→regen so the LOCAL clone-probe
// converges. A no-op with a real source-probe (the source layout is fixed → one pass).
if (opts.refineSizing) {
const harness = fileURLToPath(new URL("../.harness", import.meta.url));
const res = await refineSizing(runDir, harness, { maxIters: 4, log: logBoth });
logBoth({ event: "refine_sizing_done", ...res });
}
writeText(join(runDir, "logs", "compiler.log.jsonl"), logEvents.map((e) => JSON.stringify(e)).join("\n") + "\n");
if (out) {
// Publish the deliverable to <siteName>/app; keep working artifacts in .clone.
exportApp(appDir, out.appDir);
logBoth({ event: "exported", app: out.appDir });
} else {
// latest pointer (runs/ layout, used by --reuse / regen)
writeJSON(join(runsDir, siteId, "latest.json"), { runDir, ts: timestamp() });
}
return { runDir, sourceDir, appDir: out ? out.appDir : appDir, sourceUrl: opts.url };
}
function copySourceRef(from: string, to: string): void {
// Symlink-free copy of the whole source dir for self-contained runs.
if (resolve(from) === resolve(to)) return;
ensureDir(to);
cpSync(from, to, { recursive: true });
}
/** Latest prior run's source/ dir for a URL (for --reuse: regenerate, skip capture). */
export function latestSourceDir(runsDir: string, url: string): string | null {
const siteDir = join(runsDir, siteIdFromUrl(url));
if (!existsSync(siteDir)) return null;
const runs = readdirSync(siteDir).filter((d) => /^\d/.test(d)).sort();
for (let i = runs.length - 1; i >= 0; i--) {
const src = join(siteDir, runs[i]!, "source");
if (fileExists(join(src, "capture", "capture-result.json"))) return src;
}
return null;
}
// ---- CLI ----
type ProductMode = "single" | "multi";
type ProductStyling = "tailwind" | "css";
type ProductFramework = AppFramework;
function flagValue(args: string[], name: string): string | undefined {
return args.find((a) => a.startsWith(`${name}=`))?.split("=")[1];
}
function firstFlagValue(args: string[], names: string[]): string | undefined {
for (const name of names) {
const value = flagValue(args, name);
if (value !== undefined) return value;
}
return undefined;
}
function hasAnyFlag(args: string[], names: string[]): boolean {
return names.some((name) => args.includes(name));
}
function parseProductMode(args: string[]): ProductMode {
const raw = flagValue(args, "--mode");
if (!raw) return "single";
if (raw === "single" || raw === "multi") return raw;
throw new Error(`invalid --mode=${raw}; expected "single" or "multi"`);
}
function parseProductStyling(args: string[]): ProductStyling {
const raw = flagValue(args, "--styling");
if (raw) {
if (raw === "tailwind" || raw === "css") return raw;
throw new Error(`invalid --styling=${raw}; expected "tailwind" or "css"`);
}
return args.includes("--css") ? "css" : "tailwind";
}
function parseProductFramework(args: string[]): ProductFramework {
const raw = flagValue(args, "--framework");
if (raw) {
if (raw === "next" || raw === "vite") return raw;
throw new Error(`invalid --framework=${raw}; expected "next" or "vite"`);
}
return args.includes("--vite") ? "vite" : "next";
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
const url = args.find((a) => !a.startsWith("--"));
if (!url) {
console.error("usage: clone-static <url> [--mode=single|multi] [--styling=tailwind|css] [--framework=next|vite] [--out=<dir>]");
process.exit(1);
}
const mode = parseProductMode(args);
const styling = parseProductStyling(args);
const framework = parseProductFramework(args);
const vpArg = firstFlagValue(args, ["--dev-viewports", "--viewports"]);
const runsArg = firstFlagValue(args, ["--dev-runs", "--runs"]);
// --out=<dir>: clean <dir>/<siteName>/{app,.clone} layout (default: ./output when bare --out).
const outArg = args.find((a) => a === "--out" || a.startsWith("--out="));
const outDir = outArg ? (outArg.includes("=") ? outArg.split("=")[1] : "output") : undefined;
const viewports = vpArg ? vpArg.split(",").map((s) => parseInt(s, 10)) : undefined;
// Best-by-default: interactions (Stage 4) and component extraction (Stage 4.5) are
// ON unless explicitly disabled. Both are safe — interactions apply nothing on mount
// and are gate-pruned; extraction is render-identical — so the base clone is unchanged.
const interactions = !hasAnyFlag(args, ["--dev-no-interactions", "--no-interactions"]);
const components = !hasAnyFlag(args, ["--dev-no-components", "--no-components"]);
const motion = !hasAnyFlag(args, ["--dev-no-motion", "--no-motion"]);
const dense = hasAnyFlag(args, ["--dev-dense", "--dense"]);
const refineSizingFlag = hasAnyFlag(args, ["--dev-refine-sizing", "--refine-sizing"]);
// Reflow trade is ON by default (flow all heights → cleaner code; content re-positions between
// breakpoints, backstopped by the perceptual/layout gates). Pass --no-reflow for sites where it
// hurts mobile fidelity.
const reflow = !hasAnyFlag(args, ["--dev-no-reflow", "--no-reflow"]);
// Screenshots are a validation-only artifact (generation never reads pixels); --no-screenshots skips
// the per-viewport full-page shots — the dominant capture cost on tall pages — for a faster production clone.
const screenshots = !hasAnyFlag(args, ["--dev-no-screenshots", "--no-screenshots"]);
const runsDir = runsArg ? resolve(runsArg) : resolve(process.cwd(), "..", "runs");
// --reuse: regenerate from the latest existing capture (skip the browser pass).
const reuseSource = hasAnyFlag(args, ["--dev-reuse", "--reuse"]) ? latestSourceDir(runsDir, url) ?? undefined : undefined;
if (mode === "multi") {
const { runCloneSite } = await import("./site/cloneSite.js");
const maxRoutes = firstFlagValue(args, ["--dev-max-routes", "--max-routes"]);
const maxCollection = firstFlagValue(args, ["--dev-max-collection", "--max-collection"]);
const maxDepth = firstFlagValue(args, ["--dev-depth", "--depth"]);
const concurrency = firstFlagValue(args, ["--dev-concurrency", "--concurrency"]);
const validationConcurrency = firstFlagValue(args, ["--dev-validate-concurrency", "--validate-concurrency", "--validation-concurrency"]);
const viewportConcurrency = firstFlagValue(args, ["--dev-viewport-concurrency", "--viewport-concurrency"]);
const tier = firstFlagValue(args, ["--dev-tier", "--tier"]);
const validate = hasAnyFlag(args, ["--dev-validate", "--validate"]) && !hasAnyFlag(args, ["--dev-no-validate", "--no-validate"]);
const res = await runCloneSite({
url,
runsDir,
maxRoutes: maxRoutes ? parseInt(maxRoutes, 10) : undefined,
maxCollectionInstances: maxCollection ? parseInt(maxCollection, 10) : undefined,
maxDepth: maxDepth ? parseInt(maxDepth, 10) : undefined,
captureConcurrency: concurrency ? parseInt(concurrency, 10) : undefined,
validationConcurrency: validationConcurrency ? parseInt(validationConcurrency, 10) : undefined,
viewportConcurrency: viewportConcurrency ? parseInt(viewportConcurrency, 10) : undefined,
validate,
interactions,
components,
humanizeMode: styling,
framework,
reflow,
screenshots: validate && screenshots,
outDir,
tier,
log: (e) => console.log(JSON.stringify(e)),
});
console.log(JSON.stringify({ event: "done", runDir: res.runDir, app: res.appDir }));
return;
}
const res = await runClone({
url,
viewports,
runsDir,
interactions,
components,
motion,
dense,
refineSizing: refineSizingFlag,
reflow,
screenshots,
humanizeMode: styling,
framework,
outDir,
reuseSource,
log: (e) => console.log(JSON.stringify(e)),
});
console.log(JSON.stringify({ event: "done", runDir: res.runDir, app: res.appDir }));
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((e) => { console.error(e); process.exit(1); });
}
+218
View File
@@ -0,0 +1,218 @@
/**
* Site crawl: discover same-origin route paths from one entry URL, bounded and
* deterministic. Sources: robots.txt (respected), sitemap.xml (incl. sitemap
* indexes), the entry page's links, and a bounded BFS over discovered internal
* links. Discovery is intentionally *lightweight* (load + harvest hrefs) the
* heavy single-load+resize capture runs later, only on the selected routes.
*/
import { chromium, type Browser, type BrowserContext, type Page } from "playwright";
import { toRoutePath, segmentsOf } from "./url.js";
const DESKTOP_UA =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
export type CrawlResult = {
entryUrl: string;
entryPath: string;
origin: string;
paths: string[]; // sorted unique route paths (includes entry)
depthByPath: Record<string, number>; // link-distance from entry (sitemap-only: segment count)
sourcesByPath: Record<string, string[]>; // "entry" | "link" | "sitemap"
robotsDisallow: string[];
};
export type CrawlOptions = {
url: string;
maxDepth?: number; // BFS link-distance cap (default 3)
maxDiscoverPages?: number; // cap on lightweight navigations (default 30)
maxSitemapUrls?: number; // cap on sitemap <loc> entries consumed (default 2000)
respectRobots?: boolean; // default true
log?: (e: Record<string, unknown>) => void;
};
/** Parse robots.txt into the Disallow prefixes that apply to our crawler (the
* catch-all `User-agent: *` group). Deterministic; unknown directives ignored. */
export function parseRobotsDisallow(text: string): string[] {
const lines = text.split(/\r?\n/);
let inStar = false;
let sawGroup = false;
const disallow: string[] = [];
for (const raw of lines) {
const line = raw.replace(/#.*$/, "").trim();
if (!line) continue;
const m = /^([a-zA-Z-]+)\s*:\s*(.*)$/.exec(line);
if (!m) continue;
const field = m[1]!.toLowerCase();
const value = m[2]!.trim();
if (field === "user-agent") {
// A run of consecutive user-agent lines shares the next rule block.
if (!sawGroup) inStar = value === "*" || inStar;
else { inStar = value === "*"; sawGroup = false; }
} else if (field === "disallow") {
sawGroup = true;
if (inStar && value) disallow.push(value);
} else if (field === "allow" || field === "sitemap" || field === "crawl-delay") {
sawGroup = true;
}
}
return [...new Set(disallow)].sort();
}
export function isAllowed(path: string, disallow: string[]): boolean {
for (const d of disallow) {
if (d === "/") return false;
// robots prefixes may contain a trailing * — treat as prefix match either way.
const prefix = d.replace(/\*+$/, "");
if (prefix && path.startsWith(prefix)) return false;
}
return true;
}
async function fetchText(ctx: BrowserContext, url: string, timeoutMs = 20000): Promise<string | null> {
try {
const resp = await ctx.request.get(url, { timeout: timeoutMs, failOnStatusCode: false });
if (!resp.ok()) return null;
return await resp.text();
} catch {
return null;
}
}
/** Extract route paths from sitemap.xml (following one level of sitemap indexes). */
async function fetchSitemapPaths(ctx: BrowserContext, origin: string, base: string, cap: number, log: (e: Record<string, unknown>) => void): Promise<string[]> {
const out = new Set<string>();
const locRe = /<loc>\s*([^<\s]+)\s*<\/loc>/gi;
const seenSitemaps = new Set<string>();
const queue = [origin + "/sitemap.xml", origin + "/sitemap_index.xml"];
let fetched = 0;
while (queue.length && out.size < cap && fetched < 12) {
const sm = queue.shift()!;
if (seenSitemaps.has(sm)) continue;
seenSitemaps.add(sm);
const text = await fetchText(ctx, sm);
if (!text) continue;
fetched++;
const isIndex = /<sitemapindex/i.test(text);
let m: RegExpExecArray | null;
locRe.lastIndex = 0;
while ((m = locRe.exec(text)) !== null) {
const loc = m[1]!.replace(/&amp;/g, "&");
if (isIndex) {
if (seenSitemaps.size + queue.length < 12) queue.push(loc);
} else {
const p = toRoutePath(loc, base);
if (p) out.add(p);
if (out.size >= cap) break;
}
}
}
if (out.size) log({ event: "sitemap", urls: out.size, sitemaps: fetched });
return [...out];
}
/** A path whose last segment looks like a content slug/id a leaf we record but
* don't navigate into during discovery (it seldom links to new site structure). */
function looksLikeLeaf(path: string): boolean {
const segs = segmentsOf(path);
if (segs.length === 0) return false;
const last = segs[segs.length - 1]!;
return last.includes("-") || last.length >= 14 || /^\d/.test(last) || segs.length >= 3;
}
async function harvestLinks(page: Page, base: string): Promise<string[]> {
const hrefs = await Promise.race([
page.evaluate(() => Array.from(document.querySelectorAll("a[href]")).map((a) => a.getAttribute("href"))),
new Promise<string[]>((res) => setTimeout(() => res([]), 8000)),
]).catch(() => [] as (string | null)[]);
const out = new Set<string>();
for (const h of hrefs) {
const p = toRoutePath(h, base);
if (p) out.add(p);
}
return [...out];
}
export async function crawlSite(opts: CrawlOptions): Promise<CrawlResult> {
const log = opts.log ?? (() => {});
const maxDepth = opts.maxDepth ?? 3;
const maxDiscoverPages = opts.maxDiscoverPages ?? 30;
const maxSitemapUrls = opts.maxSitemapUrls ?? 2000;
const respectRobots = opts.respectRobots ?? true;
const base = opts.url;
const origin = new URL(base).origin;
const entryPath = toRoutePath(base, base) ?? "/";
const browser: Browser = await chromium.launch({
headless: true,
args: ["--disable-blink-features=AutomationControlled", "--disable-dev-shm-usage"],
});
const depthByPath: Record<string, number> = {};
const sourcesByPath: Record<string, string[]> = {};
let robotsDisallow: string[] = [];
const addSource = (p: string, s: string): void => {
(sourcesByPath[p] ??= []);
if (!sourcesByPath[p]!.includes(s)) sourcesByPath[p]!.push(s);
};
try {
const ctx: BrowserContext = await browser.newContext({ ignoreHTTPSErrors: true, userAgent: DESKTOP_UA, viewport: { width: 1280, height: 800 } });
// robots.txt
if (respectRobots) {
const robotsText = await fetchText(ctx, origin + "/robots.txt");
if (robotsText) { robotsDisallow = parseRobotsDisallow(robotsText); log({ event: "robots", disallow: robotsDisallow.length }); }
}
const allow = (p: string): boolean => !respectRobots || isAllowed(p, robotsDisallow);
// sitemap
const sitemapPaths = await fetchSitemapPaths(ctx, origin, base, maxSitemapUrls, log);
for (const p of sitemapPaths) {
if (!allow(p)) continue;
if (!(p in depthByPath)) depthByPath[p] = segmentsOf(p).length; // proxy depth for sitemap-only
addSource(p, "sitemap");
}
// BFS from entry (lightweight navigations)
depthByPath[entryPath] = 0;
addSource(entryPath, "entry");
const queue: Array<{ path: string; depth: number }> = [{ path: entryPath, depth: 0 }];
const visited = new Set<string>();
let navs = 0;
const page = await ctx.newPage();
page.setDefaultTimeout(20000);
while (queue.length && navs < maxDiscoverPages) {
queue.sort((a, b) => a.depth - b.depth || a.path.localeCompare(b.path));
const { path, depth } = queue.shift()!;
if (visited.has(path)) continue;
visited.add(path);
if (depth > maxDepth) continue;
const url = origin + (path === "/" ? "/" : path);
let ok = true;
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 20000 });
await page.waitForTimeout(1200);
} catch { ok = false; }
navs++;
if (!ok) { log({ event: "discover_nav_fail", path }); continue; }
const links = await harvestLinks(page, base);
log({ event: "discovered", path, depth, links: links.length, navs });
for (const l of links) {
if (!allow(l)) continue;
addSource(l, "link");
if (!(l in depthByPath) || depthByPath[l]! > depth + 1) depthByPath[l] = depth + 1;
// Record every discovered path (template induction needs them all), but only
// *navigate into* likely hub/listing pages — descending into every content
// leaf (blog post, doc article) is wasteful and rarely reveals new structure.
if (!visited.has(l) && depth + 1 <= maxDepth && !looksLikeLeaf(l)) queue.push({ path: l, depth: depth + 1 });
}
}
await ctx.close();
} finally {
await browser.close();
}
const paths = Object.keys(depthByPath).sort();
log({ event: "crawl_done", paths: paths.length });
return { entryUrl: base, entryPath, origin, paths, depthByPath, sourcesByPath, robotsDisallow };
}
+284
View File
@@ -0,0 +1,284 @@
/**
* Route-template induction + the multi-route reproduction policy. Given the
* discovered same-origin route paths, group them into URL templates by *segment cardinality*
* (a position is dynamic when many siblings sharing its templated prefix vary at
* it), then classify each template:
*
* - singleton (1 instance) -> reproduce it
* - pair (2 instances) -> reproduce both (too few to collapse)
* - collection (>=3 instances) -> reproduce the listing + ONE representative,
* record the full instance list (CMS handoff)
*
* Pure + deterministic: a function of the sorted path list only. Structural
* confirmation (capture a sibling, compare DOM) happens later in the orchestrator
* and can *explode* a collection back to singletons via applyConfirmation().
*/
import { segmentsOf } from "./url.js";
// >= this many instances under a shared container segment => a collection.
export const COLLECTION_MIN = 3;
// Root-level (flat, no container) collections need stronger evidence: top-level routes are
// almost always *distinct pages* (about/pricing/cashew/coffee), not a collection — only a
// large, overwhelmingly machine-id-like set (a flat numeric/date archive) qualifies.
export const ROOT_COLLECTION_MIN = 8;
// Default ceiling on routes we actually clone (distinct templates, not instances).
export const DEFAULT_MAX_ROUTES = 12;
const NUMERIC_RE = /^\d+$/;
const HASH_RE = /^[0-9a-f]{8,}$/i;
const DATEPART_RE = /^\d{1,4}$/; // year / month / day / numeric id
function isIdLike(seg: string): boolean {
return NUMERIC_RE.test(seg) || HASH_RE.test(seg) || DATEPART_RE.test(seg);
}
export type RouteTemplate = {
template: string; // "/blog/:id", "/docs/:id/:id", "/about", "/"
instances: string[]; // sorted real paths matching this template
dynamicPositions: number[];
kind: "singleton" | "pair" | "collection";
/** Literal path before the first dynamic segment (the listing/index), if any. */
containerPath: string | null;
};
function decideDynamic(pos: number, values: string[], count: number): boolean {
if (pos === 0) {
// Root-level pages are almost always DISTINCT site pages (about / pricing / cashew /
// coffee), even when their URLs are descriptive slugs — collapsing them discards real
// content, and shared chrome makes them look structurally similar so confirmation can't
// un-collapse them either. Only collapse a root set that is overwhelmingly machine-id-like
// (a true flat numeric / hash / date list, e.g. /12345), NEVER a descriptive-slug set.
if (values.length < ROOT_COLLECTION_MIN) return false;
return values.filter(isIdLike).length >= Math.ceil(values.length * 0.9);
}
if (values.length >= COLLECTION_MIN) return true;
// Date/numeric/hash segments are dynamic even in small numbers (sparse date
// blogs like /blog/2025/10/16/slug), as long as there's more than one.
if (count >= 2 && values.every(isIdLike)) return true;
return false;
}
/** Induce URL templates from a set of route paths (recursive segment trie). */
export function induceTemplates(paths: string[]): RouteTemplate[] {
const uniq = [...new Set(paths)].sort();
const segs = new Map<string, string[]>();
for (const p of uniq) segs.set(p, segmentsOf(p));
type Acc = { instances: string[]; dyn: number[] };
const byTemplate = new Map<string, Acc>();
const record = (tpl: string, path: string, dyn: number[]): void => {
const a = byTemplate.get(tpl) ?? { instances: [], dyn };
a.instances.push(path);
byTemplate.set(tpl, a);
};
// `group` shares the same templated prefix (tokens). Decide position `pos`.
const recurse = (group: string[], pos: number, tokens: string[], dyn: number[]): void => {
const ending = group.filter((p) => segs.get(p)!.length === pos);
if (ending.length) {
const tpl = "/" + tokens.join("/");
for (const p of ending) record(tpl === "/" ? "/" : tpl, p, dyn.slice());
}
const cont = group.filter((p) => segs.get(p)!.length > pos);
if (cont.length === 0) return;
const valueSet = new Set<string>();
for (const p of cont) valueSet.add(segs.get(p)![pos]!);
const values = [...valueSet].sort();
const dynamic = decideDynamic(pos, values, cont.length);
if (dynamic) {
recurse(cont, pos + 1, [...tokens, ":id"], [...dyn, pos]);
} else {
const buckets = new Map<string, string[]>();
for (const p of cont) {
const v = segs.get(p)![pos]!;
(buckets.get(v) ?? buckets.set(v, []).get(v)!).push(p);
}
for (const v of [...buckets.keys()].sort()) {
recurse(buckets.get(v)!, pos + 1, [...tokens, v], dyn);
}
}
};
recurse(uniq, 0, [], []);
const templates: RouteTemplate[] = [];
for (const [template, acc] of byTemplate) {
const instances = [...new Set(acc.instances)].sort();
const kind = instances.length >= COLLECTION_MIN ? "collection" : instances.length === 2 ? "pair" : "singleton";
let containerPath: string | null = null;
if (acc.dyn.length) {
const firstDyn = Math.min(...acc.dyn);
const lit = template.split("/").filter(Boolean).slice(0, firstDyn);
containerPath = "/" + lit.join("/");
if (containerPath === "/" && firstDyn === 0) containerPath = "/";
}
templates.push({ template, instances, dynamicPositions: acc.dyn.slice().sort((a, b) => a - b), kind, containerPath });
}
templates.sort((a, b) => b.instances.length - a.instances.length || a.template.localeCompare(b.template));
return templates;
}
export type SelectedRoute = {
path: string;
role: "entry" | "page" | "listing" | "representative";
template: string;
depth: number;
};
export type CollapsedCollection = {
template: string;
listing: string | null; // listing/index path, if it exists in the route set
representative: string; // the single detail page we reproduce
siblingProbe: string | null; // a second instance, captured only to confirm the template
instanceCount: number;
instances: string[]; // the full map (the CMS-handoff boundary)
confirmed: boolean; // set by applyConfirmation() after structural comparison
};
export type RoutePlan = {
entry: string;
maxRoutes: number;
selected: SelectedRoute[];
collections: CollapsedCollection[];
templates: RouteTemplate[];
skipped: Array<{ path: string; reason: string }>;
};
function depth(path: string): number {
return segmentsOf(path).length;
}
const ROLE_PRIORITY: Record<SelectedRoute["role"], number> = { entry: 0, listing: 1, representative: 2, page: 3 };
/**
* Build the route plan from discovered paths: collapse collections to listing + one
* representative, keep singletons/pairs, always include the entry, and cap the total
* at maxRoutes (spent on distinct templates, not instances).
*/
export function selectRoutes(opts: {
entryPath: string;
paths: string[];
maxRoutes?: number;
/** Cap on a collection's instance count for its LISTING page to be reproduced.
* A directory listing of a very large collection (e.g. 11ty's 882-instance authors
* index) is a single page that renders one card often with one image per
* instance; capturing/grading it is intractable inside the dev time budget. Above
* this cap the listing is left as a CMS-handoff (links to it absolutize to the
* source origin) while the representative detail page is still reproduced. Opt-in
* (undefined no cap), so normal sites are unaffected. */
maxCollectionInstances?: number;
}): RoutePlan {
const maxRoutes = opts.maxRoutes ?? DEFAULT_MAX_ROUTES;
const maxColl = opts.maxCollectionInstances ?? Infinity;
const entry = opts.entryPath || "/";
const allPaths = [...new Set([entry, ...opts.paths])];
const templates = induceTemplates(allPaths);
const pathSet = new Set(allPaths);
const selected: SelectedRoute[] = [];
const collections: CollapsedCollection[] = [];
const preSkipped: RoutePlan["skipped"] = [];
const seen = new Set<string>();
const add = (path: string, role: SelectedRoute["role"], template: string): void => {
if (seen.has(path)) return;
seen.add(path);
selected.push({ path, role: path === entry ? "entry" : role, template, depth: depth(path) });
};
for (const t of templates) {
if (t.kind === "collection") {
const representative = t.instances[0]!;
let listing = t.containerPath && pathSet.has(t.containerPath) && t.containerPath !== representative ? t.containerPath : null;
const siblingProbe = t.instances.find((p) => p !== representative) ?? null;
// Oversized directory: drop the listing from reproduction (keep the detail
// representative). `listing: null` makes link-rewriting absolutize links to it
// to the source origin, so link-integrity still holds (no dangling clone route).
if (listing && t.instances.length > maxColl) {
preSkipped.push({ path: listing, reason: `oversized_collection_listing (${t.instances.length} > ${maxColl})` });
listing = null;
}
collections.push({
template: t.template,
listing,
representative,
siblingProbe,
instanceCount: t.instances.length,
instances: t.instances,
confirmed: false,
});
if (listing) add(listing, "listing", t.template);
add(representative, "representative", t.template);
} else {
for (const p of t.instances) add(p, "page", t.template);
}
}
// Entry always present.
if (!seen.has(entry)) add(entry, "entry", "/");
// An oversized listing path is also induced as its own singleton template (the
// directory index is a real page), so it was added as a "page" above — drop it
// too, so the heavy directory is genuinely not reproduced (the entry is never
// dropped, even in the unlikely case it is a container).
const droppedPaths = new Set(preSkipped.map((s) => s.path));
const candidates = droppedPaths.size
? selected.filter((r) => r.role === "entry" || !droppedPaths.has(r.path))
: selected;
// Deterministic priority order, then cap. Entry first, then SHALLOWEST routes — a site's
// top-level nav pages (/about, /pricing, /cashew) are its primary content and should be
// reproduced before deep collection representatives (a single /blog/:id or /2026/02/26
// archive). At equal depth prefer listing > representative > page, then alphabetical.
candidates.sort((a, b) =>
(a.role === "entry" ? 0 : 1) - (b.role === "entry" ? 0 : 1)
|| a.depth - b.depth
|| ROLE_PRIORITY[a.role] - ROLE_PRIORITY[b.role]
|| a.path.localeCompare(b.path),
);
const kept = candidates.slice(0, maxRoutes);
const keptPaths = new Set(kept.map((r) => r.path));
const skipped: RoutePlan["skipped"] = [...preSkipped];
for (const r of candidates.slice(maxRoutes)) skipped.push({ path: r.path, reason: "route_cap" });
// Drop collections whose representative got capped out (keep the plan consistent).
const keptCollections = collections.filter((c) => keptPaths.has(c.representative));
return { entry, maxRoutes, selected: kept, collections: keptCollections, templates, skipped };
}
/**
* After capturing each collection's representative + siblingProbe and comparing
* their page structural signatures, apply the verdicts: a confirmed collection stays
* collapsed; an unconfirmed one (the URL grouping was wrong distinct pages sharing
* a prefix) is exploded back into individual page routes, subject to the cap.
*/
export function applyConfirmation(
plan: RoutePlan,
verdicts: Map<string, boolean>, // template -> similar?
): RoutePlan {
const confirmed: CollapsedCollection[] = [];
const exploded: string[] = [];
for (const c of plan.collections) {
const ok = verdicts.get(c.template);
if (ok === false) exploded.push(...c.instances);
else confirmed.push({ ...c, confirmed: ok === true });
}
if (exploded.length === 0) return { ...plan, collections: confirmed };
// Rebuild selected: drop representatives/listings of exploded collections, add
// exploded instances as pages, keep everything else, re-cap.
const explodedTemplates = new Set(plan.collections.filter((c) => verdicts.get(c.template) === false).map((c) => c.template));
const keep = plan.selected.filter((r) => !(explodedTemplates.has(r.template) && r.role !== "entry"));
const seen = new Set(keep.map((r) => r.path));
for (const p of [...new Set(exploded)].sort()) {
if (seen.has(p)) continue;
seen.add(p);
keep.push({ path: p, role: p === plan.entry ? "entry" : "page", template: "(exploded)", depth: depth(p) });
}
keep.sort((a, b) =>
(a.role === "entry" ? 0 : 1) - (b.role === "entry" ? 0 : 1)
|| a.depth - b.depth
|| ROLE_PRIORITY[a.role] - ROLE_PRIORITY[b.role]
|| a.path.localeCompare(b.path));
const kept = keep.slice(0, plan.maxRoutes);
const skipped = [...plan.skipped, ...keep.slice(plan.maxRoutes).map((r) => ({ path: r.path, reason: "route_cap" }))];
return { ...plan, selected: kept, collections: confirmed, skipped };
}
+69
View File
@@ -0,0 +1,69 @@
/**
* URL normalization for crawling. A "route path" is the canonical key we crawl,
* dedupe, and template on: same-origin, pathname only (query + hash dropped
* the plan dedupes by pathname to avoid query-string explosions), trailing slash
* stripped (except root). Everything off-origin, non-http, asset-like, or a bare
* anchor/mailto/tel is rejected (returns null).
*/
// Asset-like extensions we never treat as crawlable routes.
const ASSET_EXT =
/\.(png|jpe?g|gif|webp|avif|svg|ico|bmp|css|js|mjs|cjs|json|xml|txt|pdf|zip|gz|tgz|tar|rar|7z|mp4|webm|mov|m4v|ogv|mp3|wav|ogg|flac|woff2?|ttf|otf|eot|map|rss|atom|csv|xlsx?|docx?|pptx?|wasm)$/i;
/** Host compared without a leading www. so example.com and www.example.com match. */
export function normHost(host: string): string {
return host.replace(/^www\./i, "").toLowerCase();
}
export function originOf(url: string): string {
return new URL(url).origin;
}
/** True when `href` resolves to the same origin (ignoring www.) as `base`. */
export function isSameOrigin(href: string, base: string): boolean {
try {
const u = new URL(href, base);
const b = new URL(base);
return /^https?:$/.test(u.protocol) && normHost(u.hostname) === normHost(b.hostname);
} catch {
return false;
}
}
/**
* Resolve an href against `base` to a normalized same-origin route path, or null
* if it is not a crawlable internal route. Deterministic and idempotent.
*/
export function toRoutePath(href: string | null | undefined, base: string): string | null {
if (!href) return null;
const h = href.trim();
if (!h || h.startsWith("#")) return null;
if (/^(mailto:|tel:|javascript:|data:|blob:|sms:|ftp:)/i.test(h)) return null;
let u: URL;
try {
u = new URL(h, base);
} catch {
return null;
}
if (!/^https?:$/.test(u.protocol)) return null;
try {
if (normHost(u.hostname) !== normHost(new URL(base).hostname)) return null;
} catch {
return null;
}
let p = u.pathname || "/";
if (ASSET_EXT.test(p)) return null;
p = p.replace(/\/{2,}/g, "/");
if (p.length > 1) p = p.replace(/\/+$/, "");
return p || "/";
}
/** Split a route path into its non-empty segments ("/blog/x" -> ["blog","x"]). */
export function segmentsOf(path: string): string[] {
return path.split("/").filter(Boolean);
}
/** Depth = number of path segments ("/" is depth 0, "/about" is 1, "/a/b" is 2). */
export function depthOf(path: string): number {
return segmentsOf(path).length;
}
File diff suppressed because it is too large Load Diff
+192
View File
@@ -0,0 +1,192 @@
/**
* Semantic class map (output-quality, fidelity-neutral).
*
* The per-node fidelity engine (css.ts) emits one `.c<id>{…}` rule per DOM node
* pixel-exact but a wall of opaque, unshared rules. This module rewrites that into a
* small set of SHARED, semantically-named classes:
*
* 1. Compute each node's full rule (base + per-band deltas + pseudos) via the same
* collectNodeRules used by css.ts one source of truth.
* 2. Dedup nodes whose rule is BYTE-IDENTICAL into one class. Identical own-decls
* identical own-style; each node still inherits from its real parent, so the
* grader (which compares each node's FINAL computed style by data-cid) is
* unaffected. This is the safety invariant: sharing a class never changes a
* node's computed style.
* 3. Name each class by ROLE (primitive type btn/link/icon/, else tag/layout
* heading/list/row/stack/box), deduped with a numeric suffix.
*
* Result: ditto.css shrinks dramatically and reads as a component stylesheet, and
* every element carries a meaningful class instead of `c142`. Determinism holds
* grouping + naming are pure functions of document order.
*/
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { PrimitiveType } from "../infer/primitives.js";
import type { TokenResolver } from "../infer/tokens.js";
import { collectNodeRules, assembleCss, keyframesCss, computeBands, type NodeRule } from "./css.js";
export type ClassMap = {
/** cid → semantic class name (null/absent when the node has no own styles). */
classOf: Map<string, string>;
css: string;
stats: { nodes: number; classes: number; reusePct: number };
};
function serDecls(m: Map<string, string>): string {
let s = "";
for (const [k, v] of m) s += k + ":" + v + ";";
return s;
}
/** Canonical (exact, order-preserving) serialization of a node's full rule. Two nodes
* with the same string have byte-identical declarations at every viewport + pseudo. */
function signature(nr: NodeRule): string {
let s = serDecls(nr.base);
for (const b of nr.bands) s += `@${b.media}{${serDecls(b.decls)}}`;
if (nr.before) { s += "::before{" + serDecls(nr.before.base); for (const b of nr.before.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
if (nr.after) { s += "::after{" + serDecls(nr.after.base); for (const b of nr.after.bands) s += `@${b.media}{${serDecls(b.decls)}}`; s += "}"; }
return s;
}
const PRIM_CLASS: Record<PrimitiveType, string> = {
button: "btn", link: "link", input: "input", select: "select", textarea: "textarea",
icon: "icon", image: "image", avatar: "avatar", badge: "badge", heading: "heading", nav: "nav",
};
/** A node's display role for class naming (only used for divs/spans/generic boxes). */
function layoutHint(nr: NodeRule): string | null {
const disp = nr.base.get("display");
if (disp === "grid" || disp === "inline-grid") return "grid";
if (disp === "flex" || disp === "inline-flex") {
const dir = nr.base.get("flex-direction");
return dir === "column" || dir === "column-reverse" ? "stack" : "row";
}
return null;
}
const TAG_CLASS: Record<string, string> = {
section: "section", header: "header", footer: "footer", nav: "nav", main: "main",
aside: "aside", article: "article", ul: "list", ol: "list", li: "list-item",
a: "link", button: "btn", p: "text", span: "text", label: "label", form: "form",
figure: "figure", figcaption: "caption", blockquote: "quote", table: "table",
thead: "thead", tbody: "tbody", tr: "row", td: "cell", th: "cell", img: "image",
picture: "image", svg: "icon", video: "video", input: "input", select: "select",
textarea: "textarea", h1: "h1", h2: "h2", h3: "h3", h4: "h4", h5: "h5", h6: "h6",
};
/** Semantic base name for a node's class primitive type first (the strongest signal),
* then tag, then layout role for generic boxes. */
function classBaseName(node: IRNode, prim: string | undefined, nr: NodeRule): string {
if (prim && PRIM_CLASS[prim as PrimitiveType]) return PRIM_CLASS[prim as PrimitiveType]!;
const t = node.tag;
if (TAG_CLASS[t]) return TAG_CLASS[t]!;
if (t.includes("-")) return "widget"; // custom element
// div / unknown: name by layout role so the markup reads (stack/row/grid/box).
return layoutHint(nr) ?? "box";
}
function indexNodes(ir: IR): Map<string, IRNode> {
const m = new Map<string, IRNode>();
const walk = (n: IRNode): void => { m.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) walk(c); };
walk(ir.root);
return m;
}
// Typographic / inherited properties. Split into their own SHARED class so the (few
// distinct) text styles on a page are reused across all elements that share them — the
// way a real stylesheet factors typography out of per-box layout. The remaining box/
// layout/visual props stay on a role-named box class. The two prop sets are disjoint, so
// an element carrying both classes resolves to exactly its original declarations.
const TYPO_PROPS = new Set([
"color", "font-family", "font-size", "font-weight", "font-style", "line-height",
"letter-spacing", "word-spacing", "text-align", "text-transform", "text-decoration-line",
"text-decoration-style", "text-decoration-color", "white-space", "word-break", "overflow-wrap",
"text-indent", "text-shadow", "font-variant-caps", "font-feature-settings", "list-style-type",
"list-style-position", "writing-mode", "direction", "-webkit-text-fill-color", "-webkit-text-stroke",
]);
// Layout / flow properties (flex/grid container + item, gaps, overflow). These few
// patterns repeat heavily across a page, so factoring them into a shared layout class
// (.row/.stack/.grid/.flow) lifts reuse far above what whole-block classes allow — while
// geometry (width/height/padding/margin/border/position/visuals) stays per-element.
const LAYOUT_PROPS = new Set([
"display", "flex-direction", "flex-wrap", "justify-content", "align-items", "align-content",
"align-self", "flex-grow", "flex-shrink", "flex-basis", "order", "gap", "row-gap", "column-gap",
"grid-template-columns", "grid-template-rows", "grid-template-areas", "grid-auto-flow",
"grid-auto-rows", "grid-auto-columns", "justify-items", "overflow-x", "overflow-y",
]);
function partition(m: Map<string, string>): { typo: Map<string, string>; layout: Map<string, string>; box: Map<string, string> } {
const typo = new Map<string, string>(), layout = new Map<string, string>(), box = new Map<string, string>();
for (const [k, v] of m) (TYPO_PROPS.has(k) ? typo : LAYOUT_PROPS.has(k) ? layout : box).set(k, v);
return { typo, layout, box };
}
/** Split a node's rule into typography / layout / geometry(box) rules (disjoint prop
* sets), with banded deltas split too. Pseudo-elements stay with the box rule. */
function splitRule(nr: NodeRule): { typo: NodeRule; layout: NodeRule; box: NodeRule } {
const b0 = partition(nr.base);
const typo: NodeRule = { base: b0.typo, bands: [] };
const layout: NodeRule = { base: b0.layout, bands: [] };
const box: NodeRule = { base: b0.box, bands: [], before: nr.before, after: nr.after };
for (const band of nr.bands) {
const bs = partition(band.decls);
if (bs.typo.size) typo.bands.push({ media: band.media, decls: bs.typo });
if (bs.layout.size) layout.bands.push({ media: band.media, decls: bs.layout });
if (bs.box.size) box.bands.push({ media: band.media, decls: bs.box });
}
return { typo, layout, box };
}
/** Layout-class base name from the display/flex role. */
function layoutName(nr: NodeRule): string {
const disp = nr.base.get("display");
if (disp === "grid" || disp === "inline-grid") return "grid";
if (disp === "flex" || disp === "inline-flex") {
const dir = nr.base.get("flex-direction");
return dir === "column" || dir === "column-reverse" ? "stack" : "row";
}
return "flow";
}
export function buildClassMap(ir: IR, assetMap: Map<string, string>, colorVar?: (v: string) => string | null, primitives?: Map<string, string>, tokenResolver?: TokenResolver): ClassMap {
const rules = collectNodeRules(ir, assetMap, undefined, colorVar, tokenResolver);
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
const nodeByCid = indexNodes(ir);
const sigToClass = new Map<string, string>();
const classToRule = new Map<string, NodeRule>();
const order: string[] = [];
const nameCount = new Map<string, number>();
const classOf = new Map<string, string>();
// Dedup a (typo or box) rule by its signature → one shared class; name it `base`,
// suffixing for distinct rules that want the same base name. Disjoint typo/box prop
// sets mean their signatures never collide, so one map is safe.
const assign = (sig: string, base: string, rule: NodeRule): string => {
if (sig === "") return "";
let cls = sigToClass.get(sig);
if (!cls) {
const n = (nameCount.get(base) ?? 0) + 1;
nameCount.set(base, n);
cls = n === 1 ? base : `${base}-${n}`;
sigToClass.set(sig, cls);
classToRule.set(cls, rule);
order.push(cls);
}
return cls;
};
let usages = 0;
for (const [cid, nr] of rules) { // document pre-order
const { typo, layout, box } = splitRule(nr);
const boxCls = assign(signature(box), classBaseName(nodeByCid.get(cid)!, primitives?.get(cid), box), box);
const layoutCls = assign(signature(layout), layoutName(layout), layout);
const typoCls = assign(signature(typo), "type", typo);
// role/geometry class first, then layout, then typography
const full = [boxCls, layoutCls, typoCls].filter(Boolean).join(" ");
if (full) { classOf.set(cid, full); usages += (boxCls ? 1 : 0) + (layoutCls ? 1 : 0) + (typoCls ? 1 : 0); }
}
const kf = keyframesCss(ir, assetMap);
const css = assembleCss(order, (c) => classToRule.get(c)!, (c) => `.${c}`, bands, kf);
const reusePct = usages ? Math.round((1 - order.length / usages) * 1000) / 10 : 0;
return { classOf, css, stats: { nodes: classOf.size, classes: order.length, reusePct } };
}
+249
View File
@@ -0,0 +1,249 @@
import { basename } from "node:path";
import { collectFiles, scoreApp, type QualityReport, type SrcFile } from "../runner/qualityScore.js";
import type { RecipeCandidate, RecipeReport } from "../infer/recipes.js";
export type CodeQualityMetrics = {
files: number;
jsxTags: number;
arbitraryPxRem: number;
decimalArbitraryPx: number;
arbitraryBands: number;
breakpointUtilities: number;
dataCid: number;
dataDittoId: number;
styleRefs: number;
switchCases: number;
variantSlotComponents: number;
mapCalls: number;
};
export type RecipeCodeQuality = {
id: string;
kind: RecipeCandidate["kind"];
confidence: number;
rootCid: string;
itemParentCid: string | null;
dataModel: string | null;
itemCount: number | null;
files: string[];
metrics: CodeQualityMetrics;
notes: string[];
};
export type CodeQualityReport = {
version: 1;
appDir: string;
quality: QualityReport;
summary: CodeQualityMetrics & {
componentModules: number;
totalTags: number;
qualityTotal: number;
};
hotspots: Array<{ file: string; metrics: CodeQualityMetrics }>;
recipes: RecipeCodeQuality[];
};
function count(text: string, re: RegExp): number {
return (text.match(re) ?? []).length;
}
function countJsxTags(text: string): number {
return count(text, /<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g);
}
function metricsFor(files: SrcFile[]): CodeQualityMetrics {
let arbitraryPxRem = 0;
let decimalArbitraryPx = 0;
const text = files.map((f) => f.text).join("\n");
for (const m of text.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) {
arbitraryPxRem++;
const px = parseFloat(m[1] ?? "0") * (m[2] === "rem" ? 16 : 1);
if (Math.abs(px - Math.round(px)) > 0.02) decimalArbitraryPx++;
}
return {
files: files.length,
jsxTags: files.reduce((sum, f) => sum + countJsxTags(f.text), 0),
arbitraryPxRem,
decimalArbitraryPx,
arbitraryBands: count(text, /\b(?:min|max)-\[[0-9]+px\]:/g),
breakpointUtilities: count(text, /\b(?:sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g),
dataCid: count(text, /data-cid/g),
dataDittoId: count(text, /data-ditto-id/g),
styleRefs: count(text, /\b(?:styles\.[A-Za-z_]\w*|\w+Styles|styles)\b/g),
switchCases: count(text, /\bcase\s+["']/g),
variantSlotComponents: count(text, /function\s+\w+Slot\d+\b/g),
mapCalls: count(text, /\.map\(/g),
};
}
function addMetrics(a: CodeQualityMetrics, b: CodeQualityMetrics): CodeQualityMetrics {
return {
files: a.files + b.files,
jsxTags: a.jsxTags + b.jsxTags,
arbitraryPxRem: a.arbitraryPxRem + b.arbitraryPxRem,
decimalArbitraryPx: a.decimalArbitraryPx + b.decimalArbitraryPx,
arbitraryBands: a.arbitraryBands + b.arbitraryBands,
breakpointUtilities: a.breakpointUtilities + b.breakpointUtilities,
dataCid: a.dataCid + b.dataCid,
dataDittoId: a.dataDittoId + b.dataDittoId,
styleRefs: a.styleRefs + b.styleRefs,
switchCases: a.switchCases + b.switchCases,
variantSlotComponents: a.variantSlotComponents + b.variantSlotComponents,
mapCalls: a.mapCalls + b.mapCalls,
};
}
function emptyMetrics(): CodeQualityMetrics {
return {
files: 0,
jsxTags: 0,
arbitraryPxRem: 0,
decimalArbitraryPx: 0,
arbitraryBands: 0,
breakpointUtilities: 0,
dataCid: 0,
dataDittoId: 0,
styleRefs: 0,
switchCases: 0,
variantSlotComponents: 0,
mapCalls: 0,
};
}
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function filesForRecipe(files: SrcFile[], c: RecipeCandidate): SrcFile[] {
const cidNeedles = [
c.rootCid,
c.itemParentCid,
...(c.repeatedItems ?? []).map((item) => item.cid),
].filter((x): x is string => !!x);
const cidPatterns = cidNeedles.map((cid) => new RegExp(`(?<![A-Za-z0-9_-])${escapeRegExp(cid)}(?![A-Za-z0-9_-])`));
const kindNeedle = c.kind.replace(/-/g, "-");
const componentNeedle = c.componentName;
const componentPattern = componentNeedle ? new RegExp(`\\b${escapeRegExp(componentNeedle)}\\b`) : null;
const matched = files.filter((f) => {
if (cidPatterns.some((re) => re.test(f.text))) return true;
const rel = f.rel.toLowerCase();
if (componentPattern?.test(f.text)) return true;
if (rel.includes(kindNeedle)) return true;
if (c.kind === "card-grid" && rel.includes("card-grid-item")) return true;
if (c.kind === "feature-grid" && rel.includes("feature-grid-item")) return true;
if (c.kind === "logo-cloud" && rel.includes("logo-cloud-item")) return true;
return false;
});
return [...new Map(matched.map((f) => [f.rel, f])).values()].sort((a, b) => a.rel.localeCompare(b.rel));
}
function notesForRecipe(c: RecipeCandidate, m: CodeQualityMetrics): string[] {
const notes: string[] = [];
if (m.variantSlotComponents > 0) notes.push("uses variant-slot helper(s) for heterogeneous media/content");
if (m.switchCases > 0 && m.variantSlotComponents === 0) notes.push("still emitted as a full switch fallback");
if (m.styleRefs > 0) notes.push("uses per-instance style override plumbing");
if (m.arbitraryBands === 0) notes.push("no arbitrary breakpoint bands in matched files");
if (m.decimalArbitraryPx > 0) notes.push("contains frozen non-integer arbitrary measurements");
if (m.breakpointUtilities > 0 && m.arbitraryBands === 0) notes.push("responsive logic uses named breakpoints");
if (m.files === 0) notes.push("no generated file could be matched back to this recipe");
if (c.confidence < 0.86) notes.push("low-confidence recipe; report-only for now");
return notes;
}
export function buildCodeQualityReport(appDir: string, recipes: RecipeReport): CodeQualityReport {
const files = collectFiles(appDir);
const quality = { ...scoreApp(appDir), dir: "app" };
const summaryMetrics = metricsFor(files);
const hotspots = files
.map((f) => ({ file: f.rel, metrics: metricsFor([f]) }))
.filter((h) => h.metrics.arbitraryPxRem || h.metrics.decimalArbitraryPx || h.metrics.arbitraryBands || h.metrics.switchCases || h.metrics.variantSlotComponents || h.metrics.styleRefs)
.sort((a, b) => {
const aScore = a.metrics.decimalArbitraryPx * 8 + a.metrics.arbitraryPxRem * 3 + a.metrics.arbitraryBands * 6 + a.metrics.switchCases + a.metrics.styleRefs + a.metrics.variantSlotComponents;
const bScore = b.metrics.decimalArbitraryPx * 8 + b.metrics.arbitraryPxRem * 3 + b.metrics.arbitraryBands * 6 + b.metrics.switchCases + b.metrics.styleRefs + b.metrics.variantSlotComponents;
return bScore - aScore || a.file.localeCompare(b.file);
})
.slice(0, 20);
return {
version: 1,
appDir: "app",
quality,
summary: {
...summaryMetrics,
componentModules: quality.raw.componentModules ?? 0,
totalTags: quality.raw.totalTags ?? 0,
qualityTotal: quality.total,
},
hotspots,
recipes: recipes.candidates.map((c) => {
const matched = filesForRecipe(files, c);
const metrics = matched.reduce((sum, f) => addMetrics(sum, metricsFor([f])), emptyMetrics());
return {
id: c.id,
kind: c.kind,
confidence: c.confidence,
rootCid: c.rootCid,
itemParentCid: c.itemParentCid ?? null,
dataModel: c.dataModel ?? null,
itemCount: c.itemCount ?? null,
files: matched.map((f) => f.rel),
metrics,
notes: notesForRecipe(c, metrics),
};
}),
};
}
function metricSummary(m: CodeQualityMetrics): string {
return [
`${m.files} files`,
`${m.jsxTags} tags`,
`${m.arbitraryPxRem} arb px/rem`,
`${m.decimalArbitraryPx} decimal`,
`${m.breakpointUtilities} breakpoints`,
`${m.arbitraryBands} arb bands`,
`${m.switchCases} cases`,
`${m.variantSlotComponents} slots`,
].join(" · ");
}
export function codeQualityReportToMarkdown(report: CodeQualityReport): string {
const lines: string[] = [];
lines.push(`# Code Quality Report`);
lines.push("");
lines.push(`App: \`${basename(report.appDir)}\``);
lines.push(`Quality: **${report.quality.total}/100**`);
lines.push("");
lines.push(`## Summary`);
lines.push("");
lines.push(`- ${metricSummary(report.summary)}`);
lines.push(`- component modules: ${report.summary.componentModules}; total tags: ${report.summary.totalTags}; data-cid in generated validation tree: ${report.summary.dataCid}; data-ditto-id: ${report.summary.dataDittoId}`);
lines.push("");
lines.push(`## Recipes`);
lines.push("");
if (!report.recipes.length) {
lines.push(`No recipe candidates detected.`);
} else {
for (const r of report.recipes) {
lines.push(`### ${r.id} · ${r.kind} · confidence ${r.confidence}`);
lines.push("");
lines.push(`- model: ${r.dataModel ?? "none"}; items: ${r.itemCount ?? "n/a"}; root: \`${r.rootCid}\`; item parent: \`${r.itemParentCid ?? "none"}\``);
lines.push(`- ${metricSummary(r.metrics)}`);
if (r.files.length) lines.push(`- files: ${r.files.map((f) => `\`${f}\``).join(", ")}`);
if (r.notes.length) lines.push(`- notes: ${r.notes.join("; ")}`);
lines.push("");
}
}
lines.push(`## Hotspots`);
lines.push("");
if (!report.hotspots.length) {
lines.push(`No code-quality hotspots detected.`);
} else {
for (const h of report.hotspots.slice(0, 12)) {
lines.push(`- \`${h.file}\`: ${metricSummary(h.metrics)}`);
}
}
lines.push("");
return lines.join("\n");
}
File diff suppressed because it is too large Load Diff
+135
View File
@@ -0,0 +1,135 @@
import { join } from "node:path";
import { writeText } from "../util/fsx.js";
import type { SeoRouteSummary } from "./seo.js";
export type GeneratedDocsInput = {
sourceUrl: string;
routes: SeoRouteSummary[];
styling: "tailwind" | "css";
framework: "next" | "vite";
multiRoute: boolean;
components: boolean;
sectionCount: number;
componentCount: number;
svgCount: number;
hasContentModule: boolean;
runtimeUtilities: string[];
};
function routeLine(route: SeoRouteSummary): string {
return `- ${route.href} - ${route.title || route.routePath}`;
}
function runtimeLine(runtimeUtilities: string[]): string {
return runtimeUtilities.length ? runtimeUtilities.sort().join(", ") : "none emitted for this capture";
}
export function agentsMd(input: GeneratedDocsInput): string {
const routes = input.routes.length ? input.routes.map(routeLine).join("\n") : "- / - generated route";
const root = input.framework === "next" ? "src/app" : "src";
const routeBody = input.framework === "next" ? "`src/app/page.tsx` and nested route `page.tsx` files" : input.multiRoute ? "`src/routes/*/page.tsx` route modules" : "`src/page.tsx`";
const seoFiles = input.framework === "next"
? "`src/app/robots.ts`, `src/app/sitemap.ts`, and `src/app/llms.txt/route.ts`"
: "`public/robots.txt`, `public/sitemap.xml`, and `public/llms.txt`";
return `# AGENTS.md
This is a generated ditto.site clone app for ${input.sourceUrl}. It is a static ${input.framework === "next" ? "Next.js App Router" : "Vite React"} project produced from captured DOM, CSS, assets, metadata, and interaction recipes.
## Run
- \`npm install\`
- \`npm run dev\`
- \`npm run build\`
- \`npm run start\`
## Safe Edit Areas
- \`${root}/content.ts\` or \`${root}/content.tsx\`: editable structured content extracted from repeated components and sections when present.
- \`${root}/components/\`: generated component modules. Edit copy, links, and simple JSX structure with care.
- \`${root}/sections/\`: generated section modules for single-page section splits when present.
- \`${root}/svgs/\`: hoisted inline SVG modules. Edit only when intentionally changing artwork.
- \`${root}/ditto.css\`: fidelity CSS for captured layout, pseudos, keyframes, and interaction states. Small visual tweaks are reasonable; broad rewrites can break clone fidelity.
- Root SEO/docs files such as \`AGENTS.md\`, \`ARCHITECTURE.md\`, and ${seoFiles}.
## Generated Runtime
\`${root}/ditto\` contains generated runtime utilities for captured interactions and motion. Current runtime utilities: ${runtimeLine(input.runtimeUtilities)}. Do not casually rewrite these files; they are plumbing that maps captured recipes to stable \`data-ditto-id\` anchors in delivered apps.
## File Meanings
- ${routeBody}: generated route bodies.
- \`${root}/content.ts\`: structured data extracted from repeated clone regions.
- \`${root}/components/\`: reusable JSX components promoted from repeated captured subtrees.
- \`${root}/sections/\`: page sections split from the captured body.
- \`${root}/svgs/\`: inline SVGs hoisted out of page/section files.
- \`${root}/ditto.css\`: generated CSS that preserves source layout and visual details not represented by Tailwind utilities.
- \`${root}/ditto-meta.ts\`: delivered-app metadata for anchors that still need runtime or stylesheet targeting after validation-only ids are stripped.
## Routes
${routes}
## Do Not Edit Casually
- \`${root}/ditto/\` runtime utilities.
- Generated anchor metadata such as \`ditto-meta.ts\`.
- Validation-only files in working captures, including \`_cids.ts\` and \`_styles.ts\` before export stripping.
- Framework shell plumbing unless you are intentionally changing global metadata or page mounting behavior.
`;
}
export function architectureMd(input: GeneratedDocsInput): string {
const routes = input.routes.length ? input.routes.map(routeLine).join("\n") : "- / - generated route";
const root = input.framework === "next" ? "src/app" : "src";
return `# ARCHITECTURE.md
## Overview
This app is a generated ditto.site clone. The generator captured the source page${input.multiRoute ? "s" : ""}, normalized the rendered DOM into an IR, inferred assets/tokens/sections/recipes, and emitted a static ${input.framework === "next" ? "Next.js App Router" : "Vite React"} project.
## Structure
- ${input.framework === "next" ? "`src/app/layout.tsx`: root App Router layout, language, metadata, viewport, JSON-LD, and shared shell." : "`index.html` and route HTML files: Vite HTML entries with captured language, metadata, JSON-LD, and body attributes."}
- ${input.framework === "next" ? "`src/app/page.tsx` and nested route `page.tsx` files" : input.multiRoute ? "`src/routes/*/page.tsx` files" : "`src/page.tsx`"}: generated route bodies.
- \`${root}/globals.css\`: reset, font faces, design tokens, and global page base.
- \`${root}/ditto.css\`: route or page fidelity CSS.
- \`${root}/content.ts\`: editable data layer when repeated regions were promoted.
- \`${root}/components/\`, \`${root}/sections/\`, \`${root}/svgs/\`: generated JSX modules.
- \`${root}/ditto/\`: runtime helpers for interaction and motion recipes.
- \`public/assets/cloned/\`: materialized source assets.
## Styling
The generator uses ${input.styling === "tailwind" ? "Tailwind classes for declarations that can be represented as stable utilities" : "CSS classes for generated visual declarations"}. Some styles remain in \`ditto.css\` because they are route-scoped, pseudo-element based, keyframe based, interaction-state based, or too specific to translate safely without changing the rendered result.
## Anchors
\`data-ditto-id\` exists in delivered apps where runtime utilities or generated CSS still need a stable DOM anchor. Validation-only capture ids are stripped from production output and should not be reintroduced.
## Recipes And Runtime
Recipes identify higher-level patterns such as repeated cards, logo clouds, navigation, disclosures, accordions, tabs, carousels, and motion. Sections and components provide editable structure, SVG modules preserve source artwork, and \`${root}/ditto\` applies the small runtime behaviors that were captured safely. Runtime utilities emitted for this clone: ${runtimeLine(input.runtimeUtilities)}.
## Clone Metadata
- routes: ${input.routes.length}
- extracted components: ${input.componentCount}
- section modules: ${input.sectionCount}
- SVG modules: ${input.svgCount}
- content module: ${input.hasContentModule ? "yes" : "no"}
- component extraction requested: ${input.components ? "yes" : "no"}
## Routes
${routes}
## Tradeoffs
The clone prioritizes deterministic static fidelity, accessible markup, local asset materialization, and source metadata preservation. It may keep measured CSS where inferred layout intent is uncertain. It intentionally defers arbitrary JavaScript replay, video-like animation replay, and full third-party application behavior. External services, live personalization, analytics, payments, auth, and complex client app state are not reconstructed unless a specific safe recipe exists.
`;
}
export function emitGeneratedDocs(appDir: string, input: GeneratedDocsInput): void {
writeText(join(appDir, "AGENTS.md"), agentsMd(input));
writeText(join(appDir, "ARCHITECTURE.md"), architectureMd(input));
}
+110
View File
@@ -0,0 +1,110 @@
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { InteractionCapture, StyleDelta } from "../capture/interactions.js";
/**
* Stage 4 (M1) emit pure-CSS `:hover` / `:focus` rules from the captured pseudo-
* state deltas. The deltas are keyed by `data-cid-cap`; we map them to the IR's
* `c{id}` selectors. Deterministic: capture-ids are emitted in numeric order. The
* values are the captured computed deltas, so the rendered hover/focus state matches
* the source (verified by the interaction gate, which re-drives and compares).
*/
function kebab(prop: string): string {
if (prop.startsWith("webkit")) return "-webkit-" + kebab(prop[6]!.toLowerCase() + prop.slice(7));
return prop.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
}
function rule(sel: string, d: StyleDelta): string {
const body = Object.keys(d).sort().map((k) => `${kebab(k)}:${d[k]}`).join(";");
return `${sel}{${body}}`;
}
/** Map every IR node's capture-id → its cid, and cid → node. */
function capToCid(ir: IR): { capCid: Map<string, string>; cidNode: Map<string, IRNode> } {
const capCid = new Map<string, string>();
const cidNode = new Map<string, IRNode>();
const walk = (n: IRNode): void => {
cidNode.set(n.id, n);
const cap = n.attrs["data-cid-cap"];
if (cap !== undefined) capCid.set(cap, n.id);
for (const c of n.children) if (!isTextChild(c)) walk(c);
};
walk(ir.root);
return { capCid, cidNode };
}
/** The captured `transition` shorthand, but only when it actually animates (some segment
* has a positive duration). Pure state styling `transition: all 0s` / `none` returns
* null. This is what makes a captured `:hover`/`:focus` change EASE like the source
* instead of snapping; `transition` is not a graded property (gate 4) and the validator
* screenshots with animations disabled, so emitting it is gate-neutral. */
function animatedTransition(v: string | undefined): string | null {
if (!v) return null;
const t = v.trim();
if (!t || t === "none" || t === "all") return null;
const animates = t.split(",").some((seg) => {
const m = seg.trim().match(/(\d*\.?\d+)(ms|s)\b/); // first time token in a segment = its duration
return m ? parseFloat(m[1]!) * (m[2] === "ms" ? 0.001 : 1) > 0 : false;
});
return animates ? t : null;
}
/**
* Emit `:hover`/`:focus` CSS for the given IR + capture.
* - `include`: only emit for cids passing this predicate (route-body vs. chrome split).
* - `prefix`: prepend to the cid in the selector hoisted chrome is rendered with
* namespaced `L`-prefixed cids, so its interaction rules must match (`.cLn15:hover`).
*/
export function generateInteractionCss(
ir: IR,
interaction: InteractionCapture | undefined,
opts?: { include?: (cid: string) => boolean; prefix?: string; selector?: (cid: string) => string },
): string {
if (!interaction) return "";
const { capCid: map, cidNode } = capToCid(ir);
const prefix = opts?.prefix ?? "";
// How a cid maps to a CSS selector: default is the per-node `.c<id>` class (legacy CSS
// mode); Tailwind mode passes `[data-cid="<id>"]` since nodes carry no `c<id>` class.
const sel = opts?.selector ?? ((cid: string) => `.c${prefix}${cid}`);
const canon = ir.doc.canonicalViewport;
const lines: string[] = [];
const transition = new Map<string, string>();
const emit = (deltas: Record<string, StyleDelta>, pseudo: string): void => {
for (const cap of Object.keys(deltas).sort((a, b) => parseInt(a, 10) - parseInt(b, 10))) {
const cid = map.get(cap);
const d = deltas[cap]!;
if (!cid || !Object.keys(d).length) continue;
if (opts?.include && !opts.include(cid)) continue;
lines.push(rule(`${sel(cid)}${pseudo}`, d));
if (!transition.has(cid)) {
const node = cidNode.get(cid);
const t = node && animatedTransition(node.computedByVp[canon]?.transition ?? node.computedByVp[ir.doc.viewports[0]!]?.transition);
if (t) transition.set(cid, t);
}
}
};
emit(interaction.hover, ":hover");
emit(interaction.focus, ":focus");
// Descendant reveals: a hidden child overlay/CTA shown when the parent is hovered
// (framer's "Read story" card hover). Emit `parent:hover child { delta }` + the child's own
// transition on its base rule so the reveal eases. The child's hidden base (opacity:0)
// already lives in the styling output, so it stays hidden at rest and appears on parent hover.
// Uses sel() for both ends so it works in legacy-CSS (.c<id>) and Tailwind ([data-cid]) modes.
for (const parentCap of Object.keys(interaction.hoverDesc ?? {}).sort((a, b) => parseInt(a, 10) - parseInt(b, 10))) {
const pcid = map.get(parentCap); if (!pcid || (opts?.include && !opts.include(pcid))) continue;
const descs = interaction.hoverDesc![parentCap]!;
for (const childCap of Object.keys(descs).sort((a, b) => parseInt(a, 10) - parseInt(b, 10))) {
const ccid = map.get(childCap); const d = descs[childCap]!;
if (!ccid || !Object.keys(d).length) continue;
lines.push(rule(`${sel(pcid)}:hover ${sel(ccid)}`, d));
if (!transition.has(ccid)) {
const node = cidNode.get(ccid);
const t = node && animatedTransition(node.computedByVp[canon]?.transition ?? node.computedByVp[ir.doc.viewports[0]!]?.transition);
if (t) transition.set(ccid, t);
}
}
}
const transLines = [...transition.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([cid, t]) => `${sel(cid)}{transition:${t}}`);
const all = [...transLines, ...lines];
return all.length ? "\n/* Stage 4 — interaction states (hover/focus + eased transitions) */\n" + all.join("\n") + "\n" : "";
}
+329
View File
@@ -0,0 +1,329 @@
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { InteractionCapture, CapStyle, RelBox } from "../capture/interactions.js";
/**
* Stage 4 (M2) recognized interactive patterns (tabs / accordion) are reproduced
* by a single fixed `'use client'` controller, `DittoWire`, parameterized by captured
* per-state styles. The page renders the captured subtree statically (so the base
* DOM/style/perceptual gates are unaffected); DittoWire renders `null` and, after
* hydration, finds the trigger/panel elements by `data-cid` and toggles the captured
* inline styles on interaction. Its initial application reproduces the captured base
* state exactly, so it never perturbs the default render only adds behavior.
*/
type RTTab = { trigger: string; panel: string; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; descendants?: Record<string, CapStyle> };
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
type RTDisc = { trigger: string; panel: string; isDialog: boolean; hoverOpen: boolean; backdropClose: boolean; closes: string[]; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; shownBox: RelBox | null; descendants?: Record<string, CapStyle> };
export type RuntimeSpec =
| { kind: "tabs"; active: number; tabs: RTTab[] }
| { kind: "accordion"; items: RTAcc[] }
| { kind: "carousel"; track: string; next: string | null; prev: string | null; bullets: string[]; base: number; transforms: string[]; bulletOn: CapStyle; bulletOff: CapStyle }
| { kind: "disclosure"; items: RTDisc[] };
export type AccordionRuntimeSpec = Extract<RuntimeSpec, { kind: "accordion" }>;
export const INTERACTION_REJECTION_VERSION = 2;
export type InteractionRejectedArtifact = {
version: number;
rejected: string[];
};
export function interactionRejectedArtifact(rejected: string[]): InteractionRejectedArtifact {
return { version: INTERACTION_REJECTION_VERSION, rejected };
}
export function interactionRejectedSet(raw: unknown): Set<string> | undefined {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
const artifact = raw as Partial<InteractionRejectedArtifact>;
if (artifact.version !== INTERACTION_REJECTION_VERSION || !Array.isArray(artifact.rejected)) return undefined;
return new Set(artifact.rejected.filter((x): x is string => typeof x === "string" && x.length > 0));
}
/** Map every IR node's capture-id → its cid. */
function capToCid(ir: IR): Map<string, string> {
const m = new Map<string, string>();
const walk = (n: IRNode): void => {
const cap = n.attrs["data-cid-cap"];
if (cap !== undefined) m.set(cap, n.id);
for (const c of n.children) if (!isTextChild(c)) walk(c);
};
walk(ir.root);
return m;
}
/**
* Build the cid-keyed runtime specs for the patterns whose nodes all survived into
* this IR (and pass the optional include filter, for multi-route body scoping). A
* pattern missing any of its trigger/panel cids is dropped (falls back to static).
*/
/** Stable identity for a runtime spec (its primary trigger/track cid). The gate marks
* patterns that don't reproduce by this key; generation then skips them (left static).
* Both sides operate on the same cid-specs, so the keys line up. */
export function specKey(s: RuntimeSpec): string {
if (s.kind === "tabs") return "t:" + s.tabs[0]?.trigger;
if (s.kind === "accordion") return "a:" + s.items[0]?.trigger;
if (s.kind === "carousel") return "c:" + s.track;
return "d:" + s.items[0]?.trigger;
}
export function buildRuntimeSpecs(ir: IR, interaction: InteractionCapture | undefined, include?: (cid: string) => boolean, rejected?: Set<string>): RuntimeSpec[] {
if (!interaction?.patterns?.length) return [];
const map = capToCid(ir);
const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid));
// Remap a panel's open-state descendant overrides from capture-ids to surviving cids.
// Descendants that didn't survive into this IR are simply dropped (best-effort reveal).
const mapDesc = (d?: Record<string, CapStyle>): Record<string, CapStyle> | undefined => {
if (!d) return undefined;
const out: Record<string, CapStyle> = {};
for (const cap of Object.keys(d)) { const cid = map.get(cap); if (ok(cid)) out[cid] = d[cap]!; }
return Object.keys(out).length ? out : undefined;
};
const specs: RuntimeSpec[] = [];
for (const p of interaction.patterns) {
if (p.kind === "tabs") {
const tabs: RTTab[] = [];
let bad = false;
for (const t of p.tabs) {
const tr = map.get(t.triggerCap), pa = map.get(t.panelCap);
if (!ok(tr) || !ok(pa)) { bad = true; break; }
tabs.push({ trigger: tr, panel: pa, triggerOn: t.triggerOn, triggerOff: t.triggerOff, panelShown: t.panelShown, panelHidden: t.panelHidden, descendants: mapDesc(t.descendants) });
}
if (!bad && tabs.length >= 2) specs.push({ kind: "tabs", active: Math.min(p.activeIndex, tabs.length - 1), tabs });
} else if (p.kind === "accordion") {
const items: RTAcc[] = [];
for (const it of p.items) {
const tr = map.get(it.triggerCap), rg = map.get(it.regionCap);
if (!ok(tr) || !ok(rg)) continue;
items.push({ trigger: tr, region: rg, expanded: it.expandedAtBase, triggerOn: it.triggerOn, triggerOff: it.triggerOff, regionShown: it.regionShown, regionHidden: it.regionHidden });
}
if (items.length) specs.push({ kind: "accordion", items });
} else if (p.kind === "carousel") {
const track = map.get(p.trackCap);
if (!ok(track)) continue;
const next = p.nextCap ? map.get(p.nextCap) ?? null : null;
const prev = p.prevCap ? map.get(p.prevCap) ?? null : null;
// Pagination bullets are only usable if ALL survived (index-aligned with the
// transforms); otherwise fall back to prev/next navigation.
const mapped = p.bulletCaps.map((c) => map.get(c));
const bullets = mapped.every((b) => ok(b)) ? (mapped as string[]) : [];
if (!next && bullets.length < 2) continue;
if (p.transforms.length < 2) continue;
specs.push({ kind: "carousel", track, next, prev, bullets, base: p.baseIndex, transforms: p.transforms, bulletOn: p.bulletOn, bulletOff: p.bulletOff });
} else if (p.kind === "disclosure") {
const items: RTDisc[] = [];
for (const it of p.items) {
const trigger = map.get(it.triggerCap), panel = map.get(it.panelCap);
if (!ok(trigger) || !ok(panel)) continue;
const closes = it.closeCaps.map((c) => map.get(c)).filter((c): c is string => ok(c));
items.push({ trigger, panel, isDialog: it.isDialog, hoverOpen: it.hoverOpen, backdropClose: it.backdropClose, closes, triggerOn: it.triggerOn, triggerOff: it.triggerOff, panelShown: it.panelShown, panelHidden: it.panelHidden, shownBox: it.shownBox, descendants: mapDesc(it.descendants) });
}
if (items.length) specs.push({ kind: "disclosure", items });
}
}
// Drop patterns the interaction gate proved don't reproduce in the clone (they're
// left static rather than shipped broken).
return rejected && rejected.size ? specs.filter((s) => !rejected.has(specKey(s))) : specs;
}
/** Relative import path from a route page at the given app-segment depth to the
* shared DittoWire (single page / entry route: depth 0 "./ditto/DittoWire"). */
export function dittoWireImportPath(depth: number): string {
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DittoWire";
}
/** JSX for the pattern controllers, rendered at the end of a page fragment. Returns
* "" when there are no recognized patterns (no import/scaffold needed). */
export function wiresJsx(specs: RuntimeSpec[], indent: number): string {
if (!specs.length) return "";
const pad = " ".repeat(indent);
return specs.map((s) => `${pad}<DittoWire spec={${JSON.stringify(s)}} />`).join("\n");
}
export function accordionImportPath(depth: number): string {
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/Accordion";
}
export function accordionJsx(specs: AccordionRuntimeSpec[], indent: number): string {
if (!specs.length) return "";
const pad = " ".repeat(indent);
return `${pad}<Accordion specs={${JSON.stringify(specs)}} />`;
}
export const ACCORDION_TSX = `"use client";
import { useEffect, useRef } from "react";
type CapStyle = Record<string, string>;
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
export type AccordionSpec = { kind: "accordion"; items: RTAcc[] };
const kebab = (p: string) => p.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
function applyStyle(el: HTMLElement | null, s: CapStyle) {
if (!el) return;
for (const k in s) el.style.setProperty(kebab(k), s[k]);
}
/** Wires captured accordion rows with small explicit state.
* Hydration initializes the captured base state, then clicks toggle only the target row. */
export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
const wired = useRef(false);
useEffect(() => {
if (wired.current) return;
wired.current = true;
for (const spec of specs) {
const state = spec.items.map((it) => it.expanded);
const renderItem = (i: number) => {
const it = spec.items[i];
if (!it) return;
const on = state[i];
const trig = byCid(it.trigger), region = byCid(it.region);
applyStyle(trig, on ? it.triggerOn : it.triggerOff);
trig?.setAttribute("aria-expanded", on ? "true" : "false");
applyStyle(region, on ? it.regionShown : it.regionHidden);
if (region) {
if (on) region.removeAttribute("hidden");
else region.setAttribute("hidden", "");
}
};
spec.items.forEach((it, i) => {
const trig = byCid(it.trigger);
if (!trig) return;
trig.addEventListener("click", (e) => {
e.preventDefault();
state[i] = !state[i];
renderItem(i);
});
renderItem(i);
});
}
}, [specs]);
return null;
}
`;
/** The fixed DittoWire client component, written once per generated app. */
export const DITTO_WIRE_TSX = `"use client";
import { useEffect, useRef } from "react";
type CapStyle = Record<string, string>;
type RTTab = { trigger: string; panel: string; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; descendants?: Record<string, CapStyle> };
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
type RTDisc = { trigger: string; panel: string; isDialog: boolean; hoverOpen: boolean; backdropClose: boolean; closes: string[]; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; shownBox?: unknown; descendants?: Record<string, CapStyle> };
export type Spec =
| { kind: "tabs"; active: number; tabs: RTTab[] }
| { kind: "accordion"; items: RTAcc[] }
| { kind: "carousel"; track: string; next: string | null; prev: string | null; bullets: string[]; base: number; transforms: string[]; bulletOn: CapStyle; bulletOff: CapStyle }
| { kind: "disclosure"; items: RTDisc[] };
const kebab = (p: string) => p.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
function applyStyle(el: HTMLElement | null, s: CapStyle) {
if (!el) return;
for (const k in s) el.style.setProperty(kebab(k), s[k]);
}
// Apply a panel's open-state descendant overrides (cid → style). Reveals content whose
// visibility is gated by a JS-toggled class the clone doesn't carry (e.g. Elementor
// e-active). Applied only when the panel is shown; the panel's own hide masks it.
function applyDesc(d?: Record<string, CapStyle>) {
if (!d) return;
for (const cid in d) applyStyle(byCid(cid), d[cid]);
}
/** Reproduces a captured interactive pattern by toggling captured inline styles on
* the existing DOM nodes (found by data-cid). Renders nothing, and applies NOTHING
* on mount: the server-rendered markup + per-node CSS already reproduce the captured
* base state exactly, so state styles are applied only on user interaction. */
export default function DittoWire({ spec }: { spec: Spec }) {
const wired = useRef(false);
useEffect(() => {
if (wired.current) return;
wired.current = true;
if (spec.kind === "tabs") {
let active = spec.active;
const render = () => spec.tabs.forEach((t, i) => {
const on = i === active;
const trig = byCid(t.trigger), panel = byCid(t.panel);
applyStyle(trig, on ? t.triggerOn : t.triggerOff);
trig?.setAttribute("aria-selected", on ? "true" : "false");
if (trig) (trig as HTMLElement).tabIndex = on ? 0 : -1;
applyStyle(panel, on ? t.panelShown : t.panelHidden);
if (on) applyDesc(t.descendants);
if (panel) { if (on) { panel.removeAttribute("hidden"); } else { panel.setAttribute("hidden", ""); } }
});
spec.tabs.forEach((t, i) => {
const trig = byCid(t.trigger);
if (!trig) return;
trig.addEventListener("click", (e) => { e.preventDefault(); active = i; render(); });
trig.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key;
if (k === "ArrowRight" || k === "ArrowLeft") {
e.preventDefault();
active = (active + (k === "ArrowRight" ? 1 : spec.tabs.length - 1)) % spec.tabs.length;
render();
byCid(spec.tabs[active].trigger)?.focus();
}
});
});
// No initial render() — the static base state is already correct.
} else if (spec.kind === "accordion") {
const state = spec.items.map((it) => it.expanded);
const renderItem = (i: number) => {
const it = spec.items[i], on = state[i];
const trig = byCid(it.trigger), region = byCid(it.region);
applyStyle(trig, on ? it.triggerOn : it.triggerOff);
trig?.setAttribute("aria-expanded", on ? "true" : "false");
applyStyle(region, on ? it.regionShown : it.regionHidden);
if (region) { if (on) { region.removeAttribute("hidden"); } else { region.setAttribute("hidden", ""); } }
};
spec.items.forEach((it, i) => {
const trig = byCid(it.trigger);
if (trig) trig.addEventListener("click", (e) => { e.preventDefault(); state[i] = !state[i]; renderItem(i); });
});
// No initial renderItem — the static base state is already correct.
} else if (spec.kind === "carousel") {
// Carousel: move the track's transform between captured per-index positions.
const n = spec.transforms.length;
let index = spec.base;
const track = byCid(spec.track);
const go = (k: number) => {
index = Math.max(0, Math.min(n - 1, k));
if (track) track.style.transform = spec.transforms[index];
spec.bullets.forEach((b, bi) => applyStyle(byCid(b), bi === index ? spec.bulletOn : spec.bulletOff));
};
const nextEl = spec.next ? byCid(spec.next) : null;
const prevEl = spec.prev ? byCid(spec.prev) : null;
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); });
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); });
spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); }));
// No initial go() — the static base state is already correct.
} else {
// Disclosure: dropdown / mega-menu / modal — a trigger reveals a hidden overlay.
spec.items.forEach((it) => {
const trig = byCid(it.trigger), panel = byCid(it.panel);
if (!trig || !panel) return;
let open = false;
const set = (o: boolean) => {
open = o;
applyStyle(trig, o ? it.triggerOn : it.triggerOff);
trig.setAttribute("aria-expanded", o ? "true" : "false");
applyStyle(panel, o ? it.panelShown : it.panelHidden);
if (o) applyDesc(it.descendants);
if (o) panel.removeAttribute("hidden"); else panel.setAttribute("hidden", "");
};
trig.addEventListener("click", (e) => { e.preventDefault(); set(it.isDialog ? true : !open); });
if (it.hoverOpen) {
const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", () => set(true));
root.addEventListener("mouseleave", () => set(false));
}
it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); }));
if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); });
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); });
});
// No initial set() — the static base state is already correct.
}
}, [spec]);
return null;
}
`;
+72
View File
@@ -0,0 +1,72 @@
import type { IR } from "../normalize/ir.js";
import type { Section } from "../infer/sections.js";
import type { Tokens } from "../infer/tokens.js";
import type { AssetGraph } from "../infer/assets.js";
import type { FontGraph } from "../infer/fonts.js";
import type { CaptureResult } from "../capture/capture.js";
export const COMPILER_VERSION = "0.1.0";
export const SCHEMA_VERSION = 1;
export function buildManifest(args: {
ir: IR;
sections: Section[];
tokens: Tokens;
assetGraph: AssetGraph;
fontGraph: FontGraph;
capture: CaptureResult;
componentCount: number;
}): Record<string, unknown> {
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount } = args;
const byType: Record<string, number> = {};
let downloaded = 0, skipped = 0;
for (const e of assetGraph.entries) {
if (e.type === "css") continue;
byType[e.type] = (byType[e.type] ?? 0) + 1;
if (e.classification === "downloaded") downloaded++;
else skipped++;
}
const tokenCounts: Record<string, number> = {};
for (const [k, v] of Object.entries(tokens)) tokenCounts[k] = Object.keys(v).length;
const scrollHeights: Record<string, number> = {};
for (const [vp, d] of Object.entries(ir.doc.perViewport)) scrollHeights[vp] = d.scrollHeight;
return {
schemaVersion: SCHEMA_VERSION,
compilerVersion: COMPILER_VERSION,
sourceUrl: ir.doc.sourceUrl,
capturedAt: capture.capturedAt,
viewports: ir.doc.viewports,
canonicalViewport: ir.doc.canonicalViewport,
doc: {
title: ir.doc.title,
lang: ir.doc.lang,
nodeCount: ir.doc.nodeCount,
scrollHeights,
},
sections: { count: sections.length, ids: sections.map((s) => s.id) },
tokens: tokenCounts,
assets: { total: downloaded + skipped, downloaded, skipped, byType },
fonts: {
total: fontGraph.entries.length,
resolved: fontGraph.entries.filter((f) => f.status === "resolved").length,
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
},
components: { count: componentCount },
// Stage 2: capture-sanity audit — what overlays were dismissed, whether any
// still covered the page, video stills materialized, and per-viewport quiescence.
capture: {
dismissedOverlays: capture.dismissal?.dismissed ?? [],
overlaysRemoved: capture.dismissal?.removed ?? 0,
overlaysRemaining: capture.dismissal?.overlaysRemaining ?? 0,
blockingModal: capture.dismissal?.blocking ?? false,
videoStills: capture.dismissal?.videoStills ?? 0,
perViewport: capture.perViewport.map((p) => ({
viewport: p.viewport, overlaysRemaining: p.overlaysRemaining ?? 0, blocking: p.blocking ?? false, quiescent: p.quiescent ?? null,
})),
},
};
}
+154
View File
@@ -0,0 +1,154 @@
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { MenuCapture } from "../capture/interactions.js";
/**
* M4b mount-on-open menus (Radix-style portals). The panel is added to the DOM on open
* and removed on close, so the IR never sees it and the IR-based disclosure path can't
* reproduce it. Instead the panel is captured as a self-contained inline-styled fragment
* and reproduced CLIENT-SIDE: `DropdownMenu` renders null on mount and only injects the panel
* (under its trigger) on interaction so the server-rendered base that gates 06 grade is
* structurally untouched (same safety model as DittoWire). A menu that doesn't reproduce is
* pruned by the interaction gate, so a broken menu is never shipped.
*/
const TRANSPARENT_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
export type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
function capToNode(ir: IR): Map<string, IRNode> {
const m = new Map<string, IRNode>();
const walk = (n: IRNode): void => {
const cap = n.attrs["data-cid-cap"];
if (cap !== undefined) m.set(cap, n);
for (const c of n.children) if (!isTextChild(c)) walk(c);
};
walk(ir.root);
return m;
}
function textContent(n: IRNode, max = 120): string {
let out = "";
const walk = (node: IRNode): void => {
if (out.length >= max) return;
for (const c of node.children) {
if (isTextChild(c)) out += " " + c.text;
else walk(c);
if (out.length >= max) return;
}
};
walk(n);
return out.replace(/\s+/g, " ").trim().slice(0, max);
}
function isLikelySkipTrigger(n: IRNode, menu: MenuCapture): boolean {
const label = `${textContent(n, 120)} ${n.attrs["aria-label"] ?? ""}`.toLowerCase();
const href = (n.attrs.href ?? "").trim();
if (/\bskip\s+(?:to\s+)?(?:content|main|navigation)\b/.test(label)) return true;
return href.startsWith("#") && menu.gap > 360;
}
/** Rewrite the captured panel HTML so the clone stays self-contained: same-origin links
* become app-relative (via the page's linkRewrite), and every image src resolves to a
* local asset or a transparent placeholder never a remote origin (rubric Gate 2). */
function rewriteHtml(html: string, assetMap: Map<string, string>, sourceUrl: string, linkRewrite?: (href: string) => string): string {
const resolve = (u: string): string => { try { return new URL(u, sourceUrl).href; } catch { return u; } };
let out = html.replace(/\bsrc="([^"]*)"/g, (_m, u: string) => {
const local = assetMap.get(resolve(u));
return `src="${local ?? TRANSPARENT_GIF}"`;
});
if (linkRewrite) out = out.replace(/\bhref="([^"]*)"/g, (_m, u: string) => `href="${linkRewrite(u).replace(/"/g, "&quot;")}"`);
return out;
}
export function buildMenuSpecs(
ir: IR,
menus: MenuCapture[] | undefined,
assetMap: Map<string, string>,
sourceUrl: string,
linkRewrite?: (href: string) => string,
include?: (cid: string) => boolean,
): RTMenu[] {
if (!menus || !menus.length) return [];
const map = capToNode(ir);
const out: RTMenu[] = [];
for (const m of menus) {
const trigger = map.get(m.triggerCap);
if (!trigger) continue; // trigger pruned from the IR → drop
if (isLikelySkipTrigger(trigger, m)) continue;
if (include && !include(trigger.id)) continue;
out.push({ trigger: trigger.id, hoverOpen: m.hoverOpen, gap: m.gap, align: m.align, html: rewriteHtml(m.html, assetMap, sourceUrl, linkRewrite) });
}
return out;
}
export function menusJsx(menus: RTMenu[], indent: number): string {
if (!menus.length) return "";
const pad = " ".repeat(indent);
return `${pad}<DropdownMenu menus={${JSON.stringify(menus)}} />`;
}
export function dropdownMenuImportPath(depth: number): string {
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DropdownMenu";
}
/** The fixed DropdownMenu client component, written once per generated app. */
export const DROPDOWN_MENU_TSX = `"use client";
import { useEffect, useRef } from "react";
type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
/** Reproduces mount-on-open dropdown/nav menus: renders nothing and applies NOTHING on mount; only on
* user interaction does it inject the captured panel fragment under its trigger. The base
* render is therefore unchanged. */
export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
const wired = useRef(false);
useEffect(() => {
if (wired.current) return;
wired.current = true;
for (const m of menus) {
const trig = byCid(m.trigger);
if (!trig) continue;
let panel: HTMLElement | null = null;
const place = () => {
if (!panel) return;
const r = trig.getBoundingClientRect();
panel.style.position = "absolute";
panel.style.top = (r.bottom + window.scrollY + m.gap) + "px";
if (m.align === "right") { panel.style.left = ""; panel.style.right = (document.documentElement.clientWidth - (r.right + window.scrollX)) + "px"; }
else { panel.style.right = ""; panel.style.left = (r.left + window.scrollX) + "px"; }
panel.style.zIndex = "9999";
};
const open = () => {
if (panel) return;
const wrap = document.createElement("div");
wrap.innerHTML = m.html;
panel = wrap.firstElementChild as HTMLElement | null;
if (!panel) return;
document.body.appendChild(panel);
place();
trig.setAttribute("aria-expanded", "true");
};
const close = () => { if (panel) { panel.remove(); panel = null; } trig.setAttribute("aria-expanded", "false"); };
const toggle = () => (panel ? close() : open());
if (m.hoverOpen) {
const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", open);
root.addEventListener("mouseleave", close);
} else {
trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); });
}
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
document.addEventListener("click", (e) => {
const t = e.target as Node;
if (panel && !trig.contains(t) && !panel.contains(t)) close();
});
window.addEventListener("resize", place);
window.addEventListener("scroll", place, { passive: true });
}
(window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically
}, [menus]);
return null;
}
`;
+201
View File
@@ -0,0 +1,201 @@
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { MotionCapture, WaapiAnim, RotatorSpec, RevealSpec, MarqueeSpec } from "../capture/motion.js";
/**
* Stage 5 motion controller. CSS @keyframes motion is reproduced declaratively in
* ditto.css (the animation plays on load with no JS). The two families that the
* stylesheet can't express are reproduced by one fixed `'use client'` component,
* `DittoMotion`, parameterized by captured specs:
* - **WAAPI** re-issues `element.animate(keyframes, timing)` on the cid'd node.
* - **rotating text** cycles the captured text values on an interval.
*
* Unlike DittoWire (which applies nothing on mount to keep the base frame untouched),
* DittoMotion DOES start motion on mount the clone is meant to REPLAY motion on load.
* The validator measures the settled base by cancelling Web Animations and calling
* `window.__dittoMotionStop()` (which DittoMotion installs) to restore rotator text, so
* gates 06 still grade the static frame. The motion gate drives it un-frozen to verify.
*/
export type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
export type RTRotator = { cid: string; texts: string[]; intervalMs: number };
export type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
export type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
/** Map every IR node's capture-id → cid (the rendered identity). */
function capToCid(ir: IR): Map<string, string> {
const m = new Map<string, string>();
const walk = (n: IRNode): void => {
const cap = n.attrs["data-cid-cap"];
if (cap !== undefined) m.set(cap, n.id);
for (const c of n.children) if (!isTextChild(c)) walk(c);
};
walk(ir.root);
return m;
}
/** Resolve cap-keyed motion specs to cids that survived into this IR (optionally
* scoped by an include filter, for multi-route body/chrome splitting). Specs whose
* element was pruned are dropped (left static). */
export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, include?: (cid: string) => boolean): MotionSpec {
if (!motion) return { waapi: [], rotators: [], reveals: [], marquees: [] };
const map = capToCid(ir);
const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid));
const waapi: RTWaapi[] = [];
for (const w of motion.waapi as WaapiAnim[]) {
const cid = map.get(w.cap);
if (!ok(cid)) continue;
waapi.push({ cid, keyframes: w.keyframes, duration: w.duration, delay: w.delay, easing: w.easing, iterations: w.iterations, direction: w.direction, fill: w.fill });
}
const rotators: RTRotator[] = [];
for (const r of motion.rotators as RotatorSpec[]) {
const cid = map.get(r.cap);
if (!ok(cid)) continue;
rotators.push({ cid, texts: r.texts, intervalMs: r.intervalMs });
}
const reveals: RTReveal[] = [];
for (const rv of (motion.reveals ?? []) as RevealSpec[]) {
const cid = map.get(rv.cap);
if (!ok(cid)) continue;
reveals.push({ cid, opacity: rv.opacity, transform: rv.transform, transition: rv.transition });
}
const marquees: RTMarquee[] = [];
for (const m of (motion.marquees ?? []) as MarqueeSpec[]) {
const cid = map.get(m.cap);
if (!ok(cid)) continue;
marquees.push({ cid, pxPerSec: m.pxPerSec, periodPx: m.periodPx });
}
return { waapi, rotators, reveals, marquees };
}
export function motionHasContent(spec: MotionSpec): boolean {
return spec.waapi.length > 0 || spec.rotators.length > 0 || spec.reveals.length > 0 || spec.marquees.length > 0;
}
/** Relative import path from a route page at the given app-segment depth to the shared
* DittoMotion (single page / entry route: depth 0 "./ditto/DittoMotion"). */
export function dittoMotionImportPath(depth: number): string {
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DittoMotion";
}
/** JSX for the motion controller, rendered at the end of a page fragment. "" when empty. */
export function motionWireJsx(spec: MotionSpec, indent: number): string {
if (!motionHasContent(spec)) return "";
const pad = " ".repeat(indent);
return `${pad}<DittoMotion spec={${JSON.stringify(spec)}} />`;
}
/** The fixed DittoMotion client component, written once per generated app. */
export const DITTO_MOTION_TSX = `"use client";
import { useEffect } from "react";
type RTWaapi = { cid: string; keyframes: Array<Record<string, string | number>>; duration: number; delay: number; easing: string; iterations: number; direction: string; fill: string };
type RTRotator = { cid: string; texts: string[]; intervalMs: number };
type RTReveal = { cid: string; opacity: string; transform: string; transition: string };
type RTMarquee = { cid: string; pxPerSec: number; periodPx: number };
export type MotionSpec = { waapi: RTWaapi[]; rotators: RTRotator[]; reveals: RTReveal[]; marquees: RTMarquee[] };
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
/** Replays captured motion the stylesheet can't express: WAAPI animations (re-issued via
* element.animate), rotating text (interval-cycled), and scroll-triggered reveals (start
* hidden, transition in when scrolled into view). Starts on mount. Installs
* window.__dittoMotionStop, and honors window.__dittoMotionStopped, so the validator can
* restore the fully-settled/revealed base for grading gates 06 measure the static frame.
* The stopped FLAG (set by the validator even before this mounts) makes a late mount skip
* applying any motion, closing the hydration race that could otherwise leave content hidden. */
export default function DittoMotion({ spec }: { spec: MotionSpec }) {
useEffect(() => {
if ((window as any).__dittoMotionStopped) return; // measurement mode — apply nothing
const intervals: ReturnType<typeof setInterval>[] = [];
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
const anims: Animation[] = [];
const revealed: Array<() => void> = []; // per-reveal "show now" fns (also the cleanup)
let io: IntersectionObserver | null = null;
let forceTimer: ReturnType<typeof setTimeout> | null = null;
for (const w of spec.waapi) {
const el = byCid(w.cid);
if (!el) continue;
try {
anims.push(el.animate(w.keyframes, {
duration: w.duration || 0, delay: w.delay || 0, easing: w.easing || "linear",
iterations: w.iterations < 0 ? Infinity : (w.iterations || 1),
direction: (w.direction as PlaybackDirection) || "normal", fill: (w.fill as FillMode) || "none",
}));
} catch { /* unsupported keyframe shape — leave static */ }
}
// Marquees: rAF-driven continuous tickers, reconstructed as an infinite linear translateX
// loop over one duplicated copy (periodPx). Leftward (pxPerSec<0): 0 -> -period; rightward:
// -period -> 0. Cancelled by stopAll so the graded frame shows the element's base transform.
for (const m of spec.marquees) {
const el = byCid(m.cid);
if (!el || !m.periodPx || !m.pxPerSec) continue;
const left = m.pxPerSec < 0;
const a = "translateX(0px)", z = "translateX(-" + m.periodPx + "px)";
const durationMs = Math.max(1000, Math.round((m.periodPx / Math.abs(m.pxPerSec)) * 1000));
try {
anims.push(el.animate([{ transform: left ? a : z }, { transform: left ? z : a }], {
duration: durationMs, iterations: Infinity, easing: "linear",
}));
} catch { /* leave static */ }
}
for (const r of spec.rotators) {
const el = byCid(r.cid);
if (!el || r.texts.length < 2) continue;
const original = el.textContent;
const start = r.texts.findIndex((t) => t === (original || "").replace(/\\s+/g, " ").trim());
let i = start < 0 ? 0 : start;
rotators.push({ el, original });
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
}
// Scroll reveals: hide each element (opacity/transform) with the captured transition,
// then reveal (clear the inline overrides → transitions to the base CSS) when it scrolls
// into view. A force-reveal timer guarantees nothing stays hidden if the observer misses.
if (spec.reveals.length) {
// Reveal to the full resting state. Setting 1/none (not clearing to base) is correct for
// every reveal — the revealed state is always full + un-offset — and is REQUIRED for
// scroll-scrub panels whose captured base CSS is a frozen mid-scrub value (opacity 0.63).
const show = (el: HTMLElement) => { el.style.opacity = "1"; el.style.transform = "none"; };
const byEl = new Map<Element, HTMLElement>();
for (const rv of spec.reveals) {
const el = byCid(rv.cid);
if (!el) continue;
el.style.transition = rv.transition;
el.style.opacity = rv.opacity;
if (rv.transform !== "none") el.style.transform = rv.transform;
byEl.set(el, el);
revealed.push(() => show(el));
}
io = new IntersectionObserver((entries) => {
for (const e of entries) if (e.isIntersecting) { const el = byEl.get(e.target); if (el) { show(el); io!.unobserve(e.target); } }
}, { rootMargin: "0px 0px -8% 0px" });
for (const el of byEl.keys()) io.observe(el);
forceTimer = setTimeout(() => { for (const f of revealed) f(); }, 4000);
}
const stopAll = () => {
(window as any).__dittoMotionStopped = true;
for (const id of intervals) clearInterval(id);
for (const r of rotators) r.el.textContent = r.original;
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
if (io) io.disconnect();
if (forceTimer) clearTimeout(forceTimer);
for (const f of revealed) f(); // reveal everything → base CSS settled frame
};
// Measurement hook: restore the fully-settled/revealed base for grading.
(window as any).__dittoMotionStop = stopAll;
return () => {
for (const id of intervals) clearInterval(id);
if (io) io.disconnect();
if (forceTimer) clearTimeout(forceTimer);
try { delete (window as any).__dittoMotionStop; } catch { /* ignore */ }
};
}, [spec]);
return null;
}
`;
+111
View File
@@ -0,0 +1,111 @@
import { join } from "node:path";
import { backfillLazyBackgrounds, buildIR, writeIR, type IR } from "../normalize/ir.js";
import { detectSections, type Section } from "../infer/sections.js";
import { extractTokens, tokensToCss, buildTokenResolver, type Tokens } from "../infer/tokens.js";
import { buildColorPalette } from "../infer/semanticTokens.js";
import { recognizePrimitives, inventoryOf } from "../infer/primitives.js";
import { buildAssetGraph, materializeAssets, type AssetGraph } from "../infer/assets.js";
import { buildFontGraph, type FontGraph } from "../infer/fonts.js";
import { buildRecipeReport, recipeReportToMarkdown, type RecipeReport } from "../infer/recipes.js";
import { buildInteractionRecipeReport, interactionRecipeReportToMarkdown, type InteractionRecipeReport } from "../infer/interactionRecipes.js";
import { generateApp, type AppFramework } from "./app.js";
import { buildCodeQualityReport, codeQualityReportToMarkdown, type CodeQualityReport } from "./codeQuality.js";
import { interactionRejectedSet } from "./interactive.js";
import { buildManifest } from "./manifest.js";
import { buildSeoInventory, seoInventoryToMarkdown, type SeoInventory } from "./seo.js";
import { writeJSON, writeText, readJSON, fileExists } from "../util/fsx.js";
import type { CaptureResult } from "../capture/capture.js";
export type GenerateAllResult = {
ir: IR;
sections: Section[];
tokens: Tokens;
assetGraph: AssetGraph;
fontGraph: FontGraph;
recipeReport: RecipeReport;
interactionRecipeReport: InteractionRecipeReport;
seoInventory: SeoInventory;
codeQuality: CodeQualityReport;
manifest: Record<string, unknown>;
assetsCopied: number;
assetsMissing: string[];
};
/**
* Pure deterministic generation from a frozen capture: IR infer generate app
* emit artifacts. Called by the CLI and twice by the determinism gate; given
* the same sourceDir it must produce byte-identical output (rubric Gate 6).
*/
export function generateAll(opts: {
sourceDir: string;
capture: CaptureResult;
viewports: number[]; // band/gate widths (standard breakpoints)
sampleViewports?: number[]; // dense set for size inference (defaults to viewports)
url: string;
outDir: string; // the generated/ dir
ignoreRejectedInteractions?: boolean; // validation retest mode: wire everything before pruning
}): GenerateAllResult {
const { sourceDir, capture, viewports, url, outDir } = opts;
const sampleViewports = opts.sampleViewports ?? viewports;
const appDir = join(outDir, "app");
// Stage 5: carry @keyframes only when motion capture ran (motion on by default;
// `--no-motion` / the plain static benchmark leave `capture.motion` unset), so
// `--no-motion` output is byte-identical to the pre-Stage-5 frozen clone. `!!capture.motion`
// is stable across processes (persisted in capture-result.json) ⇒ determinism holds.
const ir = buildIR(sourceDir, sampleViewports, { motion: !!capture.motion, bandViewports: viewports });
backfillLazyBackgrounds(ir);
writeIR(ir, sourceDir);
const sections = detectSections(ir);
const tokens = extractTokens(ir);
const assetGraph = buildAssetGraph(capture);
const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url);
const seoInventory = buildSeoInventory(ir, assetGraph, capture);
// Stage 3.5: semantic color tokens (load-bearing). The palette's :root supersedes
// the decorative color group; ditto.css references var(--token) for colors.
const palette = buildColorPalette(ir);
const tokensCss = (palette.css ? palette.css + "\n" : "") + tokensToCss(tokens, true);
const tokenResolver = buildTokenResolver(tokens);
const primitives = recognizePrimitives(ir);
const recipeReport = buildRecipeReport(ir, sections, primitives);
const interactionRecipeReport = buildInteractionRecipeReport(ir, sections, capture.interaction);
// Patterns the interaction gate previously rejected (don't reproduce) → left static.
const rejPath = join(sourceDir, "interaction-rejected.json");
const rejectedSpecs = !opts.ignoreRejectedInteractions && fileExists(rejPath)
? interactionRejectedSet(readJSON<unknown>(rejPath))
: undefined;
// Stage 4.5: component extraction is opt-in, persisted in the source dir at clone
// time so every generateAll for this run (deliverable, determinism gate, prune
// regen) makes the SAME choice — keeping output deterministic across processes.
const optPath = join(sourceDir, "clone-options.json");
const cloneOpts = fileExists(optPath) ? readJSON<{ components?: boolean; humanizeMode?: "tailwind" | "css"; framework?: AppFramework; reflow?: boolean }>(optPath) : {};
const components = !!cloneOpts.components;
const humanizeMode = cloneOpts.humanizeMode; // undefined → generateApp default ("tailwind")
const gen = generateApp({ ir, assetGraph, fontGraph, appDir, sourceDir, sourceUrl: url, seoInventory, colorVar: palette.varForColor, tokenResolver, primitives, recipeReport, interaction: capture.interaction, rejectedSpecs, components, humanizeMode, framework: cloneOpts.framework, motion: capture.motion, reflow: !!cloneOpts.reflow }, tokensCss);
const mat = materializeAssets(assetGraph, sourceDir, join(appDir, "public"));
// Stage 4.5: record promoted components (empty when extraction is off).
writeJSON(join(outDir, "extracted-components.json"), gen.components);
writeJSON(join(outDir, "sections.json"), sections);
writeJSON(join(outDir, "tokens.json"), tokens);
writeJSON(join(outDir, "assets.json"), assetGraph.entries);
writeJSON(join(outDir, "fonts.json"), fontGraph.entries);
const inventory = inventoryOf(ir, primitives);
writeJSON(join(outDir, "components.json"), inventory);
writeJSON(join(outDir, "recipes.json"), recipeReport);
writeText(join(outDir, "recipes.md"), recipeReportToMarkdown(recipeReport));
writeJSON(join(outDir, "interaction-recipes.json"), interactionRecipeReport);
writeText(join(outDir, "interaction-recipes.md"), interactionRecipeReportToMarkdown(interactionRecipeReport));
writeJSON(join(outDir, "seo.json"), seoInventory);
writeText(join(outDir, "seo.md"), seoInventoryToMarkdown(seoInventory));
const codeQuality = buildCodeQualityReport(appDir, recipeReport);
writeJSON(join(outDir, "code-quality.json"), codeQuality);
writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality));
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count });
writeJSON(join(outDir, "manifest.json"), manifest);
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Self-converging sizing refinement.
*
* The sizing probe is ground truth when it runs against the SOURCE at capture. In this sandbox the
* egress proxy can't tunnel the browser to the live site, so we fall back to probing the LOCAL clone
* render but then there's a compounding effect: each element is probed against the OTHER elements'
* still-baked widths, so dropping ~120 at once shifts context and the first regen overshoots. The
* fix is to iterate renderregen until the output stops changing: each cycle re-probes the layout
* the previous drops produced, so the flags and the render agree at the fixed point.
*
* (With a real source-probe this is a one-pass no-op the source layout never changes, so the very
* first set of flags is already consistent and the loop exits after one render.)
*/
import { join } from "node:path";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { buildApp, serveStatic, renderApp } from "../validate/render.js";
import { generateAll } from "./pipeline.js";
import { readJSON } from "../util/fsx.js";
import type { CaptureResult } from "../capture/capture.js";
/** A COARSE signature: the count of decimal-px dimensions + responsive breakpoints across the
* generated app. We converge on this (not an exact content hash) because the clone-probe can leave
* a handful of boundary elements oscillating between two near-equivalent layouts forever the macro
* state (how many measurements leaked) is what we're driving to a fixed point. */
function coarseSignature(appDir: string): string {
let decimals = 0, breakpoints = 0;
const reDecimal = /-\[[0-9]+\.[0-9]+px\]/g;
const reBp = /\b(sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g;
const walk = (dir: string): void => {
for (const name of readdirSync(dir).sort()) {
const p = join(dir, name);
const st = statSync(p);
if (st.isDirectory()) { if (name !== "node_modules" && !name.startsWith(".")) walk(p); }
else if (name.endsWith(".tsx")) {
const txt = readFileSync(p, "utf8");
decimals += (txt.match(reDecimal) || []).length;
breakpoints += (txt.match(reBp) || []).length;
}
}
};
walk(appDir);
return `${decimals}/${breakpoints}`;
}
export async function refineSizing(
runDir: string,
harnessDir: string,
opts?: { maxIters?: number; log?: (e: Record<string, unknown>) => void },
): Promise<{ iters: number; converged: boolean }> {
const maxIters = opts?.maxIters ?? 4;
const log = opts?.log ?? (() => {});
const sourceDir = join(runDir, "source");
const generatedDir = join(runDir, "generated");
const appDir = join(generatedDir, "app");
const renderedDir = join(runDir, "rendered");
const input = readJSON<{ url: string; viewports: number[] }>(join(runDir, "input.json"));
const capture = readJSON<CaptureResult>(join(sourceDir, "capture", "capture-result.json"));
const viewports = input.viewports;
let lastSig = coarseSignature(appDir);
let iters = 0;
let converged = false;
for (let i = 0; i < maxIters; i++) {
// 1) render the current clone locally → writes rendered/dom/dom-*.json with fresh probe flags
const build = buildApp(appDir, harnessDir);
if (!build.ok || !build.outDir) { log({ event: "refine_build_failed", i }); break; }
const server = await serveStatic(build.outDir);
try { await renderApp({ url: server.url + "/", viewports, renderedDir }); }
finally { await server.close(); }
// 2) regen consuming those flags (buildIR overlays them by cid)
generateAll({ sourceDir, capture, viewports, sampleViewports: capture.viewports, url: input.url, outDir: generatedDir });
iters = i + 1;
const sig = coarseSignature(appDir);
log({ event: "refine_iter", i, sig, prev: lastSig });
if (sig === lastSig) { converged = true; break; } // macro state reproduced ⇒ fixed point
lastSig = sig;
}
return { iters, converged };
}
+306
View File
@@ -0,0 +1,306 @@
/**
* Section splitting (output-quality, fidelity-neutral).
*
* The page is one deep tree, so inlined it becomes a single 400-line page.tsx. A readable
* app should instead be a short composition of named sections Navbar, HeroSection,
* AboutSection, ..., Footer each in its own file. This module
* finds those section roots and names them; the generator (app.ts) emits each section
* subtree as its own module and leaves a `<HeroSection />` placeholder in page.tsx.
*
* Render-identical: a section module renders the exact same subtree (same tags, cids,
* classes), so the composed DOM is byte-for-byte what inlining produced gates align
* by data-cid with no change. We do NOT flatten the wrapper chain (those divs are real,
* styled nodes); page.tsx keeps the wrappers and plugs section components into them.
*/
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { RecipeCandidate, RecipeKind, RecipeReport } from "../infer/recipes.js";
import { subtreeSignature } from "../site/sharedLayout.js";
export type SectionPlan = {
/** section-root cid → PascalCase component name. */
roots: Map<string, string>;
};
const MIN_SECTION_H = 56;
const MAX_SECTIONS = 24;
function box(n: IRNode, cw: number): { width: number; height: number } | undefined {
return n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0];
}
function yOf(n: IRNode, cw: number): number {
return (n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0])?.y ?? 0;
}
function visible(n: IRNode, cw: number): boolean {
return !!(n.visibleByVp[cw] ?? Object.values(n.visibleByVp)[0]);
}
function elementChildren(n: IRNode): IRNode[] {
return n.children.filter((c): c is IRNode => !isTextChild(c));
}
function significantChildren(n: IRNode, cw: number): IRNode[] {
return elementChildren(n).filter((c) => visible(c, cw) && (box(c, cw)?.height ?? 0) >= MIN_SECTION_H);
}
function subtreeHasTag(n: IRNode, tag: string, depth = 4): boolean {
if (depth < 0) return false;
for (const c of elementChildren(n)) {
if (c.tag === tag) return true;
if (subtreeHasTag(c, tag, depth - 1)) return true;
}
return false;
}
function collectIds(n: IRNode, out = new Set<string>()): Set<string> {
out.add(n.id);
for (const c of elementChildren(n)) collectIds(c, out);
return out;
}
/** First heading text (h1h6) in a subtree, else the first non-trivial text run. */
function titleText(n: IRNode, depth = 6): string {
const fromHeadings = (node: IRNode, d: number): string => {
if (d < 0) return "";
for (const c of elementChildren(node)) {
if (/^h[1-6]$/.test(c.tag)) { const t = textOf(c); if (t.trim()) return t; }
const sub = fromHeadings(c, d - 1); if (sub) return sub;
}
return "";
};
const h = fromHeadings(n, depth);
if (h) return h;
return textOf(n);
}
function textOf(n: IRNode): string {
let s = "";
const walk = (x: IRNode): void => {
for (const c of x.children) {
if (isTextChild(c)) { if (c.text.trim()) s += " " + c.text.trim(); }
else walk(c);
if (s.length > 60) return;
}
};
walk(n);
return s.trim();
}
const STOP = new Set(["the", "a", "an", "of", "and", "to", "in", "for", "with", "your", "our", "is", "are", "be", "at", "on", "we", "you", "that", "this", "from", "by", "or", "it", "as", "new"]);
/** Slugify prominent text into ≤3 PascalCase words for a section name. */
function slugWords(text: string): string {
const words = text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter(Boolean);
const kept: string[] = [];
for (const w of words) {
if (kept.length >= 3) break;
if (STOP.has(w) && kept.length === 0) continue;
if (/^\d+$/.test(w) && kept.length === 0) continue; // drop leading numeric-only noise (e.g. layer ids "0019")
if (w.length < 2 && !/\d/.test(w)) continue;
kept.push(w);
}
return kept.map((w) => w[0]!.toUpperCase() + w.slice(1)).join("");
}
/** Ensure a generated name is a valid JS identifier (it's used as an import/export id,
* and the kebab filename derives from it so fixing it here keeps id + path in sync).
* A name that doesn't start with a letter/_/$ (e.g. "3dSection") gets an "S" prefix. */
function identName(name: string): string {
return /^[A-Za-z_$]/.test(name) ? name : "S" + name;
}
function looksLikeNav(n: IRNode, cw: number): boolean {
if (n.tag === "nav" || n.tag === "header") return true;
if (subtreeHasTag(n, "nav")) return true;
const b = box(n, cw);
return !!b && b.height <= 140; // a short full-width bar at the top
}
const RECIPE_SECTION_NAME: Partial<Record<RecipeKind, string>> = {
"logo-cloud": "LogoCloudSection",
"feature-grid": "FeatureGridSection",
"product-grid": "ProductGridSection",
"gallery-showcase": "GalleryShowcaseSection",
"cta-band": "CtaSection",
};
const RECIPE_FALLBACK_SECTION_NAME: Partial<Record<RecipeKind, string>> = {
...RECIPE_SECTION_NAME,
"card-grid": "CardGridSection",
};
function recipeEligible(r: RecipeCandidate): boolean {
if (r.kind === "gallery-showcase") return r.confidence >= 0.82;
if (r.kind === "cta-band") return r.confidence >= 0.7;
return r.confidence >= 0.82;
}
function recipeRank(kind: RecipeKind): number {
switch (kind) {
case "gallery-showcase": return 5;
case "product-grid": return 4;
case "cta-band": return 3;
case "feature-grid": return 2;
case "logo-cloud": return 1;
default: return 0;
}
}
function recipeNameForSection(sec: IRNode, recipes?: RecipeReport): string | null {
if (!recipes) return null;
const ids = collectIds(sec);
const matches = recipes.candidates
.filter((r) => recipeEligible(r) && RECIPE_SECTION_NAME[r.kind] && ids.has(r.rootCid))
.sort((a, b) => recipeRank(b.kind) - recipeRank(a.kind) || b.confidence - a.confidence || a.id.localeCompare(b.id));
const best = matches[0];
return best ? RECIPE_SECTION_NAME[best.kind] ?? null : null;
}
function indexTree(root: IRNode): { byId: Map<string, IRNode>; parentById: Map<string, IRNode | undefined> } {
const byId = new Map<string, IRNode>();
const parentById = new Map<string, IRNode | undefined>();
const walk = (node: IRNode, parent: IRNode | undefined): void => {
byId.set(node.id, node);
parentById.set(node.id, parent);
for (const c of elementChildren(node)) walk(c, node);
};
walk(root, undefined);
return { byId, parentById };
}
function sourceLooksSectionish(n: IRNode): boolean {
return n.tag === "section" || n.tag === "article" || /\b(?:section|wrapper|container|surface|layout|above.?the.?fold|scroll.?container|content|root)\b/i.test(n.srcClass ?? "");
}
function recipeFallbackAnchor(ir: IR, recipe: RecipeCandidate, byId: Map<string, IRNode>, parentById: Map<string, IRNode | undefined>): IRNode | undefined {
const cw = ir.doc.canonicalViewport;
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
const root = byId.get(recipe.rootCid);
const rb = root ? box(root, cw) : undefined;
const rootTooBroad = !root || root.id === ir.root.id || (!!pageH && !!rb && rb.height > pageH * 0.55);
let anchor = rootTooBroad && recipe.itemParentCid ? byId.get(recipe.itemParentCid) : root;
if (!anchor) return undefined;
const maxH = pageH ? Math.max(1600, pageH * 0.35) : 1600;
let best = anchor;
let cur: IRNode | undefined = anchor;
while (cur) {
const parent = parentById.get(cur.id);
if (!parent || parent.id === ir.root.id || parent.tag === "body") break;
const pb = box(parent, cw);
if (!pb || pb.height < MIN_SECTION_H || pb.height > maxH) break;
if (pb.width >= cw * 0.45 && sourceLooksSectionish(parent)) best = parent;
cur = parent;
}
return best;
}
function recipeFallbackSections(ir: IR, recipes?: RecipeReport): { sections: IRNode[]; names: Map<string, string> } {
const names = new Map<string, string>();
if (!recipes) return { sections: [], names };
const { byId, parentById } = indexTree(ir.root);
const byRoot = new Map<string, { node: IRNode; name: string; rank: number; confidence: number }>();
for (const recipe of recipes.candidates) {
if (!recipeEligible(recipe)) continue;
const name = RECIPE_FALLBACK_SECTION_NAME[recipe.kind];
if (!name) continue;
const node = recipeFallbackAnchor(ir, recipe, byId, parentById);
if (!node || node.id === ir.root.id) continue;
const b = box(node, ir.doc.canonicalViewport);
if (!b || b.height < MIN_SECTION_H) continue;
const rank = recipeRank(recipe.kind);
const prev = byRoot.get(node.id);
if (!prev || rank > prev.rank || (rank === prev.rank && recipe.confidence > prev.confidence)) {
byRoot.set(node.id, { node, name, rank, confidence: recipe.confidence });
}
}
let sections = [...byRoot.values()]
.map((v) => v.node)
.sort((a, b) => yOf(a, ir.doc.canonicalViewport) - yOf(b, ir.doc.canonicalViewport));
sections = sections.filter((node, index) => {
const ids = collectIds(node);
return !sections.some((other, otherIndex) => otherIndex !== index && ids.has(other.id) && (box(other, ir.doc.canonicalViewport)?.height ?? 0) < (box(node, ir.doc.canonicalViewport)?.height ?? Infinity));
});
for (const node of sections) {
const hit = byRoot.get(node.id);
if (hit) names.set(node.id, hit.name);
}
return { sections, names };
}
export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
const cw = ir.doc.canonicalViewport;
// Descend through the wrapper chain (single significant child) to the container
// whose children are the actual sections.
let node = ir.root;
for (let i = 0; i < 10; i++) {
const sig = significantChildren(node, cw);
if (sig.length >= 2) break;
if (sig.length === 1) { node = sig[0]!; continue; }
const kids = elementChildren(node);
if (kids.length === 1) { node = kids[0]!; continue; }
break;
}
// Exclude any child that is part of a REPEATED run (≥3 same-signature siblings): that's
// a component cluster (a card/logo grid), which component extraction should turn into a
// `.map()` over a data array — not a wall of near-identical "section" files. Only the
// distinct, one-off blocks become sections.
const distinctOf = (list: IRNode[]): IRNode[] => {
const count = new Map<string, number>();
for (const s of list) { const sig = subtreeSignature(s); count.set(sig, (count.get(sig) ?? 0) + 1); }
return list.filter((s) => (count.get(subtreeSignature(s)) ?? 0) < 3);
};
let candidates = significantChildren(node, cw);
let sections = distinctOf(candidates);
const fallback = recipeFallbackSections(ir, recipes);
// If the container yields too few sections, a dominant child is a wrapper (e.g. <main>)
// holding the real sections — expand such oversized children into their own significant
// children, keeping the other siblings (e.g. a sibling <footer>). Gated on <3 so a page
// that already splits cleanly is untouched.
if (sections.length < 3) {
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
const expanded: IRNode[] = [];
for (const c of candidates) {
const inner = significantChildren(c, cw);
const h = box(c, cw)?.height ?? 0;
if (inner.length >= 3 && pageH > 0 && h >= pageH * 0.4) expanded.push(...inner);
else expanded.push(c);
}
candidates = expanded;
sections = distinctOf(candidates);
}
let recipeNameHints = new Map<string, string>();
if (sections.length < 3 && fallback.sections.length >= 3) {
sections = fallback.sections;
recipeNameHints = fallback.names;
}
const roots = new Map<string, string>();
if (sections.length < 3 || sections.length > MAX_SECTIONS) return { roots };
const used = new Map<string, number>();
const dedupe = (base: string): string => {
const n = (used.get(base) ?? 0) + 1;
used.set(base, n);
return n === 1 ? base : `${base}${n}`;
};
let heroAssigned = false;
sections.forEach((sec, i) => {
const isLast = i === sections.length - 1;
const recipeName = recipeNameHints.get(sec.id) ?? recipeNameForSection(sec, recipes);
let name: string;
if (sec.tag === "footer" || (isLast && looksLikeNav(sec, cw) === false && subtreeHasTag(sec, "a") && (box(sec, cw)?.height ?? 0) < 700)) {
name = sec.tag === "footer" ? "Footer" : (titleText(sec) ? slugWords(titleText(sec)) + "Section" : "Footer");
} else if (i === 0 && looksLikeNav(sec, cw)) {
name = "Navbar";
} else if (sec.tag === "header") {
name = "Header";
} else if (!heroAssigned) {
heroAssigned = true;
name = "HeroSection";
} else if (recipeName) {
name = recipeName;
} else {
const slug = slugWords(titleText(sec));
name = slug ? `${slug}Section` : `Section${i + 1}`;
}
roots.set(sec.id, dedupe(identName(name)));
});
return { roots };
}
+552
View File
@@ -0,0 +1,552 @@
import { copyFileSync, readdirSync, rmSync } from "node:fs";
import { extname, join } from "node:path";
import { ensureDir, fileExists, writeText } from "../util/fsx.js";
import type { CaptureResult, SeoResource } from "../capture/capture.js";
import type { AssetEntry, AssetGraph } from "../infer/assets.js";
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
export type SeoInventory = {
sourceUrl: string;
title: string;
description: string;
keywords: string;
canonicalUrl: string;
robots: string;
referrer: string;
themeColor: string;
colorScheme: string;
openGraph: Array<{ property: string; content: string }>;
twitter: Array<{ name: string; content: string }>;
icons: Array<{
rel: string;
href: string;
localPath: string | null;
type?: string;
sizes?: string;
media?: string;
color?: string;
}>;
manifest: {
href: string;
localPath: string | null;
assets: Array<{ sourceUrl: string; localPath: string | null; type: string }>;
} | null;
alternates: Array<{ hrefLang: string; href: string }>;
jsonLd: Array<{ id?: string; text: string; types: string[] }>;
resources: SeoResource[];
metrics: {
metaFields: number;
openGraphTags: number;
twitterTags: number;
iconLinks: number;
manifestAssets: number;
alternates: number;
jsonLdBlocks: number;
llmsTxt: boolean;
llmsFullTxt: boolean;
};
};
export type SeoRouteSummary = {
routePath: string;
href: string;
url: string;
title: string;
description: string;
excerpt: string;
};
type HeadMeta = { name?: string; property?: string; httpEquiv?: string; content: string };
const clean = (value: string | undefined | null): string => (value ?? "").replace(/\s+/g, " ").trim();
const lower = (value: string | undefined): string => (value ?? "").toLowerCase();
const relTokens = (rel: string): Set<string> => new Set(rel.toLowerCase().split(/\s+/).filter(Boolean));
function metaContent(meta: HeadMeta[], key: string, kind: "name" | "property" | "httpEquiv" = "name"): string {
const wanted = key.toLowerCase();
for (const m of meta) {
const actual = kind === "name" ? m.name : kind === "property" ? m.property : m.httpEquiv;
if (actual && actual.toLowerCase() === wanted && m.content) return m.content.trim();
}
return "";
}
function metaPrefix(meta: HeadMeta[], prefix: string, kind: "name" | "property"): Array<{ key: string; content: string }> {
const p = prefix.toLowerCase();
const out: Array<{ key: string; content: string }> = [];
for (const m of meta) {
const key = kind === "name" ? m.name : m.property;
const content = clean(m.content);
if (key && key.toLowerCase().startsWith(p) && content) out.push({ key, content });
}
return out;
}
function assetFor(assetGraph: AssetGraph, url: string): AssetEntry | undefined {
return assetGraph.byUrl.get(url);
}
function jsonLdTypes(text: string): string[] {
let parsed: unknown;
try { parsed = JSON.parse(text); } catch { return []; }
const types = new Set<string>();
const visit = (value: unknown): void => {
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!value || typeof value !== "object") return;
const obj = value as Record<string, unknown>;
const t = obj["@type"];
if (typeof t === "string" && t.trim()) types.add(t.trim());
else if (Array.isArray(t)) for (const item of t) if (typeof item === "string" && item.trim()) types.add(item.trim());
if (Array.isArray(obj["@graph"])) visit(obj["@graph"]);
};
visit(parsed);
return [...types].sort();
}
function collectText(node: IRNode, out: string[]): void {
for (const child of node.children) {
if (isTextChild(child)) {
const text = clean(child.text);
if (text) out.push(text);
} else {
collectText(child, out);
}
}
}
export function routeSummaryFromIr(ir: IR, routePath: string, href: string, url: string): SeoRouteSummary {
const parts: string[] = [];
collectText(ir.root, parts);
const excerpt = parts.join(" ").replace(/\s+/g, " ").slice(0, 700).trim();
return {
routePath,
href,
url,
title: ir.doc.title || (routePath === "/" ? "Home" : routePath),
description: ir.doc.head?.description || "",
excerpt,
};
}
export function buildSeoInventory(ir: IR, assetGraph: AssetGraph, capture?: CaptureResult): SeoInventory {
const head = ir.doc.head ?? {};
const meta = head.meta ?? [];
const links = head.links ?? [];
const title = ir.doc.title || "";
const description = metaContent(meta, "description") || head.description || "";
const keywords = metaContent(meta, "keywords") || head.keywords || "";
const canonicalUrl = links.find((l) => relTokens(l.rel).has("canonical"))?.href || head.canonical || "";
const robots = metaContent(meta, "robots") || head.robots || "";
const referrer = metaContent(meta, "referrer") || head.referrer || "";
const themeColor = metaContent(meta, "theme-color") || head.themeColor || "";
const colorScheme = metaContent(meta, "color-scheme") || head.colorScheme || "";
const openGraph = metaPrefix(meta, "og:", "property");
if (!openGraph.length) {
if (head.ogTitle) openGraph.push({ key: "og:title", content: head.ogTitle });
if (head.ogDescription) openGraph.push({ key: "og:description", content: head.ogDescription });
if (head.ogImage) openGraph.push({ key: "og:image", content: head.ogImage });
if (head.ogType) openGraph.push({ key: "og:type", content: head.ogType });
if (head.ogSiteName) openGraph.push({ key: "og:site_name", content: head.ogSiteName });
}
const twitter = metaPrefix(meta, "twitter:", "name");
if (!twitter.length && head.twitterCard) twitter.push({ key: "twitter:card", content: head.twitterCard });
const icons = links.filter((link) => {
const rel = link.rel.toLowerCase();
return /\b(?:icon|shortcut|apple-touch-icon|mask-icon)\b/.test(rel);
}).map((link) => ({
rel: link.rel,
href: link.href,
localPath: assetFor(assetGraph, link.href)?.localPath ?? null,
...(link.type ? { type: link.type } : {}),
...(link.sizes ? { sizes: link.sizes } : {}),
...(link.media ? { media: link.media } : {}),
...(link.color ? { color: link.color } : {}),
}));
const manifestLink = links.find((link) => relTokens(link.rel).has("manifest"));
const manifestAssets = assetGraph.entries
.filter((entry) => entry.via.some((via) => via.startsWith("manifest:")))
.map((entry) => ({ sourceUrl: entry.sourceUrl, localPath: entry.localPath, type: entry.type }));
const manifest = manifestLink ? {
href: manifestLink.href,
localPath: assetFor(assetGraph, manifestLink.href)?.localPath ?? null,
assets: manifestAssets,
} : null;
const alternates = links.filter((link) => relTokens(link.rel).has("alternate") && link.hrefLang && link.href)
.map((link) => ({ hrefLang: link.hrefLang!, href: link.href }));
const jsonLd = (head.jsonLd ?? []).map((entry) => ({ ...entry, types: jsonLdTypes(entry.text) }));
const resources = [...(capture?.seoResources ?? [])].sort((a, b) => a.url.localeCompare(b.url) || a.kind.localeCompare(b.kind));
const metaFields = [title, description, keywords, canonicalUrl, robots, referrer, themeColor, colorScheme].filter(Boolean).length;
return {
sourceUrl: ir.doc.sourceUrl,
title,
description,
keywords,
canonicalUrl,
robots,
referrer,
themeColor,
colorScheme,
openGraph: openGraph.map((m) => ({ property: m.key, content: m.content })),
twitter: twitter.map((m) => ({ name: m.key, content: m.content })),
icons,
manifest,
alternates,
jsonLd,
resources,
metrics: {
metaFields,
openGraphTags: openGraph.length,
twitterTags: twitter.length,
iconLinks: icons.length,
manifestAssets: manifestAssets.length,
alternates: alternates.length,
jsonLdBlocks: jsonLd.length,
llmsTxt: resources.some((r) => r.kind === "llms" && r.status === 200 && !!r.text),
llmsFullTxt: resources.some((r) => r.kind === "llms-full" && r.status === 200 && !!r.text),
},
};
}
export function seoInventoryToMarkdown(report: SeoInventory): string {
const lines: string[] = [];
lines.push("# SEO Inventory", "");
lines.push(`- source: ${report.sourceUrl}`);
lines.push(`- title: ${report.title || "(missing)"}`);
lines.push(`- description: ${report.description || "(missing)"}`);
lines.push(`- keywords: ${report.keywords || "(missing)"}`);
lines.push(`- canonical: ${report.canonicalUrl || "(missing)"}`);
lines.push(`- robots: ${report.robots || "(missing)"}`);
lines.push(`- referrer: ${report.referrer || "(missing)"}`);
lines.push(`- theme-color: ${report.themeColor || "(missing)"}`);
lines.push(`- color-scheme: ${report.colorScheme || "(missing)"}`);
lines.push("");
lines.push("## Coverage", "");
lines.push(`- Open Graph tags: ${report.metrics.openGraphTags}`);
lines.push(`- Twitter card tags: ${report.metrics.twitterTags}`);
lines.push(`- icon links: ${report.metrics.iconLinks}`);
lines.push(`- manifest assets: ${report.metrics.manifestAssets}`);
lines.push(`- alternates/hreflang: ${report.metrics.alternates}`);
lines.push(`- JSON-LD blocks: ${report.metrics.jsonLdBlocks}`);
lines.push(`- source llms.txt: ${report.metrics.llmsTxt ? "yes" : "no"}`);
lines.push(`- source llms-full.txt: ${report.metrics.llmsFullTxt ? "yes" : "no"}`);
if (report.icons.length) {
lines.push("", "## Icons", "");
for (const icon of report.icons) lines.push(`- ${icon.rel}: ${icon.localPath || icon.href}${icon.sizes ? ` (${icon.sizes})` : ""}`);
}
if (report.manifest) {
lines.push("", "## Manifest", "");
lines.push(`- link: ${report.manifest.localPath || report.manifest.href}`);
for (const asset of report.manifest.assets) lines.push(`- asset: ${asset.localPath || asset.sourceUrl}`);
}
if (report.jsonLd.length) {
lines.push("", "## Structured Data", "");
for (const block of report.jsonLd) lines.push(`- ${block.types.length ? block.types.join(", ") : "JSON-LD"}${block.id ? ` (#${block.id})` : ""}`);
}
if (report.resources.length) {
lines.push("", "## Discovered Files", "");
for (const resource of report.resources) lines.push(`- ${resource.kind}: ${resource.status ?? "unreachable"} ${resource.url}`);
}
return lines.join("\n") + "\n";
}
function firstValue(entries: Array<{ property?: string; name?: string; content: string }>, key: string): string {
const wanted = key.toLowerCase();
const found = entries.find((entry) => (entry.property ?? entry.name ?? "").toLowerCase() === wanted);
return found?.content ?? "";
}
function keywordList(keywords: string): string[] | undefined {
const parts = keywords.split(",").map((part) => part.trim()).filter(Boolean);
return parts.length ? parts : undefined;
}
function iconUrl(icon: SeoInventory["icons"][number]): string {
return icon.localPath || icon.href;
}
function metadataObject(report: SeoInventory): Record<string, unknown> {
const metadata: Record<string, unknown> = { title: report.title || "Cloned Page" };
if (report.description) metadata.description = report.description;
const keywords = keywordList(report.keywords);
if (keywords) metadata.keywords = keywords;
if (report.robots) metadata.robots = report.robots;
if (report.referrer) metadata.referrer = report.referrer;
const alternates: Record<string, unknown> = {};
if (report.canonicalUrl) alternates.canonical = report.canonicalUrl;
if (report.alternates.length) {
const languages: Record<string, string> = {};
for (const alt of report.alternates) languages[alt.hrefLang] = alt.href;
alternates.languages = languages;
}
if (Object.keys(alternates).length) metadata.alternates = alternates;
const ogEntries = report.openGraph.map((entry) => ({ property: entry.property, content: entry.content }));
const og: Record<string, unknown> = {};
const ogTitle = firstValue(ogEntries, "og:title");
const ogDescription = firstValue(ogEntries, "og:description");
const ogType = firstValue(ogEntries, "og:type");
const ogSiteName = firstValue(ogEntries, "og:site_name");
const ogUrl = firstValue(ogEntries, "og:url");
const ogImages = ogEntries.filter((entry) => entry.property?.toLowerCase() === "og:image").map((entry) => entry.content);
if (ogTitle) og.title = ogTitle;
if (ogDescription) og.description = ogDescription;
if (ogType) og.type = ogType;
if (ogSiteName) og.siteName = ogSiteName;
if (ogUrl) og.url = ogUrl;
if (ogImages.length) og.images = ogImages;
if (Object.keys(og).length) metadata.openGraph = og;
const twEntries = report.twitter.map((entry) => ({ name: entry.name, content: entry.content }));
const twitter: Record<string, unknown> = {};
const twCard = firstValue(twEntries, "twitter:card");
const twTitle = firstValue(twEntries, "twitter:title");
const twDescription = firstValue(twEntries, "twitter:description");
const twSite = firstValue(twEntries, "twitter:site");
const twCreator = firstValue(twEntries, "twitter:creator");
const twImages = twEntries.filter((entry) => entry.name?.toLowerCase() === "twitter:image").map((entry) => entry.content);
if (twCard) twitter.card = twCard;
if (twTitle) twitter.title = twTitle;
if (twDescription) twitter.description = twDescription;
if (twSite) twitter.site = twSite;
if (twCreator) twitter.creator = twCreator;
if (twImages.length) twitter.images = twImages;
if (Object.keys(twitter).length) metadata.twitter = twitter;
const icons: Record<string, unknown[]> = {};
for (const icon of report.icons) {
const rel = icon.rel.toLowerCase();
const item: Record<string, unknown> = { url: iconUrl(icon) };
if (icon.type) item.type = icon.type;
if (icon.sizes) item.sizes = icon.sizes;
if (icon.media) item.media = icon.media;
if (rel.includes("apple-touch-icon")) (icons.apple ??= []).push(item);
else if (rel.includes("shortcut")) (icons.shortcut ??= []).push(item);
else if (rel.includes("mask-icon")) (icons.other ??= []).push({ ...item, rel: "mask-icon", ...(icon.color ? { color: icon.color } : {}) });
else (icons.icon ??= []).push(item);
}
if (Object.keys(icons).length) metadata.icons = icons;
if (report.manifest) metadata.manifest = report.manifest.localPath || report.manifest.href;
return metadata;
}
export function metadataExport(report: SeoInventory): string {
return `export const metadata = ${JSON.stringify(metadataObject(report), null, 2)};\n`;
}
export function viewportExport(report: SeoInventory): string {
const viewport: Record<string, unknown> = { width: "device-width", initialScale: 1 };
if (report.themeColor) viewport.themeColor = report.themeColor;
if (report.colorScheme) viewport.colorScheme = report.colorScheme;
return `export const viewport = ${JSON.stringify(viewport, null, 2)};\n`;
}
function safeJsonLd(text: string): string {
return text.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--");
}
export function jsonLdHeadMarkup(report: SeoInventory, indent = 8): string {
if (!report.jsonLd.length) return "";
const pad = " ".repeat(indent);
return report.jsonLd.map((entry, index) => `${pad}<script
${pad} key="ditto-json-ld-${index}"
${pad} type="application/ld+json"
${pad} dangerouslySetInnerHTML={{ __html: ${JSON.stringify(safeJsonLd(entry.text))} }}
${pad}/>`).join("\n");
}
function resourceText(report: SeoInventory, kind: SeoResource["kind"]): string | null {
const resource = report.resources.find((r) => r.kind === kind && r.status === 200 && r.text);
return resource?.text ?? null;
}
function plainTextRoute(text: string): string {
return `export const dynamic = "force-static";
export function GET() {
return new Response(${JSON.stringify(text)}, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
`;
}
function originOf(url: string): string {
try { return new URL(url).origin; } catch { return "https://example.com"; }
}
function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string {
const title = report.title || routes[0]?.title || "Generated Clone";
const lines: string[] = [`# ${title}`, ""];
if (report.description) lines.push(report.description, "");
lines.push("This is a generated ditto.site clone. It preserves captured page content, metadata, route structure, and static assets where available.", "");
lines.push("## Routes", "");
for (const route of routes) {
const label = route.title || route.routePath || route.href;
const desc = route.description ? ` - ${route.description}` : "";
lines.push(`- [${label}](${route.url})${desc}`);
}
const contentRoutes = routes.filter((route) => route.excerpt);
if (contentRoutes.length) {
lines.push("", "## Captured Content", "");
for (const route of contentRoutes.slice(0, 8)) {
lines.push(`### ${route.title || route.routePath}`);
lines.push(route.excerpt);
lines.push("");
}
}
return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
}
export function seoRouteFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
const origin = originOf(report.sourceUrl);
const sitemapUrl = origin + "/sitemap.xml";
const robots = `import type { MetadataRoute } from "next";
export const dynamic = "force-static";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: ${JSON.stringify(sitemapUrl)},
};
}
`;
const sitemapEntries = (routes.length ? routes : [{
routePath: "/",
href: "/",
url: report.canonicalUrl || report.sourceUrl || origin + "/",
title: report.title || "Home",
description: report.description,
excerpt: "",
}]).map((route, index) => ({ url: route.url, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 }));
const sitemap = `import type { MetadataRoute } from "next";
export const dynamic = "force-static";
export default function sitemap(): MetadataRoute.Sitemap {
return ${JSON.stringify(sitemapEntries, null, 2)};
}
`;
const sourceLlms = resourceText(report, "llms");
const sourceLlmsFull = resourceText(report, "llms-full");
const files: Array<[string, string]> = [
["robots.ts", robots],
["sitemap.ts", sitemap],
[join("llms.txt", "route.ts"), plainTextRoute(sourceLlms ?? generatedLlms(report, routes))],
];
if (sourceLlmsFull) files.push([join("llms-full.txt", "route.ts"), plainTextRoute(sourceLlmsFull)]);
return files;
}
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
const origin = originOf(report.sourceUrl);
const sitemapUrl = origin + "/sitemap.xml";
const robots = [
"User-agent: *",
"Allow: /",
`Sitemap: ${sitemapUrl}`,
"",
].join("\n");
const sitemapRoutes = routes.length ? routes : [{
routePath: "/",
href: "/",
url: report.canonicalUrl || report.sourceUrl || origin + "/",
title: report.title || "Home",
description: report.description,
excerpt: "",
}];
const sitemap = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
...sitemapRoutes.map((route, index) => [
" <url>",
` <loc>${xmlEscape(route.url)}</loc>`,
" <changefreq>weekly</changefreq>",
` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`,
" </url>",
].join("\n")),
"</urlset>",
"",
].join("\n");
const sourceLlms = resourceText(report, "llms");
const sourceLlmsFull = resourceText(report, "llms-full");
const files: Array<[string, string]> = [
["robots.txt", robots],
["sitemap.xml", sitemap],
["llms.txt", sourceLlms ?? generatedLlms(report, routes)],
];
if (sourceLlmsFull) files.push(["llms-full.txt", sourceLlmsFull]);
return files;
}
function storedAssetPath(sourceDir: string, entry: AssetEntry): string | null {
if (!entry.storedFile) return null;
const store = join(sourceDir, "assets-store", entry.storedFile);
if (fileExists(store)) return store;
const css = join(sourceDir, "capture", "css", entry.storedFile);
return fileExists(css) ? css : null;
}
function extFor(entry: AssetEntry, href: string): string {
const fromStored = entry.storedFile ? extname(entry.storedFile).toLowerCase() : "";
if (fromStored) return fromStored;
try { return extname(new URL(href).pathname).toLowerCase(); } catch { return extname(href).toLowerCase(); }
}
function copyIcon(appDir: string, sourceDir: string, entry: AssetEntry | undefined, href: string, destName: string): boolean {
if (!entry) return false;
const src = storedAssetPath(sourceDir, entry);
if (!src) return false;
const dest = join(appDir, "src", "app", destName);
ensureDir(join(dest, ".."));
copyFileSync(src, dest);
return true;
}
export function emitSeoAssetFiles(appDir: string, sourceDir: string, assetGraph: AssetGraph, report: SeoInventory): void {
const appRoot = join(appDir, "src", "app");
if (fileExists(appRoot)) {
for (const name of readdirSync(appRoot)) {
if (/^(?:favicon\.ico|icon\d*\.(?:ico|jpg|jpeg|png|svg)|apple-icon\d*\.(?:jpg|jpeg|png))$/.test(name)) {
rmSync(join(appRoot, name), { force: true });
}
}
}
const firstIcon = report.icons.find((icon) => /\bicon\b/i.test(icon.rel) && !/apple-touch-icon|mask-icon/i.test(icon.rel));
const firstIco = report.icons.find((icon) => /\.ico(?:$|[?#])/i.test(icon.href) || lower(icon.type).includes("icon"));
if (firstIco) copyIcon(appDir, sourceDir, assetFor(assetGraph, firstIco.href), firstIco.href, "favicon.ico");
if (firstIcon) {
const entry = assetFor(assetGraph, firstIcon.href);
const ext = entry ? extFor(entry, firstIcon.href) : "";
if (/^\.(?:ico|jpg|jpeg|png|svg)$/.test(ext)) copyIcon(appDir, sourceDir, entry, firstIcon.href, `icon${ext}`);
}
const apple = report.icons.find((icon) => /apple-touch-icon/i.test(icon.rel));
if (apple) {
const entry = assetFor(assetGraph, apple.href);
const ext = entry ? extFor(entry, apple.href) : "";
if (/^\.(?:jpg|jpeg|png)$/.test(ext)) copyIcon(appDir, sourceDir, entry, apple.href, `apple-icon${ext}`);
}
}
export function emitSeoRoutes(appDir: string, report: SeoInventory, routes: SeoRouteSummary[]): void {
rmSync(join(appDir, "src", "app", "llms-full.txt"), { recursive: true, force: true });
for (const [rel, body] of seoRouteFiles(report, routes)) writeText(join(appDir, "src", "app", rel), body);
}
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
/**
* Public library boundary for the deterministic cloner.
*
* This barrel exists so the service layer (packages/core) has ONE clean import
* surface and never reaches into compiler internals. It only re-exports existing
* functions/types it adds no behavior and changes no clone semantics. Every
* entry module guards its CLI `main()` behind `import.meta.url === ...`, so
* importing them here is side-effect free.
*/
// ---- Single-page clone (capture + generate, no build) ----
export { runClone, siteIdFromUrl, latestSourceDir } from "./cli.js";
export type { CloneOptions, CloneResult } from "./cli.js";
// ---- Multi-page / whole-site clone ----
export { runCloneSite, regenerateSite } from "./site/cloneSite.js";
export type { CloneSiteOptions, CloneSiteResult } from "./site/cloneSite.js";
// ---- Validation / verify (build + serve + re-render + grade) ----
export { validateRun } from "./validate/validate.js";
export { validateSite } from "./site/validateSite.js";
export type { SiteReport, SiteRouteReport } from "./site/validateSite.js";
export type { Report, Scorecard } from "./validate/report.js";
// ---- Pure generation from a frozen capture (deterministic; Gate 6) ----
export { generateAll } from "./generate/pipeline.js";
export type { GenerateAllResult } from "./generate/pipeline.js";
// ---- Capture sanity (pollution / bot-wall detection — no build needed) ----
export { gatePollution } from "./validate/gates.js";
export type { GateResult } from "./validate/gates.js";
export { buildIR } from "./normalize/ir.js";
export type { IR } from "./normalize/ir.js";
// ---- Capture surface + version ----
export { captureSite, REQUIRED_VIEWPORTS } from "./capture/capture.js";
export type { CaptureResult } from "./capture/capture.js";
export { COMPILER_VERSION, SCHEMA_VERSION } from "./generate/manifest.js";
// ---- Filesystem helpers (canonical/deterministic JSON, etc.) ----
export { readJSON, fileExists, writeJSON, ensureDir } from "./util/fsx.js";
export { canonicalStringify } from "./util/canonical.js";
+115
View File
@@ -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 };
}
+398
View File
@@ -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 };
}
+129
View File
@@ -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 };
+669
View File
@@ -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";
}
+118
View File
@@ -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 };
}
+909
View File
@@ -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";
}
+128
View File
@@ -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;
}
+187
View File
@@ -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 (01) 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 };
}
+181
View File
@@ -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";
}
+514
View File
@@ -0,0 +1,514 @@
import { join } from "node:path";
import { readJSON, writeJSONCompact, writeJSON, fileExists } from "../util/fsx.js";
import type { PageSnapshot, RawNode, RawChild, RawStyle, RawBBox, RawSizing } from "../capture/walker.js";
import type { InteractionCapture } from "../capture/interactions.js";
/**
* Normalized Render IR. Merges the per-viewport capture snapshots into a single
* tree whose structure comes from the canonical (1280) capture; each node carries
* per-viewport computed styles, bounding boxes, and visibility. This is the single
* source of truth for section/token inference, generation, and validation.
*/
export type BBox = RawBBox;
export type StyleMap = RawStyle;
export type IRNode = {
id: string; // stable pre-order index, e.g. "n0", "n1"
tag: string;
attrs: Record<string, string>;
// Source authored `class` attribute (canonical capture), kept for diagnostics only — it is
// NEVER emitted into the clone (not in the attr whitelist). For Tailwind-built sources this is
// the author's fluid intent (`grid-cols-3`, `h-full`, `flex-1`, `line-clamp-5`), the exact
// utility our generator SHOULD infer; `scripts/tw-diff.ts` diffs it against our emitted className
// per node to rank where we baked per-vp px against a source fluid rule. Absent when the source
// element had no class.
srcClass?: string;
rawHTML?: string; // inline svg
visibleByVp: Record<number, boolean>;
bboxByVp: Record<number, BBox>;
computedByVp: Record<number, StyleMap>;
// Sizing-intent probe per viewport: does width:auto / width:100% /
// height:auto re-derive this box, and which insets are filled-in used values? Ground truth for
// whether the generator may omit the baked dimension / inset.
sizingByVp?: Record<number, RawSizing>;
beforeByVp?: Record<number, StyleMap>;
afterByVp?: Record<number, StyleMap>;
children: IRChild[];
};
export type IRTextNode = { text: string };
export type IRChild = IRNode | IRTextNode;
export type SeoHead = {
description?: string; canonical?: string; ogTitle?: string; ogDescription?: string;
ogImage?: string; ogType?: string; ogSiteName?: string; twitterCard?: string; themeColor?: string;
keywords?: string; robots?: string; referrer?: string; colorScheme?: string;
meta?: Array<{ name?: string; property?: string; httpEquiv?: string; content: string }>;
links?: Array<{
rel: string; href: string; as?: string; type?: string; sizes?: string; media?: string;
color?: string; hrefLang?: string; title?: string; crossOrigin?: string; referrerPolicy?: string;
}>;
jsonLd?: Array<{ id?: string; text: string }>;
};
export type IRDoc = {
sourceUrl: string;
title: string;
head?: SeoHead;
lang: string;
charset: string;
metaViewport: string;
viewports: number[]; // band/gate widths (the standard responsive breakpoints)
sampleViewports: number[]; // ALL captured widths — the dense set for size inference
canonicalViewport: number;
perViewport: Record<number, { scrollHeight: number; scrollWidth: number; htmlBg: string; bodyBg: string; bodyColor: string; bodyFont: string }>;
nodeCount: number;
// Stage 5 (motion): raw @keyframes blocks from the canonical capture's accessible
// stylesheets (deduped + sorted for determinism). Carried so the generator can
// re-emit the keyframes that the per-node `animation-name` declarations reference —
// without these the animation properties are inert (the half-plumbed pre-Stage-5 gap).
keyframes: string[];
};
export type IR = {
doc: IRDoc;
root: IRNode;
};
export function isTextChild(c: IRChild): c is IRTextNode {
return (c as IRTextNode).text !== undefined;
}
/**
* Recover lazy-loaded CSS backgrounds dropped by uneven per-viewport capture.
* If an element has exactly one URL background at some sampled widths and
* `none`/missing at the rest, treat the gaps as lazy-load misses rather than a
* responsive swap. Multi-URL cases are left untouched.
*/
export function backfillLazyBackgrounds(ir: IR): void {
const hasUrl = (bg: string | undefined): boolean =>
!!bg && /\burl\(/.test(bg) && !/^\s*(?:linear|radial|conic)-gradient/.test(bg);
const visit = (n: IRNode): void => {
const urls = new Set<string>();
let hasGap = false;
for (const k of Object.keys(n.computedByVp)) {
const bg = n.computedByVp[Number(k)]?.backgroundImage;
if (hasUrl(bg)) urls.add(bg!);
else if (bg === undefined || bg === "none" || bg === "") hasGap = true;
}
if (hasGap && urls.size === 1) {
const bg = [...urls][0]!;
for (const k of Object.keys(n.computedByVp)) {
const cs = n.computedByVp[Number(k)];
if (cs && (cs.backgroundImage === undefined || cs.backgroundImage === "none" || cs.backgroundImage === "")) {
cs.backgroundImage = bg;
}
}
}
for (const c of n.children) if (!isTextChild(c)) visit(c);
};
visit(ir.root);
}
const ATTR_WHITELIST = new Set([
"id", "href", "src", "srcset", "sizes", "alt", "title", "role", "type", "name",
"value", "placeholder", "target", "rel", "for", "datetime", "colspan", "rowspan",
"span", "start", "reversed", "controls", "autoplay", "loop", "muted", "playsinline",
"poster", "preload", "width", "height", "open", "lang", "dir", "download",
"hreflang", "media", "content", "property", "itemprop", "cite", "label", "selected",
"checked", "disabled", "readonly", "multiple", "step", "min", "max", "pattern",
"viewBox", "xmlns", "fill", "stroke", "d", "points", "cx", "cy", "r", "x", "y",
]);
// An empty value is meaningful for these (decorative `alt=""`, a cleared input `value=""`, and the
// boolean attrs whose mere presence is the state); for everything else `id=""`/`target=""`/`title=""`
// is just captured noise a human would never type — drop it.
const KEEP_IF_EMPTY = new Set([
"alt", "value", "disabled", "checked", "selected", "readonly", "multiple", "open",
"controls", "autoplay", "loop", "muted", "playsinline", "reversed", "download",
]);
function filterAttrs(attrs: Record<string, string>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(attrs)) {
// data-cid-cap is the Stage-4 capture-id (present only when interactions are
// enabled); carried through so generation can map interaction deltas → cid,
// then stripped from the emitted markup.
if (!(ATTR_WHITELIST.has(k) || k.startsWith("aria-") || k === "data-cid-cap")) continue;
if (v === "" && !KEEP_IF_EMPTY.has(k) && !k.startsWith("aria-")) continue;
out[k] = v;
}
return out;
}
const LAZY_SRC_ATTRS = ["data-lazy-src", "data-src", "data-original", "data-ll-src"];
const LAZY_SRCSET_ATTRS = ["data-lazy-srcset", "data-srcset"];
function resolveLazyAttrs(attrs: Record<string, string> | undefined): Record<string, string> {
if (!attrs) return {};
const realSrc = LAZY_SRC_ATTRS.map((k) => attrs[k]).find((v) => v && !v.startsWith("data:"));
const realSrcset = LAZY_SRCSET_ATTRS.map((k) => attrs[k]).find((v) => v && !v.startsWith("data:"));
if (!realSrc && !realSrcset) return attrs;
const placeholder = !attrs.src || attrs.src.startsWith("data:");
if (!placeholder && !realSrcset) return attrs;
const out = { ...attrs };
if (realSrc && placeholder) out.src = realSrc;
if (realSrcset && (placeholder || !attrs.srcset || attrs.srcset.startsWith("data:"))) out.srcset = realSrcset;
return out;
}
// `<iframe>` is intentionally NOT noise: it is kept as a sized placeholder box (its
// external document is dropped at generation — see propsList — so the clone stays
// self-contained while preserving the layout the embed occupied). Truly invisible
// iframes (tracking/chat, 0-size or display:none) are removed by the visibility prune.
const NOISE_TAGS = new Set(["next-route-announcer"]);
// Third-party overlay widgets (chat launchers, cookie/consent bars, captcha badges) are
// JS-injected chrome, not site content. They live in the INT_MAX z-index band and/or carry a
// vendor class/id prefix, and the capture freezes their hidden state PER VIEWPORT — which then
// bands into a stray visible artifact in the clone (e.g. Intercom's messenger frame is
// `opacity:0; transform:matrix(0,…)` at most widths but `opacity:initial; transform:none` at the
// width where capture happened to read it open → an empty 400×700 white rectangle at 2xl). Drop
// the whole subtree so it never reaches generation.
const THIRD_PARTY_OVERLAY = /(?:^|[\s_-])(?:intercom|drift-|hubspot-messages|zendesk|zd-|onetrust|ot-sdk-|usercentrics|grecaptcha|crisp-client|tawk-|livechat|helpscout|beacon-container|cookiebot|cky-)/;
function isThirdPartyOverlay(raw: RawNode): boolean {
const idClass = `${raw.attrs?.id ?? ""} ${raw.attrs?.class ?? ""}`.toLowerCase();
if (THIRD_PARTY_OVERLAY.test(idClass)) return true;
// INT_MAX band: overlay libraries stack above everything. Real content should not reach here,
// so this cannot swallow site chrome under normal captures.
const zi = parseInt(raw.computed?.zIndex ?? "", 10);
return Number.isFinite(zi) && zi >= 2147483000;
}
function elementChildren(n: RawNode): RawNode[] {
return n.children.filter((c) => (c as { text?: string }).text === undefined) as RawNode[];
}
/** Full identity signature (tag + id + class). */
function sigFull(n: RawNode): string {
return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`;
}
/** Loose signature (tag + id, no class) for nodes whose class changes responsively. */
function sigTag(n: RawNode): string {
return `${n.tag}#${n.attrs?.id ?? ""}`;
}
/** Order-preserving LCS over two key sequences; returns matched (i, j) index
* pairs in increasing order. Deterministic; falls back to positional matching
* for pathologically large sibling groups. */
function lcsPairs(a: string[], b: string[]): Array<[number, number]> {
const n = a.length, m = b.length;
const pairs: Array<[number, number]> = [];
if (n === 0 || m === 0) return pairs;
if (n * m > 1_000_000) {
for (let i = 0; i < Math.min(n, m); i++) if (a[i] === b[i]) pairs.push([i, i]);
return pairs;
}
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--)
for (let j = m - 1; j >= 0; j--)
dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!);
let i = 0, j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) { pairs.push([i, j]); i++; j++; }
else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) i++;
else j++;
}
return pairs;
}
/**
* Order-preserving alignment of a parent's element children across two captures.
* Returns, for each canonical child, the matching child in the other viewport (or
* undefined). Two passes:
* 1. LCS on the full signature (tag+id+class) anchors stable identities and
* keeps responsive insertions/removals from shifting the rest (e.g. a
* mobile-only wrapper between <header> and <main>).
* 2. Within each gap between pass-1 anchors, LCS on tag+id only so elements
* whose class is swapped per breakpoint (JS responsive typography, e.g.
* `type-md-lg` `type-xl`) still align, while a genuinely conditionally
* rendered element with no counterpart in its gap stays unmatched ( the
* generator hides it at that viewport). Deterministic.
*/
function alignChildren(canon: RawNode[], other: RawNode[]): (RawNode | undefined)[] {
const n = canon.length;
const out: (RawNode | undefined)[] = new Array(n).fill(undefined);
if (n === 0 || other.length === 0) return out;
const matchJ = new Array<number>(n).fill(-1);
for (const [i, j] of lcsPairs(canon.map(sigFull), other.map(sigFull))) {
matchJ[i] = j; out[i] = other[j];
}
// Pass 2: align leftover nodes inside each gap bounded by pass-1 anchors.
const aI = [-1], aJ = [-1];
for (let i = 0; i < n; i++) { const mj = matchJ[i]!; if (mj >= 0) { aI.push(i); aJ.push(mj); } }
aI.push(n); aJ.push(other.length);
for (let a = 0; a + 1 < aI.length; a++) {
const cg: number[] = []; for (let i = aI[a]! + 1; i < aI[a + 1]!; i++) cg.push(i);
const og: number[] = []; for (let j = aJ[a]! + 1; j < aJ[a + 1]!; j++) og.push(j);
if (cg.length === 0 || og.length === 0) continue;
for (const [gi, gj] of lcsPairs(cg.map((i) => sigTag(canon[i]!)), og.map((j) => sigTag(other[j]!)))) {
out[cg[gi]!] = other[og[gj]!];
}
}
return out;
}
/** Capture-ids of recognized-pattern panels/regions to force-keep through pruning
* (they are display:none in the inactive/collapsed base state). Empty when no
* interactions were captured, so non-interactive runs prune exactly as before. */
function readPreserveCaps(sourceDir: string): Set<string> {
const set = new Set<string>();
const p = join(sourceDir, "interaction.json");
if (!fileExists(p)) return set;
try {
const it = readJSON<InteractionCapture>(p);
for (const pat of it.patterns ?? []) {
if (pat.kind === "tabs") for (const t of pat.tabs) set.add(t.panelCap);
else if (pat.kind === "accordion") for (const i of pat.items) set.add(i.regionCap);
else if (pat.kind === "carousel") set.add(pat.trackCap); // keep the whole slide track
else for (const i of pat.items) set.add(i.panelCap); // disclosure: keep hidden overlay panels
}
} catch { /* malformed — preserve nothing */ }
return set;
}
export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?: boolean; bandViewports?: number[] }): IR {
const captureDir = join(sourceDir, "capture");
// `viewports` are ALL captured widths — the dense sample set used for size inference. The
// BAND/gate widths (the responsive breakpoints we emit + grade) are the standard subset.
const bandVps = (opts?.bandViewports ?? viewports).filter((v) => viewports.includes(v));
const snapshots: Record<number, PageSnapshot> = {};
for (const vw of viewports) {
snapshots[vw] = readJSON<PageSnapshot>(join(captureDir, `dom-${vw}.json`));
}
const canonical = viewports.includes(1280) ? 1280 : viewports[Math.floor(viewports.length / 2)]!;
const canonSnap = snapshots[canonical]!;
// Stage 4: recognized interactive panels (tabs/accordion) are often display:none
// at base (the inactive/collapsed state). Their subtrees must survive pruning so
// the controller can reveal them — collect their capture-ids to force-keep. Read
// from the same source dir, so generation and validation stay consistent.
const preserveCaps = readPreserveCaps(sourceDir);
let counter = 0;
const nextId = (): string => `n${counter++}`;
// `matched` carries, for each viewport, the raw node corresponding to `raw`
// (the canonical node) — found by aligning siblings, not by raw index.
const convert = (raw: RawNode, matched: Record<number, RawNode | undefined>): IRNode | null => {
if (NOISE_TAGS.has(raw.tag)) return null;
if (isThirdPartyOverlay(raw)) return null;
const visibleByVp: Record<number, boolean> = {};
const bboxByVp: Record<number, BBox> = {};
const computedByVp: Record<number, StyleMap> = {};
const sizingByVp: Record<number, RawSizing> = {};
const beforeByVp: Record<number, StyleMap> = {};
const afterByVp: Record<number, StyleMap> = {};
for (const vw of viewports) {
const match = matched[vw];
if (!match || match.tag !== raw.tag) continue;
visibleByVp[vw] = match.visible;
bboxByVp[vw] = match.bbox;
computedByVp[vw] = match.computed;
if (match.sizing) sizingByVp[vw] = match.sizing;
if (match.before) beforeByVp[vw] = match.before;
if (match.after) afterByVp[vw] = match.after;
}
const node: IRNode = {
id: nextId(),
tag: raw.tag,
attrs: filterAttrs(resolveLazyAttrs(raw.attrs)),
visibleByVp,
bboxByVp,
computedByVp,
children: [],
};
if (raw.rawHTML) node.rawHTML = raw.rawHTML;
const srcClass = (raw.attrs?.class ?? "").trim();
if (srcClass) node.srcClass = srcClass;
if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp;
if (Object.keys(beforeByVp).length) node.beforeByVp = beforeByVp;
if (Object.keys(afterByVp).length) node.afterByVp = afterByVp;
if (!raw.rawHTML) {
const canonKids = elementChildren(raw);
// Align this node's canonical element children to each viewport's children.
const aligned: Record<number, (RawNode | undefined)[]> = {};
for (const vw of viewports) {
if (vw === canonical) continue;
const m = matched[vw];
const vwKids = m && m.tag === raw.tag ? elementChildren(m) : [];
aligned[vw] = alignChildren(canonKids, vwKids);
}
let ei = 0;
for (const c of raw.children) {
if ((c as IRTextNode).text !== undefined) {
node.children.push({ text: (c as IRTextNode).text });
continue;
}
const child = c as RawNode;
const childMatched: Record<number, RawNode | undefined> = {};
for (const vw of viewports) childMatched[vw] = vw === canonical ? child : aligned[vw]![ei];
ei++;
const converted = convert(child, childMatched);
if (converted) node.children.push(converted);
}
}
return node;
};
const rootMatched: Record<number, RawNode | undefined> = {};
for (const vw of viewports) rootMatched[vw] = snapshots[vw]!.root;
const root = convert(canonSnap.root, rootMatched)!;
// Prune nodes invisible in every viewport with no visible descendants and no
// text — these are unobserved (display:none everywhere) and should not be
// invented in the clone.
const prune = (node: IRNode, forced: boolean): boolean => {
// returns true if node should be kept. `forced` propagates down from a preserved
// interactive panel so its whole (possibly display:none) subtree is retained.
const keepAll = forced || preserveCaps.has(node.attrs["data-cid-cap"] ?? "");
const keptChildren: IRChild[] = [];
let hasVisibleDescendant = false;
let hasText = false;
for (const c of node.children) {
if (isTextChild(c)) {
if (c.text.trim().length > 0) hasText = true;
keptChildren.push(c);
continue;
}
if (keepAll) {
prune(c, true);
keptChildren.push(c);
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
} else if (prune(c, false)) {
keptChildren.push(c);
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
}
}
node.children = keptChildren;
if (keepAll) return true;
const selfVisible = Object.values(node.visibleByVp).some(Boolean);
return selfVisible || hasVisibleDescendant || hasText || !!node.rawHTML;
};
const childHasVisible = (n: IRNode): boolean => {
for (const c of n.children) {
if (isTextChild(c)) continue;
if (Object.values(c.visibleByVp).some(Boolean)) return true;
if (childHasVisible(c)) return true;
}
return false;
};
prune(root, false);
// Re-number ids after pruning so they are a dense pre-order sequence (stable
// and deterministic for matching during validation).
counter = 0;
const renumber = (node: IRNode): void => {
node.id = nextId();
for (const c of node.children) if (!isTextChild(c)) renumber(c);
};
renumber(root);
const perViewport: IRDoc["perViewport"] = {};
for (const vw of viewports) {
const d = snapshots[vw]!.doc;
perViewport[vw] = {
scrollHeight: d.scrollHeight,
scrollWidth: d.scrollWidth,
htmlBg: d.htmlBg,
bodyBg: d.bodyBg,
bodyColor: d.bodyColor,
bodyFont: d.bodyFont,
};
}
const doc: IRDoc = {
sourceUrl: canonSnap.doc.url,
title: canonSnap.doc.title,
head: (canonSnap.doc as { head?: IRDoc["head"] }).head,
lang: canonSnap.doc.lang,
charset: canonSnap.doc.charset,
metaViewport: canonSnap.doc.metaViewport,
viewports: bandVps,
sampleViewports: viewports,
canonicalViewport: canonical,
perViewport,
nodeCount: counter,
// Stage 5: only carry @keyframes when motion is enabled. Off (the plain static
// benchmark + all multi-page, which don't capture motion) ⇒ none emitted, so the
// clone stays byte-identical to the pre-Stage-5 frozen output.
keyframes: opts?.motion ? [...new Set(canonSnap.keyframes ?? [])].sort() : [],
};
// Repair transient root scroll-locks. A site that locks scrolling during an intro
// (body/html { height:100vh; overflow-y:scroll|hidden }) can be captured in that
// state: the root's bbox is pinned to one viewport even though the real document
// scrolled taller (perViewport.scrollHeight). Left as-is, the root box — and any
// whole-page section derived from it — under-reports its height, so a clone that
// correctly renders the settled (un-clamped) document fails the section-bbox gate
// against a stale 1-viewport height. When the captured scrollHeight exceeds the
// pinned root height AND the overflow clips, correct the root's bbox to the real
// document extent (a genuine internal-scroll shell instead reports scrollHeight ==
// its own height, so it is left untouched). Mirrors the CSS un-clamp.
const fixRootScrollLock = (node: IRNode): void => {
if (node.tag === "body" || node.tag === "html") {
for (const vw of viewports) {
const bb = node.bboxByVp[vw]; const cs = node.computedByVp[vw];
const sh = perViewport[vw]?.scrollHeight ?? 0;
if (!bb || !cs) continue;
const ov = cs.overflowY || cs.overflow || "visible";
if (/^(scroll|hidden|auto|clip)$/.test(ov) && sh > bb.height + 4) bb.height = sh;
}
}
for (const c of node.children) if (!isTextChild(c)) fixRootScrollLock(c);
};
fixRootScrollLock(root);
// Sizing-intent overlay: when the SOURCE capture predates the probe — e.g. this
// sandbox cannot re-fetch the live site through the egress proxy — fall back to the LOCAL clone
// render's probe (the gate-verified faithful proxy), matched back to IR nodes by cid. In normal
// operation the source capture carries `sizing` natively and this overlay is skipped.
if (!(canonSnap.root as RawNode).sizing) attachSizingOverlay(root, join(sourceDir, "..", "rendered", "dom"), viewports);
return { doc, root };
}
function attachSizingOverlay(root: IRNode, renderedDomDir: string, viewports: number[]): void {
const byVp: Record<number, Map<string, { wAuto: boolean; wFill: boolean; hAuto: boolean }>> = {};
for (const vw of viewports) {
const p = join(renderedDomDir, `dom-${vw}.json`);
if (!fileExists(p)) continue;
const snap = readJSON<PageSnapshot>(p);
const m = new Map<string, { wAuto: boolean; wFill: boolean; hAuto: boolean }>();
const walk = (n: RawNode | RawChild | undefined): void => {
if (!n || typeof n !== "object" || "text" in n) return;
const cid = n.attrs?.["data-cid"]; if (cid && n.sizing) m.set(cid, n.sizing);
for (const c of n.children || []) walk(c);
};
walk(snap.root);
byVp[vw] = m;
}
const apply = (node: IRNode): void => {
for (const vw of viewports) {
const s = byVp[vw]?.get(node.id);
if (s) { (node.sizingByVp ??= {})[vw] = s; }
}
for (const c of node.children) if (!("text" in c)) apply(c);
};
apply(root);
}
export function writeIR(ir: IR, sourceDir: string): void {
writeJSONCompact(join(sourceDir, "normalized-dom", "ir.json"), ir);
// A small summary for quick human inspection.
writeJSON(join(sourceDir, "normalized-dom", "ir-summary.json"), {
doc: ir.doc,
rootTag: ir.root.tag,
nodeCount: ir.doc.nodeCount,
});
}
+70
View File
@@ -0,0 +1,70 @@
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, readdirSync } from "node:fs";
import { readJSON } from "../util/fsx.js";
import type { Report } from "../validate/report.js";
/**
* Aggregates the latest per-site validation reports under a runs dir into a
* prioritized view: which gates fail across sites, which computed-style
* properties dominate failures, and a per-site one-liner. Used to decide what to
* fix next without paging through individual reports.
*/
const HERE = dirname(fileURLToPath(import.meta.url));
function latestReport(siteDir: string): Report | null {
const runs = readdirSync(siteDir).filter((d) => /^\d/.test(d)).sort();
for (let i = runs.length - 1; i >= 0; i--) {
const rp = join(siteDir, runs[i]!, "validation", "report.json");
if (existsSync(rp)) return readJSON<Report>(rp);
}
return null;
}
function main(): void {
const runsArg = process.argv.find((a) => a.startsWith("--runs="))?.split("=")[1];
const runsDir = runsArg ? resolve(runsArg) : resolve(HERE, "..", "..", "..", "runs");
if (!existsSync(runsDir)) { console.error("no runs dir:", runsDir); process.exit(1); }
const siteDirs = readdirSync(runsDir).filter((d) => existsSync(join(runsDir, d)) && !d.startsWith("benchmark") && !d.endsWith(".json"));
const reports: Array<{ site: string; r: Report }> = [];
for (const d of siteDirs) {
const r = latestReport(join(runsDir, d));
if (r) reports.push({ site: d, r });
}
reports.sort((a, b) => a.site.localeCompare(b.site));
const gateFailCounts: Record<string, number> = {};
const stylePropFails: Record<string, number> = {};
const layoutReasons: Record<string, number> = {};
let pass = 0;
console.log(`\n=== ${reports.length} sites ===\n`);
console.log("site".padEnd(26), "score", "g0-6", "failing");
for (const { site, r } of reports) {
if (r.gates0to6Pass) pass++;
const failing = Object.entries(r.gates).filter(([, g]) => !g.pass).map(([k]) => k);
for (const g of failing) gateFailCounts[g] = (gateFailCounts[g] ?? 0) + 1;
const sp = (r.gates.style?.metrics?.topFailingProps ?? {}) as Record<string, number>;
if (!r.gates.style?.pass) for (const [p, c] of Object.entries(sp)) stylePropFails[p] = (stylePropFails[p] ?? 0) + c;
if (r.gates.layout && !r.gates.layout.pass) for (const iss of r.gates.layout.issues) {
const key = iss.replace(/vp\d+ /, "").replace(/[\d.]+/g, "N");
layoutReasons[key] = (layoutReasons[key] ?? 0) + 1;
}
console.log(site.padEnd(26), String(r.scorecard.total).padStart(5), r.gates0to6Pass ? " Y " : " N ", failing.join(",") || "PASS");
}
console.log(`\n=== Gates 0-6 passing: ${pass}/${reports.length} ===`);
console.log("gate fail counts:", JSON.stringify(sortObj(gateFailCounts)));
console.log("style prop fails (sum):", JSON.stringify(sortObj(stylePropFails)));
console.log("layout reasons:", JSON.stringify(sortObj(layoutReasons)));
const avg = reports.reduce((s, x) => s + x.r.scorecard.total, 0) / (reports.length || 1);
console.log("avg score:", Math.round(avg * 10) / 10);
}
function sortObj(o: Record<string, number>): Record<string, number> {
return Object.fromEntries(Object.entries(o).sort((a, b) => b[1] - a[1]));
}
main();
+36
View File
@@ -0,0 +1,36 @@
import { readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
/**
* Sensibility audit for extracted components: for a generated runDir, print each
* promoted component with its instance count and a sample of the first instance's
* data, FLAGGING ones that look like noise (every text field 2 chars likely a
* per-letter text-effect split, not a reusable unit). A dev aid for "do the extracted
* components make sense?" not a gate. Usage: `auditComponents <runDir>`.
*/
function main(): void {
const runDir = process.argv[2];
if (!runDir) { console.error("usage: auditComponents <runDir>"); process.exit(1); }
const page = join(runDir, "generated", "app", "src", "app", "page.tsx");
if (!existsSync(page)) { console.log("no page.tsx"); return; }
const src = readFileSync(page, "utf8");
const re = /const (\w+)_data = \[([\s\S]*?)\n\];/g;
let m: RegExpExecArray | null;
let flagged = 0;
const rows: string[] = [];
while ((m = re.exec(src)) !== null) {
const name = m[1]!;
const body = m[2]!;
const instances = (body.match(/\n {4}\{/g) ?? []).length;
const first = body.split("\n").find((l) => l.includes("{ "))?.trim() ?? "";
// text fields = string-literal values that aren't cids (k*) or urls
const texts = [...first.matchAll(/\b(f\d+): "([^"]*)"/g)].map((x) => x[2]!).filter((t) => !/^https?:|^\//.test(t));
const allTiny = texts.length > 0 && texts.every((t) => t.trim().length <= 2);
if (allTiny) flagged++;
rows.push(` ${allTiny ? "⚠️ " : " "}${name}×${instances} ${first.slice(0, 96)}`);
}
console.log(`${runDir.split("/").slice(-2)[0]}: ${rows.length} components${flagged ? `, ${flagged} FLAGGED` : ""}`);
for (const r of rows) console.log(r);
}
main();
+58
View File
@@ -0,0 +1,58 @@
// TEMP diagnostic (untracked): audit the width sizing-probe verdict.
// How often does sizingVerdict() return null despite wAuto holding at SOME (not all) painted vps?
// That's the "drop at the painted vps, band the dissenter" opportunity. Also reports whether the
// node currently emits a baked px width (an arbitrary w-[..] in the output would be the symptom).
// Usage: npx tsx src/runner/auditProbe.ts <site>
import { readFileSync } from "node:fs";
import { join } from "node:path";
const site = process.argv[2] || "sample";
const ir = JSON.parse(readFileSync(join("output", site, ".clone", "source", "normalized-dom", "ir.json"), "utf8"));
const VPS: number[] = ir.doc?.sampleViewports || ir.doc?.viewports || [375, 768, 1280, 1920];
const REPLACED = new Set(["img", "svg", "video", "canvas", "iframe", "input", "picture", "image", "object", "embed"]);
let probed = 0; // nodes with any sizing data
let unanimousAuto = 0; // sizingVerdict -> "auto" today
let unanimousFill = 0; // -> "fill" today
let mixedAutoNull = 0; // wAuto at >=1 but <n vps, not all-fill -> null today (the opportunity)
let neither = 0;
const examples: string[] = [];
function walk(node: any) {
if (node?.tag && node.computedByVp && node.sizingByVp) {
if (!REPLACED.has(node.tag) && !node.tag.includes("-")) {
let n = 0, autoC = 0, fillC = 0;
const pat: string[] = [];
for (const vp of VPS) {
const s = node.sizingByVp[vp];
if (!s) { pat.push(`${vp}:-`); continue; }
n++; if (s.wAuto) autoC++; if (s.wFill) fillC++;
pat.push(`${vp}:${s.wAuto ? "A" : ""}${s.wFill ? "F" : ""}${!s.wAuto && !s.wFill ? "x" : ""}`);
}
if (n > 0) {
probed++;
const allAuto = autoC === n, allFill = fillC === n;
if (allAuto) unanimousAuto++;
else if (allFill) unanimousFill++;
else if (autoC >= 1) {
mixedAutoNull++;
// does it carry a per-vp width that VARIES (would otherwise bake a band)?
const widths = VPS.map((vp) => node.computedByVp[vp]?.width).filter(Boolean);
const wset = new Set(widths.map((w: string) => Math.round(parseFloat(w))));
if (examples.length < 16) examples.push(`#${node.id} <${node.tag}> autoC=${autoC}/${n} [${pat.join(" ")}] width@vps={${[...wset].join(",")}}`);
} else neither++;
}
}
}
if (node?.children) for (const c of node.children) if (c?.tag) walk(c);
}
walk(ir.root || ir.doc?.root || ir);
console.log(`SITE=${site} VPS=${VPS}`);
console.log(`probed nodes (have sizing data): ${probed}`);
console.log(` unanimous wAuto -> "auto" today: ${unanimousAuto}`);
console.log(` unanimous wFill -> "fill" today: ${unanimousFill}`);
console.log(` MIXED wAuto (>=1 but <n), not all-fill -> NULL today (opportunity): ${mixedAutoNull}`);
console.log(` never wAuto/Fill: ${neither}`);
console.log(`--- mixed examples (A=wAuto F=wFill x=neither -=no-data) ---`);
for (const e of examples) console.log(e);
+177
View File
@@ -0,0 +1,177 @@
import { join, resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync, readdirSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { runClone, siteIdFromUrl } from "../cli.js";
import { validateRun } from "../validate/validate.js";
import { readJSON, writeJSON, writeText } from "../util/fsx.js";
import { COMPILER_VERSION } from "../generate/manifest.js";
import type { Report } from "../validate/report.js";
const HERE = dirname(fileURLToPath(import.meta.url));
const HARNESS = resolve(HERE, "..", "..", ".harness");
type BenchSite = { id: string; url: string; tier: string; notes?: string };
type SiteOutcome = {
id: string;
url: string;
tier: string;
status: "pass" | "partial" | "fail" | "error";
score: number;
gates0to6Pass: boolean;
stage2Pass: boolean;
motionPass: boolean;
motion?: string; // short motion-gate metric summary
failingGates: string[];
runDir: string | null;
error?: string;
};
function ensureHarness(): void {
if (!existsSync(join(HARNESS, "node_modules", ".bin", "next"))) {
console.log(JSON.stringify({ event: "harness_install" }));
const r = spawnSync("npm", ["install"], { cwd: HARNESS, encoding: "utf8", stdio: "inherit" });
if (r.status !== 0) throw new Error("harness npm install failed");
}
}
function gitSha(): string {
try {
const r = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: resolve(HERE, "..", "..", ".."), encoding: "utf8" });
return r.status === 0 ? r.stdout.trim() : "nogit";
} catch { return "nogit"; }
}
function latestSourceDir(runsDir: string, url: string): string | null {
const siteDir = join(runsDir, siteIdFromUrl(url));
if (!existsSync(siteDir)) return null;
const runs = readdirSync(siteDir).filter((d) => /^\d/.test(d)).sort();
for (let i = runs.length - 1; i >= 0; i--) {
const src = join(siteDir, runs[i]!, "source");
if (existsSync(join(src, "capture", "capture-result.json"))) return src;
}
return null;
}
export async function runBenchmark(opts: {
sites: BenchSite[];
runsDir: string;
reuseCaptures?: boolean;
interactions?: boolean;
components?: boolean;
motion?: boolean;
log?: (e: Record<string, unknown>) => void;
}): Promise<void> {
const log = opts.log ?? ((e) => console.log(JSON.stringify(e)));
ensureHarness();
const outcomes: SiteOutcome[] = [];
const withTimeout = <T>(p: Promise<T>, ms: number, label: string): Promise<T> =>
Promise.race([p, new Promise<T>((_, rej) => setTimeout(() => rej(new Error(`timeout: ${label} (${ms}ms)`)), ms))]);
for (let i = 0; i < opts.sites.length; i++) {
const site = opts.sites[i]!;
const t0 = Date.now();
log({ event: "site_start", i: i + 1, total: opts.sites.length, id: site.id, url: site.url });
try {
const reuseSource = opts.reuseCaptures ? latestSourceDir(opts.runsDir, site.url) ?? undefined : undefined;
// Per-site wall-clock backstop: even with every in-capture wait bounded, no
// single site should be able to stall the whole tier.
const res = await withTimeout(runClone({ url: site.url, runsDir: opts.runsDir, reuseSource, interactions: opts.interactions, components: opts.components, motion: opts.motion }), 8 * 60_000, `${site.id} clone`);
const report: Report = await withTimeout(validateRun(res.runDir, { tier: site.tier }), 6 * 60_000, `${site.id} validate`);
const failingGates = Object.entries(report.gates).filter(([, g]) => !g.pass).map(([k]) => k);
const mg = report.gates.motion;
const motionStr = mg && !mg.metrics.na ? `css ${mg.metrics.css ?? "-"} waapi ${mg.metrics.waapi ?? "-"} rot ${mg.metrics.rotators ?? "-"} rev ${mg.metrics.reveals ?? "-"}` : undefined;
outcomes.push({
id: site.id, url: site.url, tier: site.tier,
status: report.status, score: report.scorecard.total,
gates0to6Pass: report.gates0to6Pass, stage2Pass: report.stage2Pass,
motionPass: mg ? mg.pass : true, motion: motionStr, failingGates, runDir: res.runDir,
});
log({ event: "site_done", id: site.id, score: report.scorecard.total, status: report.status, gates0to6: report.gates0to6Pass, stage2: report.stage2Pass, motion: mg ? { pass: mg.pass, m: motionStr } : undefined, failing: failingGates, sec: Math.round((Date.now() - t0) / 1000) });
} catch (e) {
outcomes.push({
id: site.id, url: site.url, tier: site.tier, status: "error",
score: 0, gates0to6Pass: false, stage2Pass: false, motionPass: false, failingGates: ["error"], runDir: null, error: String(e).slice(0, 500),
});
log({ event: "site_error", id: site.id, error: String(e).slice(0, 300), sec: Math.round((Date.now() - t0) / 1000) });
}
}
// Aggregate
const byTier: Record<string, { passed: number; stage2: number; total: number; sumScore: number }> = {};
const failuresByGate: Record<string, number> = {};
for (const o of outcomes) {
const t = byTier[o.tier] ?? (byTier[o.tier] = { passed: 0, stage2: 0, total: 0, sumScore: 0 });
t.total++; t.sumScore += o.score;
if (o.gates0to6Pass) t.passed++;
if (o.stage2Pass) t.stage2++;
for (const g of o.failingGates) failuresByGate[g] = (failuresByGate[g] ?? 0) + 1;
}
const tierSummary: Record<string, unknown> = {};
for (const [tier, v] of Object.entries(byTier)) {
tierSummary[tier] = { passed: v.passed, stage2Passed: v.stage2, total: v.total, averageScore: Math.round((v.sumScore / v.total) * 10) / 10 };
}
const summary = {
runId: new Date().toISOString().replace(/[:.]/g, "-"),
compilerVersion: COMPILER_VERSION,
gitSha: gitSha(),
sitesTotal: outcomes.length,
sitesGates0to6Passed: outcomes.filter((o) => o.gates0to6Pass).length,
sitesStage2Passed: outcomes.filter((o) => o.stage2Pass).length,
tiers: tierSummary,
failuresByGate,
outcomes: outcomes.sort((a, b) => a.id.localeCompare(b.id)),
};
writeJSON(join(opts.runsDir, "benchmark-summary.json"), summary);
writeText(join(opts.runsDir, "benchmark-summary.md"), summaryMd(summary, outcomes));
log({ event: "benchmark_done", passed: summary.sitesGates0to6Passed, stage2: summary.sitesStage2Passed, total: summary.sitesTotal, tiers: tierSummary, failuresByGate });
}
function summaryMd(summary: Record<string, unknown>, outcomes: SiteOutcome[]): string {
const lines: string[] = [
`# Benchmark summary`,
``,
`- Compiler: ${summary.compilerVersion} (${summary.gitSha})`,
`- Gates 06 passed: **${summary.sitesGates0to6Passed} / ${summary.sitesTotal}**`,
`- Stage-2 passed (G06 + pollution + perceptual): **${summary.sitesStage2Passed} / ${summary.sitesTotal}**`,
`- Tiers: ${JSON.stringify(summary.tiers)}`,
`- Failures by gate: ${JSON.stringify(summary.failuresByGate)}`,
``,
`| ID | Status | Score | G0-6 | Stage2 | Motion | Failing gates | URL |`,
`| --- | --- | --- | --- | --- | --- | --- | --- |`,
];
for (const o of outcomes) {
lines.push(`| ${o.id} | ${o.status} | ${o.score} | ${o.gates0to6Pass ? "✅" : "❌"} | ${o.stage2Pass ? "✅" : "❌"} | ${o.motionPass ? "✅" : "❌"}${o.motion ? " " + o.motion : ""} | ${o.failingGates.join(", ") || "—"} | ${o.url} |`);
}
return lines.join("\n") + "\n";
}
// ---- CLI ----
async function main(): Promise<void> {
const args = process.argv.slice(2);
const tier = args.find((a) => a.startsWith("--tier="))?.split("=")[1] ?? "easy";
const only = args.find((a) => a.startsWith("--only="))?.split("=")[1]?.split(",");
const limit = args.find((a) => a.startsWith("--limit="))?.split("=")[1];
const runsArg = args.find((a) => a.startsWith("--runs="))?.split("=")[1];
const reuseCaptures = args.includes("--reuse");
// Stage 4/4.5/5 features are opt-in for the benchmark runner (the static benchmark
// measures plain fidelity); enable per-run with flags. The motion tier turns motion on.
const interactions = args.includes("--interactions");
const components = args.includes("--components");
const motion = args.includes("--motion") || tier === "motion";
const runsDir = runsArg ? resolve(runsArg) : resolve(HERE, "..", "..", "..", "runs");
const benchFile = resolve(HERE, "..", "..", "benchmarks", `${tier}.json`);
let sites: BenchSite[] = readJSON<BenchSite[]>(benchFile);
if (only) sites = sites.filter((s) => only.includes(s.id) || only.includes(s.url));
if (limit) sites = sites.slice(0, parseInt(limit, 10));
await runBenchmark({ sites, runsDir, reuseCaptures, interactions, components, motion });
}
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((e) => { console.error(e); process.exit(1); });
}
+17
View File
@@ -0,0 +1,17 @@
/** Build one deliverable into the shared harness and copy its static export to /tmp/<site>-site. */
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { cpSync, rmSync } from "node:fs";
import { buildApp } from "../validate/render.js";
const HARNESS = resolve(fileURLToPath(new URL("../../.harness", import.meta.url)));
const site = process.argv[2];
if (!site) { console.error("usage: build-one <site>"); process.exit(1); }
const appDir = resolve(fileURLToPath(new URL(`../../output/${site}/app`, import.meta.url)));
const t0 = Date.now();
const b = buildApp(appDir, HARNESS);
if (!b.ok || !b.outDir) { console.error("BUILD FAILED\n" + b.stderr.slice(-2000)); process.exit(1); }
const dest = `/tmp/${site}-site`;
rmSync(dest, { recursive: true, force: true });
cpSync(b.outDir, dest, { recursive: true });
console.log(`built ${site} in ${Date.now() - t0}ms → ${dest}`);
+23
View File
@@ -0,0 +1,23 @@
/** Screenshot live vs clone at given viewports (top region) for side-by-side eyeballing. Temp. */
import { chromium } from "playwright";
import { resolve } from "node:path";
const [live, clone, name] = process.argv.slice(2);
const SHOTS = "/tmp/cmp";
async function shot(url: string, tag: string, vw: number, clip?: { h: number }): Promise<void> {
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: vw, height: 1000 }, userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" });
await p.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 }).catch(() => {});
await p.waitForTimeout(2500); // let fonts + hero load
await p.screenshot({ path: resolve(SHOTS, `${name}-${tag}-${vw}.png`), clip: { x: 0, y: 0, width: vw, height: clip?.h ?? 1000 } });
await b.close();
}
async function main(): Promise<void> {
const { mkdirSync } = await import("node:fs");
mkdirSync(SHOTS, { recursive: true });
for (const vw of [1280, 390]) {
await shot(live!, "live", vw);
await shot(clone!, "clone", vw);
console.log(`done ${vw}`);
}
}
main().catch((e) => { console.error(e); process.exit(1); });
+142
View File
@@ -0,0 +1,142 @@
/**
* Code-quality audit count the "bad code" tells that make a generated clone read robotic, for any
* number of app source trees, side by side. Repeatable: point it at shipped deliverables and it
* prints one column per tree.
*
* npm run audit # auto: every output/<site>/app
* npm run audit -- <dir> [<dir> ...] # explicit trees; .clone resolves to sibling app/
* npm run audit -- output/example/app
*
* Scans .tsx/.jsx/.ts/.css under each dir (skips node_modules/.next/out/dotfiles). Every metric is a
* COUNT where lower is better, except the two "good" context rows (fluid width / standard scale).
* The decisive "decimal" tell is non-integer-PX: each arbitrary px/rem is converted to px and flagged
* if it isn't a whole pixel (a frozen measurement), so a clean 12.5rem (=200px) does NOT count.
*/
import { readdirSync, statSync, readFileSync, existsSync } from "node:fs";
import { join, resolve, basename, dirname, sep } from "node:path";
type Row = { label: string; lowerBetter: boolean; values: number[] };
type Target = { inputDir: string; scanDir: string; validationDir?: string };
function readTree(dir: string): { tsx: string; css: string; all: string } {
const tsxParts: string[] = [], cssParts: string[] = [];
const walk = (d: string): void => {
for (const n of readdirSync(d)) {
if (n === "node_modules" || n === ".next" || n === "out" || n.startsWith(".")) continue;
const p = join(d, n);
const st = statSync(p);
if (st.isDirectory()) walk(p);
else if (/\.(tsx|jsx|ts)$/.test(n)) tsxParts.push(readFileSync(p, "utf8"));
else if (/\.css$/.test(n)) cssParts.push(readFileSync(p, "utf8"));
}
};
walk(dir);
const tsx = tsxParts.join("\n"), css = cssParts.join("\n");
return { tsx, css, all: tsx + "\n" + css };
}
/** All metric counts for one tree. */
function audit(target: Target): Record<string, number> {
const dir = target.scanDir;
const { tsx, css, all } = readTree(dir);
const n = (re: RegExp, s = all): number => (s.match(re) || []).length;
const validationDataCid = target.validationDir && existsSync(target.validationDir)
? (readTree(target.validationDir).all.match(/data-cid/g) || []).length
: 0;
// Arbitrary px/rem values + the non-integer-px ("decimal") subset, the decisive measurement tell.
let arb = 0, decimal = 0;
for (const m of all.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) {
arb++;
const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1);
if (Math.abs(px - Math.round(px)) > 0.02) decimal++;
}
return {
"files (tsx+css)": (tsx ? 1 : 0), // overwritten below with real file counts
"── BAD (lower=better) ──": -1,
"fixed width w-[Npx/rem]": n(/\bw-\[-?[0-9.]+(?:px|rem)\]/g),
"fixed height h-[Npx/rem]": n(/\bh-\[-?[0-9.]+(?:px|rem)\]/g),
"breakpoint utilities": n(/\b(?:sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g),
"arbitrary bands min/max-[Npx]:": n(/\b(?:min|max)-\[[0-9]+px\]:/g),
"arbitrary […px/rem] total": arb,
"decimal (non-integer px)": decimal,
"baked position top/left/inset-[N]": n(/\b(?:top|left|right|bottom|inset(?:-x|-y)?)-\[-?[0-9.]+(?:px|rem)\]/g),
"raw color literal [#hex/rgb]": n(/\[(?:#[0-9a-fA-F]{3,8}|rgba?\([^\]]*)\]/g),
"per-side border longhand": n(/\[border-(?:top|right|bottom|left)-(?:style|color):/g),
"data-cid (shipped)": n(/data-cid/g),
"data-cid (validation-only)": validationDataCid,
"dangerouslySetInnerHTML": n(/dangerouslySetInnerHTML/g),
"': any' props": n(/:\s*any\b/g),
"robotic 'Generated by' comments": n(/Generated by clone|clone-static/g),
"── GOOD (higher=better) ──": -1,
"fluid width (w-full/auto/fraction)": n(/\bw-(?:full|auto|fit|screen|\d{1,2}\/\d{1,2})\b/g),
"standard scale (gap-2/w-10/p-4…)": n(/\b(?:gap|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|w|h)-(?:0|0\.5|1|1\.5|2|2\.5|3|3\.5|4|5|6|7|8|9|10|11|12|14|16|20|24|28|32|36|40|44|48|52|56|60|64|72|80|96|px)\b/g),
};
}
function fileCount(dir: string): number {
let c = 0;
const walk = (d: string): void => { for (const x of readdirSync(d)) { if (x === "node_modules" || x === ".next" || x === "out" || x.startsWith(".")) continue; const p = join(d, x); statSync(p).isDirectory() ? walk(p) : (/\.(tsx|jsx|ts|css)$/.test(x) && c++); } };
walk(dir);
return c;
}
function normalizeTarget(input: string): Target {
const d = resolve(input);
const asApp = (appDir: string, validationDir?: string): Target => ({ inputDir: d, scanDir: appDir, validationDir });
if (basename(d) === ".clone") {
const app = join(dirname(d), "app");
const validation = join(d, "generated", "app");
if (existsSync(join(app, "src"))) return asApp(app, existsSync(validation) ? validation : undefined);
}
const generatedSuffix = `${sep}.clone${sep}generated${sep}app`;
if (d.endsWith(generatedSuffix)) {
const cloneDir = d.slice(0, -`${sep}generated${sep}app`.length);
const app = join(dirname(cloneDir), "app");
if (existsSync(join(app, "src"))) return asApp(app, d);
}
if (basename(d) === "generated" && existsSync(join(d, "app", "src"))) {
const cloneDir = dirname(d);
const app = join(dirname(cloneDir), "app");
if (basename(cloneDir) === ".clone" && existsSync(join(app, "src"))) return asApp(app, join(d, "app"));
}
return { inputDir: d, scanDir: d };
}
function main(): void {
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
let targets: Target[];
if (args.length) targets = args.map(normalizeTarget);
else {
// Auto: every output/<site>/app deliverable.
const outRoot = resolve("output");
const sites = existsSync(outRoot)
? readdirSync(outRoot).map((s) => join(outRoot, s, "app")).filter((p) => existsSync(join(p, "src")))
: [];
targets = sites.map(normalizeTarget);
}
targets = targets.filter((t) => existsSync(t.scanDir));
if (!targets.length) { console.error("no app trees found — pass dirs explicitly"); process.exit(1); }
// A readable column label: the deliverable dir's parent name, or the scanned dir name.
const label = (t: Target): string => (basename(dirname(t.scanDir)) || basename(t.scanDir)).slice(0, 16);
const labels = targets.map(label);
const audits = targets.map(audit);
audits.forEach((a, i) => { a["files (tsx+css)"] = fileCount(targets[i]!.scanDir); });
const keys = Object.keys(audits[0]!);
const w0 = Math.max(...keys.map((k) => k.length));
const colW = Math.max(11, ...labels.map((l) => l.length));
const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length));
console.log("\n" + pad("metric", w0) + " " + labels.map((l) => pad(l, colW)).join(""));
console.log("─".repeat(w0 + 2 + colW * labels.length));
for (const k of keys) {
if (audits[0]![k] === -1) { console.log(pad(k, w0)); continue; } // section header
const cells = audits.map((a) => pad(String(a[k] ?? 0), colW)).join("");
console.log(pad(k, w0) + " " + cells);
}
console.log("\n(counts; lower is better in the BAD block, higher in the GOOD block. 'decimal' = the\n arbitrary value's px isn't a whole pixel — the frozen-measurement tell.)\n");
}
main();

Some files were not shown because too many files have changed in this diff Show More