Author SHA1 Message Date
dependabot[bot]andGitHub aea1d638b6 Bump the dev-dependencies group across 1 directory with 3 updates
Bumps the dev-dependencies group with 3 updates in the / directory: [tsx](https://github.com/privatenumber/tsx), [typescript](https://github.com/microsoft/TypeScript) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


Updates `tsx` from 4.22.4 to 4.23.0
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.4...v4.23.0)

Updates `typescript` from 5.7.3 to 6.0.3
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.7.3...v6.0.3)

Updates `@types/node` from 22.10.5 to 26.1.0
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.1.0
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
- dependency-name: tsx
  dependency-version: 4.23.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 16:56:23 +00:00
51 changed files with 204 additions and 2705 deletions
-55
View File
@@ -1,55 +0,0 @@
{
"version": 1,
"description": "Frozen pattern catalog: signature-indexed widget/platform/animation detectors. Matched against IR srcClass/tag/attrs (frozen capture evidence) by src/knowledge/patternIndex.ts. Deterministic: same catalog + same IR => same hints. Regenerate the lock after edits: node --import tsx src/knowledge/patternIndex.ts --write-lock",
"patterns": [
{ "id": "carousel_slick", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["slick-slider", "slick-track", "slick-slide", "slick-dots", "slick-list"] } },
{ "id": "carousel_swiper", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["swiper", "swiper-wrapper", "swiper-slide", "swiper-container", "swiper-pagination"] } },
{ "id": "carousel_splide", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["splide", "splide__track", "splide__slide", "splide__list"] } },
{ "id": "carousel_owl", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["owl-carousel", "owl-stage", "owl-item"] } },
{ "id": "carousel_flickity", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["flickity-enabled", "flickity-viewport", "flickity-slider"] } },
{ "id": "carousel_glide", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["glide", "glide__track", "glide__slides", "glide__slide"] } },
{ "id": "carousel_embla", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["embla", "embla__container", "embla__slide"] } },
{ "id": "carousel_keen", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["keen-slider", "keen-slider__slide"] } },
{ "id": "carousel_tiny", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["tns-outer", "tns-inner", "tns-slider", "tns-item"] } },
{ "id": "carousel_bootstrap", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["carousel-inner", "carousel-item", "carousel-indicators"] } },
{ "id": "marquee_rfm", "kind": "marquee", "flags": ["deferred_interactive"], "match": { "classPrefixes": ["rfm-"] } },
{ "id": "marquee_generic", "kind": "marquee", "flags": ["deferred_interactive"], "match": { "classTokens": ["marquee", "ticker", "scroller"], "tags": ["marquee"] } },
{ "id": "counter_odometer", "kind": "counter", "flags": ["counter", "deferred_interactive"], "match": { "classTokens": ["odometer", "odometer-inside", "odometer-digit"] } },
{ "id": "counter_countup", "kind": "counter", "flags": ["counter", "deferred_interactive"], "match": { "classTokens": ["countup", "counter-up", "purecounter"] } },
{ "id": "typed_text", "kind": "text_effect", "flags": ["deferred_interactive"], "match": { "classTokens": ["typed-cursor", "typewrite", "typewriter"] } },
{ "id": "anim_aos", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classTokens": ["aos-init", "aos-animate"], "attrNames": ["data-aos"] } },
{ "id": "anim_wow", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classTokens": ["wow"], "attrNames": ["data-wow-delay", "data-wow-duration"] } },
{ "id": "anim_animate_css", "kind": "css_animation", "flags": ["motion_lib"], "match": { "classPrefixes": ["animate__"], "classTokens": ["animated"] } },
{ "id": "anim_locomotive", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "attrNames": ["data-scroll", "data-scroll-container", "data-scroll-section"] } },
{ "id": "anim_rellax", "kind": "parallax", "flags": ["motion_lib"], "match": { "classTokens": ["rellax"], "attrNames": ["data-rellax-speed"] } },
{ "id": "anim_gsap_scrolltrigger", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classPrefixes": ["gsap-"], "attrNames": ["data-gsap"] } },
{ "id": "anim_sal", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "attrNames": ["data-sal", "data-sal-delay"] } },
{ "id": "lottie_widget", "kind": "lottie", "flags": ["deferred_interactive", "motion_lib"], "match": { "classTokens": ["lottie", "lottie-animation"], "tags": ["lottie-player", "dotlottie-player"], "attrNames": ["data-lottie", "data-animation-path"] } },
{ "id": "particles_bg", "kind": "background_effect", "flags": ["deferred_interactive"], "match": { "classTokens": ["particles-js", "tsparticles"], "idPrefixes": ["particles-js"] } },
{ "id": "video_player_lib", "kind": "video_player", "flags": ["deferred_interactive"], "match": { "classTokens": ["video-js", "plyr", "jwplayer", "vjs-tech"] } },
{ "id": "lightbox_gallery", "kind": "lightbox", "flags": ["deferred_interactive", "gallery"], "match": { "classPrefixes": ["lg-", "pswp"], "classTokens": ["fancybox", "glightbox", "lightbox"] } },
{ "id": "platform_elementor", "kind": "platform", "flags": ["platform_elementor"], "match": { "classPrefixes": ["elementor-"], "classTokens": ["elementor"] } },
{ "id": "platform_wp_blocks", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["wp-block-", "wp-container-"] } },
{ "id": "platform_wpbakery", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["vc_"], "classTokens": ["wpb_wrapper", "wpb_row"] } },
{ "id": "platform_divi", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["et_pb_"] } },
{ "id": "platform_webflow", "kind": "platform", "flags": ["platform_webflow"], "match": { "classTokens": ["w-nav", "w-slider", "w-dropdown", "w-container", "w-richtext"], "attrNames": ["data-wf-page", "data-wf-site"] } },
{ "id": "platform_shopify", "kind": "platform", "flags": ["platform_shopify"], "match": { "classTokens": ["shopify-section"], "idPrefixes": ["shopify-section"] } },
{ "id": "platform_squarespace", "kind": "platform", "flags": ["platform_squarespace"], "match": { "classPrefixes": ["sqs-"], "attrNames": ["data-block-type"] } },
{ "id": "platform_framer", "kind": "platform", "flags": ["platform_framer"], "match": { "classPrefixes": ["framer-"], "attrNames": ["data-framer-name"] } },
{ "id": "platform_woocommerce", "kind": "platform", "flags": ["platform_wordpress", "ecommerce"], "match": { "classTokens": ["woocommerce", "woocommerce-page"] } },
{ "id": "nav_hamburger", "kind": "nav_toggle", "flags": ["nav_toggle"], "match": { "classTokens": ["hamburger", "navbar-toggler", "menu-toggle", "nav-toggle", "burger", "menu-btn"] } },
{ "id": "nav_mega_menu", "kind": "nav_menu", "flags": ["nav_toggle"], "match": { "classTokens": ["mega-menu", "megamenu", "dropdown-menu", "sub-menu"] } },
{ "id": "consent_onetrust", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["ot-sdk-", "onetrust-"], "idPrefixes": ["onetrust-"] } },
{ "id": "consent_cookiebot", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "idPrefixes": ["CybotCookiebot"] } },
{ "id": "consent_osano", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["osano-"] } },
{ "id": "consent_cookieyes", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["cky-"] } },
{ "id": "consent_complianz", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["cmplz-"] } },
{ "id": "chat_intercom", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["intercom-"], "idPrefixes": ["intercom-"] } },
{ "id": "chat_crisp", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["crisp-"], "idPrefixes": ["crisp-"] } },
{ "id": "chat_drift", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["drift-"], "idPrefixes": ["drift-"] } },
{ "id": "chat_hubspot", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["hs-chat-", "hubspot-"], "idPrefixes": ["hubspot-"] } },
{ "id": "chat_tawk", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "idPrefixes": ["tawk-"] } },
{ "id": "accordion_generic", "kind": "accordion", "flags": ["nav_toggle"], "match": { "classTokens": ["accordion", "accordion-item", "accordion-header", "collapsible"] } },
{ "id": "tabs_generic", "kind": "tabs", "flags": ["nav_toggle"], "match": { "classTokens": ["tabs", "tab-content", "tab-pane", "tablist"] } }
]
}
-1
View File
@@ -1 +0,0 @@
3f46aca40bb69d5c114e957c28b3a4924c58902cecb35332d34f3065114d0e75
+3 -3
View File
@@ -41,9 +41,9 @@
"pngjs": "7.0.0" "pngjs": "7.0.0"
}, },
"devDependencies": { "devDependencies": {
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3", "typescript": "6.0.3",
"@types/node": "22.10.5", "@types/node": "26.1.0",
"@types/pixelmatch": "5.2.6", "@types/pixelmatch": "5.2.6",
"@types/pngjs": "6.0.5" "@types/pngjs": "6.0.5"
} }
+53 -238
View File
@@ -15,8 +15,6 @@ import { discoverBreakpoints } from "./breakpoints.js";
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js"; import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
import { sha1_12, round } from "../util/canonical.js"; import { sha1_12, round } from "../util/canonical.js";
import { isZipArchive, extractDotLottieJson } from "./dotlottie.js"; import { isZipArchive, extractDotLottieJson } from "./dotlottie.js";
import { buildDeterministicEnvShim, captureEpochMs, DEFAULT_PRNG_SEED } from "../util/envShim.js";
import { isBotWall, classifyNavFailure } from "../util/captureFailure.js";
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const; 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 // The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing
@@ -67,10 +65,6 @@ export type SeoResource = {
export type CaptureResult = { export type CaptureResult = {
sourceUrl: string; sourceUrl: string;
capturedAt: string; capturedAt: string;
// Deterministic-env shim parameters actually used for this run (Item 1). Recorded
// so a recapture can reuse the exact {seed, epochMs} — the pinned wall clock is a
// run-metadata value, not a hardcoded constant. Absent when the shim was disabled.
deterministicEnv?: { seed: number; epochMs: number };
viewports: number[]; viewports: number[];
// Browser-as-oracle: the widths where the SOURCE layout actually restructures (display / // 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 // flex-direction / wrap / grid-track-count / position / visibility flips), found by sweeping the
@@ -803,66 +797,6 @@ async function captureCanvasStills(page: import("playwright").Page): Promise<Can
// instead of returning a truncated image on pathologically tall pages. // instead of returning a truncated image on pathologically tall pages.
const CDP_MAX_SHOT_DIMENSION = 16_384; const CDP_MAX_SHOT_DIMENSION = 16_384;
/**
* Item 2: extended lazy-asset DISCOVERY. Returns the deduped, sorted set of asset
* URLs referenced through channels the walker + promoteLazyMedia miss:
* (1) <noscript> fallback markup (the walker skips <noscript> entirely) — parsed as
* HTML so real img/source src + srcset are read, not regex-scraped;
* (2) inline element style="…url(…)…" (the walker harvests url() from STYLESHEET
* rules only, not from an element's own style attribute);
* (3) data-background / data-background-image lazy-bg aliases (beyond promoteLazyMedia's
* single data-bg);
* (4) img[loading=lazy] currentSrc — the variant a below-fold image resolved to.
* data: URLs and unresolvable refs are dropped. Discovery-only: the caller RECORDS these
* for the fallback downloader; nothing here mutates the DOM (snapshots are already taken).
* Exported for the fixture tests (page.evaluate'd directly).
*/
export function discoverLazyAssetsInPage(): string[] {
const urls = new Set<string>();
const push = (v: string | null | undefined): void => {
if (!v) return;
const s = v.trim();
if (!s || s.startsWith("data:")) return;
try { urls.add(new URL(s, document.baseURI).href); } catch { /* not a URL */ }
};
const firstOfSrcset = (ss: string): void => {
for (const part of ss.split(",")) push(part.trim().split(/\s+/)[0]);
};
// (1) <noscript> fallback markup.
for (const ns of Array.from(document.querySelectorAll("noscript"))) {
const html = ns.textContent ?? "";
if (!html.includes("<")) continue;
let frag: Document;
try { frag = new DOMParser().parseFromString(html, "text/html"); } catch { continue; }
for (const el of Array.from(frag.querySelectorAll("img,source"))) {
push(el.getAttribute("src"));
const ss = el.getAttribute("srcset");
if (ss) firstOfSrcset(ss);
}
}
// (2) inline style url(...) on any element (background-image / mask / etc.).
for (const el of Array.from(document.querySelectorAll("[style]"))) {
const st = el.getAttribute("style") ?? "";
if (!st.includes("url(")) continue;
for (const m of st.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)) push(m[2]);
}
// (3) lazy-background data-* aliases the promoter doesn't cover.
const BG_ATTRS = ["data-background", "data-background-image"];
for (const el of Array.from(document.querySelectorAll(BG_ATTRS.map((a) => `[${a}]`).join(",")))) {
for (const a of BG_ATTRS) {
const raw = el.getAttribute(a);
if (!raw) continue;
const m = /url\(\s*(['"]?)([^'")]+)\1\s*\)/i.exec(raw);
push(m ? m[2] : raw);
}
}
// (4) the variant a lazy image actually resolved to.
for (const img of Array.from(document.querySelectorAll<HTMLImageElement>("img[loading=lazy]"))) {
push(img.currentSrc || img.src);
}
return [...urls].sort();
}
/** /**
* Full-page screenshot via CDP `Page.captureScreenshot` with `captureBeyondViewport:true`. * Full-page screenshot via CDP `Page.captureScreenshot` with `captureBeyondViewport:true`.
* Unlike Playwright's `fullPage:true` (which scroll-stitches — scrolling the page to render * Unlike Playwright's `fullPage:true` (which scroll-stitches — scrolling the page to render
@@ -1070,14 +1004,6 @@ export async function captureSite(opts: {
interactions?: boolean; // Stage 4: opt-in interaction capture (hover/focus + patterns) interactions?: boolean; // Stage 4: opt-in interaction capture (hover/focus + patterns)
motion?: boolean; // Stage 5: opt-in motion capture (WAAPI + rotating text) 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) breakpoints?: boolean; // discover the source's real responsive band edges (default on; read-only sweep)
deterministicEnv?: boolean; // Item 1: seed Math.random + pin the Date epoch BEFORE page JS runs
// (default on), so shuffled carousels / random ids / relative-time text
// ("posted N minutes ago") render the same across captures. Relative time
// still advances (timers behave) and performance.now() stays real.
captureEpochMs?: number | string; // pinned wall-clock origin for the shim (ms since epoch, or a
// date string). Recorded in the result; a recapture passes the
// previously-recorded value to reproduce time-dependent content.
prngSeed?: number; // mulberry32 seed for the pinned Math.random (recorded in the result).
screenshots?: boolean; // write per-viewport full-page PNGs (default on). ONLY the validator reads these 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 // (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 // dominant capture cost — so a production clone that won't be perceptually graded
@@ -1087,13 +1013,6 @@ export async function captureSite(opts: {
}): Promise<CaptureResult> { }): Promise<CaptureResult> {
const viewports = opts.viewports ?? [...REQUIRED_VIEWPORTS]; const viewports = opts.viewports ?? [...REQUIRED_VIEWPORTS];
const log = opts.log ?? (() => {}); const log = opts.log ?? (() => {});
// Item 1: resolve the deterministic-env parameters once, at run start, and record
// them in the result. The epoch is a run-metadata value (recorded, reproducible),
// not a hardcoded constant; performance.now() is left real so motion capture is
// unaffected. Defaults on; opts.deterministicEnv === false disables the shim.
const deterministicEnvOn = opts.deterministicEnv !== false;
const prngSeed = Number.isFinite(opts.prngSeed) ? Math.trunc(opts.prngSeed!) : DEFAULT_PRNG_SEED;
const epochMs = captureEpochMs(opts.captureEpochMs);
const captureDir = join(opts.outDir, "capture"); const captureDir = join(opts.outDir, "capture");
const screenshotsDir = join(opts.outDir, "screenshots"); const screenshotsDir = join(opts.outDir, "screenshots");
const cssDir = join(captureDir, "css"); const cssDir = join(captureDir, "css");
@@ -1259,136 +1178,64 @@ export async function captureSite(opts: {
// hero on each fresh load) and session-reuse degeneration (warbyparker returned // 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 // 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. // 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>[] = []; const bodyPromises: Promise<void>[] = [];
// Response listener + init scripts are attached the same way to the initial page page.on("response", (resp) => {
// and to any fresh recovery page (Item 3c), so factor the wiring into one helper. try {
const attachResponseListener = (pg: import("playwright").Page): void => { const url = resp.url();
pg.on("response", (resp) => { if (url.startsWith("data:") || url.startsWith("blob:")) return;
try { const ct = resp.headers()["content-type"] || null;
const url = resp.url(); const type = classifyAsset(url, ct);
if (url.startsWith("data:") || url.startsWith("blob:")) return; if (!type) return;
const ct = resp.headers()["content-type"] || null; const status = resp.status();
const type = classifyAsset(url, ct); recordAsset(url, type, ct, status, "network");
if (!type) return; const existing = assetMap.get(url);
const status = resp.status(); if (existing?.storedAs) return;
recordAsset(url, type, ct, status, "network"); if (status >= 400) return;
const existing = assetMap.get(url); // A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would
if (existing?.storedAs) return; // ship a corrupt file under first-stored-wins and the full-download fallback
if (status >= 400) return; // would then skip the asset. Record only; the fallback pass fetches the 200 body.
// A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would if (status === 206) return;
// ship a corrupt file under first-stored-wins and the full-download fallback bodyPromises.push(
// would then skip the asset. Record only; the fallback pass fetches the 200 body. (async () => {
if (status === 206) return; try {
bodyPromises.push( const buf = await resp.body();
(async () => { storeBytes(url, type, buf);
try { if (type === "css") cssTextsForParsing.push({ baseUrl: url, text: buf.toString("utf8") });
const buf = await resp.body(); if (type === "manifest") parseManifestForAssets(buf.toString("utf8"), url);
storeBytes(url, type, buf); } catch { /* body unavailable */ }
if (type === "css") cssTextsForParsing.push({ baseUrl: url, text: buf.toString("utf8") }); })(),
if (type === "manifest") parseManifestForAssets(buf.toString("utf8"), url);
} catch { /* body unavailable */ }
})(),
);
} catch { /* ignore */ }
});
};
const newSession = async (): Promise<{ context: BrowserContext; page: import("playwright").Page }> => {
const ctx: BrowserContext = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: canonical, height: viewportHeight(canonical) },
deviceScaleFactor: 1,
userAgent: DESKTOP_UA,
javaScriptEnabled: true,
});
const pg = await ctx.newPage();
attachResponseListener(pg);
// 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 pg.addInitScript(ESBUILD_SHIM);
// Item 1: deterministic-env shim — seed Math.random + pin the Date epoch BEFORE
// any page script runs. Raw string so tsx/esbuild leaves it byte-exact; the
// {seed, epochMs} are the recorded run-metadata values. performance.now() stays
// real, so motion.ts marquee/rotator velocity sampling is unaffected.
if (deterministicEnvOn) {
await pg.addInitScript(buildDeterministicEnvShim({ seed: prngSeed, epochMs }));
}
return { context: ctx, page: pg };
};
// Single context + single navigation; every viewport is captured by RESIZING the
// same loaded page (see the block comment above). Item 3c: session recovery is
// deliberately scoped to the INITIAL navigation only — carrying one session across
// viewports is load-bearing (a reload can degenerate: warbyparker's 13-node shell),
// so we never recover mid-loop; we only retry the first nav with a fresh context
// when the page/browser died before any content was captured.
let { context, page } = await newSession();
// Total nav budget (Item 3a): a hung origin used to stall for up to ~3×45s + retries
// with no ceiling. Bound the WHOLE navigation phase so a dead host fails fast with a
// structured error instead of tying up the pipeline. Attempt 0 waits for `load`;
// later attempts fall back to `domcontentloaded` (a heavy page may never fire `load`).
const NAV_BUDGET_MS = 90_000;
const navigateLoaded = async (pg: import("playwright").Page): Promise<void> => {
log({ event: "goto", url: opts.url });
const navStart = Date.now();
let navigated = false;
let navErr: unknown = null;
for (let attempt = 0; attempt < 3 && !navigated; attempt++) {
const remaining = NAV_BUDGET_MS - (Date.now() - navStart);
if (remaining < 5_000) break; // not enough budget left for a meaningful attempt
try {
await pg.goto(opts.url, {
waitUntil: attempt === 0 ? "load" : "domcontentloaded",
timeout: Math.min(attempt === 0 ? 45_000 : 20_000, remaining),
});
navigated = true;
} catch (e) {
navErr = e;
await pg.waitForTimeout(1000);
}
}
if (!navigated) {
throw new Error(
`navigation failed for ${opts.url} within ${Math.round((Date.now() - navStart) / 1000)}s: ${String((navErr as { message?: string })?.message ?? navErr).slice(0, 300)}`,
); );
} 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);
} }
await settle(pg);
};
try {
await navigateLoaded(page);
} catch (navErr) {
// Item 3c: one fresh-context retry, but ONLY for a session-death class of failure
// (browser/page/context closed, crash, transient reset/timeout). A wall or a hard
// terminal error (DNS/cert/refused) is not retried — a fresh context can't fix it.
const cls = classifyNavFailure(navErr);
if (cls !== "retryable") throw navErr;
log({ event: "capture_recover", reason: "session_death_on_initial_nav" });
try { if (!page.isClosed()) await page.close(); } catch { /* ignore */ }
try { await context.close(); } catch { /* ignore */ }
({ context, page } = await newSession());
await navigateLoaded(page); // second failure propagates (no further retry)
}
// Item 3b: bot/auth-wall fast-fail. A wall page would otherwise burn the full
// multi-viewport capture and only get flagged by the pollution gate afterward.
// Uses the SAME signatures + node-count threshold as the gate (util/captureFailure.ts)
// so the abort and the grade agree; the pollution gate stays the shipped-capture
// authority, this only saves the wasted work of capturing an obvious wall.
const wallProbe = await page
.evaluate(() => ({
text: (document.body?.innerText ?? "").slice(0, 20_000),
nodes: document.querySelectorAll("*").length,
}))
.catch(() => null);
if (isBotWall(wallProbe)) {
throw new Error(
`auth/bot wall detected at ${opts.url} (${wallProbe!.nodes} nodes, wall text matched): capture aborted early`,
);
} }
if (!navigated) throw navErr;
await settle(page);
// Stage 2: lazy-loader promotion. WP Rocket/lazysizes keep a 0-size placeholder in // Stage 2: lazy-loader promotion. WP Rocket/lazysizes keep a 0-size placeholder in
// `src` with the real URL in data attrs; autoScroll outruns their IntersectionObserver // `src` with the real URL in data attrs; autoScroll outruns their IntersectionObserver
@@ -1794,37 +1641,6 @@ export async function captureSite(opts: {
log({ event: "captured", viewport: vw, nodes: snapshot.doc.nodeCount, scrollHeight: snapshot.doc.scrollHeight }); log({ event: "captured", viewport: vw, nodes: snapshot.doc.nodeCount, scrollHeight: snapshot.doc.scrollHeight });
} }
// Item 2: extended lazy-asset discovery sweep. The walker + promoteLazyMedia
// already harvest img/source src/srcset and the common data-* lazy attrs
// (data-src / data-lazy-src / data-original / data-ll-src / data-(lazy-)srcset)
// and picture <source> variants — this sweep ONLY adds the references those miss:
// • <noscript> contents — the walker skips <noscript> entirely, but the fallback
// markup real browsers render with JS off often carries the true <img>/<source> URL;
// • inline element style="…url(…)…" — the walker harvests url() from STYLESHEET
// rules, not from an element's own style attribute (hero background-image inlined);
// • data-background / data-background-image — lazy-bg aliases beyond promoteLazyMedia's
// single data-bg;
// • img[loading=lazy] currentSrc — the resolved variant a below-fold image settled on.
// Discovery-ONLY: URLs are recorded through recordAsset so the fallback downloader
// fetches them; nothing is promoted into the DOM (the snapshots are already taken, so
// this can never shift a captured layout). Bounded so a pathological page can't explode
// the fallback fetch phase.
try {
const sweptRefs: string[] = await page.evaluate(discoverLazyAssetsInPage);
let swept = 0;
for (const url of sweptRefs) {
if (swept >= 120) break; // bound pathological pages (fallback fetch is 30s/asset)
if (!/^https?:\/\//i.test(url) || assetMap.has(url)) continue;
const t = classifyAsset(url, null);
if (!t || !["image", "svg", "video", "font", "lottie"].includes(t)) continue;
recordAsset(url, t, null, null, "lazy-sweep");
swept++;
}
if (swept) log({ event: "lazy_sweep", recorded: swept, seen: sweptRefs.length });
} catch (e) {
log({ event: "lazy_sweep_error", error: String((e as { message?: string })?.message ?? e).slice(0, 160) });
}
// Browser-as-oracle: discover the source's real responsive band edges by sweeping the viewport // 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 // 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 // changes. Read-only and bounded; runs once here — overlays are dismissed and the DOM settled, so
@@ -1938,7 +1754,6 @@ export async function captureSite(opts: {
const result: CaptureResult = { const result: CaptureResult = {
sourceUrl: opts.url, sourceUrl: opts.url,
capturedAt: new Date().toISOString(), capturedAt: new Date().toISOString(),
...(deterministicEnvOn ? { deterministicEnv: { seed: prngSeed, epochMs } } : {}),
viewports, viewports,
breakpoints: discoveredBreakpoints, breakpoints: discoveredBreakpoints,
perViewport, perViewport,
+2 -13
View File
@@ -14,7 +14,6 @@ import type { FontGraph } from "../infer/fonts.js";
import { SYSTEM_FALLBACK } from "../infer/fonts.js"; import { SYSTEM_FALLBACK } from "../infer/fonts.js";
import { detectComponents, type ComponentPlan, type ComponentCluster } from "../infer/components.js"; import { detectComponents, type ComponentPlan, type ComponentCluster } from "../infer/components.js";
import { buildClassMap } from "./classMap.js"; import { buildClassMap } from "./classMap.js";
import { generatePreviewHtml } from "./preview.js";
import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js"; import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js";
import { planSections, type SectionPlan } from "./sectionSplit.js"; import { planSections, type SectionPlan } from "./sectionSplit.js";
import type { RecipeReport } from "../infer/recipes.js"; import type { RecipeReport } from "../infer/recipes.js";
@@ -2660,7 +2659,7 @@ export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | und
}; };
} }
export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[]; previewHtml: string } { export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[] } {
const { ir, assetGraph, fontGraph, appDir, sourceUrl } = input; const { ir, assetGraph, fontGraph, appDir, sourceUrl } = input;
const assetMap = buildAssetMap(assetGraph); const assetMap = buildAssetMap(assetGraph);
const framework = input.framework ?? "next"; const framework = input.framework ?? "next";
@@ -2790,16 +2789,6 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals); writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
} }
writeText(join(rootDir, "ditto.css"), cloneCss); writeText(join(rootDir, "ditto.css"), cloneCss);
// Fast, self-contained static preview (runtime-free single HTML file) the service can
// render within seconds of `generate`, BEFORE the Next build + deploy completes. Emitted
// at generated/app/preview.html (assets relative to public/); shares the same IR + css.ts
// rule/banding collectors and the same propsList/resolveTag decision code as the app,
// so it shows what the built app will show without a second maintained emission path.
const previewPath = "preview.html";
writeText(join(appDir, previewPath), generatePreviewHtml({
ir, assetMap, fontGraph, tokensCss, sourceUrl,
colorVar: input.colorVar, tokenResolver: input.tokenResolver,
}));
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE); writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
// SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files). // SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files).
if (framework === "next") writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE); if (framework === "next") writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE);
@@ -2841,7 +2830,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
], ],
}); });
return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [], previewHtml: previewPath }; return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [] };
} }
/** Add lottie-web to a generated package.json's dependencies — DittoLottie imports it at /** Add lottie-web to a generated package.json's dependencies — DittoLottie imports it at
+2 -20
View File
@@ -1952,7 +1952,6 @@ function fixedWidthButtonLike(node: IRNode, viewports: number[]): boolean {
if (node.tag !== "a" && node.tag !== "button") return false; if (node.tag !== "a" && node.tag !== "button") return false;
const widths: number[] = []; const widths: number[] = [];
let sampled = 0; let sampled = 0;
let contentFit = true;
for (const vp of viewports) { for (const vp of viewports) {
const cs = node.computedByVp[vp]; const cs = node.computedByVp[vp];
const bb = node.bboxByVp[vp]; const bb = node.bboxByVp[vp];
@@ -1963,21 +1962,7 @@ function fixedWidthButtonLike(node: IRNode, viewports: number[]): boolean {
if (pf(cs.borderTopLeftRadius) < 12 && pf(cs.borderTopRightRadius) < 12) return false; if (pf(cs.borderTopLeftRadius) < 12 && pf(cs.borderTopRightRadius) < 12) return false;
if (bb.width < 120 || bb.height < 28) return false; if (bb.width < 120 || bb.height < 28) return false;
widths.push(bb.width); widths.push(bb.width);
// Content-fit evidence: the sizing probe proved `width:auto` reproduces this box AND it paints
// exactly at its own max-content width, with genuinely wrappable text inside (wMin < wMax).
const sz = node.sizingByVp?.[vp];
if (!(sz?.wAuto === true && sz.wMax != null && sz.wMin != null && sz.wMin < sz.wMax - 2 &&
Math.abs(bb.width - sz.wMax) <= Math.max(1.5, 0.01 * sz.wMax))) contentFit = false;
} }
// A constant width alone is weak evidence of an AUTHORED fixed width: a content-sized chip whose
// label never changes paints the same width at every viewport it's shown at. When the probe proved
// width:auto reproduces the box at its max-content at EVERY sample, the width is content-derived —
// don't lock it. Baking that px invites the emitters' quantization (snapLen's 0.1px rounding,
// snapBase's ≤0.25px spacing-scale snap) to land fractionally BELOW the intrinsic single-line
// width, wrapping the label onto a second line; the probe verdict (sizingVerdict → width:auto)
// reproduces the box intrinsically at every width instead. Authored intent (sourceFixedWidth /
// sourceFixedSize, checked by the caller) still locks.
if (sampled >= 1 && contentFit) return false;
return sampled >= 2 && widths.length >= 2 && Math.max(...widths) - Math.min(...widths) <= 2; return sampled >= 2 && widths.length >= 2 && Math.max(...widths) - Math.min(...widths) <= 2;
} }
@@ -3135,10 +3120,7 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
* column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit * column resolving fractionally narrower) tips it onto a second line. Such a node earns an explicit
* `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture. * `white-space:nowrap` (see declsForViewport) to stay one line as it did in the capture.
* Conservative by construction every guard must hold at every painted viewport: * Conservative by construction every guard must hold at every painted viewport:
* text leaf (direct text; element children only if REPLACED an icon <svg>/<img> beside the * text leaf (direct text, no element children), whitespace not already preserved (`pre*`);
* label, the icon+text chip shape. A replaced box can't wrap and its constant width is already
* inside the probe's wMin/wMax, so the math below is unchanged; any other element child may
* carry its own text and bails), whitespace not already preserved (`pre*`);
* single line: box height one line box (line-height + vertical padding/border), so a genuinely * single line: box height one line box (line-height + vertical padding/border), so a genuinely
* wrapping paragraph (2 line boxes tall) is excluded; * wrapping paragraph (2 line boxes tall) is excluded;
* wrap-vulnerable: max-content (unwrapped) width available container width 2 and it + 1 * wrap-vulnerable: max-content (unwrapped) width available container width 2 and it + 1
@@ -3148,7 +3130,7 @@ function centeredAtVp(node: IRNode, parentNode: IRNode, vp: number): boolean {
* Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent no emit. */ * Relies on the sizing probe's wMin/wMax (present only for in-flow probed leaves); absent no emit. */
function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean { function nowrapWrapVulnerable(node: IRNode, parentNode: IRNode | undefined, viewports: number[]): boolean {
if (!parentNode) return false; if (!parentNode) return false;
if (node.children.some((c) => !isTextChild(c) && !REPLACED.has(c.tag))) return false; if (hasElementChild(node)) return false;
if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false; if (!node.children.some((c) => isTextChild(c) && c.text.trim() !== "")) return false;
if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false; if (REPLACED.has(node.tag) || node.tag === "canvas" || node.tag.includes("-")) return false;
let painted = 0; let painted = 0;
+1 -27
View File
@@ -4,7 +4,6 @@ import type { Tokens } from "../infer/tokens.js";
import type { AssetGraph } from "../infer/assets.js"; import type { AssetGraph } from "../infer/assets.js";
import type { FontGraph } from "../infer/fonts.js"; import type { FontGraph } from "../infer/fonts.js";
import type { CaptureResult } from "../capture/capture.js"; import type { CaptureResult } from "../capture/capture.js";
import type { PatternHints } from "../knowledge/patternIndex.js";
export const COMPILER_VERSION = "0.1.0"; export const COMPILER_VERSION = "0.1.0";
export const SCHEMA_VERSION = 1; export const SCHEMA_VERSION = 1;
@@ -17,13 +16,8 @@ export function buildManifest(args: {
fontGraph: FontGraph; fontGraph: FontGraph;
capture: CaptureResult; capture: CaptureResult;
componentCount: number; componentCount: number;
patternHints?: PatternHints;
// Relative path (within generated/app) of the static preview artifact, when emitted.
// Lets the service/client show the cloned page from the file map BEFORE the Next build
// + deploy finishes, then swap to the deployed app. A fixed string — no timestamps.
previewHtml?: string;
}): Record<string, unknown> { }): Record<string, unknown> {
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount, patternHints, previewHtml } = args; const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount } = args;
const byType: Record<string, number> = {}; const byType: Record<string, number> = {};
let downloaded = 0, skipped = 0; let downloaded = 0, skipped = 0;
@@ -62,26 +56,6 @@ export function buildManifest(args: {
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length, fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
}, },
components: { count: componentCount }, components: { count: componentCount },
// Static preview artifact (runtime-free, single HTML file) the client can render
// immediately from the file map. Omitted when not emitted.
...(previewHtml ? { preview_html: previewHtml } : {}),
// Frozen-catalog pattern evidence (hint-only, additive): library/platform fingerprints
// detected in the IR, with the node cids that carried each signature (bounded pre-order
// sample). Deterministic — matches are id-sorted and cids are pre-order, so the same
// catalog + IR yields byte-identical evidence. Generated docs read this to say e.g.
// "Swiper carousel detected". Omitted when no hints were computed.
...(patternHints
? {
patterns: {
catalogVersion: patternHints.catalogVersion,
catalogHash: patternHints.catalogHash,
flags: patternHints.flags,
platforms: patternHints.platforms,
simpleStatic: patternHints.simpleStatic,
matches: patternHints.matches.map((m) => ({ id: m.id, kind: m.kind, count: m.count, cids: m.cids })),
},
}
: {}),
// Fidelity note: containers whose children were a DIFFERENT SET at some band viewport(s) // Fidelity note: containers whose children were a DIFFERENT SET at some band viewport(s)
// (content-identity drift — the source deterministically served other content there). The // (content-identity drift — the source deterministically served other content there). The
// clone shows the canonical-viewport children at those widths (faithful-at-canonical) instead // clone shows the canonical-viewport children at those widths (faithful-at-canonical) instead
+3 -10
View File
@@ -13,7 +13,6 @@ import { buildCodeQualityReport, codeQualityReportToMarkdown, type CodeQualityRe
import { interactionRejectedSet } from "./interactive.js"; import { interactionRejectedSet } from "./interactive.js";
import { buildManifest } from "./manifest.js"; import { buildManifest } from "./manifest.js";
import { buildSeoInventory, seoInventoryToMarkdown, type SeoInventory } from "./seo.js"; import { buildSeoInventory, seoInventoryToMarkdown, type SeoInventory } from "./seo.js";
import { resolvePatternHints, type PatternHints } from "../knowledge/patternIndex.js";
import { writeJSON, writeText, readJSON, fileExists } from "../util/fsx.js"; import { writeJSON, writeText, readJSON, fileExists } from "../util/fsx.js";
import type { CaptureResult } from "../capture/capture.js"; import type { CaptureResult } from "../capture/capture.js";
@@ -25,7 +24,6 @@ export type GenerateAllResult = {
fontGraph: FontGraph; fontGraph: FontGraph;
recipeReport: RecipeReport; recipeReport: RecipeReport;
interactionRecipeReport: InteractionRecipeReport; interactionRecipeReport: InteractionRecipeReport;
patternHints: PatternHints;
seoInventory: SeoInventory; seoInventory: SeoInventory;
codeQuality: CodeQualityReport; codeQuality: CodeQualityReport;
manifest: Record<string, unknown>; manifest: Record<string, unknown>;
@@ -61,10 +59,6 @@ export function generateAll(opts: {
const sections = detectSections(ir); const sections = detectSections(ir);
const tokens = extractTokens(ir); const tokens = extractTokens(ir);
// Pattern hints: frozen-catalog signature scan over the IR (deterministic; the
// pin is asserted here and throws on catalog/lock drift). Fed as ADDITIVE evidence
// into recipe recognition — never overrides captured geometry.
const patternHints = resolvePatternHints(ir);
const assetGraph = buildAssetGraph(capture); const assetGraph = buildAssetGraph(capture);
const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url); const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url);
const seoInventory = buildSeoInventory(ir, assetGraph, capture); const seoInventory = buildSeoInventory(ir, assetGraph, capture);
@@ -75,7 +69,7 @@ export function generateAll(opts: {
const tokensCss = (palette.css ? palette.css + "\n" : "") + tokensToCss(tokens, true); const tokensCss = (palette.css ? palette.css + "\n" : "") + tokensToCss(tokens, true);
const tokenResolver = buildTokenResolver(tokens); const tokenResolver = buildTokenResolver(tokens);
const primitives = recognizePrimitives(ir); const primitives = recognizePrimitives(ir);
const recipeReport = buildRecipeReport(ir, sections, primitives, patternHints); const recipeReport = buildRecipeReport(ir, sections, primitives);
const interactionRecipeReport = buildInteractionRecipeReport(ir, sections, capture.interaction); const interactionRecipeReport = buildInteractionRecipeReport(ir, sections, capture.interaction);
// Patterns the interaction gate previously rejected (don't reproduce) → left static. // Patterns the interaction gate previously rejected (don't reproduce) → left static.
const rejPath = join(sourceDir, "interaction-rejected.json"); const rejPath = join(sourceDir, "interaction-rejected.json");
@@ -101,7 +95,6 @@ export function generateAll(opts: {
writeJSON(join(outDir, "fonts.json"), fontGraph.entries); writeJSON(join(outDir, "fonts.json"), fontGraph.entries);
const inventory = inventoryOf(ir, primitives); const inventory = inventoryOf(ir, primitives);
writeJSON(join(outDir, "components.json"), inventory); writeJSON(join(outDir, "components.json"), inventory);
writeJSON(join(outDir, "patterns.json"), patternHints);
writeJSON(join(outDir, "recipes.json"), recipeReport); writeJSON(join(outDir, "recipes.json"), recipeReport);
writeText(join(outDir, "recipes.md"), recipeReportToMarkdown(recipeReport)); writeText(join(outDir, "recipes.md"), recipeReportToMarkdown(recipeReport));
writeJSON(join(outDir, "interaction-recipes.json"), interactionRecipeReport); writeJSON(join(outDir, "interaction-recipes.json"), interactionRecipeReport);
@@ -111,8 +104,8 @@ export function generateAll(opts: {
const codeQuality = buildCodeQualityReport(appDir, recipeReport); const codeQuality = buildCodeQualityReport(appDir, recipeReport);
writeJSON(join(outDir, "code-quality.json"), codeQuality); writeJSON(join(outDir, "code-quality.json"), codeQuality);
writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality)); writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality));
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count, patternHints, previewHtml: gen.previewHtml }); const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count });
writeJSON(join(outDir, "manifest.json"), manifest); writeJSON(join(outDir, "manifest.json"), manifest);
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, patternHints, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing }; return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
} }
-243
View File
@@ -1,243 +0,0 @@
import { isTextChild, type IR, type IRNode, type IRChild } from "../normalize/ir.js";
import type { FontGraph } from "../infer/fonts.js";
import { SYSTEM_FALLBACK } from "../infer/fonts.js";
import { collectNodeRules, keyframesCss, computeBands, assembleCss, RESET_CSS } from "./css.js";
import type { TokenResolver } from "../infer/tokens.js";
import { propsList, resolveTag, resolveHtmlBg, htmlBgRule } from "./app.js";
/**
* Fast, self-contained static preview artifact (`preview.html`).
*
* Product moment: the service shows the cloned page within seconds of `generate`
* finishing BEFORE the Next.js build + deploy completes then swaps to the
* deployed app. This artifact renders WITHOUT any build toolchain (no next / tailwind
* / esbuild): it is pure string building over the same frozen IR + rules the JSX
* emitter consumes.
*
* SHARED DECISION CODE (not a parallel emission path):
* - CSS: `collectNodeRules` + `keyframesCss` + `computeBands` + `assembleCss` from
* css.ts the identical per-node rule collection + media-query banding the semantic
* class-map emitter (classMap.ts) and the legacy per-node emitter (generateCss) use.
* We emit `.c<id>` selectors, exactly like `generateCss`, so the preview's computed
* styles are byte-for-byte the same rules the built app resolves to. Crucially this
* holds even though the app's DEFAULT output is Tailwind (utilities in the JSX, no
* `.c<id>` rules) the preview reuses the css-mode rule collector directly.
* - HTML: `propsList` + `resolveTag` from app.ts the SAME tag resolution, attribute
* filtering, asset-URL mapping (src/poster/srcset), video/lottie poster stills, and
* class assignment the JSX renderer uses. We translate the JSX-flavoured attribute
* pairs to raw HTML attributes; we do NOT re-derive which attrs/assets to keep.
*
* Runtime-free: no DittoWire / DittoLottie / DittoMotion scripts. Lottie mounts and
* <video> render as their captured poster/first-frame stills (the still is already
* baked into the node's `poster`/`src` attr by capture + the asset pipeline, so
* `propsList` emits it for us). Animations are frozen at their captured start state.
*
* Determinism (sacred): derived purely from the IR + rules + asset map; no timestamps,
* no randomness. Two runs over the same frozen capture emit byte-identical HTML.
*
* Asset paths: the asset map yields root-absolute `/assets/cloned/...` (public/ served
* at the app root under Next). preview.html physically sits at generated/app/preview.html
* with assets under generated/app/public/assets/cloned/..., so we rewrite the leading
* `/assets/` to `public/assets/` a relative path that resolves both when the file is
* opened at the generated app-dir root and when it is fetched alongside the file map.
*/
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
// Inverse of app.ts's ATTR_RENAME (HTML → React). The JSX `propsList` renames HTML
// attribute names to their React spelling (for → htmlFor, class → className, …); the
// preview emits raw HTML, so map them back. Anything not listed is already an HTML name.
const REACT_TO_HTML: Record<string, string> = {
htmlFor: "for", className: "class", srcSet: "srcset", colSpan: "colspan",
rowSpan: "rowspan", dateTime: "datetime", itemProp: "itemprop", hrefLang: "hreflang",
autoPlay: "autoplay", playsInline: "playsinline", readOnly: "readonly",
maxLength: "maxlength", crossOrigin: "crossorigin", noValidate: "novalidate",
tabIndex: "tabindex", contentEditable: "contenteditable", defaultChecked: "checked",
};
/** Rewrite the asset map's root-absolute `/assets/` refs to the preview's relative
* `public/assets/…` layout (preview.html at generated/app/, assets at generated/app/public/).
* Applied to both inline CSS (url(/assets/)) and HTML attribute values. Pure string op. */
function relativizeAssets(s: string): string {
return s.replace(/\/assets\//g, "public/assets/");
}
function escapeAttr(v: string): string {
return v.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeText(raw: string): string {
return raw.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** Collapse a text run under white-space:normal the way CSS renders it (mirrors app.ts's
* collapseWs): every whitespace run a single space. Verbatim under pre/pre-wrap etc. */
function collapseWs(raw: string): string {
return raw.replace(/\s+/g, " ");
}
function preservesWhitespace(n: IRNode, canonical: number): boolean {
const ws = (n.computedByVp[canonical] ?? Object.values(n.computedByVp)[0])?.whiteSpace;
if (ws) return /^(pre|pre-wrap|pre-line|break-spaces)$/.test(ws);
return n.tag === "pre" || n.tag === "textarea";
}
// Element-only parents whose direct whitespace-only text children are non-significant
// (mirrors app.ts's ELEMENT_ONLY_PARENTS): a stray "\n " between <li>s is source
// indentation, not content.
const ELEMENT_ONLY_PARENTS = new Set(["ul", "ol", "table", "thead", "tbody", "tfoot", "tr", "select", "colgroup", "optgroup", "menu", "dl"]);
/** Translate one JSX-flavoured [key, valueSrc] pair from propsList into a raw HTML
* attribute string (leading space), or "" to drop it. valueSrc is ready-to-emit JS
* source: a JSON string literal, `true`, or a `{ __html: … }` object (SVG inner the
* caller handles that separately, so we skip it here). */
function htmlAttr(key: string, valueSrc: string): string {
// The SVG inner-markup prop is emitted as literal inner HTML by the caller.
if (key === "dangerouslySetInnerHTML") return "";
const rawName = key.startsWith('"') ? key.slice(1, -1) : key;
const name = REACT_TO_HTML[rawName] ?? rawName;
if (valueSrc === "true") return ` ${name}`;
if (valueSrc === "false") return "";
let value: string;
if (valueSrc.startsWith('"')) {
try { value = JSON.parse(valueSrc) as string; } catch { return ""; }
} else {
// A `{ __html … }` object or other non-string expression — not a plain HTML attr.
return "";
}
// Asset attrs (src/poster/srcset) and any inline url() carry the /assets/ prefix.
if (name === "src" || name === "poster" || name === "srcset" || name === "style") value = relativizeAssets(value);
return ` ${name}="${escapeAttr(value)}"`;
}
/** The raw-HTML attribute string for a node REUSES propsList (app.ts) so tag/attr/asset
* decisions are identical to the JSX path; we only re-serialize to HTML. No ctx is passed,
* so propsList assigns the legacy `c<id>` class (matching our `.c<id>` CSS) and emits
* data-cid; component/section/svg-module machinery is inert (the composed DOM is the same). */
function attrsForNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: string): string {
return propsList(node, assetMap, sourceUrl).map(([k, v]) => htmlAttr(k, v)).join("");
}
/** The inner markup of an inline <svg> node, taken verbatim from the propsList
* `dangerouslySetInnerHTML` prop (already browser-serialized, capture-id stripped, start
* state revealed) shared with the JSX path, url()s relativized. */
function svgInner(node: IRNode, assetMap: Map<string, string>, sourceUrl: string): string {
for (const [k, v] of propsList(node, assetMap, sourceUrl)) {
if (k !== "dangerouslySetInnerHTML") continue;
const m = /\{\s*__html:\s*(".*")\s*\}$/s.exec(v);
if (!m) return "";
try { return relativizeAssets(JSON.parse(m[1]!) as string); } catch { return ""; }
}
return "";
}
function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: string, canonical: number, indent: number, insideInteractive: boolean, insideTable: boolean): string {
const pad = " ".repeat(indent);
const tag = resolveTag(node, insideInteractive, insideTable);
const attrs = attrsForNode(node, assetMap, sourceUrl);
const childInteractive = insideInteractive || node.tag === "a" || node.tag === "button";
const childTable = insideTable || node.tag === "table";
// Inline SVG: emit the shared inner markup verbatim inside the <svg> box.
if (node.rawHTML && tag === "svg") {
const inner = svgInner(node, assetMap, sourceUrl);
return inner ? `${pad}<svg${attrs}>${inner}</svg>` : `${pad}<svg${attrs}></svg>`;
}
if (VOID_TAGS.has(tag)) return `${pad}<${tag}${attrs} />`;
const children = renderChildren(node.children, tag, assetMap, sourceUrl, canonical, indent + 1, childInteractive, childTable, preservesWhitespace(node, canonical));
if (children.length === 0) return `${pad}<${tag}${attrs}></${tag}>`;
return `${pad}<${tag}${attrs}>\n${children.join("\n")}\n${pad}</${tag}>`;
}
function renderChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, canonical: number, indent: number, insideInteractive: boolean, insideTable: boolean, preserveWs: boolean): string[] {
const pad = " ".repeat(indent);
const parts: string[] = [];
// Coalesce adjacent text children into one node (the browser merges them anyway).
let textBuf = "";
const flush = () => {
if (parentTag && ELEMENT_ONLY_PARENTS.has(parentTag) && textBuf.trim() === "") { textBuf = ""; return; }
const out = preserveWs ? textBuf : collapseWs(textBuf);
if (out.length) parts.push(`${pad}${escapeText(out)}`);
textBuf = "";
};
for (const c of children) {
if (isTextChild(c)) { textBuf += c.text; continue; }
// <source>/<track> children: kept only if the JSX path would keep them. The JSX
// emitter filters video <track> and non-materialized <source>; but propsList/resolveTag
// don't, so filter here to match. A video/picture <source> without a local candidate
// and any <video> <track> would otherwise reference a missing/remote file.
if (parentTag === "video" && c.tag === "track") continue;
flush();
parts.push(renderNode(c, assetMap, sourceUrl, canonical, indent, insideInteractive, insideTable));
}
flush();
return parts;
}
/** The inline stylesheet: reset + fonts + tokens + page base + per-node rules + keyframes.
* The per-node rules and keyframes come from the SHARED css.ts collectors (identical to
* what the built app resolves to); all url()/asset refs are relativized. */
function previewCss(ir: IR, assetMap: Map<string, string>, tokensCss: string, fontGraph: FontGraph, colorVar?: (v: string) => string | null, tokenResolver?: TokenResolver): string {
const rules = collectNodeRules(ir, assetMap, undefined, colorVar, tokenResolver);
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
const kf = keyframesCss(ir, assetMap);
const nodeCss = assembleCss([...rules.keys()], (cid) => rules.get(cid)!, (cid) => `.c${cid}`, bands, kf);
const pv = ir.doc.perViewport[ir.doc.canonicalViewport];
const htmlBg = resolveHtmlBg(pv);
const noHScroll = Object.entries(ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03);
const clip = noHScroll ? "\nhtml, body { overflow-x: clip; }" : "";
const doc = `${RESET_CSS}
/* fonts */
${fontGraph.css}
/* tokens */
${tokensCss}
/* page base */
${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
/* nodes */
${nodeCss}`;
return relativizeAssets(doc);
}
/**
* Emit the standalone `preview.html` document as a string. Pure + deterministic.
* `tokensCss` is the assembled color-palette + token CSS (same string the pipeline
* feeds generateApp). `sourceUrl` drives asset-URL resolution (matching the JSX path).
*/
export function generatePreviewHtml(input: {
ir: IR;
assetMap: Map<string, string>;
fontGraph: FontGraph;
tokensCss: string;
sourceUrl: string;
colorVar?: (v: string) => string | null;
tokenResolver?: TokenResolver;
}): string {
const { ir, assetMap, fontGraph, tokensCss, sourceUrl, colorVar, tokenResolver } = input;
const canonical = ir.doc.canonicalViewport;
const css = previewCss(ir, assetMap, tokensCss, fontGraph, colorVar, tokenResolver);
const body = renderChildren(ir.root.children, ir.root.tag, assetMap, sourceUrl, canonical, 2, false, false, false).join("\n");
const bodyAttrs = attrsForNode(ir.root, assetMap, sourceUrl);
const lang = ir.doc.lang || "en";
const title = ir.doc.title || "Preview";
return `<!doctype html>
<html lang="${escapeAttr(lang)}">
<head>
<meta charset="${escapeAttr(ir.doc.charset || "utf-8")}" />
<meta name="viewport" content="${escapeAttr(ir.doc.metaViewport || "width=device-width, initial-scale=1")}" />
<title>${escapeText(title)}</title>
<style>
${css}
</style>
</head>
<body${bodyAttrs}>
${body}
</body>
</html>
`;
}
-4
View File
@@ -32,10 +32,6 @@ export type { GateResult } from "./validate/gates.js";
export { buildIR } from "./normalize/ir.js"; export { buildIR } from "./normalize/ir.js";
export type { IR } from "./normalize/ir.js"; export type { IR } from "./normalize/ir.js";
// ---- Pattern knowledge (frozen catalog; deterministic hints; pin asserted on load) ----
export { resolvePatternHints, loadPatternIndex, assertPinnedCatalog, matchCatalogNode } from "./knowledge/patternIndex.js";
export type { PatternHints, PatternMatch, PatternDef, PatternCatalog } from "./knowledge/patternIndex.js";
// ---- Capture surface + version ---- // ---- Capture surface + version ----
export { captureSite, REQUIRED_VIEWPORTS } from "./capture/capture.js"; export { captureSite, REQUIRED_VIEWPORTS } from "./capture/capture.js";
export type { CaptureResult } from "./capture/capture.js"; export type { CaptureResult } from "./capture/capture.js";
+7 -58
View File
@@ -3,7 +3,6 @@ import { isTextChild } from "../normalize/ir.js";
import type { PrimitiveType } from "./primitives.js"; import type { PrimitiveType } from "./primitives.js";
import type { Section } from "./sections.js"; import type { Section } from "./sections.js";
import { round } from "../util/canonical.js"; import { round } from "../util/canonical.js";
import { matchCatalogNode, type PatternDef, type PatternHints } from "../knowledge/patternIndex.js";
export type RecipeKind = "logo-cloud" | "feature-grid" | "card-grid" | "product-grid" | "gallery-showcase" | "cta-band"; export type RecipeKind = "logo-cloud" | "feature-grid" | "card-grid" | "product-grid" | "gallery-showcase" | "cta-band";
@@ -61,8 +60,6 @@ export type RecipeReport = {
highConfidence: number; highConfidence: number;
byKind: Record<string, number>; byKind: Record<string, number>;
templateReadyKinds: RecipeKind[]; templateReadyKinds: RecipeKind[];
/** page-level frozen-catalog pattern ids (from resolvePatternHints), when provided */
catalogPatterns?: string[];
}; };
candidates: RecipeCandidate[]; candidates: RecipeCandidate[];
}; };
@@ -81,10 +78,6 @@ type RecipeContext = {
parentById: ParentMap; parentById: ParentMap;
sectionByNodeId: SectionMap; sectionByNodeId: SectionMap;
primitives: Map<string, PrimitiveType>; primitives: Map<string, PrimitiveType>;
/** per-node frozen-catalog matches, memoized (pattern catalog as a 4th evidence source) */
catalogByCid: Map<string, PatternDef[]>;
/** page-level catalog flags from resolvePatternHints (e.g. platform_shopify, ecommerce) */
pageFlags: Set<string>;
}; };
type ItemStats = { type ItemStats = {
@@ -240,7 +233,7 @@ function nodeStats(ctx: RecipeContext, n: IRNode): ItemStats {
}; };
} }
function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>, patternHints?: PatternHints): RecipeContext { function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeContext {
const nodes: IRNode[] = []; const nodes: IRNode[] = [];
const byId: NodeMap = new Map(); const byId: NodeMap = new Map();
const parentById: ParentMap = new Map(); const parentById: ParentMap = new Map();
@@ -261,32 +254,9 @@ function buildContext(ir: IR, sections: Section[], primitives: Map<string, Primi
parentById, parentById,
sectionByNodeId: new Map(sections.map((s) => [s.nodeId, s])), sectionByNodeId: new Map(sections.map((s) => [s.nodeId, s])),
primitives, primitives,
catalogByCid: new Map(),
pageFlags: new Set(patternHints?.flags ?? []),
}; };
} }
function catalogDefs(ctx: RecipeContext, node: IRNode): PatternDef[] {
let defs = ctx.catalogByCid.get(node.id);
if (!defs) {
defs = matchCatalogNode(node);
ctx.catalogByCid.set(node.id, defs);
}
return defs;
}
/** First catalog pattern in `root`'s subtree matching `pred` (pre-order; memoized
* per node). The frozen catalog is deterministic, so recipe evidence stays
* byte-stable across regenerations. */
function subtreeCatalogMatch(ctx: RecipeContext, root: IRNode, pred: (d: PatternDef) => boolean): PatternDef | null {
for (const d of catalogDefs(ctx, root)) if (pred(d)) return d;
for (const c of elementChildren(root)) {
const hit = subtreeCatalogMatch(ctx, c, pred);
if (hit) return hit;
}
return null;
}
function nearestSection(ctx: RecipeContext, n: IRNode): Section | undefined { function nearestSection(ctx: RecipeContext, n: IRNode): Section | undefined {
let cur: IRNode | undefined = n; let cur: IRNode | undefined = n;
while (cur) { while (cur) {
@@ -575,23 +545,18 @@ function detectLogoClouds(ctx: RecipeContext): RecipeDraft[] {
const rootText = textContent(root, 1200); const rootText = textContent(root, 1200);
const layout = isLayoutish(ctx, parent, logos.map((s) => s.node)); 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 avgHeight = logos.reduce((sum, s) => sum + (s.bbox?.height ?? 0), 0) / logos.length;
// Logo strips often ride marquee libraries (react-fast-marquee, ticker) —
// a catalog marquee hit is strong logo-cloud evidence.
const catalogMarquee = subtreeCatalogMatch(ctx, parent, (d) => d.kind === "marquee");
const signals = [ const signals = [
`${logos.length} repeated media-light children`, `${logos.length} repeated media-light children`,
layout ? "item parent behaves like grid/flex/wrapped row" : "item parent has repeated logo geometry", 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" : "", TRUSTED_COPY.test(rootText) ? "nearby copy matches trusted-by/brand language" : "",
avgHeight <= 96 ? "logo item boxes stay small" : "", avgHeight <= 96 ? "logo item boxes stay small" : "",
catalogMarquee ? `pattern catalog identifies a ${catalogMarquee.id} strip` : "",
].filter(Boolean); ].filter(Boolean);
const confidence = 0.58 const confidence = 0.58
+ Math.min(0.18, logos.length * 0.025) + Math.min(0.18, logos.length * 0.025)
+ (logos.length / kids.length) * 0.12 + (logos.length / kids.length) * 0.12
+ (layout ? 0.08 : 0) + (layout ? 0.08 : 0)
+ (TRUSTED_COPY.test(rootText) ? 0.10 : 0) + (TRUSTED_COPY.test(rootText) ? 0.10 : 0)
+ (avgHeight <= 96 ? 0.04 : 0) + (avgHeight <= 96 ? 0.04 : 0);
+ (catalogMarquee ? 0.04 : 0);
out.push(baseDraft(ctx, "logo-cloud", parent, logos, confidence, signals)); out.push(baseDraft(ctx, "logo-cloud", parent, logos, confidence, signals));
} }
return out; return out;
@@ -603,10 +568,6 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
for (const parent of ctx.nodes) { for (const parent of ctx.nodes) {
if (parent.id === ctx.ir.root.id) continue; if (parent.id === ctx.ir.root.id) continue;
if (subtreeSourceClassMatches(parent, DEFERRED_INTERACTIVE_COLLECTION)) continue; if (subtreeSourceClassMatches(parent, DEFERRED_INTERACTIVE_COLLECTION)) continue;
// Catalog-known carousel/marquee libraries the regex misses (embla, flickity,
// keen-slider, glide, tns, …) are the same deferred-interactive class: their
// children are slides, not a static grid.
if (subtreeCatalogMatch(ctx, parent, (d) => d.kind === "carousel" || d.kind === "marquee")) continue;
const kids = visibleElementChildren(parent, ctx.cw); const kids = visibleElementChildren(parent, ctx.cw);
if (kids.length < 2 || kids.length > 36) continue; if (kids.length < 2 || kids.length > 36) continue;
const stats = kids.map((k) => nodeStats(ctx, k)); const stats = kids.map((k) => nodeStats(ctx, k));
@@ -620,10 +581,7 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
const rootTooBroad = rootInfo.root.id === ctx.ir.root.id || (!!pageH && !!rootBox && rootBox.height > pageH * 0.55); const rootTooBroad = rootInfo.root.id === ctx.ir.root.id || (!!pageH && !!rootBox && rootBox.height > pageH * 0.55);
const localText = textContent(parent, 1200); const localText = textContent(parent, 1200);
const parentText = rootTooBroad ? localText : textContent(rootInfo.root, 1600); const parentText = rootTooBroad ? localText : textContent(rootInfo.root, 1600);
// Lightbox libraries (fancybox, photoswipe, lightGallery, …) mark a thumbnail const galleryContext = subtreeAttrOrSourceMatches(parent, GALLERY_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, GALLERY_SOURCE));
// grid as a gallery even when no gallery-ish class names are present.
const catalogLightbox = subtreeCatalogMatch(ctx, parent, (d) => d.kind === "lightbox");
const galleryContext = subtreeAttrOrSourceMatches(parent, GALLERY_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, GALLERY_SOURCE)) || !!catalogLightbox;
const testimonialContext = subtreeAttrOrSourceMatches(parent, TESTIMONIAL_SOURCE) || TESTIMONIAL_COPY.test(localText); const testimonialContext = subtreeAttrOrSourceMatches(parent, TESTIMONIAL_SOURCE) || TESTIMONIAL_COPY.test(localText);
const featureItems = stats.filter(isLikelyFeatureItem); const featureItems = stats.filter(isLikelyFeatureItem);
const cardItems = stats.filter(isLikelyCardItem); const cardItems = stats.filter(isLikelyCardItem);
@@ -640,7 +598,6 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
const signals = [ const signals = [
`${cardLikeItems.length} repeated ${testimonialContext ? "testimonial/gallery" : "media/gallery"} items`, `${cardLikeItems.length} repeated ${testimonialContext ? "testimonial/gallery" : "media/gallery"} items`,
galleryContext ? "source attributes/classes identify a gallery or carousel track" : "", galleryContext ? "source attributes/classes identify a gallery or carousel track" : "",
catalogLightbox ? `pattern catalog identifies a ${catalogLightbox.id} lightbox gallery` : "",
testimonialContext ? "source text/classes identify a testimonial or horizontal story strip" : "", testimonialContext ? "source text/classes identify a testimonial or horizontal story strip" : "",
oneRowRegimes >= 2 ? "items remain in a horizontal gallery row across sampled widths" : "", oneRowRegimes >= 2 ? "items remain in a horizontal gallery row across sampled widths" : "",
mediaRatio >= 0.6 ? "most gallery items include media" : "", mediaRatio >= 0.6 ? "most gallery items include media" : "",
@@ -662,13 +619,9 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
const productItems = stats.filter(isLikelyProductItem); const productItems = stats.filter(isLikelyProductItem);
const forbiddenProductContext = subtreeAttrOrSourceMatches(parent, /\b(?:footer|directory|nav|menu)\b/i) const forbiddenProductContext = subtreeAttrOrSourceMatches(parent, /\b(?:footer|directory|nav|menu)\b/i)
|| (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, /\b(?:footer|directory|nav|menu)\b/i)); || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, /\b(?:footer|directory|nav|menu)\b/i));
// Page-level catalog prior: a Shopify/WooCommerce page makes repeated card
// grids product grids even when per-node class names carry no commerce words.
const catalogCommerce = ctx.pageFlags.has("ecommerce") || ctx.pageFlags.has("platform_shopify");
const productContext = !forbiddenProductContext && const productContext = !forbiddenProductContext &&
(PRODUCT_COPY.test(localText) || subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) (PRODUCT_COPY.test(localText) || subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE)
|| (!rootTooBroad && (PRODUCT_COPY.test(parentText) || subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) || (!rootTooBroad && (PRODUCT_COPY.test(parentText) || subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))));
|| catalogCommerce);
const productStats = productItems.length >= 2 ? productItems : (productContext ? cardLikeItems : []); const productStats = productItems.length >= 2 ? productItems : (productContext ? cardLikeItems : []);
if (productStats.length >= 2 && productStats.length / kids.length >= 0.45 && productContext) { if (productStats.length >= 2 && productStats.length / kids.length >= 0.45 && productContext) {
const key = itemSetKey("product-grid", productStats); const key = itemSetKey("product-grid", productStats);
@@ -682,7 +635,6 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
productCopyRatio >= 0.5 ? "most items contain commerce/product copy" : "", productCopyRatio >= 0.5 ? "most items contain commerce/product copy" : "",
ctaRatio >= 0.5 ? "most products include link/button affordances" : "", 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" : "", (subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? "source context is product/promo/commerce-like" : "",
catalogCommerce ? "pattern catalog identifies an e-commerce platform page" : "",
].filter(Boolean); ].filter(Boolean);
const confidence = 0.58 const confidence = 0.58
+ Math.min(0.14, productStats.length * 0.025) + Math.min(0.14, productStats.length * 0.025)
@@ -690,8 +642,7 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
+ (layout ? 0.10 : 0) + (layout ? 0.10 : 0)
+ (productCopyRatio >= 0.5 ? 0.07 : 0) + (productCopyRatio >= 0.5 ? 0.07 : 0)
+ (ctaRatio >= 0.5 ? 0.05 : 0) + (ctaRatio >= 0.5 ? 0.05 : 0)
+ ((subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? 0.06 : 0) + ((subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? 0.06 : 0);
+ (catalogCommerce ? 0.05 : 0);
out.push(baseDraft(ctx, "product-grid", parent, productStats, confidence, signals)); out.push(baseDraft(ctx, "product-grid", parent, productStats, confidence, signals));
} }
continue; continue;
@@ -881,8 +832,8 @@ function candidateSort(ctx: RecipeContext, a: RecipeDraft, b: RecipeDraft): numb
|| b.confidence - a.confidence; || b.confidence - a.confidence;
} }
export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>, patternHints?: PatternHints): RecipeReport { export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeReport {
const ctx = buildContext(ir, sections, primitives, patternHints); const ctx = buildContext(ir, sections, primitives);
const drafts = suppressDuplicates(ctx, [ const drafts = suppressDuplicates(ctx, [
...detectLogoClouds(ctx), ...detectLogoClouds(ctx),
...detectGrids(ctx), ...detectGrids(ctx),
@@ -902,7 +853,6 @@ export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<s
highConfidence: candidates.filter((c) => c.confidence >= 0.82).length, highConfidence: candidates.filter((c) => c.confidence >= 0.82).length,
byKind, byKind,
templateReadyKinds: TEMPLATE_READY, templateReadyKinds: TEMPLATE_READY,
...(patternHints ? { catalogPatterns: patternHints.matches.map((m) => m.id) } : {}),
}, },
candidates, candidates,
}; };
@@ -930,7 +880,6 @@ export function recipeReportToMarkdown(report: RecipeReport): string {
lines.push(""); lines.push("");
lines.push(`- Candidates: ${report.summary.totalCandidates}`); lines.push(`- Candidates: ${report.summary.totalCandidates}`);
lines.push(`- High confidence: ${report.summary.highConfidence}`); lines.push(`- High confidence: ${report.summary.highConfidence}`);
if (report.summary.catalogPatterns?.length) lines.push(`- Catalog patterns on page: ${report.summary.catalogPatterns.join(", ")}`);
lines.push(`- By kind: ${Object.entries(report.summary.byKind).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}`); lines.push(`- By kind: ${Object.entries(report.summary.byKind).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}`);
lines.push(""); lines.push("");
lines.push("## Candidates"); lines.push("## Candidates");
-206
View File
@@ -1,206 +0,0 @@
/**
* Pattern index: O(1) signature lookup over the frozen pattern catalog
* (data/pattern-catalog.json). Matches known widget/platform/animation
* signatures against the IR's diagnostic evidence (srcClass, tags, attrs)
* and produces deterministic PatternHints consumed as a generation artifact
* (patterns.json) and as an ADDITIVE evidence source for recipe recognition.
*
* Determinism contract: the catalog is FROZEN data pinned by sha256
* (data/pattern-catalog.lock). Same catalog + same IR => byte-identical hints.
* There is no learning layer here hints are catalog-only by construction.
* The pin is UNCONDITIONAL: a catalog/lock mismatch (or a missing lock) always
* throws, so pinned evidence can never drift silently.
*/
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { IR, IRNode, IRChild } from "../normalize/ir.js";
export type PatternMatchSpec = {
/** exact class-token equality (lowercase) */
classTokens?: string[];
/** class-token prefix, e.g. "elementor-", "wp-block-" */
classPrefixes?: string[];
/** element tag names, e.g. custom elements like "lottie-player" */
tags?: string[];
/** attribute presence, e.g. "data-aos" */
attrNames?: string[];
/** id-attribute prefix, e.g. "shopify-section" */
idPrefixes?: string[];
};
export type PatternDef = {
id: string;
kind: string;
flags: string[];
match: PatternMatchSpec;
};
export type PatternCatalog = {
version: number;
description?: string;
patterns: PatternDef[];
};
export type PatternMatch = {
id: string;
kind: string;
flags: string[];
/** how many IR nodes matched at least one signature of this pattern */
count: number;
/** first few matching node ids, pre-order (bounded sample for diagnostics) */
cids: string[];
};
export type PatternHints = {
catalogVersion: number;
catalogHash: string;
/** matched patterns, sorted by id for determinism */
matches: PatternMatch[];
/** union of flags across matches, sorted */
flags: string[];
/** platform_* flags with the prefix stripped, sorted (e.g. ["elementor","wordpress"]) */
platforms: string[];
/** no deferred-interactive / motion-lib / counter / nav-toggle signatures and a small tree
* callers may skip optional capture/inference stages for such pages */
simpleStatic: boolean;
};
const CATALOG_PATH = fileURLToPath(new URL("../../data/pattern-catalog.json", import.meta.url));
const LOCK_PATH = fileURLToPath(new URL("../../data/pattern-catalog.lock", import.meta.url));
const SAMPLE_CIDS = 5;
/** simpleStatic requires the whole tree under this node count (matches "small landing page"). */
const SIMPLE_STATIC_MAX_NODES = 1500;
const SIMPLE_STATIC_BLOCKERS = ["deferred_interactive", "motion_lib", "counter", "nav_toggle"];
type CompiledIndex = {
catalog: PatternCatalog;
hash: string;
byClassToken: Map<string, PatternDef[]>;
classPrefixes: Array<[string, PatternDef]>;
byTag: Map<string, PatternDef[]>;
byAttrName: Map<string, PatternDef[]>;
idPrefixes: Array<[string, PatternDef]>;
};
let cached: CompiledIndex | null = null;
function addTo<K>(map: Map<K, PatternDef[]>, key: K, def: PatternDef): void {
const list = map.get(key);
if (list) list.push(def);
else map.set(key, [def]);
}
export function loadPatternIndex(): CompiledIndex {
if (cached) return cached;
const raw = readFileSync(CATALOG_PATH, "utf8");
const hash = createHash("sha256").update(raw).digest("hex");
const catalog = JSON.parse(raw) as PatternCatalog;
if (!Array.isArray(catalog.patterns)) throw new Error("pattern catalog: missing patterns[]");
const idx: CompiledIndex = {
catalog,
hash,
byClassToken: new Map(),
classPrefixes: [],
byTag: new Map(),
byAttrName: new Map(),
idPrefixes: [],
};
for (const p of catalog.patterns) {
for (const t of p.match.classTokens ?? []) addTo(idx.byClassToken, t.toLowerCase(), p);
for (const pre of p.match.classPrefixes ?? []) idx.classPrefixes.push([pre.toLowerCase(), p]);
for (const t of p.match.tags ?? []) addTo(idx.byTag, t.toLowerCase(), p);
for (const a of p.match.attrNames ?? []) addTo(idx.byAttrName, a.toLowerCase(), p);
for (const pre of p.match.idPrefixes ?? []) idx.idPrefixes.push([pre, p]);
}
cached = idx;
return idx;
}
/** Enforce the frozen-catalog pin. UNCONDITIONAL: a missing lock or a hash
* mismatch always throws pinned evidence must never drift silently, so the
* generator refuses to run against an unpinned/edited catalog. Regenerate the
* lock deliberately after a catalog edit with `--write-lock`. */
export function assertPinnedCatalog(): void {
const idx = loadPatternIndex();
if (!existsSync(LOCK_PATH)) {
throw new Error("pattern catalog lock missing (data/pattern-catalog.lock)");
}
const pinned = readFileSync(LOCK_PATH, "utf8").trim();
if (pinned !== idx.hash) {
throw new Error(
`pattern catalog hash mismatch: pinned ${pinned.slice(0, 12)}… actual ${idx.hash.slice(0, 12)}`,
);
}
}
function isElement(c: IRChild): c is IRNode {
return (c as IRNode).id !== undefined;
}
/** Match ONE node's own evidence (srcClass tokens, tag, attr names, id prefix)
* against the catalog. Shared by the page-level hint scan and per-candidate
* recipe scoring. Returns matched defs, deduped, in catalog order. */
export function matchCatalogNode(node: Pick<IRNode, "tag" | "attrs" | "srcClass">): PatternDef[] {
const idx = loadPatternIndex();
const hits = new Set<PatternDef>();
if (node.srcClass) {
for (const tok of node.srcClass.toLowerCase().split(/\s+/)) {
if (!tok) continue;
for (const def of idx.byClassToken.get(tok) ?? []) hits.add(def);
for (const [pre, def] of idx.classPrefixes) if (tok.startsWith(pre)) hits.add(def);
}
}
for (const def of idx.byTag.get(node.tag.toLowerCase()) ?? []) hits.add(def);
for (const attr of Object.keys(node.attrs)) {
for (const def of idx.byAttrName.get(attr.toLowerCase()) ?? []) hits.add(def);
}
const id = node.attrs.id;
if (id) for (const [pre, def] of idx.idPrefixes) if (id.startsWith(pre)) hits.add(def);
return idx.catalog.patterns.filter((p) => hits.has(p));
}
/** Single deterministic pre-order walk of the IR: tokenize srcClass, check tag /
* attr / id signatures, accumulate per-pattern counts + a bounded cid sample.
* Asserts the frozen-catalog pin first (throws on mismatch). */
export function resolvePatternHints(ir: IR): PatternHints {
const idx = loadPatternIndex();
assertPinnedCatalog();
const counts = new Map<string, { def: PatternDef; count: number; cids: string[] }>();
const hit = (def: PatternDef, cid: string, seen: Set<string>) => {
if (seen.has(def.id)) return; // one hit per node per pattern
seen.add(def.id);
let e = counts.get(def.id);
if (!e) { e = { def, count: 0, cids: [] }; counts.set(def.id, e); }
e.count++;
if (e.cids.length < SAMPLE_CIDS) e.cids.push(cid);
};
const visit = (node: IRNode) => {
const seen = new Set<string>();
for (const def of matchCatalogNode(node)) hit(def, node.id, seen);
for (const c of node.children) if (isElement(c)) visit(c);
};
visit(ir.root);
const matches: PatternMatch[] = [...counts.values()]
.map((e) => ({ id: e.def.id, kind: e.def.kind, flags: e.def.flags, count: e.count, cids: e.cids }))
.sort((a, b) => a.id.localeCompare(b.id));
const flags = [...new Set(matches.flatMap((m) => m.flags))].sort();
const platforms = flags
.filter((f) => f.startsWith("platform_"))
.map((f) => f.slice("platform_".length))
.sort();
const simpleStatic =
ir.doc.nodeCount < SIMPLE_STATIC_MAX_NODES && !flags.some((f) => SIMPLE_STATIC_BLOCKERS.includes(f));
return { catalogVersion: idx.catalog.version, catalogHash: idx.hash, matches, flags, platforms, simpleStatic };
}
// `--write-lock` maintenance entry: refresh the pin after a deliberate catalog edit.
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href && process.argv.includes("--write-lock")) {
const idx = loadPatternIndex();
writeFileSync(LOCK_PATH, idx.hash + "\n");
console.log("pinned pattern catalog:", idx.hash);
}
-17
View File
@@ -12,7 +12,6 @@ import { join } from "node:path";
import { rmSync } from "node:fs"; import { rmSync } from "node:fs";
import { writeText, readJSON, fileExists } from "../util/fsx.js"; import { writeText, readJSON, fileExists } from "../util/fsx.js";
import { generateCss, RESET_CSS } from "../generate/css.js"; import { generateCss, RESET_CSS } from "../generate/css.js";
import { generatePreviewHtml } from "../generate/preview.js";
import { generateInteractionCss } from "../generate/interactionCss.js"; import { generateInteractionCss } from "../generate/interactionCss.js";
import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js"; import { buildRuntimeSpecs, wiresJsx, dittoWireImportPath, DITTO_WIRE_TSX, interactionRejectedSet } from "../generate/interactive.js";
import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js"; import { buildLottieSpec, lottieHasContent, lottieWireJsx, dittoLottieImportPath, DITTO_LOTTIE_TSX } from "../generate/lottie.js";
@@ -455,22 +454,6 @@ export function generateSiteApp(opts: {
const globals = globalsCss(entry, unionFontCss(routes), palette.css); const globals = globalsCss(entry, unionFontCss(routes), palette.css);
writeText(join(appDir, "src", isVite ? "globals.css" : join("app", "globals.css")), isVite ? viteGlobalsCss(globals) : globals); writeText(join(appDir, "src", isVite ? "globals.css" : join("app", "globals.css")), isVite ? viteGlobalsCss(globals) : globals);
} }
// Static preview of the ENTRY page only (the product moment — the first page shown
// within seconds of generate, before the Next build + deploy completes). Runtime-free
// single HTML file over the entry IR, sharing the same css.ts rule collectors +
// propsList/resolveTag decisions as the app. Assets relative to public/.
const previewTokensCss = tw
? palette.css + "\n" + tokensToCss(entry.tokens, true) + (interner.defs.size ? "\n" + colorDefsCssOf(interner) : "")
: palette.css + "\n" + tokensToCss(entry.tokens, true);
writeText(join(appDir, "preview.html"), generatePreviewHtml({
ir: entry.ir,
assetMap: buildAssetMap(entry.assetGraph),
fontGraph: { entries: entry.fontGraph.entries, css: unionFontCss(routes) },
tokensCss: previewTokensCss,
sourceUrl: entry.ir.doc.sourceUrl,
colorVar: palette.varForColor,
}));
emitGeneratedDocs(appDir, { emitGeneratedDocs(appDir, {
sourceUrl: entry.ir.doc.sourceUrl, sourceUrl: entry.ir.doc.sourceUrl,
routes: seoRoutes, routes: seoRoutes,
-73
View File
@@ -1,73 +0,0 @@
/** Capture-side fast-fail helpers (Item 3).
*
* Two concerns share this module because both are pure, testable predicates the
* capture flow keys off:
*
* 1. Wall-text detection the SAME signature set the validator's pollution gate
* uses (validate/gates.ts imports WALL_RE from here). Extracted so the two
* judgments capture-side abort and validator-side grade can never drift.
* The pollution gate remains the AUTHORITY on whether a shipped capture is
* polluted; this module only lets capture bail early on an obvious wall
* instead of burning the full multi-viewport pass on garbage.
*
* 2. Nav-failure classification decides whether a navigation/session error is
* worth ONE fresh-context retry (transient: the browser/page died, a socket
* reset, a nav timeout) versus terminal (a wall, a hard DNS/cert failure that
* a retry cannot fix). Pure string classification so it unit-tests without a
* browser.
*/
/** Bot/egress/auth-wall text signatures. One regex, shared with the pollution gate
* (validate/gates.ts) so capture-abort and validator-grade use identical fingerprints. */
export const WALL_RE =
/blocked by egress|access denied|access to this page has been denied|are you a (human|robot)|verify you are human|enable javascript to|please enable javascript|checking your browser|just a moment|attention required|request blocked|why have i been blocked|captcha|cf-browser-verification|ddos protection by/i;
export function isWallText(text: string): boolean {
return WALL_RE.test(text);
}
/** Node-count ceiling under which wall text is treated as a genuine interstitial
* rather than an incidental mention on a real page. Matches the pollution gate's
* `wall && nodeCount < 220` threshold so the two agree on the same boundary. */
export const WALL_MAX_NODES = 220;
export type WallProbe = { text: string; nodes: number };
/** Capture-side wall verdict: the page is a bot/auth wall worth aborting on iff it
* is BOTH small (few nodes) AND carries wall text identical to the gate's rule.
* A large page that merely mentions "captcha" in body copy is NOT a wall. */
export function isBotWall(probe: WallProbe | null | undefined): boolean {
if (!probe) return false;
return probe.nodes < WALL_MAX_NODES && isWallText(probe.text);
}
export type NavFailureClass = "retryable" | "wall" | "terminal";
/** Classify a navigation/session failure by its error text.
*
* - "wall" a bot/auth interstitial; a retry with a fresh context won't help
* and the caller should surface it as a pollution-style abort.
* - "retryable" the browser/page/context died, a transient socket/timeout/reset;
* ONE fresh-context retry is worthwhile.
* - "terminal" a hard failure a retry cannot change (DNS, cert, refused, unknown).
*
* Ordering matters: a wall signature wins over a transient one (a wall served over
* a reset connection is still a wall). */
export function classifyNavFailure(error: unknown): NavFailureClass {
const msg = String((error as { message?: string })?.message ?? error ?? "");
if (WALL_RE.test(msg)) return "wall";
// Session death / crash / transient network — a fresh context can recover these.
if (
/Target (page, context or browser|closed)|context or browser has been closed|page(?:,)? .*has been closed|browser has been closed|page closed|has crashed|Navigation .*interrupted|net::ERR_(?:CONNECTION_RESET|CONNECTION_CLOSED|TIMED_OUT|ABORTED|EMPTY_RESPONSE|NETWORK_CHANGED|SOCKET_NOT_CONNECTED)|Timeout .*exceeded|Navigation timeout/i.test(
msg,
)
) {
return "retryable";
}
return "terminal";
}
/** Whether a classified failure earns the single fresh-context retry. */
export function isRetryableNavFailure(error: unknown): boolean {
return classifyNavFailure(error) === "retryable";
}
-103
View File
@@ -1,103 +0,0 @@
/** Deterministic capture-environment shim (Stage 0, runs before ANY page script).
*
* Two entropy sources make a page render differently on each load Math.random
* (shuffled carousels, random ids, A/B jitter) and the wall clock ("posted N
* minutes ago", schedule/countdown logic). Seeding the first and pinning the
* second makes a capture reproducible: the same site captured under the same
* {seed, epoch} paints the same DOM.
*
* CAUTIONS (all honored below):
* - performance.now() is left REAL. Motion capture (marquee/rotator sampling in
* capture/motion.ts) measures real velocities and observation windows off it;
* pinning it would corrupt those measurements. Only the Date wall clock moves.
* - The Date epoch is a PARAMETER, recorded in capture metadata a recapture
* uses a fresh-but-recorded value. Generation determinism only requires a
* frozen capture to produce byte-stable output, which is unaffected by which
* epoch was chosen (the epoch is frozen into the capture, not re-derived).
* - The clock still ADVANCES from the pinned epoch (via the real-clock delta),
* so elapsed-time logic (setTimeout-driven reveals, animation timing that
* reads Date, lazy-load debounces) behaves only the absolute origin is
* pinned, not the passage of time.
* - Date is patched consistently across constructor / Date.now() / getTime() /
* valueOf() / Symbol.toPrimitive by shifting the underlying instant for BOTH
* the no-arg constructor and Date.now(); every instance method then reads the
* shifted instant through the native prototype, so cookie-banner / schedule
* gates that call getTime()/valueOf() see the same pinned clock as now().
*/
/** Default fixed epoch: 2026-01-01T00:00:00.000Z. Callers should pass an explicit,
* recorded epoch (see captureEpochMs); this is only the fallback. */
export const DEFAULT_CAPTURE_EPOCH_MS = 1767225600000;
/** Default PRNG seed (mulberry32 initial state). A non-zero constant so the first
* draw is well-mixed; recorded alongside the epoch for reproducibility. */
export const DEFAULT_PRNG_SEED = 0x9e3779b9;
/** Resolve the epoch a capture run should pin to. Env override lets a recapture
* reuse a recorded value; otherwise a fresh timestamp is taken at run start and
* recorded in metadata. Determinism of GENERATION does not depend on this value
* only on the capture being frozen once chosen. */
export function captureEpochMs(override?: number | string): number {
if (typeof override === "number" && Number.isFinite(override)) return override;
if (typeof override === "string" && override.trim()) {
const parsed = Number(override);
if (Number.isFinite(parsed)) return parsed;
const asDate = Date.parse(override);
if (Number.isFinite(asDate)) return asDate;
}
return DEFAULT_CAPTURE_EPOCH_MS;
}
/**
* Build the init-script SOURCE STRING injected via page.addInitScript before any
* page script runs. Returned as a raw string (not a function) so tsx/esbuild never
* transforms it the browser receives exactly these bytes.
*
* Interpolation is numeric-only (Number.isFinite-guarded), so no string escaping /
* injection surface exists.
*/
export function buildDeterministicEnvShim(opts?: { seed?: number; epochMs?: number }): string {
const seed = Number.isFinite(opts?.seed) ? Math.trunc(opts!.seed!) : DEFAULT_PRNG_SEED;
const epoch = Number.isFinite(opts?.epochMs) ? Math.trunc(opts!.epochMs!) : DEFAULT_CAPTURE_EPOCH_MS;
// NOTE: `delta` is computed once at shim-eval time (the earliest possible moment,
// before page scripts) so the pinned origin is stable; the real clock then adds
// elapsed time on top, keeping relative time truthful.
return `(() => {
"use strict";
// ---- Seeded PRNG (mulberry32): deterministic, well-distributed, cheap ----
let __s = ${seed} | 0;
const __rand = function random() {
__s = (__s + 0x6d2b79f5) | 0;
let t = Math.imul(__s ^ (__s >>> 15), 1 | __s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
try { Object.defineProperty(Math, "random", { value: __rand, writable: true, configurable: true }); }
catch (e) { try { Math.random = __rand; } catch (e2) {} }
// ---- Pinned-but-advancing wall clock. performance.now() stays REAL. ----
const RealDate = Date;
const realNow = () => RealDate.now();
// Shift so the FIRST observed instant is the pinned epoch; real elapsed time is
// then added on top (delta is negative if the machine clock is ahead of epoch).
const delta = ${epoch} - realNow();
const shiftedNow = () => realNow() + delta;
function DittoDate(...args) {
// \`new Date()\` with no args => pinned clock; every other form is delegated to
// the native constructor unchanged (parsing a string, y/m/d, a millis value).
const inst = args.length === 0 ? new RealDate(shiftedNow()) : new RealDate(...args);
// Re-tag the prototype so instanceof + inherited getTime/valueOf/toISOString work.
Object.setPrototypeOf(inst, DittoDate.prototype);
return inst;
}
// Inherit the full native Date prototype (getTime, valueOf, getFullYear,
// toISOString, Symbol.toPrimitive, ...) so every read is consistent with now().
DittoDate.prototype = Object.create(RealDate.prototype);
DittoDate.prototype.constructor = DittoDate;
DittoDate.now = shiftedNow;
DittoDate.parse = RealDate.parse;
DittoDate.UTC = RealDate.UTC;
try { Object.defineProperty(globalThis, "Date", { value: DittoDate, writable: true, configurable: true }); }
catch (e) { try { globalThis.Date = DittoDate; } catch (e2) {} }
})();`;
}
+1 -3
View File
@@ -7,7 +7,6 @@ import type { AssetGraph } from "../infer/assets.js";
import type { FontGraph } from "../infer/fonts.js"; import type { FontGraph } from "../infer/fonts.js";
import type { Section } from "../infer/sections.js"; import type { Section } from "../infer/sections.js";
import type { CaptureResult } from "../capture/capture.js"; import type { CaptureResult } from "../capture/capture.js";
import { WALL_RE } from "../util/captureFailure.js";
export type GateResult = { export type GateResult = {
gate: string; gate: string;
@@ -118,8 +117,7 @@ export function countVisibleInCaptureHiddenInClone(
// page: an egress/bot wall, a near-empty shell, or a cookie/consent modal that was // page: an egress/bot wall, a near-empty shell, or a cookie/consent modal that was
// never dismissed (the clone then reproduces the modal, so the perceptual gate is // never dismissed (the clone then reproduces the modal, so the perceptual gate is
// fooled too). This gate flags those captures so a "perfect" score can't hide them. // fooled too). This gate flags those captures so a "perfect" score can't hide them.
// WALL_RE lives in util/captureFailure.ts — shared with the capture-side fast-fail so const WALL_RE = /blocked by egress|access denied|access to this page has been denied|are you a (human|robot)|verify you are human|enable javascript to|please enable javascript|checking your browser|just a moment|attention required|request blocked|why have i been blocked|captcha|cf-browser-verification|ddos protection by/i;
// the abort and the grade can never drift on what counts as a wall.
export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[]): GateResult { export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[]): GateResult {
const issues: string[] = []; const issues: string[] = [];
-117
View File
@@ -1,117 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
WALL_RE,
isWallText,
isBotWall,
WALL_MAX_NODES,
classifyNavFailure,
isRetryableNavFailure,
} from "../src/util/captureFailure.js";
// ---------------------------------------------------------------------------
// Item 3: capture-side fast-fail predicates (pure — no browser).
// ---------------------------------------------------------------------------
describe("captureFailure: wall-text detection", () => {
it("matches common bot/auth-wall fingerprints", () => {
for (const t of [
"Just a moment...",
"Checking your browser before accessing",
"Please enable JavaScript to continue",
"Access to this page has been denied",
"Are you a human?",
"Verify you are human",
"Attention Required! | Cloudflare",
"Please complete the CAPTCHA",
"DDoS protection by Cloudflare",
]) {
assert.ok(isWallText(t), `expected wall text to match: ${t}`);
}
});
it("does not match ordinary marketing/body copy", () => {
for (const t of [
"Welcome to our store — shop the new collection",
"Our team of humans is here to help you 24/7",
"Enable notifications to get the latest deals",
"About us · Careers · Contact",
]) {
assert.equal(isWallText(t), false, `expected NOT to match: ${t}`);
}
});
it("WALL_RE is the same instance shared with the pollution gate (single source of truth)", () => {
// gates.ts imports WALL_RE from this module; assert the export is a real RegExp
// so a refactor that turns it into a function would fail here.
assert.ok(WALL_RE instanceof RegExp);
});
});
describe("captureFailure: isBotWall (matches the pollution gate's small+wall rule)", () => {
it("flags a small page with wall text", () => {
assert.ok(isBotWall({ text: "Just a moment...", nodes: 8 }));
assert.ok(isBotWall({ text: "checking your browser", nodes: WALL_MAX_NODES - 1 }));
});
it("does NOT flag a large page that merely mentions a wall phrase", () => {
// A real page can say "captcha" in its help docs — node count is the discriminator.
assert.equal(isBotWall({ text: "how our captcha works", nodes: WALL_MAX_NODES }), false);
assert.equal(isBotWall({ text: "how our captcha works", nodes: 5000 }), false);
});
it("does NOT flag a small page with no wall text", () => {
assert.equal(isBotWall({ text: "Home · About · Contact", nodes: 10 }), false);
});
it("is null-safe", () => {
assert.equal(isBotWall(null), false);
assert.equal(isBotWall(undefined), false);
});
});
describe("captureFailure: classifyNavFailure", () => {
it("classifies session-death / crash / transient network as retryable", () => {
for (const msg of [
"Target page, context or browser has been closed",
"Target closed",
"browser has been closed",
"page has crashed",
"net::ERR_CONNECTION_RESET at https://example.com",
"net::ERR_TIMED_OUT",
"Timeout 45000ms exceeded",
"Navigation timeout of 30000 ms exceeded",
]) {
assert.equal(classifyNavFailure(new Error(msg)), "retryable", msg);
assert.equal(isRetryableNavFailure(new Error(msg)), true, msg);
}
});
it("classifies wall fingerprints as wall (not retryable — a fresh context can't help)", () => {
const e = new Error("navigation failed: just a moment... checking your browser");
assert.equal(classifyNavFailure(e), "wall");
assert.equal(isRetryableNavFailure(e), false);
});
it("classifies hard failures as terminal (not retryable)", () => {
for (const msg of [
"net::ERR_NAME_NOT_RESOLVED",
"net::ERR_CERT_AUTHORITY_INVALID",
"net::ERR_CONNECTION_REFUSED",
"something totally unexpected",
]) {
assert.equal(classifyNavFailure(new Error(msg)), "terminal", msg);
assert.equal(isRetryableNavFailure(new Error(msg)), false, msg);
}
});
it("wall wins over a transient signature (a wall served over a reset is still a wall)", () => {
const e = new Error("net::ERR_CONNECTION_RESET — access denied");
assert.equal(classifyNavFailure(e), "wall");
});
it("accepts a bare string or non-Error value", () => {
assert.equal(classifyNavFailure("Target closed"), "retryable");
assert.equal(classifyNavFailure(undefined), "terminal");
});
});
-72
View File
@@ -566,78 +566,6 @@ describe("generateCss wrap-vulnerable single-line text", () => {
}); });
}); });
// BUG C, icon+text chip variant — the label sits beside a replaced icon (<svg>), so the vulnerable
// node is NOT a pure text leaf and the leaf-only guard skipped it. The chip's button ancestor bakes
// a quantized px width that can land fractionally below the intrinsic single-line width, wrapping
// the label ("Recreate a / screenshot"). The inner [icon, text] row must still earn nowrap: the icon
// can't wrap, its constant width is already inside the probe's wMin/wMax, and only the text is at risk.
describe("generateCss wrap-vulnerable icon+text chip", () => {
// Captured shape: pill button 193.44×32 (px 12 + 1px border per side) → inner row 167.44×20
// (icon 15 + gap 8 + text), line-height 20, wMin 94.88 < wMax 167.44 == avail == bbox.width.
function chipRow(iconTag: string) {
const szAt = (): RawSizing => ({ wAuto: true, wFill: true, hAuto: true, hFill: true, wMin: 94.88, wMax: 167.44 });
const iconPer = (): XPerVp => ({ cs: { display: "inline-block" }, bbox: { x: 13, y: 8.5, width: 15, height: 15 } });
const icon = xNode("n2", iconTag, { 375: iconPer(), 768: iconPer(), 1280: iconPer() });
const rowPer = (): XPerVp => ({ cs: { display: "flex", lineHeight: "20px" }, bbox: { x: 13, y: 6, width: 167.44, height: 20 }, sizing: szAt() });
const row = xNode("n1", "div", { 375: rowPer(), 768: rowPer(), 1280: rowPer() }, [icon, { text: "Recreate a screenshot" }]);
const btnPer = (): XPerVp => ({
cs: { display: "flex", cursor: "pointer", paddingLeft: "12px", paddingRight: "12px", borderLeftWidth: "1px", borderRightWidth: "1px", borderTopLeftRadius: "9999px", borderTopRightRadius: "9999px", lineHeight: "20px" },
bbox: { x: 0, y: 0, width: 193.44, height: 32 },
});
const btn = xNode("n0", "button", { 375: btnPer(), 768: btnPer(), 1280: btnPer() }, [row]);
return btn;
}
it("emits white-space:nowrap for a single-line [icon, text] row flush against its pill's content edge", () => {
const css = generateCss(xIr(chipRow("svg")), new Map());
assert.ok(baseRule(css, "n1").includes("white-space:nowrap"), `icon+text chip row should get nowrap, got: ${baseRule(css, "n1")}`);
});
it("does NOT emit nowrap when the sibling is a non-replaced element (it may carry its own text)", () => {
const css = generateCss(xIr(chipRow("span")), new Map());
assert.ok(!baseRule(css, "n1").includes("white-space:nowrap"), `non-replaced sibling must keep the leaf-only bail, got: ${baseRule(css, "n1")}`);
});
});
// A pill <button> whose captured width is CONSTANT across viewports (the label never changes) looks
// authored-fixed to the button-width heuristic — but the sizing probe proved `width:auto` reproduces
// the box AND it paints exactly at its own max-content width at every sample: the width is content,
// not authored. Baking it invites the emitters' quantization (0.1px rounding / spacing-scale snap) to
// land fractionally below the intrinsic single-line width and wrap the label; the width must be
// dropped so the clone re-derives it intrinsically at every band.
describe("generateCss content-fit button width (probe-proven auto beats the constant-width lock)", () => {
function pillBody(sizing?: () => RawSizing) {
const per = (): XPerVp => ({
cs: { display: "flex", cursor: "pointer", width: "144.188px", borderTopLeftRadius: "9999px", borderTopRightRadius: "9999px", lineHeight: "20px" },
bbox: { x: 0, y: 0, width: 144.19, height: 32 },
...(sizing ? { sizing: sizing() } : {}),
});
const btn = xNode("n1", "button", { 375: per(), 768: per(), 1280: per() }, [{ text: "Clone LinkedIn" }]);
return xNode("n0", "body", {
375: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 375, height: 32 } },
768: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 768, height: 32 } },
1280: { cs: { display: "flex" }, bbox: { x: 0, y: 0, width: 1280, height: 32 } },
}, [btn]);
}
it("drops the width when the probe proves auto AND the button paints at its max-content everywhere", () => {
const sz = (): RawSizing => ({ wAuto: true, wFill: false, hAuto: true, hFill: false, wMin: 103.14, wMax: 144.19 });
const css = generateCss(xIr(pillBody(sz)), new Map());
assert.ok(!allRulesX(css, "n1").includes("width:144"), `content-fit pill width must be dropped, got: ${allRulesX(css, "n1")}`);
});
it("still bakes the width without probe evidence (no sizing data)", () => {
const css = generateCss(xIr(pillBody()), new Map());
assert.ok(allRulesX(css, "n1").includes("width:144"), `unproven button width must stay locked, got: ${allRulesX(css, "n1")}`);
});
it("still bakes the width when the probe proves it load-bearing (wAuto:false)", () => {
const sz = (): RawSizing => ({ wAuto: false, wFill: false, hAuto: true, hFill: false, wMin: 103.14, wMax: 144.19 });
const css = generateCss(xIr(pillBody(sz)), new Map());
assert.ok(allRulesX(css, "n1").includes("width:144"), `load-bearing button width must stay locked, got: ${allRulesX(css, "n1")}`);
});
});
// Cross-band transform identity — a node with a NON-identity transform at one band must emit the // Cross-band transform identity — a node with a NON-identity transform at one band must emit the
// explicit identity `transform:none` at the bands where the source is untransformed, so the // explicit identity `transform:none` at the bands where the source is untransformed, so the
// transform can't cascade across bands and freeze at a width the source left untransformed. // transform can't cascade across bands and freeze at a width the source left untransformed.
-162
View File
@@ -1,162 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { chromium, type Browser, type Page } from "playwright";
import {
buildDeterministicEnvShim,
captureEpochMs,
DEFAULT_CAPTURE_EPOCH_MS,
DEFAULT_PRNG_SEED,
} from "../src/util/envShim.js";
const ESBUILD_SHIM =
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
// ---------------------------------------------------------------------------
// Item 1: deterministic-env shim — pure epoch resolution + in-browser behavior.
// ---------------------------------------------------------------------------
describe("envShim: captureEpochMs resolution", () => {
it("defaults to the fixed epoch when no override is given", () => {
assert.equal(captureEpochMs(), DEFAULT_CAPTURE_EPOCH_MS);
assert.equal(captureEpochMs(undefined), DEFAULT_CAPTURE_EPOCH_MS);
});
it("passes a finite numeric override through unchanged", () => {
assert.equal(captureEpochMs(1234567890), 1234567890);
});
it("parses a numeric string and an ISO date string", () => {
assert.equal(captureEpochMs("1234567890"), 1234567890);
assert.equal(captureEpochMs("2020-01-01T00:00:00Z"), Date.parse("2020-01-01T00:00:00Z"));
});
it("falls back to the default for garbage input", () => {
assert.equal(captureEpochMs("not-a-date"), DEFAULT_CAPTURE_EPOCH_MS);
assert.equal(captureEpochMs(NaN), DEFAULT_CAPTURE_EPOCH_MS);
});
});
describe("envShim: buildDeterministicEnvShim source safety", () => {
it("interpolates only numeric literals (no injection surface)", () => {
const src = buildDeterministicEnvShim({ seed: 42, epochMs: 1000 });
assert.match(src, /let __s = 42 \| 0;/);
assert.match(src, /const delta = 1000 - realNow\(\);/);
// performance.now must remain untouched — the shim only patches Math.random + Date,
// never assigns to performance.now (motion.ts velocity sampling depends on the real one).
assert.equal(/performance\s*\.\s*now\s*=/.test(src), false);
assert.equal(/defineProperty\s*\(\s*performance/.test(src), false);
});
it("truncates non-integer seed/epoch and uses defaults for non-finite", () => {
const src = buildDeterministicEnvShim({ seed: 3.9, epochMs: 5.9 });
assert.match(src, /let __s = 3 \| 0;/);
assert.match(src, /const delta = 5 - realNow\(\);/);
const dflt = buildDeterministicEnvShim({ seed: NaN, epochMs: Infinity });
assert.match(dflt, new RegExp(`let __s = ${DEFAULT_PRNG_SEED} \\| 0;`));
assert.match(dflt, new RegExp(`const delta = ${DEFAULT_CAPTURE_EPOCH_MS} - realNow\\(\\);`));
});
});
const VW = 1280, VH = 800;
describe("envShim: in-browser behavior", () => {
let browser: Browser;
before(async () => { browser = await chromium.launch(); });
after(async () => { await browser.close(); });
// Fresh page with the shim installed BEFORE any page script (via addInitScript),
// exactly as capture.ts wires it. We navigate to a data: URL rather than setContent
// so the document is CREATED via navigation — addInitScript fires on document
// creation, which setContent (a content swap on the existing about:blank) skips.
// The inline <script> then runs after the init scripts and samples the pinned env.
const loadWithShim = async (opts: { seed?: number; epochMs?: number }, bodyScript: string): Promise<Page> => {
const page = await browser.newPage();
await page.setViewportSize({ width: VW, height: VH });
await page.addInitScript(ESBUILD_SHIM);
await page.addInitScript(buildDeterministicEnvShim(opts));
const html = `<!doctype html><html><body><script>${bodyScript}</script></body></html>`;
await page.goto("data:text/html," + encodeURIComponent(html));
return page;
};
it("seeds Math.random reproducibly across two independent loads (same seed → same sequence)", async () => {
const script = "window.__draws = Array.from({length: 10}, () => Math.random());";
const p1 = await loadWithShim({ seed: 12345, epochMs: DEFAULT_CAPTURE_EPOCH_MS }, script);
const p2 = await loadWithShim({ seed: 12345, epochMs: DEFAULT_CAPTURE_EPOCH_MS }, script);
const [a, b] = await Promise.all([
p1.evaluate(() => (window as unknown as { __draws: number[] }).__draws),
p2.evaluate(() => (window as unknown as { __draws: number[] }).__draws),
]);
assert.deepEqual(a, b, "two loads with the same seed produce identical Math.random sequences");
assert.ok(a.every((n) => n >= 0 && n < 1), "draws are in [0,1)");
// Not a constant stream — a broken PRNG returning the same value would also be
// "stable"; assert genuine variation so the test can't pass trivially.
assert.ok(new Set(a).size > 1, "the sequence varies");
await p1.close(); await p2.close();
});
it("a different seed yields a different sequence", async () => {
const script = "window.__draws = Array.from({length: 10}, () => Math.random());";
const p1 = await loadWithShim({ seed: 1, epochMs: DEFAULT_CAPTURE_EPOCH_MS }, script);
const p2 = await loadWithShim({ seed: 2, epochMs: DEFAULT_CAPTURE_EPOCH_MS }, script);
const [a, b] = await Promise.all([
p1.evaluate(() => (window as unknown as { __draws: number[] }).__draws),
p2.evaluate(() => (window as unknown as { __draws: number[] }).__draws),
]);
assert.notDeepEqual(a, b);
await p1.close(); await p2.close();
});
it("pins the Date epoch consistently across constructor / now() / getTime() / valueOf() / toISOString()", async () => {
const epoch = Date.parse("2021-06-15T12:00:00Z");
const page = await loadWithShim(
{ seed: 1, epochMs: epoch },
`window.__t = {
now: Date.now(),
ctorGetTime: new Date().getTime(),
ctorValueOf: new Date().valueOf(),
iso: new Date().toISOString(),
};`,
);
const t = await page.evaluate(() => (window as unknown as { __t: { now: number; ctorGetTime: number; ctorValueOf: number; iso: string } }).__t);
// All reads land at the pinned epoch (within a few ms of real elapsed time between them).
assert.ok(Math.abs(t.now - epoch) < 5000, `Date.now near epoch: ${t.now} vs ${epoch}`);
assert.ok(Math.abs(t.ctorGetTime - epoch) < 5000, "new Date().getTime() near epoch");
assert.ok(Math.abs(t.ctorValueOf - epoch) < 5000, "new Date().valueOf() near epoch");
assert.match(t.iso, /^2021-06-15T12:00:0/, `toISOString pinned: ${t.iso}`);
// now() and getTime() must agree (both read the same shifted instant).
assert.ok(Math.abs(t.now - t.ctorGetTime) < 5000, "now() and getTime() agree");
await page.close();
});
it("keeps the clock ADVANCING from the pinned epoch (timers still behave)", async () => {
const epoch = DEFAULT_CAPTURE_EPOCH_MS;
const page = await loadWithShim(
{ seed: 1, epochMs: epoch },
`window.__adv = new Promise((res) => {
const t0 = Date.now();
setTimeout(() => res({ t0, t1: Date.now() }), 60);
});`,
);
const { t0, t1 } = await page.evaluate(() => (window as unknown as { __adv: Promise<{ t0: number; t1: number }> }).__adv);
assert.ok(t0 >= epoch - 5000 && t0 <= epoch + 5000, "start is pinned near epoch");
assert.ok(t1 > t0, "time advances across a real setTimeout delay");
await page.close();
});
it("leaves performance.now() REAL (motion sampling depends on it) and advancing", async () => {
const page = await loadWithShim(
{ seed: 1, epochMs: DEFAULT_CAPTURE_EPOCH_MS },
`window.__perf = new Promise((res) => {
const p0 = performance.now();
setTimeout(() => res({ p0, p1: performance.now() }), 40);
});`,
);
const { p0, p1 } = await page.evaluate(() => (window as unknown as { __perf: Promise<{ p0: number; p1: number }> }).__perf);
// performance.now() is a real monotonic clock from navigation start — small, not the epoch.
assert.ok(p0 >= 0 && p0 < 60_000, `performance.now() is real (not pinned to epoch): ${p0}`);
assert.ok(p1 > p0, "performance.now() advances");
await page.close();
});
});
-85
View File
@@ -1,85 +0,0 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { chromium, type Browser, type Page } from "playwright";
import { discoverLazyAssetsInPage } from "../src/capture/capture.js";
const ESBUILD_SHIM =
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
const VW = 1280, VH = 800;
const BASE = "https://example.com/page/";
// ---------------------------------------------------------------------------
// Item 2: extended lazy-asset discovery sweep (in-page discovery function).
// ---------------------------------------------------------------------------
describe("lazySweep: discoverLazyAssetsInPage", () => {
let browser: Browser;
before(async () => { browser = await chromium.launch(); });
after(async () => { await browser.close(); });
const discover = async (bodyHtml: string): Promise<string[]> => {
const page: Page = await browser.newPage();
await page.setViewportSize({ width: VW, height: VH });
await page.addInitScript(ESBUILD_SHIM);
// A <base> gives relative refs a stable absolute form to assert against.
await page.setContent(`<!doctype html><html><head><base href="${BASE}"></head><body>${bodyHtml}</body></html>`);
await page.evaluate(ESBUILD_SHIM);
const refs = await page.evaluate(discoverLazyAssetsInPage);
await page.close();
return refs;
};
it("discovers img/source src + srcset inside <noscript> fallback markup", async () => {
const refs = await discover(`
<noscript>
<img src="hero.jpg" srcset="hero-2x.jpg 2x, hero-3x.jpg 3x">
<picture><source srcset="alt.webp 1x, alt-2x.webp 2x"></picture>
</noscript>
`);
assert.ok(refs.includes(BASE + "hero.jpg"), "noscript img src");
assert.ok(refs.includes(BASE + "hero-2x.jpg"), "first srcset variant from noscript img");
assert.ok(refs.includes(BASE + "alt.webp"), "first srcset variant from noscript source");
});
it("discovers url(...) values from an element's inline style attribute", async () => {
const refs = await discover(`
<div style="background-image: url('bg.png'); color: red"></div>
<span style='background: url("sprite.svg") no-repeat'></span>
`);
assert.ok(refs.includes(BASE + "bg.png"), "inline background-image url()");
assert.ok(refs.includes(BASE + "sprite.svg"), "inline background shorthand url()");
});
it("discovers data-background / data-background-image (raw URL and url(...) forms)", async () => {
const refs = await discover(`
<div data-background="lazybg.jpg"></div>
<div data-background-image="url('lazybg2.png')"></div>
`);
assert.ok(refs.includes(BASE + "lazybg.jpg"), "raw data-background URL");
assert.ok(refs.includes(BASE + "lazybg2.png"), "url()-wrapped data-background-image");
});
it("discovers the resolved src of an img[loading=lazy]", async () => {
const refs = await discover(`<img loading="lazy" src="belowfold.jpg">`);
assert.ok(refs.includes(BASE + "belowfold.jpg"));
});
it("drops data: URIs and dedupes + sorts", async () => {
const refs = await discover(`
<div style="background: url('dup.png')"></div>
<div data-background="dup.png"></div>
<img loading="lazy" src="data:image/gif;base64,R0lGOD">
`);
assert.equal(refs.filter((u) => u.endsWith("dup.png")).length, 1, "deduped across channels");
assert.equal(refs.some((u) => u.startsWith("data:")), false, "data: URIs dropped");
assert.deepEqual(refs, [...refs].sort(), "sorted");
});
it("returns nothing for a page with no lazy references", async () => {
const refs = await discover(`<div><p>hello</p><img src="eager.jpg"></div>`);
// The eager <img src> is the walker's job, not this sweep — the sweep only harvests
// the channels the walker misses.
assert.deepEqual(refs, []);
});
});
-150
View File
@@ -1,150 +0,0 @@
import { describe, it, afterEach } from "node:test";
import assert from "node:assert/strict";
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
loadPatternIndex,
assertPinnedCatalog,
resolvePatternHints,
matchCatalogNode,
} from "../src/knowledge/patternIndex.js";
import { planForFrameUrl } from "../src/capture/graft.js";
import type { IR, IRNode, IRChild } from "../src/normalize/ir.js";
const CW = 1280;
const LOCK_PATH = fileURLToPath(new URL("../data/pattern-catalog.lock", import.meta.url));
/** Element node at the canonical viewport (ids assigned in pre-order by `page`). */
function el(
tag: string,
opts: { srcClass?: string; attrs?: Record<string, string>; children?: IRChild[] } = {},
): IRNode {
return {
id: "",
tag,
attrs: opts.attrs ?? {},
srcClass: opts.srcClass,
visibleByVp: { [CW]: true },
bboxByVp: { [CW]: { x: 0, y: 0, width: CW, height: 100 } },
computedByVp: { [CW]: { display: "block" } },
children: opts.children ?? [],
};
}
/** Wrap children in a <body> root and assign stable pre-order ids (n0, n1, …). */
function page(children: IRNode[]): IR {
const root = el("body", { children });
let i = 0;
const assign = (n: IRNode): void => {
n.id = `n${i++}`;
for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode);
};
assign(root);
return {
doc: {
sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: [CW], sampleViewports: [CW], canonicalViewport: CW,
perViewport: { [CW]: { scrollHeight: 100, scrollWidth: CW, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
nodeCount: i, keyframes: [],
},
root,
};
}
describe("pattern catalog: load + pin", () => {
it("loads the frozen catalog and pins by sha256 (lock verifies)", () => {
const idx = loadPatternIndex();
assert.equal(idx.catalog.version, 1);
assert.ok(idx.catalog.patterns.length > 0, "catalog has patterns");
// The on-disk lock must equal the catalog's live hash — no throw.
assert.doesNotThrow(() => assertPinnedCatalog());
const pinned = readFileSync(LOCK_PATH, "utf8").trim();
assert.equal(pinned, idx.hash, "lock matches catalog hash");
});
});
describe("pattern catalog: integrity check throws unconditionally", () => {
const original = readFileSync(LOCK_PATH, "utf8");
afterEach(() => {
// Always restore the real lock so later tests / runs see the pinned hash.
writeFileSync(LOCK_PATH, original);
});
it("throws on a lock/catalog hash mismatch (no env-var escape hatch)", () => {
writeFileSync(LOCK_PATH, "deadbeef".repeat(8) + "\n");
assert.throws(() => assertPinnedCatalog(), /hash mismatch/);
// Restoring must make it pass again.
writeFileSync(LOCK_PATH, original);
assert.doesNotThrow(() => assertPinnedCatalog());
});
});
describe("pattern catalog: fingerprint fixtures hit", () => {
it("matchCatalogNode identifies a generic carousel fingerprint by class token", () => {
const hits = matchCatalogNode({ tag: "div", attrs: {}, srcClass: "swiper swiper-wrapper" });
assert.deepEqual(hits.map((h) => h.id), ["carousel_swiper"]);
assert.equal(hits[0].kind, "carousel");
});
it("matchCatalogNode identifies a platform fingerprint by class prefix", () => {
const hits = matchCatalogNode({ tag: "div", attrs: {}, srcClass: "elementor-widget-container" });
assert.ok(hits.some((h) => h.id === "platform_elementor"), "elementor prefix hit");
});
it("matchCatalogNode identifies a scroll-animation fingerprint by attribute presence", () => {
const hits = matchCatalogNode({ tag: "div", attrs: { "data-aos": "fade-up" }, srcClass: undefined });
assert.ok(hits.some((h) => h.id === "anim_aos"), "data-aos attr hit");
});
it("resolvePatternHints walks the IR deterministically and records id + cid evidence", () => {
const ir = page([
el("div", { srcClass: "swiper", children: [el("div", { srcClass: "swiper-slide" })] }),
el("div", { srcClass: "swiper-slide" }),
]);
const hints = resolvePatternHints(ir);
const swiper = hints.matches.find((m) => m.id === "carousel_swiper");
assert.ok(swiper, "swiper detected");
// Three nodes carried a swiper token → count 3, cids are pre-order node ids.
assert.equal(swiper!.count, 3);
assert.deepEqual(swiper!.cids, ["n1", "n2", "n3"]);
// matches are sorted by id → deterministic evidence ordering.
const ids = hints.matches.map((m) => m.id);
assert.deepEqual(ids, [...ids].sort((a, b) => a.localeCompare(b)));
// Same catalog + same IR ⇒ byte-identical hints.
assert.deepEqual(resolvePatternHints(page([
el("div", { srcClass: "swiper", children: [el("div", { srcClass: "swiper-slide" })] }),
el("div", { srcClass: "swiper-slide" }),
])), hints);
});
it("resolvePatternHints exposes platform flags (stripped) and simpleStatic", () => {
const ir = page([el("section", { srcClass: "shopify-section" })]);
const hints = resolvePatternHints(ir);
assert.ok(hints.platforms.includes("shopify"), "shopify platform surfaced");
assert.ok(hints.flags.includes("platform_shopify"));
});
});
describe("popup-vendor skip list: FRAME_SKIP semantics preserved (single source of truth)", () => {
// The catalog's consent/chat entries are DOM class/id fingerprints; the capture
// pipeline's popup-vendor skip list (graft.ts FRAME_SKIP_RE) matches iframe host
// URLs. They are orthogonal axes — the catalog carries no URL hosts, so there is
// nothing to consolidate; capture's list stays the single source of truth for
// frame-graft decisions. Assert its semantics are unchanged.
it("skips promo/consent creative iframe hosts", () => {
assert.equal(planForFrameUrl("https://widget.privy.com/assets/popup.html"), "skip");
assert.equal(planForFrameUrl("https://app.recart.com/creative"), "skip");
assert.equal(planForFrameUrl("https://www.googletagmanager.com/ns.html"), "skip");
});
it("still grafts inline form embeds and same-origin blank frames", () => {
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/form"), "graft");
assert.equal(planForFrameUrl("about:blank"), "graft");
assert.equal(planForFrameUrl(""), "graft");
});
it("still renders media players as stills", () => {
assert.equal(planForFrameUrl("https://www.youtube.com/embed/abc"), "still");
});
});
-137
View File
@@ -1,137 +0,0 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import type { IR, IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
import type { FontGraph } from "../src/infer/fonts.js";
import { generatePreviewHtml } from "../src/generate/preview.js";
import { generateCss, collectNodeRules, assembleCss, computeBands } from "../src/generate/css.js";
const VPS = [375, 1280];
const CANONICAL = 1280;
function computed(over: StyleMap = {}): StyleMap {
return { display: "block", position: "static", visibility: "visible", listStyleType: "disc", listStylePosition: "outside", ...over };
}
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = [], attrs: Record<string, string> = {}): IRNode {
const computedByVp: Record<number, StyleMap> = {};
const bboxByVp: Record<number, BBox> = {};
const visibleByVp: Record<number, boolean> = {};
for (const vp of VPS) {
computedByVp[vp] = { ...cs };
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
visibleByVp[vp] = true;
}
return { id, tag, attrs, visibleByVp, bboxByVp, computedByVp, children };
}
function irWith(root: IRNode): IR {
return {
doc: {
sourceUrl: "https://example.test/preview",
title: "Preview Fixture",
lang: "en",
charset: "UTF-8",
metaViewport: "width=device-width, initial-scale=1",
viewports: VPS,
sampleViewports: VPS,
canonicalViewport: CANONICAL,
perViewport: Object.fromEntries(VPS.map((vp) => [vp, { scrollHeight: 800, scrollWidth: vp, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)", bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial" }])),
nodeCount: 4,
keyframes: [],
},
root,
};
}
// A @font-face block whose src points at a root-absolute cloned asset path — exactly the
// shape buildFontGraph emits. The preview must relativize the /assets/ prefix.
const FONT_GRAPH: FontGraph = {
entries: [],
css: `@font-face {\n font-family: "Demo";\n src: url("/assets/cloned/fonts/abc123.woff2") format("woff2");\n}`,
};
/** Build a small page IR: body → heading (with own styles) + <img> (asset) + text. */
function fixtureIr(): IR {
const heading = node("n1", "h1", computed({ color: "rgb(10, 20, 30)", fontSize: "40px" }), [{ text: "Hello preview" }]);
const img = node("n2", "img", computed(), [], { src: "https://example.test/logo.png", alt: "logo" });
const root = node("n0", "body", computed(), [heading, img]);
return irWith(root);
}
/** Asset map keyed as buildAssetMap keys it (source URL + pathname) → root-absolute local path. */
function assetMap(): Map<string, string> {
return new Map([
["https://example.test/logo.png", "/assets/cloned/images/logo.png"],
["/logo.png", "/assets/cloned/images/logo.png"],
]);
}
function emit(ir: IR, am: Map<string, string>): string {
return generatePreviewHtml({ ir, assetMap: am, fontGraph: FONT_GRAPH, tokensCss: ":root { --x: 1; }", sourceUrl: ir.doc.sourceUrl });
}
describe("preview.html static artifact", () => {
it("emits a self-contained HTML document with an inline <style> and no runtime scripts", () => {
const html = emit(fixtureIr(), assetMap());
assert.ok(html.startsWith("<!doctype html>"), "starts with doctype");
assert.ok(/<style>[\s\S]*<\/style>/.test(html), "carries an inline <style> block");
assert.ok(/<title>Preview Fixture<\/title>/.test(html), "carries the doc title");
// Static preview: NO Ditto runtime scripts, no <script> at all.
assert.ok(!/<script/i.test(html), "emits no <script> tags (runtime-free)");
assert.ok(!/DittoWire|DittoLottie|DittoMotion/.test(html), "no Ditto runtime references");
// The tree renders as real HTML with the per-node c<id> class + data-cid.
assert.ok(/<body class="cn0" data-cid="n0">/.test(html), "body carries its c<id> class + cid");
assert.ok(/<h1 class="cn1" data-cid="n1">/.test(html), "heading rendered as <h1> with class");
assert.ok(/Hello preview/.test(html), "text content rendered");
});
it("references assets with RELATIVE paths (public/assets/...), never root-absolute /assets", () => {
const html = emit(fixtureIr(), assetMap());
// The <img> src and the @font-face url() both resolve relative to public/.
assert.ok(/src="public\/assets\/cloned\/images\/logo\.png"/.test(html), "img src relativized to public/assets");
assert.ok(/url\("public\/assets\/cloned\/fonts\/abc123\.woff2"\)/.test(html), "font url relativized to public/assets");
// No leading-slash /assets refs survive (they'd break when opened at the app-dir root).
assert.ok(!/["(]\/assets\//.test(html), "no root-absolute /assets references remain");
});
it("is byte-stable across repeated emission from the same IR (determinism)", () => {
const a = emit(fixtureIr(), assetMap());
const b = emit(fixtureIr(), assetMap());
assert.equal(a, b, "two emissions of the same IR are byte-identical");
});
it("SHARES the css.ts rule collector — the inline CSS is the collectNodeRules/assembleCss output, not a re-derived path", () => {
const ir = fixtureIr();
const am = assetMap();
const html = emit(ir, am);
// Independently produce the per-node CSS the same way generateCss does (both go through
// collectNodeRules → assembleCss with `.c<id>` selectors). If the preview shared the
// collector, this exact block must appear verbatim inside its <style>.
const rules = collectNodeRules(ir, am);
const bands = computeBands(ir.doc.viewports, ir.doc.canonicalViewport);
const sharedNodeCss = assembleCss([...rules.keys()], (cid) => rules.get(cid)!, (cid) => `.c${cid}`, bands, "");
const styleBody = /<style>([\s\S]*)<\/style>/.exec(html)![1]!;
assert.ok(styleBody.includes(sharedNodeCss.trim()), "preview inline CSS contains the exact collectNodeRules/assembleCss output");
// Concretely: the heading's own base rule (color + font-size from collectNodeRules) is present
// exactly once — proving it's the shared collector's rule, not a duplicated re-emission.
const headingRule = /\.cn1\s*\{[^}]*\}/.exec(sharedNodeCss)![0];
assert.ok(headingRule.includes("color:rgb(10, 20, 30)") && headingRule.includes("font-size:40px"), "shared rule carries the node's decls");
const occurrences = styleBody.split(headingRule).length - 1;
assert.equal(occurrences, 1, "the shared per-node rule appears exactly once (no duplicated emission path)");
});
it("mirrors generateCss's `.c<id>` selectors so the preview and the css-mode app resolve identical rules", () => {
const ir = fixtureIr();
const am = assetMap();
// The legacy per-node css-mode emitter (generateCss) is the ground truth for `.c<id>` rules.
const appCss = generateCss(ir, am);
const styleBody = /<style>([\s\S]*)<\/style>/.exec(emit(ir, am))![1]!;
// Every `.c<id>{…}` base rule generateCss emits must be present verbatim in the preview.
for (const m of appCss.matchAll(/\.cn\d+\s*\{[^}]*\}/g)) {
assert.ok(styleBody.includes(m[0]), `preview carries app rule ${m[0].slice(0, 40)}`);
}
});
});
+1 -9
View File
@@ -16,15 +16,7 @@ Postgres and S3-compatible storage. The recommended OSS-friendly stack:
```bash ```bash
DATABASE_URL='postgres://...neon.../ditto_site?sslmode=require' npm run db:migrate DATABASE_URL='postgres://...neon.../ditto_site?sslmode=require' npm run db:migrate
``` ```
Run this on every deploy that changes `packages/db/migrations`. Drizzle tracks Run this on every deploy that changes `packages/db/migrations`.
applied migrations in the database, so re-running is a no-op for anything
already applied. (Alternatively, apply a single migration by piping its SQL
through `psql`/the Neon CLI — but prefer `db:migrate`, which also records the
journal entry so future runs stay consistent.)
Current migrations: `0000` core tables, `0001` signup tokens,
`0002` `job_events` (structured clone-progress events behind
`GET /v1/clones/:id/events`).
## 2. Blob storage (R2 / S3) ## 2. Blob storage (R2 / S3)
+5 -72
View File
@@ -24,35 +24,12 @@ packages/
Two run modes, same HTTP surface: Two run modes, same HTTP surface:
- **In-memory (no `DATABASE_URL`)**`POST` enqueues (202) and runs the clone in the - **In-memory (no `DATABASE_URL`)**the API runs single-page clones **inline** and
background within the API process, holding results in memory. Handy for a quick holds results in memory. Handy for a quick local demo.
local demo; same poll-to-completion contract as production.
- **DB + queue (`DATABASE_URL` set)**`POST` enqueues a job (202); a separate - **DB + queue (`DATABASE_URL` set)**`POST` enqueues a job (202); a separate
**worker** process consumes the queue, runs the clone, stores artifacts, and the **worker** process consumes the queue, runs the clone, stores artifacts, and the
client polls to completion. This is the production mode. client polls to completion. This is the production mode.
In both modes the job emits structured progress events as it runs — the compiler's
granular log stream (`{ event: "captured", ... }`, `{ event: "generated", ... }`,
`{ event: "build_start", ... }`, etc.) plus service phases (`{ event: "clone_done" }`,
`{ event: "clone_error", error }`). Every event carries a millisecond wall-clock
timestamp `t`; the rest of each object is the payload the emitter logged, so field sets
vary by event. There is no fixed enum of event types — treat unknown `event` strings as
opaque progress and drive UI off the coarse job `status` from `GET /v1/clones/:id`.
Poll `GET /v1/clones/:id/events?after=N``{ jobId, events: [{ t, ... }] }`. `after` is a
**sequence cursor** (a 1-based event count, not a timestamp): pass the number of events
you have already consumed to get only newer ones. Caveats:
- The returned event bodies do **not** include the `seq` value, so track the cursor
yourself as a running count of events received.
- The in-memory backend **ignores `after`** and returns the full list on every poll — so
in that mode you always get all events and must de-duplicate by count. The DB backend
honors `after` (`seq > N`).
In production (DB) mode events persist in the `job_events` table (migration
`0002_productive_wallflower.sql`) with a per-job `seq`, so they survive worker restarts
and remain readable after completion.
## Local development ## Local development
```bash ```bash
@@ -107,10 +84,8 @@ POST /v1/signup/request { email } → 202 {message} (s
POST /v1/signup/verify { token } → 201 {apiKey,message} (consume email token) POST /v1/signup/verify { token } → 201 {apiKey,message} (consume email token)
GET /v1/clones → list (metadata) GET /v1/clones → list (metadata)
GET /v1/clones/:id → status + metadata (fileCount, totalBytes, capture, timings) GET /v1/clones/:id → status + metadata (fileCount, totalBytes, capture, timings)
GET /v1/clones/:id/events?after=N → { jobId, events } — progress events after cursor N (poll while running)
GET /v1/clones/:id/result → the eager CloneResult (text files inline; binaries by URL) GET /v1/clones/:id/result → the eager CloneResult (text files inline; binaries by URL)
GET /v1/clones/:id/files/* → stream one generated file (e.g. .../files/preview.html — see Preview) GET /v1/clones/:id/files/* → stream one file
GET /v1/clones/:id/app-preview/* → serve the clone's built static export when present (404 until built)
GET /v1/clones/:id/bundle?format=tgz|zip → the whole app as one archive (302 → S3 when configured) GET /v1/clones/:id/bundle?format=tgz|zip → the whole app as one archive (302 → S3 when configured)
DELETE /v1/clones/:id → purge artifacts DELETE /v1/clones/:id → purge artifacts
GET /healthz → { ok: true } (unauthenticated) GET /healthz → { ok: true } (unauthenticated)
@@ -131,10 +106,8 @@ one-time email link, and `POST /v1/signup/verify` consumes the token, stores
only the API key's SHA-256 hash in Postgres, stores the verified email in the only the API key's SHA-256 hash in Postgres, stores the verified email in the
key label for attribution, and returns the raw key once. key label for attribution, and returns the raw key once.
Normal product `options` are `{ mode?: "single" | "multi", styling?: "tailwind" | "css", framework?: "next" | "vite", preview?: boolean }`. Normal product `options` are `{ mode?: "single" | "multi", styling?: "tailwind" | "css", framework?: "next" | "vite" }`.
`mode` defaults to `"single"`, `styling` defaults to `"tailwind"`, and `framework` defaults to `"next"`. `preview` `mode` defaults to `"single"`, `styling` defaults to `"tailwind"`, and `framework` defaults to `"next"`. Operational options
controls the browsable built export served at `/v1/clones/:id/app-preview/` (see **Preview** below); it is on by
default for single-page clones, so pass `preview:true` to force it for a multi-page clone. Operational options
remain `{ verify?, asyncVerify?, maxRoutes?, maxCollection?, captureConcurrency?, validationConcurrency?, viewportConcurrency?, noCache? }`; `noCache` is service-level and remain `{ verify?, asyncVerify?, maxRoutes?, maxCollection?, captureConcurrency?, validationConcurrency?, viewportConcurrency?, noCache? }`; `noCache` is service-level and
bypasses the cache. Deprecated aliases (`multiPage`, `humanizeMode`) and dev-only escape bypasses the cache. Deprecated aliases (`multiPage`, `humanizeMode`) and dev-only escape
hatches are still accepted for compatibility, but are not part of the normal product surface. hatches are still accepted for compatibility, but are not part of the normal product surface.
@@ -149,46 +122,6 @@ attaches the verify report afterward while the worker still has the run artifact
current async QA path; a post-hoc verify endpoint would require persisting full capture artifacts, current async QA path; a post-hoc verify endpoint would require persisting full capture artifacts,
not just the generated app bundle. not just the generated app bundle.
## Preview
Every clone emits a flat, self-contained **`preview.html`** at generate time — it is a
regular entry in the result file map (path `preview.html`, at the app root), and the
manifest records it as `preview_html: "preview.html"` (always present). It exists as soon
as the compiler's `generate` step finishes emitting the file map — well before any Next
build or deploy — so a consumer can show the cloned page within seconds. Its arrival is
visible in the event stream as a `{ event: "generated", ... }` event.
Fetch it through the ordinary files route:
```bash
curl -sS -H "authorization: Bearer $DITTO_API_KEY" \
"$DITTO_API_URL/v1/clones/$JOB_ID/files/preview.html" -o preview.html
```
**What it is — and isn't.** `preview.html` is a single HTML file with inline CSS and **no
JavaScript** (no runtime scripts). It renders the captured page statically: animations are
frozen at their captured start frame, Lottie/`<video>` mounts show their captured
poster/first-frame still, and nothing is interactive — no menus, no hover, no scroll
effects. It is a faithful *picture* of the page, not a working app. For interaction, use the
built export (`/v1/clones/:id/app-preview/`) or the deployed app.
**Relative assets.** `preview.html` references its images and fonts with relative paths
(`public/assets/cloned/...`), so those sub-paths resolve against the same files route the
HTML itself came from (`/v1/clones/:id/files/public/assets/cloned/...`). Serve or proxy the
file under that mount and the assets load with no rewriting.
**Iframe auth caveat.** Because the files route is authenticated (when `API_KEYS`/DB keys
are set), you cannot drop `.../files/preview.html` straight into an `<iframe src=...>` — the
browser can't attach your `Authorization` header to the iframe's asset sub-requests, so
images and fonts 404. Front it with a small server-side proxy that injects the key and
re-serves both the HTML and its `public/assets/...` sub-paths from your own origin, then
point the iframe at the proxy.
**Intended staging.** The preview is the middle rung of a three-step reveal for a consumer
UI: show the **original site** first (instant), swap to **`preview.html`** the moment
`generate` finishes (seconds — a static, frozen likeness), then swap to the **deployed app**
once the build + deploy completes (fully interactive).
**Incremental clone (single → multi, for speed).** Clone one URL single-page first **Incremental clone (single → multi, for speed).** Clone one URL single-page first
(fast app back), then POST the **same URL** with `{ mode: "multi" }` — the second call (fast app back), then POST the **same URL** with `{ mode: "multi" }` — the second call
reuses the first's entry capture (no re-capture of page 1), crawls + captures only the reuses the first's entry capture (no re-capture of page 1), crawls + captures only the
+37 -37
View File
@@ -13,9 +13,9 @@
"packages/*" "packages/*"
], ],
"devDependencies": { "devDependencies": {
"@types/node": "22.10.5", "@types/node": "26.1.0",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -34,11 +34,11 @@
"clone-static": "src/cli.ts" "clone-static": "src/cli.ts"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.10.5", "@types/node": "26.1.0",
"@types/pixelmatch": "5.2.6", "@types/pixelmatch": "5.2.6",
"@types/pngjs": "6.0.5", "@types/pngjs": "6.0.5",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
}, },
"node_modules/@aws-crypto/crc32": { "node_modules/@aws-crypto/crc32": {
@@ -1569,13 +1569,13 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "22.10.5", "version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.5.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-F8Q+SeGimwOo86fiovQh8qiXfFEh2/ocYv7tU5pJ3EXMSSxk1Joj5wefpFK2fHTf/N6HKGSxIDBT9f3gCxXPkQ==", "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.20.0" "undici-types": "~8.3.0"
} }
}, },
"node_modules/@types/pg": { "node_modules/@types/pg": {
@@ -3653,9 +3653,9 @@
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.22.4", "version": "4.23.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz",
"integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -3715,9 +3715,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.7.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -3729,9 +3729,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "6.20.0", "version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
@@ -3801,9 +3801,9 @@
}, },
"devDependencies": { "devDependencies": {
"@cloner/test-utils": "*", "@cloner/test-utils": "*",
"@types/node": "22.10.5", "@types/node": "26.1.0",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
}, },
"packages/cli": { "packages/cli": {
@@ -3826,9 +3826,9 @@
}, },
"devDependencies": { "devDependencies": {
"@cloner/test-utils": "*", "@cloner/test-utils": "*",
"@types/node": "22.10.5", "@types/node": "26.1.0",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
}, },
"packages/db": { "packages/db": {
@@ -3842,11 +3842,11 @@
}, },
"devDependencies": { "devDependencies": {
"@cloner/test-utils": "*", "@cloner/test-utils": "*",
"@types/node": "22.10.5", "@types/node": "26.1.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
}, },
"packages/storage": { "packages/storage": {
@@ -3859,9 +3859,9 @@
"@cloner/core": "*" "@cloner/core": "*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "22.10.5", "@types/node": "26.1.0",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
}, },
"packages/test-utils": { "packages/test-utils": {
@@ -3869,9 +3869,9 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/node": "22.10.5", "@types/node": "26.1.0",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
}, },
"packages/worker": { "packages/worker": {
@@ -3886,9 +3886,9 @@
"devDependencies": { "devDependencies": {
"@cloner/api": "*", "@cloner/api": "*",
"@cloner/test-utils": "*", "@cloner/test-utils": "*",
"@types/node": "22.10.5", "@types/node": "26.1.0",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
} }
} }
+3 -3
View File
@@ -45,9 +45,9 @@
"unpack": "node packages/cli/bin/ditto.mjs unpack" "unpack": "node packages/cli/bin/ditto.mjs unpack"
}, },
"devDependencies": { "devDependencies": {
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3", "typescript": "6.0.3",
"@types/node": "22.10.5" "@types/node": "26.1.0"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
+3 -3
View File
@@ -35,8 +35,8 @@
}, },
"devDependencies": { "devDependencies": {
"@cloner/test-utils": "*", "@cloner/test-utils": "*",
"@types/node": "22.10.5", "@types/node": "26.1.0",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
} }
+2 -39
View File
@@ -15,7 +15,6 @@ const OptionsSchema = z
mode: z.enum(["single", "multi"]).optional(), mode: z.enum(["single", "multi"]).optional(),
styling: z.enum(["tailwind", "css"]).optional(), styling: z.enum(["tailwind", "css"]).optional(),
framework: z.enum(["next", "vite"]).optional(), framework: z.enum(["next", "vite"]).optional(),
preview: z.boolean().optional(),
verify: z.boolean().optional(), verify: z.boolean().optional(),
asyncVerify: z.boolean().optional(), asyncVerify: z.boolean().optional(),
maxRoutes: z.number().int().positive().optional(), maxRoutes: z.number().int().positive().optional(),
@@ -239,12 +238,10 @@ export function createApp(deps: AppDeps): Hono {
try { try {
const out = await backend.submit(url, opts); const out = await backend.submit(url, opts);
if (out.status === "queued") return c.json({ jobId: out.jobId, status: "queued" }, out.httpStatus); if (out.status === "queued") return c.json({ jobId: out.jobId, status: "queued" }, 202);
return c.json(out.result, 200); return c.json(out.result, 200);
} catch (e) { } catch (e) {
const msg = String(e); return c.json({ status: "failed", error: String(e).slice(0, 500) }, 500);
if (msg.startsWith("BUSY:")) return c.json({ error: msg.slice(5).trim() }, 429);
return c.json({ status: "failed", error: msg.slice(0, 500) }, 500);
} }
}); });
@@ -285,40 +282,6 @@ export function createApp(deps: AppDeps): Hono {
return c.body(file.bytes); return c.body(file.bytes);
}); });
// Pipeline progress events (poll every ~300ms while a clone runs).
app.get("/v1/clones/:id/events", async (c) => {
const after = Math.max(0, Number(c.req.query("after") ?? "0") || 0);
const events = backend.events ? await backend.events(c.req.param("id"), after) : null;
if (!events) return c.json({ error: "not found" }, 404);
return c.json({ jobId: c.req.param("id"), events });
});
// Browsable preview of the built clone (static export published by the preview
// build under public/app-preview/). References are relative, so the export works
// from this mount — the only requirement is a trailing slash on the root.
const previewFile = async (c: Context, sub: string) => {
const id = c.req.param("id") ?? "";
const tryPaths = sub.includes(".")
? [`public/app-preview/${sub}`]
: [
`public/app-preview/${sub}`.replace(/\/$/, "") + "/index.html",
`public/app-preview/${sub}`,
`public/app-preview/${sub}.html`,
];
for (const p of tryPaths) {
const file = await backend.file(id, p);
if (file) {
c.header("content-type", file.contentType);
c.header("content-length", String(file.bytes.length));
return c.body(file.bytes);
}
}
return c.json({ error: "no app preview for this clone (preview builds are on by default for single-page clones; pass options.preview=true otherwise)" }, 404);
};
app.get("/v1/clones/:id/app-preview", (c) => c.redirect(`/v1/clones/${c.req.param("id")}/app-preview/`, 302));
app.get("/v1/clones/:id/app-preview/", (c) => previewFile(c, "index.html"));
app.get("/v1/clones/:id/app-preview/:path{.+}", (c) => previewFile(c, c.req.param("path") ?? ""));
app.delete("/v1/clones/:id", async (c) => { app.delete("/v1/clones/:id", async (c) => {
const ok = await backend.remove(c.req.param("id")); const ok = await backend.remove(c.req.param("id"));
return c.json({ deleted: ok }, ok ? 200 : 404); return c.json({ deleted: ok }, ok ? 200 : 404);
+2 -4
View File
@@ -1,5 +1,5 @@
import type { CloneOptions, RouteInfo } from "@cloner/core"; import type { CloneOptions, RouteInfo } from "@cloner/core";
import type { RestCloneResult, RestCloneSummary } from "./rest.js"; import type { RestCloneResult } from "./rest.js";
export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "cached"; export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "cached";
@@ -22,7 +22,7 @@ export type JobView = {
}; };
export type SubmitOutcome = export type SubmitOutcome =
| { jobId: string; status: "succeeded" | "cached"; httpStatus: 200; result: RestCloneResult | RestCloneSummary } | { jobId: string; status: "succeeded" | "cached"; httpStatus: 200; result: RestCloneResult }
| { jobId: string; status: "queued"; httpStatus: 202 }; | { jobId: string; status: "queued"; httpStatus: 202 };
export type ResultOutcome = export type ResultOutcome =
@@ -56,6 +56,4 @@ export interface Backend {
facets(jobId: string): Promise<FileFacet[] | null>; facets(jobId: string): Promise<FileFacet[] | null>;
/** The whole app as one compressed archive (null if not ready/found). */ /** The whole app as one compressed archive (null if not ready/found). */
bundle(jobId: string, format?: BundleFormat): Promise<CloneBundle | null>; bundle(jobId: string, format?: BundleFormat): Promise<CloneBundle | null>;
/** Pipeline progress events for polling UIs (null if unsupported or job unknown). */
events?(jobId: string, after?: number): Promise<Array<Record<string, unknown>> | null>;
} }
-13
View File
@@ -25,13 +25,6 @@ export class DbBackend implements Backend {
return { jobId: hit.jobId, status: "cached", httpStatus: 200, result: { ...r.result, status: "cached" } }; return { jobId: hit.jobId, status: "cached", httpStatus: 200, result: { ...r.result, status: "cached" } };
} }
} }
// In-flight dedup: an identical clone is already queued/running — attach the
// caller to that job instead of enqueueing a duplicate capture. (Best-effort:
// no unique constraint, so a same-instant race can still double-submit.)
const active = await repo.findActiveJobByCacheKey(this.deps.db, key);
if (active) {
return { jobId: active.id, status: "queued", httpStatus: 202 };
}
} }
const job = await repo.createJob(this.deps.db, { kind, url, options: options ?? {}, status: "queued", cacheKey: key }); const job = await repo.createJob(this.deps.db, { kind, url, options: options ?? {}, status: "queued", cacheKey: key });
@@ -149,10 +142,4 @@ export class DbBackend implements Backend {
const url = this.deps.store.uploadBundle ? await this.deps.store.uploadBundle(jobId, format, bytes) : undefined; const url = this.deps.store.uploadBundle ? await this.deps.store.uploadBundle(jobId, format, bytes) : undefined;
return { bytes, sha256: sha256hex(bytes), format, url }; return { bytes, sha256: sha256hex(bytes), format, url };
} }
async events(jobId: string, after = 0): Promise<Array<Record<string, unknown>> | null> {
const job = await repo.getJob(this.deps.db, jobId);
if (!job) return null;
return repo.listJobEvents(this.deps.db, jobId, after);
}
} }
+10 -44
View File
@@ -10,51 +10,24 @@ import type { Backend, BundleFormat, CloneBundle, FileFacet, JobView, ResultOutc
export type RunJob = (input: RunCloneJobInput) => Promise<CloneJobResult>; export type RunJob = (input: RunCloneJobInput) => Promise<CloneJobResult>;
/** In-memory backend: enqueues a clone (202) and runs it in the background so long /** Sync, in-memory backend (M1): runs the clone INLINE on submit and holds results
* captures (multi-viewport + verify) never hold a single HTTP connection open for * in memory. POST returns the file map immediately (200). */
* minutes which browsers surface as `TypeError: Failed to fetch` when the link
* drops (server restart, hot-reload, idle timeout). Poll /v1/clones/:id + /events. */
export class InMemoryBackend implements Backend { export class InMemoryBackend implements Backend {
constructor(private deps: { store: InMemoryStore; runJob: RunJob; makeTempBase?: () => string; captureCacheDir?: string }) {} constructor(private deps: { store: InMemoryStore; runJob: RunJob; makeTempBase?: () => string; captureCacheDir?: string }) {}
private activeClones = 0;
private makeBase(): string { private makeBase(): string {
return (this.deps.makeTempBase ?? (() => mkdtempSync(join(tmpdir(), "api-clone-"))))(); return (this.deps.makeTempBase ?? (() => mkdtempSync(join(tmpdir(), "api-clone-"))))();
} }
async submit(url: string, options: CloneOptions | undefined): Promise<SubmitOutcome> { async submit(url: string, options: CloneOptions | undefined): Promise<SubmitOutcome> {
if (this.activeClones > 0 || this.deps.store.list().some((j) => j.status === "running")) {
throw new Error("BUSY: another clone is already running — wait for it to finish (use npm run dev:api:stable to avoid hot-reload killing Playwright)");
}
const id = randomUUID(); const id = randomUUID();
const base = this.makeBase(); const base = this.makeBase();
const events: Array<Record<string, unknown>> = [];
const kind: "clone" | "clone_site" = resolveCloneMode(options) === "multi" ? "clone_site" : "clone";
const rec = { id, status: "running" as const, url, kind, options: options ?? {}, createdAt: Date.now(), base, events };
this.deps.store.put(rec);
void this.runInBackground(id, url, options, rec, base, events, kind);
return { jobId: id, status: "queued", httpStatus: 202 };
}
private async runInBackground(
id: string,
url: string,
options: CloneOptions | undefined,
rec: { id: string; status: "running"; url: string; kind: "clone" | "clone_site"; options: CloneOptions; createdAt: number; base: string; events: Array<Record<string, unknown>> },
base: string,
events: Array<Record<string, unknown>>,
kind: "clone" | "clone_site",
): Promise<void> {
this.activeClones++;
const log = (e: Record<string, unknown>) => {
events.push({ t: Date.now(), ...e });
this.deps.store.put({ ...rec, events: [...events] });
};
try { try {
const result = await this.deps.runJob({ url, options, runsDir: base, captureCacheDir: this.deps.captureCacheDir, log }); // captureCacheDir (persistent, shared across submits) powers the single→multi
log({ event: "clone_done" }); // reuse: a single-page submit stashes its capture; a later multi-page submit for
this.deps.store.put({ id, status: "succeeded", url, kind: result.kind, options: result.options, createdAt: rec.createdAt, result, base, events }); // the same URL reuses it as the entry route (no re-capture) and expands the site.
const result = await this.deps.runJob({ url, options, runsDir: base, captureCacheDir: this.deps.captureCacheDir });
this.deps.store.put({ id, status: "succeeded", url, kind: result.kind, options: result.options, createdAt: Date.now(), result, base });
if (result.options.asyncVerify) { if (result.options.asyncVerify) {
void verifyCloneJobResult(result, { void verifyCloneJobResult(result, {
validationConcurrency: result.options.validationConcurrency, validationConcurrency: result.options.validationConcurrency,
@@ -66,25 +39,18 @@ export class InMemoryBackend implements Backend {
result.verify = { error: String(e).slice(0, 500), async: true }; result.verify = { error: String(e).slice(0, 500), async: true };
}); });
} }
return { jobId: id, status: "succeeded", httpStatus: 200, result: buildRestResult(id, result, `/v1/clones/${id}/files`) };
} catch (e) { } catch (e) {
try { try {
rmSync(base, { recursive: true, force: true }); rmSync(base, { recursive: true, force: true });
} catch { } catch {
/* best effort */ /* best effort */
} }
log({ event: "clone_error", error: String(e).slice(0, 300) }); this.deps.store.put({ id, status: "failed", url, kind: resolveCloneMode(options) === "multi" ? "clone_site" : "clone", options: options ?? {}, createdAt: Date.now(), error: String(e) });
this.deps.store.put({ id, status: "failed", url, kind, options: options ?? {}, createdAt: rec.createdAt, error: String(e), events }); throw e;
} finally {
this.activeClones = Math.max(0, this.activeClones - 1);
} }
} }
async events(jobId: string): Promise<Array<Record<string, unknown>> | null> {
const rec = this.deps.store.get(jobId);
if (!rec) return null;
return rec.events ?? [];
}
async status(jobId: string): Promise<JobView | null> { async status(jobId: string): Promise<JobView | null> {
const rec = this.deps.store.get(jobId); const rec = this.deps.store.get(jobId);
if (!rec) return null; if (!rec) return null;
+1 -3
View File
@@ -6,7 +6,7 @@ import type { CloneJobResult, CloneOptions } from "@cloner/core";
* it is removed on eviction. M2/M4 replace this with DB rows + S3 objects. */ * it is removed on eviction. M2/M4 replace this with DB rows + S3 objects. */
export type JobRecord = { export type JobRecord = {
id: string; id: string;
status: "running" | "succeeded" | "failed"; status: "succeeded" | "failed";
url: string; url: string;
kind: "clone" | "clone_site"; kind: "clone" | "clone_site";
options: CloneOptions; options: CloneOptions;
@@ -14,8 +14,6 @@ export type JobRecord = {
result?: CloneJobResult; result?: CloneJobResult;
base?: string; base?: string;
error?: string; error?: string;
/** pipeline progress events (compiler log stream + service phases), poll via /events */
events?: Array<Record<string, unknown>>;
}; };
/** In-memory result store with TTL eviction. Swapped for a DB-backed store in M2. */ /** In-memory result store with TTL eviction. Swapped for a DB-backed store in M2. */
+8 -20
View File
@@ -26,25 +26,13 @@ describe("POST /v1/clones (real clone, served fixture)", { skip: hasChromium() ?
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ url: server.url + "/components.html", options: { interactions: false, components: true, motion: false } }), body: JSON.stringify({ url: server.url + "/components.html", options: { interactions: false, components: true, motion: false } }),
}); });
assert.equal(res.status, 202); assert.equal(res.status, 200);
const queued = await res.json(); const body = await res.json();
let body: Record<string, unknown> | undefined; assert.equal(body.status, "succeeded");
for (let i = 0; i < 600; i++) { assert.ok(body.files["src/app/page.tsx"], "has page.tsx");
const view = await (await app.request(`/v1/clones/${queued.jobId}`)).json(); assert.equal(body.files["src/app/page.tsx"].type, "text");
if (view.status === "succeeded") { assert.ok(body.files["package.json"], "has package.json");
body = await (await app.request(`/v1/clones/${queued.jobId}/result`)).json(); assert.ok(body.capture.nodeCount > 0);
break; assert.equal(body.capture.blocked, false);
}
if (view.status === "failed") assert.fail(view.error);
await new Promise((r) => setTimeout(r, 50));
}
assert.ok(body);
const files = body!.files as Record<string, { type: string }>;
assert.equal(body!.status, "succeeded");
assert.ok(files["src/app/page.tsx"], "has page.tsx");
assert.equal(files["src/app/page.tsx"].type, "text");
assert.ok(files["package.json"], "has package.json");
assert.ok((body!.capture as { nodeCount: number }).nodeCount > 0);
assert.equal((body!.capture as { blocked: boolean }).blocked, false);
}); });
}); });
+23 -43
View File
@@ -34,17 +34,7 @@ const fakeRunJob: RunJob = async (input) => {
} satisfies CloneJobResult; } satisfies CloneJobResult;
}; };
async function waitForResult(app: ReturnType<typeof createApp>, jobId: string) { test("POST /v1/clones returns the eager file map; binaries by reference; streaming + lifecycle", async () => {
for (let i = 0; i < 200; i++) {
const view = await (await app.request(`/v1/clones/${jobId}`)).json();
if (view.status === "succeeded") return await (await app.request(`/v1/clones/${jobId}/result`)).json();
if (view.status === "failed") throw new Error(String(view.error));
await new Promise((r) => setTimeout(r, 5));
}
throw new Error("timeout waiting for clone");
}
test("POST /v1/clones enqueues then completes; binaries by reference; streaming + lifecycle", async () => {
const store = new InMemoryStore(60_000); const store = new InMemoryStore(60_000);
const app = createApp({ backend: new InMemoryBackend({ store, runJob: fakeRunJob }) }); const app = createApp({ backend: new InMemoryBackend({ store, runJob: fakeRunJob }) });
try { try {
@@ -53,47 +43,39 @@ test("POST /v1/clones enqueues then completes; binaries by reference; streaming
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "https://example.com/", options: {} }), body: JSON.stringify({ url: "https://example.com/", options: {} }),
}); });
assert.equal(res.status, 202); assert.equal(res.status, 200);
const queued = await res.json(); const body = await res.json();
assert.ok(queued.jobId); assert.ok(body.jobId);
assert.equal(queued.status, "queued"); assert.equal(body.status, "succeeded");
assert.equal(body.kind, "clone");
let body: Record<string, unknown> | undefined;
body = await waitForResult(app, queued.jobId);
assert.ok(body);
assert.equal(body!.status, "succeeded");
assert.equal(body!.kind, "clone");
// Text inline. // Text inline.
const files = body!.files as Record<string, { type: string; content?: string; sha256?: string; url?: string }>; const page = body.files["src/app/page.tsx"];
const page = files["src/app/page.tsx"]; assert.equal(page.type, "text");
assert.ok(page); assert.ok(page.content.includes("Page"));
assert.equal(page!.type, "text"); assert.ok(page.sha256);
assert.ok(page!.content!.includes("Page"));
assert.ok(page!.sha256);
// Binary by reference (URL, not bytes). // Binary by reference (URL, not bytes).
const bin = files["public/assets/cloned/images/a.png"]; const bin = body.files["public/assets/cloned/images/a.png"];
assert.ok(bin); assert.equal(bin.type, "binary");
assert.equal(bin!.type, "binary"); assert.equal(bin.content, undefined);
assert.equal(bin!.content, undefined); assert.equal(bin.url, `/v1/clones/${body.jobId}/files/public/assets/cloned/images/a.png`);
assert.equal(bin!.url, `/v1/clones/${queued.jobId}/files/public/assets/cloned/images/a.png`);
// Per-file streaming returns the actual bytes. // Per-file streaming returns the actual bytes.
const fileRes = await app.request(bin!.url!); const fileRes = await app.request(bin.url);
assert.equal(fileRes.status, 200); assert.equal(fileRes.status, 200);
assert.equal(fileRes.headers.get("content-type"), "image/png"); assert.equal(fileRes.headers.get("content-type"), "image/png");
const bytes = Buffer.from(await fileRes.arrayBuffer()); const bytes = Buffer.from(await fileRes.arrayBuffer());
assert.equal(bytes.length, 7); assert.equal(bytes.length, 7);
// Cheap metadata overview (no file contents). // Cheap metadata overview (no file contents).
const meta = await (await app.request(`/v1/clones/${queued.jobId}`)).json(); const meta = await (await app.request(`/v1/clones/${body.jobId}`)).json();
assert.equal(meta.fileCount, 3); assert.equal(meta.fileCount, 3);
assert.equal(meta.capture.nodeCount, 42); assert.equal(meta.capture.nodeCount, 42);
assert.equal(meta.totalBytes > 0, true); assert.equal(meta.totalBytes > 0, true);
// Full result fetch. // Full result fetch.
const result = await app.request(`/v1/clones/${queued.jobId}/result`); const result = await app.request(`/v1/clones/${body.jobId}/result`);
assert.equal(result.status, 200); assert.equal(result.status, 200);
// List. // List.
@@ -101,9 +83,9 @@ test("POST /v1/clones enqueues then completes; binaries by reference; streaming
assert.equal(list.clones.length, 1); assert.equal(list.clones.length, 1);
// Delete purges; subsequent fetch 404s. // Delete purges; subsequent fetch 404s.
const del = await app.request(`/v1/clones/${queued.jobId}`, { method: "DELETE" }); const del = await app.request(`/v1/clones/${body.jobId}`, { method: "DELETE" });
assert.equal((await del.json()).deleted, true); assert.equal((await del.json()).deleted, true);
assert.equal((await app.request(`/v1/clones/${queued.jobId}`)).status, 404); assert.equal((await app.request(`/v1/clones/${body.jobId}`)).status, 404);
} finally { } finally {
store.clear(); store.clear();
} }
@@ -136,9 +118,8 @@ test("POST /v1/clones normalizes product options and legacy aliases", async () =
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "https://example.com/", options: { mode: "multi", styling: "css", framework: "vite" } }), body: JSON.stringify({ url: "https://example.com/", options: { mode: "multi", styling: "css", framework: "vite" } }),
}); });
assert.equal(product.status, 202); assert.equal(product.status, 200);
const productQueued = await product.json(); const productBody = await product.json();
const productBody = await waitForResult(app, productQueued.jobId);
assert.deepEqual(productBody.options, { mode: "multi", styling: "css", framework: "vite" }); assert.deepEqual(productBody.options, { mode: "multi", styling: "css", framework: "vite" });
const legacy = await app.request("/v1/clones", { const legacy = await app.request("/v1/clones", {
@@ -146,9 +127,8 @@ test("POST /v1/clones normalizes product options and legacy aliases", async () =
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "https://example.com/", options: { multiPage: true, humanizeMode: "css" } }), body: JSON.stringify({ url: "https://example.com/", options: { multiPage: true, humanizeMode: "css" } }),
}); });
assert.equal(legacy.status, 202); assert.equal(legacy.status, 200);
const legacyQueued = await legacy.json(); const legacyBody = await legacy.json();
const legacyBody = await waitForResult(app, legacyQueued.jobId);
assert.deepEqual(legacyBody.options, { mode: "multi", styling: "css", framework: "next" }); assert.deepEqual(legacyBody.options, { mode: "multi", styling: "css", framework: "next" });
} finally { } finally {
store.clear(); store.clear();
+4 -10
View File
@@ -52,18 +52,12 @@ test("MCP: list-then-read + bundle contract (never floods context)", async () =>
// clone_website → jobId + status only (no files). // clone_website → jobId + status only (no files).
const cw = parse(await client.callTool({ name: "clone_website", arguments: { url: "https://example.com/", options: {} } })); const cw = parse(await client.callTool({ name: "clone_website", arguments: { url: "https://example.com/", options: {} } }));
assert.ok(cw.jobId); assert.ok(cw.jobId);
assert.equal(cw.status, "queued"); assert.equal(cw.status, "succeeded");
assert.ok(!("files" in cw));
const jobId = cw.jobId; const jobId = cw.jobId;
let status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } })); // status
for (let i = 0; i < 200 && status.status !== "succeeded"; i++) { const status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
await new Promise((r) => setTimeout(r, 5));
status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
}
assert.equal(status.status, "succeeded");
// status (re-fetch after completion)
status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
assert.equal(status.status, "succeeded"); assert.equal(status.status, "succeeded");
// get_clone_result → metadata only, NO file contents. // get_clone_result → metadata only, NO file contents.
+3 -3
View File
@@ -27,8 +27,8 @@
}, },
"devDependencies": { "devDependencies": {
"@cloner/test-utils": "*", "@cloner/test-utils": "*",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3", "typescript": "6.0.3",
"@types/node": "22.10.5" "@types/node": "26.1.0"
} }
} }
-4
View File
@@ -9,7 +9,6 @@ export type ResolvedCloneOptions = CloneOptions & {
interactions: boolean; interactions: boolean;
components: boolean; components: boolean;
motion: boolean; motion: boolean;
preview: boolean;
}; };
export function resolveCloneMode(options: CloneOptions = {}): CloneMode { export function resolveCloneMode(options: CloneOptions = {}): CloneMode {
@@ -54,8 +53,5 @@ export function resolveCloneOptions(options: CloneOptions = {}): ResolvedCloneOp
interactions: options.interactions ?? true, interactions: options.interactions ?? true,
components: options.components ?? true, components: options.components ?? true,
motion: options.motion ?? true, motion: options.motion ?? true,
// Preview builds are the single-page product surface; multi-page exports can be
// large, so previewing a site clone stays an explicit opt-in.
preview: options.preview ?? mode === "single",
}; };
} }
+1 -4
View File
@@ -26,9 +26,6 @@ export type CloneOptions = {
mode?: CloneMode; mode?: CloneMode;
styling?: CloneStyling; styling?: CloneStyling;
framework?: CloneFramework; framework?: CloneFramework;
/** Build a browsable static export of the clone into `public/app-preview/`
* (served by the API's app-preview route). Defaults ON for single-page clones. */
preview?: boolean;
verify?: boolean; verify?: boolean;
/** DB/worker mode: persist the clone first, then attach verify in the background. */ /** DB/worker mode: persist the clone first, then attach verify in the background. */
asyncVerify?: boolean; asyncVerify?: boolean;
@@ -78,7 +75,7 @@ export type CaptureSanity = {
blocked: boolean; // bot/egress wall text detected blocked: boolean; // bot/egress wall text detected
}; };
export type CloneTimings = { captureMs: number; generateMs: number; verifyMs?: number; previewMs?: number }; export type CloneTimings = { captureMs: number; generateMs: number; verifyMs?: number };
export type RouteInfo = { route: string; representativeOf?: string }; export type RouteInfo = { route: string; representativeOf?: string };
@@ -1,10 +0,0 @@
CREATE TABLE "job_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"job_id" uuid NOT NULL,
"seq" integer NOT NULL,
"payload" jsonb NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "job_events" ADD CONSTRAINT "job_events_job_id_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "job_events_job_id_seq_idx" ON "job_events" USING btree ("job_id","seq");
@@ -1,461 +0,0 @@
{
"id": "de9b94b8-cefb-4d2a-a848-79a552976943",
"prevId": "e076bd1e-a7ce-40ab-abd1-9eb11d00ddb9",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.api_keys": {
"name": "api_keys",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"key_hash": {
"name": "key_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": false
},
"rate_limit": {
"name": "rate_limit",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"revoked_at": {
"name": "revoked_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"api_keys_key_hash_unique": {
"name": "api_keys_key_hash_unique",
"nullsNotDistinct": false,
"columns": [
"key_hash"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.cache": {
"name": "cache",
"schema": "",
"columns": {
"cache_key": {
"name": "cache_key",
"type": "text",
"primaryKey": true,
"notNull": true
},
"job_id": {
"name": "job_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"options_hash": {
"name": "options_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"compiler_version": {
"name": "compiler_version",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"cache_job_id_jobs_id_fk": {
"name": "cache_job_id_jobs_id_fk",
"tableFrom": "cache",
"tableTo": "jobs",
"columnsFrom": [
"job_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.clones": {
"name": "clones",
"schema": "",
"columns": {
"job_id": {
"name": "job_id",
"type": "uuid",
"primaryKey": true,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"route_count": {
"name": "route_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"file_manifest": {
"name": "file_manifest",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"bundle_s3_key": {
"name": "bundle_s3_key",
"type": "text",
"primaryKey": false,
"notNull": false
},
"verify": {
"name": "verify",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"capture_meta": {
"name": "capture_meta",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"clones_job_id_jobs_id_fk": {
"name": "clones_job_id_jobs_id_fk",
"tableFrom": "clones",
"tableTo": "jobs",
"columnsFrom": [
"job_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.job_events": {
"name": "job_events",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"job_id": {
"name": "job_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"seq": {
"name": "seq",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"job_events_job_id_seq_idx": {
"name": "job_events_job_id_seq_idx",
"columns": [
{
"expression": "job_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "seq",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"job_events_job_id_jobs_id_fk": {
"name": "job_events_job_id_jobs_id_fk",
"tableFrom": "job_events",
"tableTo": "jobs",
"columnsFrom": [
"job_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.jobs": {
"name": "jobs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"options": {
"name": "options",
"type": "jsonb",
"primaryKey": false,
"notNull": true,
"default": "'{}'::jsonb"
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'queued'"
},
"cache_key": {
"name": "cache_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"attempts": {
"name": "attempts",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false
},
"compiler_version": {
"name": "compiler_version",
"type": "text",
"primaryKey": false,
"notNull": false
},
"timings": {
"name": "timings",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"started_at": {
"name": "started_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"finished_at": {
"name": "finished_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.signup_tokens": {
"name": "signup_tokens",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"consumed_at": {
"name": "consumed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"signup_tokens_token_hash_unique": {
"name": "signup_tokens_token_hash_unique",
"nullsNotDistinct": false,
"columns": [
"token_hash"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
@@ -15,13 +15,6 @@
"when": 1782790200000, "when": 1782790200000,
"tag": "0001_signup_tokens", "tag": "0001_signup_tokens",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1783202414928,
"tag": "0002_productive_wallflower",
"breakpoints": true
} }
] ]
} }
+3 -3
View File
@@ -31,10 +31,10 @@
}, },
"devDependencies": { "devDependencies": {
"@cloner/test-utils": "*", "@cloner/test-utils": "*",
"@types/node": "22.10.5", "@types/node": "26.1.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3" "typescript": "6.0.3"
} }
} }
+9 -19
View File
@@ -31,23 +31,13 @@ export async function enqueueClone(boss: PgBoss, jobId: string): Promise<string
} }
/** Register the worker handler. Normalizes pg-boss v9 (single job) vs v10 (array) /** Register the worker handler. Normalizes pg-boss v9 (single job) vs v10 (array)
* callback shapes so the consumer just gets a jobId. * callback shapes so the consumer just gets a jobId. */
* export async function workClone(boss: PgBoss, handler: (jobId: string) => Promise<void>): Promise<string> {
* `concurrency` registers that many independent pollers on the queue, so up to return boss.work(CLONE_QUEUE, async (job: unknown) => {
* N jobs run at once in this process with no head-of-line blocking (each poller const arr = Array.isArray(job) ? job : [job];
* fetches its next job as soon as its current one finishes). */ for (const j of arr) {
export async function workClone(boss: PgBoss, handler: (jobId: string) => Promise<void>, concurrency = 1): Promise<string[]> { const data = (j as { data?: ClonePayload }).data;
const pollers: string[] = []; if (data?.jobId) await handler(data.jobId);
for (let i = 0; i < Math.max(1, concurrency); i++) { }
pollers.push( });
await boss.work(CLONE_QUEUE, async (job: unknown) => {
const arr = Array.isArray(job) ? job : [job];
for (const j of arr) {
const data = (j as { data?: ClonePayload }).data;
if (data?.jobId) await handler(data.jobId);
}
}),
);
}
return pollers;
} }
+2 -36
View File
@@ -1,6 +1,6 @@
import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm"; import { and, desc, eq, gt, isNull, sql } from "drizzle-orm";
import type { Db } from "./client.js"; import type { Db } from "./client.js";
import { jobs, clones, cache, apiKeys, signupTokens, jobEvents, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken } from "./schema.js"; import { jobs, clones, cache, apiKeys, signupTokens, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken } from "./schema.js";
// ---- jobs ---- // ---- jobs ----
@@ -40,19 +40,6 @@ export async function listJobs(db: Db, limit = 50): Promise<Job[]> {
return db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit); return db.select().from(jobs).orderBy(desc(jobs.createdAt)).limit(limit);
} }
/** The newest still-active (queued/running) job for a cache key, if any the
* in-flight dedup lookup: identical submits attach to it instead of enqueueing
* a duplicate capture. */
export async function findActiveJobByCacheKey(db: Db, key: string): Promise<Job | undefined> {
const [row] = await db
.select()
.from(jobs)
.where(and(eq(jobs.cacheKey, key), inArray(jobs.status, ["queued", "running"])))
.orderBy(desc(jobs.createdAt))
.limit(1);
return row;
}
export async function deleteJob(db: Db, id: string): Promise<boolean> { export async function deleteJob(db: Db, id: string): Promise<boolean> {
const rows = await db.delete(jobs).where(eq(jobs.id, id)).returning({ id: jobs.id }); const rows = await db.delete(jobs).where(eq(jobs.id, id)).returning({ id: jobs.id });
return rows.length > 0; return rows.length > 0;
@@ -124,24 +111,3 @@ export async function consumeSignupToken(db: Db, tokenHash: string): Promise<Sig
.returning(); .returning();
return row; return row;
} }
// ---- job events ----
export async function appendJobEvent(db: Db, jobId: string, payload: Record<string, unknown>): Promise<number> {
const [row] = await db
.select({ n: sql<number>`coalesce(max(${jobEvents.seq}), 0)` })
.from(jobEvents)
.where(eq(jobEvents.jobId, jobId));
const seq = (row?.n ?? 0) + 1;
await db.insert(jobEvents).values({ jobId, seq, payload: { t: Date.now(), ...payload } });
return seq;
}
export async function listJobEvents(db: Db, jobId: string, after = 0): Promise<Array<Record<string, unknown>>> {
const rows = await db
.select()
.from(jobEvents)
.where(and(eq(jobEvents.jobId, jobId), sql`${jobEvents.seq} > ${after}`))
.orderBy(jobEvents.seq);
return rows.map((r) => r.payload as Record<string, unknown>);
}
+1 -13
View File
@@ -1,4 +1,4 @@
import { pgTable, text, jsonb, integer, timestamp, uuid, index } from "drizzle-orm/pg-core"; import { pgTable, text, jsonb, integer, timestamp, uuid } from "drizzle-orm/pg-core";
/** Job lifecycle row. Status mirrors the queue; `cacheKey` ties it to the cache row. /** Job lifecycle row. Status mirrors the queue; `cacheKey` ties it to the cache row.
* `options`/`timings` are jsonb (the service's typed shapes). */ * `options`/`timings` are jsonb (the service's typed shapes). */
@@ -66,17 +66,6 @@ export const signupTokens = pgTable("signup_tokens", {
consumedAt: timestamp("consumed_at", { withTimezone: true }), consumedAt: timestamp("consumed_at", { withTimezone: true }),
}); });
/** Structured clone progress events (mirrors in-memory backend event stream). */
export const jobEvents = pgTable("job_events", {
id: uuid("id").defaultRandom().primaryKey(),
jobId: uuid("job_id")
.notNull()
.references(() => jobs.id, { onDelete: "cascade" }),
seq: integer("seq").notNull(),
payload: jsonb("payload").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (t) => [index("job_events_job_id_seq_idx").on(t.jobId, t.seq)]);
export type Job = typeof jobs.$inferSelect; export type Job = typeof jobs.$inferSelect;
export type NewJob = typeof jobs.$inferInsert; export type NewJob = typeof jobs.$inferInsert;
export type Clone = typeof clones.$inferSelect; export type Clone = typeof clones.$inferSelect;
@@ -85,4 +74,3 @@ export type CacheRow = typeof cache.$inferSelect;
export type ApiKey = typeof apiKeys.$inferSelect; export type ApiKey = typeof apiKeys.$inferSelect;
export type SignupToken = typeof signupTokens.$inferSelect; export type SignupToken = typeof signupTokens.$inferSelect;
export type NewSignupToken = typeof signupTokens.$inferInsert; export type NewSignupToken = typeof signupTokens.$inferInsert;
export type JobEvent = typeof jobEvents.$inferSelect;
+3 -3
View File
@@ -28,8 +28,8 @@
"@aws-sdk/s3-request-presigner": "^3.726.0" "@aws-sdk/s3-request-presigner": "^3.726.0"
}, },
"devDependencies": { "devDependencies": {
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3", "typescript": "6.0.3",
"@types/node": "22.10.5" "@types/node": "26.1.0"
} }
} }
+3 -3
View File
@@ -22,8 +22,8 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3", "typescript": "6.0.3",
"@types/node": "22.10.5" "@types/node": "26.1.0"
} }
} }
+3 -3
View File
@@ -32,8 +32,8 @@
"devDependencies": { "devDependencies": {
"@cloner/api": "*", "@cloner/api": "*",
"@cloner/test-utils": "*", "@cloner/test-utils": "*",
"tsx": "4.22.4", "tsx": "4.23.0",
"typescript": "5.7.3", "typescript": "6.0.3",
"@types/node": "22.10.5" "@types/node": "26.1.0"
} }
} }
-5
View File
@@ -11,15 +11,11 @@ export type WorkerEnv = {
/** persistent entry-capture cache dir (the single→multi speed path). "" disables it. */ /** persistent entry-capture cache dir (the single→multi speed path). "" disables it. */
captureCacheDir: string; captureCacheDir: string;
tier: string; tier: string;
/** concurrent clone jobs in this process (WORKER_CONCURRENCY, default 1). Each
* slot runs a full Chromium capture size the container accordingly. */
concurrency: number;
}; };
export function loadWorkerEnv(): WorkerEnv { export function loadWorkerEnv(): WorkerEnv {
const databaseUrl = process.env.DATABASE_URL; const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error("DATABASE_URL is required for the worker"); if (!databaseUrl) throw new Error("DATABASE_URL is required for the worker");
const concurrency = Math.max(1, parseInt(process.env.WORKER_CONCURRENCY ?? "1", 10) || 1);
return { return {
databaseUrl, databaseUrl,
artifactsDir: process.env.ARTIFACTS_DIR ?? join(process.cwd(), "local-data", "artifacts"), artifactsDir: process.env.ARTIFACTS_DIR ?? join(process.cwd(), "local-data", "artifacts"),
@@ -27,6 +23,5 @@ export function loadWorkerEnv(): WorkerEnv {
harnessDir: process.env.HARNESS_DIR ?? join(process.cwd(), "local-data", "harness"), harnessDir: process.env.HARNESS_DIR ?? join(process.cwd(), "local-data", "harness"),
captureCacheDir: process.env.CAPTURE_CACHE_DIR ?? join(process.cwd(), "local-data", "capture-cache"), captureCacheDir: process.env.CAPTURE_CACHE_DIR ?? join(process.cwd(), "local-data", "capture-cache"),
tier: process.env.VERIFY_TIER ?? "stage2", tier: process.env.VERIFY_TIER ?? "stage2",
concurrency,
}; };
} }
+1 -10
View File
@@ -37,16 +37,7 @@ export async function processCloneJob(deps: ProcessDeps, jobId: string): Promise
const options = (job.options ?? {}) as CloneOptions; const options = (job.options ?? {}) as CloneOptions;
// Provision the isolated harness only when this job actually verifies (build). // Provision the isolated harness only when this job actually verifies (build).
const harnessDir = (options.verify || options.asyncVerify) && deps.harnessProvider ? await deps.harnessProvider() : undefined; const harnessDir = (options.verify || options.asyncVerify) && deps.harnessProvider ? await deps.harnessProvider() : undefined;
const result = await deps.runJob({ const result = await deps.runJob({ url: job.url, options, runsDir: base, harnessDir, tier: deps.tier, captureCacheDir: deps.captureCacheDir || undefined, captureCacheTtlMs: deps.cacheTtlMs });
url: job.url,
options,
runsDir: base,
harnessDir,
tier: deps.tier,
captureCacheDir: deps.captureCacheDir || undefined,
captureCacheTtlMs: deps.cacheTtlMs,
log: (e) => { void repo.appendJobEvent(db, jobId, e); },
});
const manifest = await store.putClone(jobId, result.files); const manifest = await store.putClone(jobId, result.files);
const envelope = { files: manifest.files, routes: result.routes, bundleKey: manifest.bundleKey }; const envelope = { files: manifest.files, routes: result.routes, bundleKey: manifest.bundleKey };
+3 -23
View File
@@ -1,4 +1,3 @@
import { join } from "node:path";
import { runCloneJob } from "@cloner/core"; import { runCloneJob } from "@cloner/core";
import { createDb, createBoss, workClone } from "@cloner/db"; import { createDb, createBoss, workClone } from "@cloner/db";
import { artifactStoreFromEnv } from "@cloner/storage"; import { artifactStoreFromEnv } from "@cloner/storage";
@@ -17,32 +16,13 @@ async function main(): Promise<void> {
store, store,
runJob: runCloneJob, runJob: runCloneJob,
cacheTtlMs: env.cacheStaleAfterMs, cacheTtlMs: env.cacheStaleAfterMs,
harnessProvider: makeHarnessProvider(env.harnessDir),
captureCacheDir: env.captureCacheDir, captureCacheDir: env.captureCacheDir,
tier: env.tier, tier: env.tier,
}; };
// One harness per concurrency slot: verify builds must not share a harness dir await workClone(boss, (jobId) => processCloneJob(deps, jobId));
// (concurrent framework builds collide). With concurrency 1 this is the plain console.log(JSON.stringify({ event: "worker_started", artifactsDir: env.artifactsDir, harnessDir: env.harnessDir, cacheStaleAfterMs: env.cacheStaleAfterMs }));
// harnessDir, preserving existing layouts. At most `concurrency` jobs run at
// once (one per poller), so a free slot always exists at checkout.
const harnesses = Array.from({ length: env.concurrency }, (_, i) =>
makeHarnessProvider(env.concurrency > 1 ? join(env.harnessDir, `slot-${i}`) : env.harnessDir),
);
const freeSlots = harnesses.map((_, i) => i);
await workClone(
boss,
async (jobId) => {
const slot = freeSlots.pop() ?? 0;
try {
await processCloneJob({ ...deps, harnessProvider: harnesses[slot]! }, jobId);
} finally {
freeSlots.push(slot);
}
},
env.concurrency,
);
console.log(JSON.stringify({ event: "worker_started", concurrency: env.concurrency, artifactsDir: env.artifactsDir, harnessDir: env.harnessDir, cacheStaleAfterMs: env.cacheStaleAfterMs }));
} }
main().catch((e) => { main().catch((e) => {