Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5285ddc2b2 | ||
|
|
4c1dea77e1 | ||
|
|
99aca7d43d | ||
|
|
27cccd8964 | ||
|
|
03226f8a53 | ||
|
|
b66a993b35 | ||
|
|
01e59d96bd | ||
|
|
c35680eb13 | ||
|
|
bbe4a5643b | ||
|
|
f991f469cf | ||
|
|
7e8863fc7e |
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"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"] } }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
3f46aca40bb69d5c114e957c28b3a4924c58902cecb35332d34f3065114d0e75
|
||||||
+238
-53
@@ -15,6 +15,8 @@ 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
|
||||||
@@ -65,6 +67,10 @@ 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
|
||||||
@@ -797,6 +803,66 @@ 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
|
||||||
@@ -1004,6 +1070,14 @@ 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
|
||||||
@@ -1013,6 +1087,13 @@ 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");
|
||||||
@@ -1178,64 +1259,136 @@ 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>[] = [];
|
||||||
|
|
||||||
page.on("response", (resp) => {
|
// Response listener + init scripts are attached the same way to the initial page
|
||||||
try {
|
// and to any fresh recovery page (Item 3c), so factor the wiring into one helper.
|
||||||
const url = resp.url();
|
const attachResponseListener = (pg: import("playwright").Page): void => {
|
||||||
if (url.startsWith("data:") || url.startsWith("blob:")) return;
|
pg.on("response", (resp) => {
|
||||||
const ct = resp.headers()["content-type"] || null;
|
try {
|
||||||
const type = classifyAsset(url, ct);
|
const url = resp.url();
|
||||||
if (!type) return;
|
if (url.startsWith("data:") || url.startsWith("blob:")) return;
|
||||||
const status = resp.status();
|
const ct = resp.headers()["content-type"] || null;
|
||||||
recordAsset(url, type, ct, status, "network");
|
const type = classifyAsset(url, ct);
|
||||||
const existing = assetMap.get(url);
|
if (!type) return;
|
||||||
if (existing?.storedAs) return;
|
const status = resp.status();
|
||||||
if (status >= 400) return;
|
recordAsset(url, type, ct, status, "network");
|
||||||
// A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would
|
const existing = assetMap.get(url);
|
||||||
// ship a corrupt file under first-stored-wins and the full-download fallback
|
if (existing?.storedAs) return;
|
||||||
// would then skip the asset. Record only; the fallback pass fetches the 200 body.
|
if (status >= 400) return;
|
||||||
if (status === 206) return;
|
// A 206 body is a RANGE FRAGMENT (media seek), not the asset — storing it would
|
||||||
bodyPromises.push(
|
// ship a corrupt file under first-stored-wins and the full-download fallback
|
||||||
(async () => {
|
// would then skip the asset. Record only; the fallback pass fetches the 200 body.
|
||||||
try {
|
if (status === 206) return;
|
||||||
const buf = await resp.body();
|
bodyPromises.push(
|
||||||
storeBytes(url, type, buf);
|
(async () => {
|
||||||
if (type === "css") cssTextsForParsing.push({ baseUrl: url, text: buf.toString("utf8") });
|
try {
|
||||||
if (type === "manifest") parseManifestForAssets(buf.toString("utf8"), url);
|
const buf = await resp.body();
|
||||||
} catch { /* body unavailable */ }
|
storeBytes(url, type, buf);
|
||||||
})(),
|
if (type === "css") cssTextsForParsing.push({ baseUrl: url, text: buf.toString("utf8") });
|
||||||
);
|
if (type === "manifest") parseManifestForAssets(buf.toString("utf8"), url);
|
||||||
} catch { /* ignore */ }
|
} catch { /* body unavailable */ }
|
||||||
});
|
})(),
|
||||||
|
);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Single navigation at the canonical width; every viewport below is a resize.
|
const newSession = async (): Promise<{ context: BrowserContext; page: import("playwright").Page }> => {
|
||||||
log({ event: "goto", url: opts.url });
|
const ctx: BrowserContext = await browser.newContext({
|
||||||
let navigated = false;
|
ignoreHTTPSErrors: true,
|
||||||
let navErr: unknown = null;
|
viewport: { width: canonical, height: viewportHeight(canonical) },
|
||||||
for (let attempt = 0; attempt < 3 && !navigated; attempt++) {
|
deviceScaleFactor: 1,
|
||||||
try {
|
userAgent: DESKTOP_UA,
|
||||||
await page.goto(opts.url, { waitUntil: attempt === 0 ? "load" : "domcontentloaded", timeout: 45000 });
|
javaScriptEnabled: true,
|
||||||
navigated = true;
|
});
|
||||||
} catch (e) {
|
const pg = await ctx.newPage();
|
||||||
navErr = e;
|
attachResponseListener(pg);
|
||||||
await page.waitForTimeout(1000);
|
// 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)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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
|
||||||
@@ -1641,6 +1794,37 @@ 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
|
||||||
@@ -1754,6 +1938,7 @@ 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,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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";
|
||||||
@@ -2659,7 +2660,7 @@ export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | und
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[] } {
|
export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx: string; cloneCss: string; components: ExtractedComponent[]; previewHtml: string } {
|
||||||
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";
|
||||||
@@ -2789,6 +2790,16 @@ 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);
|
||||||
@@ -2830,7 +2841,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [] };
|
return { pageTsx, cloneCss, components: components ? summarizeComponents(components) : [], previewHtml: previewPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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
|
||||||
|
|||||||
@@ -1952,6 +1952,7 @@ 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];
|
||||||
@@ -1962,7 +1963,21 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3120,7 +3135,10 @@ 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, no element children), whitespace not already preserved (`pre*`);
|
* • text leaf (direct text; element children only if REPLACED — an icon <svg>/<img> beside the
|
||||||
|
* 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 —
|
||||||
@@ -3130,7 +3148,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 (hasElementChild(node)) return false;
|
if (node.children.some((c) => !isTextChild(c) && !REPLACED.has(c.tag))) 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;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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;
|
||||||
@@ -16,8 +17,13 @@ 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 } = args;
|
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount, patternHints, previewHtml } = args;
|
||||||
|
|
||||||
const byType: Record<string, number> = {};
|
const byType: Record<string, number> = {};
|
||||||
let downloaded = 0, skipped = 0;
|
let downloaded = 0, skipped = 0;
|
||||||
@@ -56,6 +62,26 @@ 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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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";
|
||||||
|
|
||||||
@@ -24,6 +25,7 @@ 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>;
|
||||||
@@ -59,6 +61,10 @@ 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);
|
||||||
@@ -69,7 +75,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);
|
const recipeReport = buildRecipeReport(ir, sections, primitives, patternHints);
|
||||||
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");
|
||||||
@@ -95,6 +101,7 @@ 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);
|
||||||
@@ -104,8 +111,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 });
|
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count, patternHints, previewHtml: gen.previewHtml });
|
||||||
writeJSON(join(outDir, "manifest.json"), manifest);
|
writeJSON(join(outDir, "manifest.json"), manifest);
|
||||||
|
|
||||||
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, patternHints, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeText(raw: string): string {
|
||||||
|
return raw.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -32,6 +32,10 @@ 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";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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";
|
||||||
|
|
||||||
@@ -60,6 +61,8 @@ 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[];
|
||||||
};
|
};
|
||||||
@@ -78,6 +81,10 @@ 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 = {
|
||||||
@@ -233,7 +240,7 @@ function nodeStats(ctx: RecipeContext, n: IRNode): ItemStats {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeContext {
|
function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>, patternHints?: PatternHints): 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();
|
||||||
@@ -254,9 +261,32 @@ 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) {
|
||||||
@@ -545,18 +575,23 @@ 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;
|
||||||
@@ -568,6 +603,10 @@ 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));
|
||||||
@@ -581,7 +620,10 @@ 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);
|
||||||
const galleryContext = subtreeAttrOrSourceMatches(parent, GALLERY_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, GALLERY_SOURCE));
|
// Lightbox libraries (fancybox, photoswipe, lightGallery, …) mark a thumbnail
|
||||||
|
// 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);
|
||||||
@@ -598,6 +640,7 @@ 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" : "",
|
||||||
@@ -619,9 +662,13 @@ 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);
|
||||||
@@ -635,6 +682,7 @@ 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)
|
||||||
@@ -642,7 +690,8 @@ 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;
|
||||||
@@ -832,8 +881,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>): RecipeReport {
|
export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>, patternHints?: PatternHints): RecipeReport {
|
||||||
const ctx = buildContext(ir, sections, primitives);
|
const ctx = buildContext(ir, sections, primitives, patternHints);
|
||||||
const drafts = suppressDuplicates(ctx, [
|
const drafts = suppressDuplicates(ctx, [
|
||||||
...detectLogoClouds(ctx),
|
...detectLogoClouds(ctx),
|
||||||
...detectGrids(ctx),
|
...detectGrids(ctx),
|
||||||
@@ -853,6 +902,7 @@ 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,
|
||||||
};
|
};
|
||||||
@@ -880,6 +930,7 @@ 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");
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ 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";
|
||||||
@@ -454,6 +455,22 @@ 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,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/** 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";
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/** 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) {} }
|
||||||
|
})();`;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ 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;
|
||||||
@@ -117,7 +118,8 @@ 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.
|
||||||
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;
|
// WALL_RE lives in util/captureFailure.ts — shared with the capture-side fast-fail so
|
||||||
|
// 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[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -566,6 +566,78 @@ 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.
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
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, []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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)}…`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+9
-1
@@ -16,7 +16,15 @@ 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`.
|
Run this on every deploy that changes `packages/db/migrations`. Drizzle tracks
|
||||||
|
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)
|
||||||
|
|
||||||
|
|||||||
+72
-5
@@ -24,12 +24,35 @@ packages/
|
|||||||
|
|
||||||
Two run modes, same HTTP surface:
|
Two run modes, same HTTP surface:
|
||||||
|
|
||||||
- **In-memory (no `DATABASE_URL`)** — the API runs single-page clones **inline** and
|
- **In-memory (no `DATABASE_URL`)** — `POST` enqueues (202) and runs the clone in the
|
||||||
holds results in memory. Handy for a quick local demo.
|
background within the API process, holding results in memory. Handy for a quick
|
||||||
|
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
|
||||||
@@ -84,8 +107,10 @@ 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 file
|
GET /v1/clones/:id/files/* → stream one generated file (e.g. .../files/preview.html — see Preview)
|
||||||
|
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)
|
||||||
@@ -106,8 +131,10 @@ 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" }`.
|
Normal product `options` are `{ mode?: "single" | "multi", styling?: "tailwind" | "css", framework?: "next" | "vite", preview?: boolean }`.
|
||||||
`mode` defaults to `"single"`, `styling` defaults to `"tailwind"`, and `framework` defaults to `"next"`. Operational options
|
`mode` defaults to `"single"`, `styling` defaults to `"tailwind"`, and `framework` defaults to `"next"`. `preview`
|
||||||
|
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.
|
||||||
@@ -122,6 +149,46 @@ 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
|
||||||
|
|||||||
+39
-2
@@ -15,6 +15,7 @@ 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(),
|
||||||
@@ -238,10 +239,12 @@ 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" }, 202);
|
if (out.status === "queued") return c.json({ jobId: out.jobId, status: "queued" }, out.httpStatus);
|
||||||
return c.json(out.result, 200);
|
return c.json(out.result, 200);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return c.json({ status: "failed", error: String(e).slice(0, 500) }, 500);
|
const msg = String(e);
|
||||||
|
if (msg.startsWith("BUSY:")) return c.json({ error: msg.slice(5).trim() }, 429);
|
||||||
|
return c.json({ status: "failed", error: msg.slice(0, 500) }, 500);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,6 +285,40 @@ 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);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { CloneOptions, RouteInfo } from "@cloner/core";
|
import type { CloneOptions, RouteInfo } from "@cloner/core";
|
||||||
import type { RestCloneResult } from "./rest.js";
|
import type { RestCloneResult, RestCloneSummary } 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 }
|
| { jobId: string; status: "succeeded" | "cached"; httpStatus: 200; result: RestCloneResult | RestCloneSummary }
|
||||||
| { jobId: string; status: "queued"; httpStatus: 202 };
|
| { jobId: string; status: "queued"; httpStatus: 202 };
|
||||||
|
|
||||||
export type ResultOutcome =
|
export type ResultOutcome =
|
||||||
@@ -56,4 +56,6 @@ 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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ 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 });
|
||||||
@@ -142,4 +149,10 @@ 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,24 +10,51 @@ import type { Backend, BundleFormat, CloneBundle, FileFacet, JobView, ResultOutc
|
|||||||
|
|
||||||
export type RunJob = (input: RunCloneJobInput) => Promise<CloneJobResult>;
|
export type RunJob = (input: RunCloneJobInput) => Promise<CloneJobResult>;
|
||||||
|
|
||||||
/** Sync, in-memory backend (M1): runs the clone INLINE on submit and holds results
|
/** In-memory backend: enqueues a clone (202) and runs it in the background so long
|
||||||
* in memory. POST returns the file map immediately (200). */
|
* captures (multi-viewport + verify) never hold a single HTTP connection open for
|
||||||
|
* 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 {
|
||||||
// captureCacheDir (persistent, shared across submits) powers the single→multi
|
const result = await this.deps.runJob({ url, options, runsDir: base, captureCacheDir: this.deps.captureCacheDir, log });
|
||||||
// reuse: a single-page submit stashes its capture; a later multi-page submit for
|
log({ event: "clone_done" });
|
||||||
// the same URL reuses it as the entry route (no re-capture) and expands the site.
|
this.deps.store.put({ id, status: "succeeded", url, kind: result.kind, options: result.options, createdAt: rec.createdAt, result, base, events });
|
||||||
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,
|
||||||
@@ -39,18 +66,25 @@ 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 */
|
||||||
}
|
}
|
||||||
this.deps.store.put({ id, status: "failed", url, kind: resolveCloneMode(options) === "multi" ? "clone_site" : "clone", options: options ?? {}, createdAt: Date.now(), error: String(e) });
|
log({ event: "clone_error", error: String(e).slice(0, 300) });
|
||||||
throw e;
|
this.deps.store.put({ id, status: "failed", url, kind, options: options ?? {}, createdAt: rec.createdAt, error: String(e), events });
|
||||||
|
} 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;
|
||||||
|
|||||||
@@ -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: "succeeded" | "failed";
|
status: "running" | "succeeded" | "failed";
|
||||||
url: string;
|
url: string;
|
||||||
kind: "clone" | "clone_site";
|
kind: "clone" | "clone_site";
|
||||||
options: CloneOptions;
|
options: CloneOptions;
|
||||||
@@ -14,6 +14,8 @@ 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. */
|
||||||
|
|||||||
@@ -26,13 +26,25 @@ 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, 200);
|
assert.equal(res.status, 202);
|
||||||
const body = await res.json();
|
const queued = await res.json();
|
||||||
assert.equal(body.status, "succeeded");
|
let body: Record<string, unknown> | undefined;
|
||||||
assert.ok(body.files["src/app/page.tsx"], "has page.tsx");
|
for (let i = 0; i < 600; i++) {
|
||||||
assert.equal(body.files["src/app/page.tsx"].type, "text");
|
const view = await (await app.request(`/v1/clones/${queued.jobId}`)).json();
|
||||||
assert.ok(body.files["package.json"], "has package.json");
|
if (view.status === "succeeded") {
|
||||||
assert.ok(body.capture.nodeCount > 0);
|
body = await (await app.request(`/v1/clones/${queued.jobId}/result`)).json();
|
||||||
assert.equal(body.capture.blocked, false);
|
break;
|
||||||
|
}
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,7 +34,17 @@ const fakeRunJob: RunJob = async (input) => {
|
|||||||
} satisfies CloneJobResult;
|
} satisfies CloneJobResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
test("POST /v1/clones returns the eager file map; binaries by reference; streaming + lifecycle", async () => {
|
async function waitForResult(app: ReturnType<typeof createApp>, jobId: string) {
|
||||||
|
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 {
|
||||||
@@ -43,39 +53,47 @@ test("POST /v1/clones returns the eager file map; binaries by reference; streami
|
|||||||
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, 200);
|
assert.equal(res.status, 202);
|
||||||
const body = await res.json();
|
const queued = await res.json();
|
||||||
assert.ok(body.jobId);
|
assert.ok(queued.jobId);
|
||||||
assert.equal(body.status, "succeeded");
|
assert.equal(queued.status, "queued");
|
||||||
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 page = body.files["src/app/page.tsx"];
|
const files = body!.files as Record<string, { type: string; content?: string; sha256?: string; url?: string }>;
|
||||||
assert.equal(page.type, "text");
|
const page = files["src/app/page.tsx"];
|
||||||
assert.ok(page.content.includes("Page"));
|
assert.ok(page);
|
||||||
assert.ok(page.sha256);
|
assert.equal(page!.type, "text");
|
||||||
|
assert.ok(page!.content!.includes("Page"));
|
||||||
|
assert.ok(page!.sha256);
|
||||||
|
|
||||||
// Binary by reference (URL, not bytes).
|
// Binary by reference (URL, not bytes).
|
||||||
const bin = body.files["public/assets/cloned/images/a.png"];
|
const bin = files["public/assets/cloned/images/a.png"];
|
||||||
assert.equal(bin.type, "binary");
|
assert.ok(bin);
|
||||||
assert.equal(bin.content, undefined);
|
assert.equal(bin!.type, "binary");
|
||||||
assert.equal(bin.url, `/v1/clones/${body.jobId}/files/public/assets/cloned/images/a.png`);
|
assert.equal(bin!.content, undefined);
|
||||||
|
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/${body.jobId}`)).json();
|
const meta = await (await app.request(`/v1/clones/${queued.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/${body.jobId}/result`);
|
const result = await app.request(`/v1/clones/${queued.jobId}/result`);
|
||||||
assert.equal(result.status, 200);
|
assert.equal(result.status, 200);
|
||||||
|
|
||||||
// List.
|
// List.
|
||||||
@@ -83,9 +101,9 @@ test("POST /v1/clones returns the eager file map; binaries by reference; streami
|
|||||||
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/${body.jobId}`, { method: "DELETE" });
|
const del = await app.request(`/v1/clones/${queued.jobId}`, { method: "DELETE" });
|
||||||
assert.equal((await del.json()).deleted, true);
|
assert.equal((await del.json()).deleted, true);
|
||||||
assert.equal((await app.request(`/v1/clones/${body.jobId}`)).status, 404);
|
assert.equal((await app.request(`/v1/clones/${queued.jobId}`)).status, 404);
|
||||||
} finally {
|
} finally {
|
||||||
store.clear();
|
store.clear();
|
||||||
}
|
}
|
||||||
@@ -118,8 +136,9 @@ 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, 200);
|
assert.equal(product.status, 202);
|
||||||
const productBody = await product.json();
|
const productQueued = 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", {
|
||||||
@@ -127,8 +146,9 @@ 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, 200);
|
assert.equal(legacy.status, 202);
|
||||||
const legacyBody = await legacy.json();
|
const legacyQueued = 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();
|
||||||
|
|||||||
@@ -52,12 +52,18 @@ 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, "succeeded");
|
assert.equal(cw.status, "queued");
|
||||||
assert.ok(!("files" in cw));
|
|
||||||
const jobId = cw.jobId;
|
const jobId = cw.jobId;
|
||||||
|
|
||||||
// status
|
let status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
|
||||||
const status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
|
for (let i = 0; i < 200 && status.status !== "succeeded"; i++) {
|
||||||
|
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.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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 {
|
||||||
@@ -53,5 +54,8 @@ 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",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ 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;
|
||||||
@@ -75,7 +78,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 };
|
export type CloneTimings = { captureMs: number; generateMs: number; verifyMs?: number; previewMs?: number };
|
||||||
|
|
||||||
export type RouteInfo = { route: string; representativeOf?: string };
|
export type RouteInfo = { route: string; representativeOf?: string };
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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");
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
{
|
||||||
|
"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,6 +15,13 @@
|
|||||||
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -31,13 +31,23 @@ 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> {
|
*
|
||||||
return boss.work(CLONE_QUEUE, async (job: unknown) => {
|
* `concurrency` registers that many independent pollers on the queue, so up to
|
||||||
const arr = Array.isArray(job) ? job : [job];
|
* N jobs run at once in this process with no head-of-line blocking (each poller
|
||||||
for (const j of arr) {
|
* fetches its next job as soon as its current one finishes). */
|
||||||
const data = (j as { data?: ClonePayload }).data;
|
export async function workClone(boss: PgBoss, handler: (jobId: string) => Promise<void>, concurrency = 1): Promise<string[]> {
|
||||||
if (data?.jobId) await handler(data.jobId);
|
const pollers: string[] = [];
|
||||||
}
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-2
@@ -1,6 +1,6 @@
|
|||||||
import { and, desc, eq, gt, isNull, sql } from "drizzle-orm";
|
import { and, desc, eq, gt, inArray, isNull, sql } from "drizzle-orm";
|
||||||
import type { Db } from "./client.js";
|
import type { Db } from "./client.js";
|
||||||
import { jobs, clones, cache, apiKeys, signupTokens, type Job, type NewJob, type Clone, type NewClone, type CacheRow, type ApiKey, type SignupToken } from "./schema.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";
|
||||||
|
|
||||||
// ---- jobs ----
|
// ---- jobs ----
|
||||||
|
|
||||||
@@ -40,6 +40,19 @@ 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;
|
||||||
@@ -111,3 +124,24 @@ 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,4 +1,4 @@
|
|||||||
import { pgTable, text, jsonb, integer, timestamp, uuid } from "drizzle-orm/pg-core";
|
import { pgTable, text, jsonb, integer, timestamp, uuid, index } 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,6 +66,17 @@ 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;
|
||||||
@@ -74,3 +85,4 @@ 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;
|
||||||
|
|||||||
@@ -11,11 +11,15 @@ 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"),
|
||||||
@@ -23,5 +27,6 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ 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({ url: job.url, options, runsDir: base, harnessDir, tier: deps.tier, captureCacheDir: deps.captureCacheDir || undefined, captureCacheTtlMs: deps.cacheTtlMs });
|
const result = await deps.runJob({
|
||||||
|
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 };
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
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";
|
||||||
@@ -16,13 +17,32 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
await workClone(boss, (jobId) => processCloneJob(deps, jobId));
|
// One harness per concurrency slot: verify builds must not share a harness dir
|
||||||
console.log(JSON.stringify({ event: "worker_started", artifactsDir: env.artifactsDir, harnessDir: env.harnessDir, cacheStaleAfterMs: env.cacheStaleAfterMs }));
|
// (concurrent framework builds collide). With concurrency 1 this is the plain
|
||||||
|
// 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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user