Merge pull request #16 from ion-design/more-fidelity-upgrades
Fidelity campaign: capture integrity, emission geometry, honest validation
This commit is contained in:
+689
-113
@@ -14,6 +14,7 @@ import {
|
||||
import { discoverBreakpoints } from "./breakpoints.js";
|
||||
import { writeJSON, writeJSONCompact, writeBytes, ensureDir } from "../util/fsx.js";
|
||||
import { sha1_12, round } from "../util/canonical.js";
|
||||
import { isZipArchive, extractDotLottieJson } from "./dotlottie.js";
|
||||
|
||||
export const REQUIRED_VIEWPORTS = [375, 768, 1280, 1920] as const;
|
||||
// The dense width set captured for SIZE INFERENCE: a node sampled at 9 widths reveals its sizing
|
||||
@@ -115,13 +116,19 @@ export function isRetryableAssetFailure(type: string, status: number | null): bo
|
||||
return status >= 500 || status === 429;
|
||||
}
|
||||
|
||||
function extFromUrl(url: string): string {
|
||||
// Bound on a preserved file extension. Real extensions are short, but a hard 5-char cap
|
||||
// silently truncates legitimate ones (`.lottie` → `.lotti`, `.webmanifest` → `.webma`),
|
||||
// which then mis-materializes the asset. Keep the guard generous enough for the longest
|
||||
// real extensions and reject anything absurdly long (a dotted path segment, not an ext).
|
||||
const MAX_EXT_LEN = 12;
|
||||
|
||||
export function extFromUrl(url: string): string {
|
||||
try {
|
||||
const p = new URL(url).pathname;
|
||||
const dot = p.lastIndexOf(".");
|
||||
if (dot >= 0 && dot > p.lastIndexOf("/")) {
|
||||
const ext = p.slice(dot + 1).toLowerCase().slice(0, 5);
|
||||
if (/^[a-z0-9]+$/.test(ext)) return ext;
|
||||
const ext = p.slice(dot + 1).toLowerCase();
|
||||
if (ext.length <= MAX_EXT_LEN && /^[a-z0-9]+$/.test(ext)) return ext;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return "";
|
||||
@@ -175,6 +182,83 @@ export function looksLikeVideoFile(bytes: Buffer): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Container-magic check for accepted font bytes, mirroring `looksLikeVideoFile`. A router that
|
||||
* answers 200+HTML for an unknown `/media/*` path (common on SPA hosts) otherwise gets stored as
|
||||
* a `.woff2`; the browser rejects it as a font and the whole page falls through to a system
|
||||
* fallback. Accept only the real font-container signatures — woff2 (`wOF2`), woff (`wOFF`),
|
||||
* OpenType/CFF (`OTTO`), TrueType (`\x00\x01\x00\x00` or `true`/`ttcf`), and EOT (the version
|
||||
* header at bytes 8..11 is `\x01\x00\x01\x00`/`\x02\x00\x01\x00`, so key EOT off its unique
|
||||
* 0x504C signature at offset 34) — and explicitly reject an HTML/text body. */
|
||||
export function looksLikeFontFile(bytes: Buffer): boolean {
|
||||
if (bytes.length < 4) return false;
|
||||
const b0 = bytes[0]!, b1 = bytes[1]!, b2 = bytes[2]!, b3 = bytes[3]!;
|
||||
// A leading `<` (`<!DOCTYPE`, `<html`, `<?xml`, `<svg`) is never a binary font container.
|
||||
if (b0 === 0x3c) return false;
|
||||
const tag = bytes.subarray(0, 4).toString("latin1");
|
||||
if (tag === "wOF2" || tag === "wOFF") return true; // woff2 / woff
|
||||
if (tag === "OTTO" || tag === "true" || tag === "ttcf") return true; // CFF OpenType / TrueType / TrueType collection
|
||||
if (b0 === 0x00 && b1 === 0x01 && b2 === 0x00 && b3 === 0x00) return true; // TrueType sfnt (\x00\x01\x00\x00)
|
||||
// EOT: a little-endian byte count precedes a version/flags header; its stable marker is the
|
||||
// 0x504C ("LP") magic at byte offset 34.
|
||||
if (bytes.length >= 36 && bytes[34] === 0x4c && bytes[35] === 0x50) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `<source>` candidate for the HTML media resource-selection algorithm. `media`/`type` are the
|
||||
* raw attribute strings (null/undefined when absent, matching a missing attribute).
|
||||
*/
|
||||
export interface VideoSourceCandidate {
|
||||
media?: string | null;
|
||||
type?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure re-implementation of the source-selection step of the HTML media resource-selection
|
||||
* algorithm: return the index of the FIRST `<source>` (document order) that is eligible NOW, or -1
|
||||
* when none is. A source is eligible when its `media` query matches (a missing/empty media matches
|
||||
* unconditionally) AND the UA can play its `type` (a missing/empty type is not a disqualifier —
|
||||
* `canPlayType` is only consulted when a type is present).
|
||||
*
|
||||
* The predicates are injected so this is testable in Node (the in-page caller passes the real
|
||||
* `matchMedia`/`canPlayType`). Kept deterministic: no state, first-match wins in document order.
|
||||
*/
|
||||
export function selectVideoSourceIndex(
|
||||
sources: VideoSourceCandidate[],
|
||||
mediaMatches: (media: string) => boolean,
|
||||
canPlay: (type: string) => boolean,
|
||||
): number {
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const s = sources[i]!;
|
||||
const media = (s.media ?? "").trim();
|
||||
if (media && !mediaMatches(media)) continue;
|
||||
const type = (s.type ?? "").trim();
|
||||
if (type && !canPlay(type)) continue;
|
||||
return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure decision for the per-video seek in normalizeVideoTime, extracted so the reloaded-set /
|
||||
* seek-target branch is unit-testable in Node (the in-page loop applies the same rule to real
|
||||
* elements). Returns the seek to perform after the re-selection reload pass, or null to fast-skip.
|
||||
*
|
||||
* - A `reloaded` video sits at t=0 with the HTML element's show-poster flag freshly set. Seeking to
|
||||
* 0 would be a no-op that fires no `seeked` and leaves the poster showing, so force a genuine seek
|
||||
* to a tiny epsilon to clear the flag and paint the new source's frame 0.
|
||||
* - A non-reloaded video already at t≈0 needs nothing (skip). Otherwise seek it back to 0.
|
||||
*/
|
||||
export function planVideoSeek(
|
||||
reloaded: boolean,
|
||||
currentTime: number,
|
||||
): { target: number } | null {
|
||||
const EPSILON = 1e-4;
|
||||
if (reloaded) return { target: EPSILON };
|
||||
if (Math.abs(currentTime) < 1e-3) return null;
|
||||
return { target: 0 };
|
||||
}
|
||||
|
||||
async function autoScroll(page: import("playwright").Page, vpHeight: number): Promise<void> {
|
||||
// Scroll through the page to trigger lazy-loaded images/backgrounds, then return
|
||||
// to the top so document-coordinate bboxes are measured from a settled layout.
|
||||
@@ -209,6 +293,41 @@ async function settle(page: import("playwright").Page, maxMs = 2500): Promise<vo
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stage 2 — scroll-state reset immediately before a per-viewport snapshot. The motion /
|
||||
* dwell-scroll / carousel / element-screenshot passes above all leave the page scrolled or
|
||||
* mid-transition; scroll-linked styles (Webflow scroll-state transforms, position:sticky
|
||||
* offsets) then get baked into the captured computed styles for THAT viewport only, so a
|
||||
* scroll-driven translateY leaks into one band and cascades. Reset window + inner scrollers
|
||||
* to the top, wait for scroll-linked effects to settle across a few rAF ticks plus a short
|
||||
* quiescence window, THEN let the caller snapshot. Bounded and deterministic.
|
||||
*/
|
||||
async function settleScrollTopBeforeSnapshot(page: import("playwright").Page): Promise<void> {
|
||||
try {
|
||||
await Promise.race([
|
||||
page.evaluate(async () => {
|
||||
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||
const resetAll = () => {
|
||||
window.scrollTo(0, 0);
|
||||
for (const el of Array.from(document.querySelectorAll("*"))) {
|
||||
if (el.scrollLeft) el.scrollLeft = 0;
|
||||
if (el.scrollTop) el.scrollTop = 0;
|
||||
}
|
||||
};
|
||||
resetAll();
|
||||
// Let scroll-linked effects (scroll-state classes, sticky offsets, JS scroll handlers)
|
||||
// recompute at scroll 0 over several frames, re-asserting the top position each tick in
|
||||
// case a handler nudged it, then hold briefly for quiescence.
|
||||
for (let i = 0; i < 6; i++) { await raf(); resetAll(); }
|
||||
await new Promise<void>((r) => setTimeout(r, 120));
|
||||
resetAll();
|
||||
await raf();
|
||||
}),
|
||||
new Promise<void>((r) => setTimeout(r, 4000)),
|
||||
]);
|
||||
} catch { /* ignore — a best-effort reset never blocks the snapshot */ }
|
||||
}
|
||||
|
||||
export type DismissResult = { dismissed: string[]; overlaysRemaining: number; removed: number; blocking: boolean };
|
||||
|
||||
/**
|
||||
@@ -219,64 +338,102 @@ export type DismissResult = { dismissed: string[]; overlaysRemaining: number; re
|
||||
* Removal of a stuck overlay happens later (finalizeOverlays) AFTER a settle, so a
|
||||
* just-clicked dialog has time to close and unlock scrolling before we judge it.
|
||||
*/
|
||||
/**
|
||||
* In-page dismissal click pass (exported for fixture tests). Runs in ONE document — the main
|
||||
* page OR a cross-origin frame (Attentive/Recart/Klaviyo creatives host their Decline/× inside
|
||||
* an iframe, so the caller runs this in every `page.frames()`). Traverses open shadow roots
|
||||
* (Recart mounts its popup in a shadow tree) when scanning both containers and buttons.
|
||||
*/
|
||||
export function clickDismissInPage(): string[] {
|
||||
const dismissed: string[] = [];
|
||||
// Deep collector across open shadow roots.
|
||||
const deepEls = (root: ParentNode, sel: string): Element[] => {
|
||||
const out: Element[] = [];
|
||||
const walk = (r: ParentNode): void => {
|
||||
let hits: Element[] = [];
|
||||
try { hits = Array.from(r.querySelectorAll(sel)); } catch { /* invalid selector */ }
|
||||
out.push(...hits);
|
||||
for (const el of Array.from(r.querySelectorAll("*"))) {
|
||||
const sr = (el as HTMLElement).shadowRoot;
|
||||
if (sr) walk(sr);
|
||||
}
|
||||
};
|
||||
walk(root);
|
||||
return out;
|
||||
};
|
||||
const vis = (el: Element): boolean => {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) return false;
|
||||
const r = (el as HTMLElement).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
const click = (el: Element): void => { try { (el as HTMLElement).click(); } catch { /* ignore */ } };
|
||||
|
||||
// 1) Known consent-framework / generic close affordances, in priority order.
|
||||
const KNOWN = [
|
||||
"#onetrust-accept-btn-handler", "#accept-recommended-btn-handler", ".onetrust-close-btn-handler",
|
||||
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", "#CybotCookiebotDialogBodyButtonAccept",
|
||||
"#truste-consent-button", ".osano-cm-accept-all", ".osano-cm-dialog__close",
|
||||
"[data-testid='uc-accept-all-button']", "[data-testid='uc-deny-all-button']",
|
||||
"#didomi-notice-agree-button", ".didomi-continue-without-agreeing",
|
||||
".qc-cmp2-summary-buttons button[mode='primary']", "button[aria-label='Consent']",
|
||||
".cc-allow", ".cookie-consent-accept", "#hs-eu-confirmation-button", "#gdpr-consent-tool-wrapper button",
|
||||
];
|
||||
for (const sel of KNOWN) {
|
||||
for (const el of deepEls(document, sel)) {
|
||||
if (vis(el)) { click(el); dismissed.push(sel); break; }
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Scoped text-button pass: only inside overlay-ish containers (dialogs, or id/class naming
|
||||
// cookie/consent/modal/popup/newsletter/overlay/ccpa/privacy) so we never click an ordinary
|
||||
// page button. ACCEPT now also covers email-capture DECLINE affordances ("decline",
|
||||
// "no thanks", "not now", …) — a popup's own opt-out is the surest deterministic close.
|
||||
const ACCEPT = new Set([
|
||||
"accept", "accept all", "accept all cookies", "accept cookies", "accept & close",
|
||||
"i accept", "i agree", "agree", "agree and continue", "allow all", "allow cookies",
|
||||
"allow all cookies", "got it", "ok", "okay", "continue", "no thanks", "no, thanks",
|
||||
"dismiss", "close", "got it!", "understood", "yes, i agree",
|
||||
"decline", "no, thank you", "not now", "maybe later", "no thank you", "skip", "reject all",
|
||||
]);
|
||||
const containerSel =
|
||||
"[role='dialog'],[aria-modal='true'],[id*='cookie' i],[class*='cookie' i],[id*='consent' i]," +
|
||||
"[class*='consent' i],[class*='gdpr' i],[id*='gdpr' i],[class*='modal' i],[class*='popup' i]," +
|
||||
"[class*='newsletter' i],[class*='interstitial' i],[class*='overlay' i],[id*='overlay' i]," +
|
||||
"[class*='ccpa' i],[id*='ccpa' i],[class*='privacy' i],[id*='pop' i]";
|
||||
const containers = deepEls(document, containerSel);
|
||||
for (const c of containers) {
|
||||
if (!vis(c)) continue;
|
||||
// Text/value-matched accept/decline buttons.
|
||||
const btns = deepEls(c, "button,[role='button'],a,input[type='button'],input[type='submit']");
|
||||
let clicked = false;
|
||||
for (const b of btns) {
|
||||
const t = (b.textContent || (b as HTMLInputElement).value || "").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
if (t && ACCEPT.has(t) && vis(b)) { click(b); dismissed.push("text:" + t); clicked = true; break; }
|
||||
}
|
||||
if (clicked) continue;
|
||||
// aria-label / title close affordance (icon-only ×, no text content to match).
|
||||
const closers = deepEls(c, "[aria-label*='close' i],[aria-label*='dismiss' i],[title*='close' i],button.close,.close-button,[class*='close' i][role='button']");
|
||||
for (const x of closers) {
|
||||
if (vis(x)) { click(x); dismissed.push("close:" + ((x.getAttribute("aria-label") || x.getAttribute("title") || "x")).slice(0, 24)); break; }
|
||||
}
|
||||
}
|
||||
return dismissed;
|
||||
}
|
||||
|
||||
async function clickDismiss(page: import("playwright").Page): Promise<string[]> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
page.evaluate(() => {
|
||||
const dismissed: string[] = [];
|
||||
const vis = (el: Element): boolean => {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) return false;
|
||||
const r = (el as HTMLElement).getBoundingClientRect();
|
||||
return r.width > 0 && r.height > 0;
|
||||
};
|
||||
const click = (el: Element): void => { try { (el as HTMLElement).click(); } catch { /* ignore */ } };
|
||||
|
||||
// 1) Known consent-framework / generic close affordances, in priority order.
|
||||
const KNOWN = [
|
||||
"#onetrust-accept-btn-handler", "#accept-recommended-btn-handler", ".onetrust-close-btn-handler",
|
||||
"#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", "#CybotCookiebotDialogBodyButtonAccept",
|
||||
"#truste-consent-button", ".osano-cm-accept-all", ".osano-cm-dialog__close",
|
||||
"[data-testid='uc-accept-all-button']", "[data-testid='uc-deny-all-button']",
|
||||
"#didomi-notice-agree-button", ".didomi-continue-without-agreeing",
|
||||
".qc-cmp2-summary-buttons button[mode='primary']", "button[aria-label='Consent']",
|
||||
".cc-allow", ".cookie-consent-accept", "#hs-eu-confirmation-button", "#gdpr-consent-tool-wrapper button",
|
||||
];
|
||||
for (const sel of KNOWN) {
|
||||
try {
|
||||
for (const el of Array.from(document.querySelectorAll(sel))) {
|
||||
if (vis(el)) { click(el); dismissed.push(sel); break; }
|
||||
}
|
||||
} catch { /* invalid selector in this browser */ }
|
||||
}
|
||||
|
||||
// 2) Scoped text-button pass: only inside overlay-ish containers (dialogs,
|
||||
// or id/class naming cookie/consent/modal/popup/newsletter) so we never
|
||||
// click an ordinary page button.
|
||||
const ACCEPT = new Set([
|
||||
"accept", "accept all", "accept all cookies", "accept cookies", "accept & close",
|
||||
"i accept", "i agree", "agree", "agree and continue", "allow all", "allow cookies",
|
||||
"allow all cookies", "got it", "ok", "okay", "continue", "no thanks", "no, thanks",
|
||||
"dismiss", "close", "got it!", "understood", "yes, i agree",
|
||||
]);
|
||||
const containerSel =
|
||||
"[role='dialog'],[aria-modal='true'],[id*='cookie' i],[class*='cookie' i],[id*='consent' i]," +
|
||||
"[class*='consent' i],[class*='gdpr' i],[id*='gdpr' i],[class*='modal' i],[class*='popup' i]," +
|
||||
"[class*='newsletter' i],[class*='interstitial' i]";
|
||||
let containers: Element[] = [];
|
||||
try { containers = Array.from(document.querySelectorAll(containerSel)); } catch { /* ignore */ }
|
||||
for (const c of containers) {
|
||||
if (!vis(c)) continue;
|
||||
const btns = Array.from(c.querySelectorAll("button,[role='button'],a,input[type='button'],input[type='submit']"));
|
||||
for (const b of btns) {
|
||||
const t = (b.textContent || (b as HTMLInputElement).value || "").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
if (t && ACCEPT.has(t) && vis(b)) { click(b); dismissed.push("text:" + t); break; }
|
||||
}
|
||||
}
|
||||
return dismissed;
|
||||
}),
|
||||
// Run the click pass in the main document AND every frame — cross-origin popup creatives
|
||||
// (Attentive/Recart/…) host their Decline/close controls inside an iframe that the top
|
||||
// document cannot reach, but Playwright can evaluate inside each frame from Node.
|
||||
const runOne = (frame: import("playwright").Frame): Promise<string[]> =>
|
||||
Promise.race([
|
||||
frame.evaluate(clickDismissInPage).catch(() => [] as string[]),
|
||||
new Promise<string[]>((res) => setTimeout(() => res([]), 6000)),
|
||||
]);
|
||||
try {
|
||||
const frames = page.frames();
|
||||
const results = await Promise.all(frames.map((f) => runOne(f).catch(() => [] as string[])));
|
||||
return results.flat();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -292,57 +449,168 @@ async function clickDismiss(page: import("playwright").Page): Promise<string[]>
|
||||
* sticky chrome is never stripped. Reports `blocking` = a scroll-locking overlay we
|
||||
* could not clear (the pollution gate keys off this, not mere overlay presence).
|
||||
*/
|
||||
async function finalizeOverlays(page: import("playwright").Page): Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }> {
|
||||
export type FinalizeOverlaysResult = { overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] };
|
||||
|
||||
/**
|
||||
* In-page overlay finalizer (exported so fixture tests can page.evaluate it directly).
|
||||
* Detects any full-viewport, fixed/sticky BLOCKING layer still present and — when the page
|
||||
* is scroll-locked (a state legit pages never enter) — removes it. Key robustness rules:
|
||||
*
|
||||
* (a) EFFECTIVE z-index: an element with `z-index:auto` inherits its stacking position from
|
||||
* the nearest positioned/opacity/transform ancestor that establishes a stacking context.
|
||||
* A vendor popup iframe is z:auto but sits inside a z=INT_MAX wrapper, so per-element
|
||||
* `parseInt("auto")` under-reads it — resolve z through the ancestor chain instead.
|
||||
* (b) LOCK relaxes the z gate: on a scroll-locked page, ANY fixed/sticky full-viewport layer
|
||||
* is blocking regardless of z (catches a z:auto creative and a z=50 CCPA dialog). Off a
|
||||
* locked page we keep the z>=100 floor to avoid stripping legit fixed content.
|
||||
* (c) OVERLAY UNIT: a 0-size (height:0) max-z wrapper whose descendant is a full-viewport
|
||||
* fixed iframe is ONE overlay — the wrapper carries the z, the iframe carries the pixels.
|
||||
* Detecting either means removing BOTH (climb from a removable iframe to its high-z
|
||||
* wrapper ancestor; and treat a max-z 0-size wrapper as an overlay via its full-viewport
|
||||
* descendant). Neither alone passes the area+z gate, so they must be grouped.
|
||||
* (d) LOUD FAILURE: if the lock persists after removal (or was locked with nothing detected),
|
||||
* report blocking=true so the pollution gate fails rather than shipping a polluted capture.
|
||||
*/
|
||||
export function finalizeOverlaysInPage(): FinalizeOverlaysResult {
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
// Deep element walk that descends into OPEN shadow roots. A shadow host reports 0 light-DOM
|
||||
// children, so a popup mounted inside a shadow tree (Recart's `#recart-popup-root`) is invisible
|
||||
// to a plain `querySelectorAll("*")` — its full-viewport fixed layer would never be detected and
|
||||
// would paint into every screenshot. Traverse `element.shadowRoot` (open only; closed roots are
|
||||
// unreachable — the host itself is still caught by the geometry gate below and removed whole).
|
||||
const deepEls = (root: ParentNode): Element[] => {
|
||||
const out: Element[] = [];
|
||||
const push = (r: ParentNode): void => {
|
||||
for (const el of Array.from(r.querySelectorAll("*"))) {
|
||||
out.push(el);
|
||||
const sr = (el as HTMLElement).shadowRoot;
|
||||
if (sr) push(sr);
|
||||
}
|
||||
};
|
||||
push(root);
|
||||
return out;
|
||||
};
|
||||
const isLocked = (): boolean => {
|
||||
const b = document.body, h = document.documentElement;
|
||||
const bs = getComputedStyle(b), hs = getComputedStyle(h);
|
||||
return bs.overflow === "hidden" || hs.overflow === "hidden" ||
|
||||
bs.overflowY === "hidden" || hs.overflowY === "hidden" ||
|
||||
bs.position === "fixed" || bs.position === "absolute";
|
||||
};
|
||||
// Effective z-index: climb to the nearest ancestor that both is positioned/creates a stacking
|
||||
// context AND reports a numeric z-index. `z-index:auto` on a positioned child paints in its
|
||||
// parent stacking context, so the parent's z is the layer's true stacking rank.
|
||||
const effectiveZ = (el: Element): number => {
|
||||
let cur: Element | null = el;
|
||||
let hops = 0;
|
||||
while (cur && hops < 20) {
|
||||
const cs = getComputedStyle(cur);
|
||||
const zi = parseInt(cs.zIndex || "", 10);
|
||||
const positioned = cs.position !== "static";
|
||||
if (Number.isFinite(zi) && positioned) return zi;
|
||||
cur = cur.parentElement;
|
||||
hops++;
|
||||
}
|
||||
return 0;
|
||||
};
|
||||
const rectOf = (el: Element) => (el as HTMLElement).getBoundingClientRect();
|
||||
const fullViewport = (r: DOMRect): boolean => r.width >= vw * 0.7 && r.height >= vh * 0.5 && (r.width * r.height) / (vw * vh) >= 0.5;
|
||||
const painting = (cs: CSSStyleDeclaration): boolean =>
|
||||
cs.display !== "none" && cs.visibility !== "hidden" && parseFloat(cs.opacity || "1") !== 0;
|
||||
|
||||
const locked = isLocked();
|
||||
|
||||
// Candidate blocking layers. A layer qualifies when it is fixed/sticky, painting, and either
|
||||
// (1) itself full-viewport, or (2) a 0-size/high-z wrapper whose descendant is full-viewport
|
||||
// (the overlay-unit case — the wrapper holds the z, an inner iframe holds the pixels).
|
||||
// Full-viewport descendant test that pierces shadow roots (Recart mounts the covering layer
|
||||
// inside the host's shadow tree, so a light-DOM `querySelectorAll` would find nothing).
|
||||
const hasFullDescendant = (el: Element): boolean => {
|
||||
const scope: ParentNode = (el as HTMLElement).shadowRoot ?? el;
|
||||
return deepEls(scope).some((d) => { const dcs = getComputedStyle(d); return painting(dcs) && (dcs.position === "fixed" || dcs.position === "sticky" || dcs.position === "absolute") && fullViewport(rectOf(d)); });
|
||||
};
|
||||
const bigOverlays = (): HTMLElement[] => {
|
||||
const out: HTMLElement[] = [];
|
||||
for (const el of deepEls(document.body)) {
|
||||
const cs = getComputedStyle(el);
|
||||
const isShadowHost = !!(el as HTMLElement).shadowRoot;
|
||||
// A shadow host is usually position:static with a fixed layer INSIDE its root; still test it
|
||||
// (via its shadow descendants) so the whole host can be removed as one overlay unit.
|
||||
if (cs.position !== "fixed" && cs.position !== "sticky" && !isShadowHost) continue;
|
||||
if (!painting(cs)) continue;
|
||||
const r = rectOf(el);
|
||||
const z = effectiveZ(el);
|
||||
// Off a locked page keep the z>=100 floor; on a locked page any full-viewport fixed
|
||||
// layer is blocking (rule b). A max-z wrapper is a blocking layer even at 0 size when it
|
||||
// wraps a full-viewport descendant (rule c) — legit chrome never reaches the INT_MAX band.
|
||||
const positioned = cs.position === "fixed" || cs.position === "sticky";
|
||||
const selfFull = positioned && fullViewport(r);
|
||||
const wrapsFull = (isShadowHost || r.width < 4 || r.height < 4) && (isShadowHost || z >= 100) && hasFullDescendant(el);
|
||||
if (!selfFull && !wrapsFull) continue;
|
||||
const zPass = locked || z >= 100 || (wrapsFull && isShadowHost);
|
||||
if (zPass) out.push(el as HTMLElement);
|
||||
}
|
||||
// Keep outermost only (a wrapper subsumes its inner full-viewport iframe — the overlay unit).
|
||||
// `Node.contains` does NOT cross shadow boundaries, so also treat a candidate that lives inside
|
||||
// another candidate's shadow tree as contained — otherwise a shadow host AND its inner layer
|
||||
// both survive and we double-remove (or leave the inner layer behind).
|
||||
const shadowContains = (host: Element, node: Element): boolean => {
|
||||
let cur: Node | null = node;
|
||||
while (cur) {
|
||||
const rootNode = cur.getRootNode();
|
||||
if (rootNode instanceof ShadowRoot) {
|
||||
if (rootNode.host === host || host.contains(rootNode.host)) return true;
|
||||
cur = rootNode.host;
|
||||
} else break;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return out.filter((el) => !out.some((o) => o !== el && (o.contains(el) || shadowContains(o, el))));
|
||||
};
|
||||
|
||||
// A scroll-locked page behind a full-viewport overlay IS a blocking modal by definition
|
||||
// (legit pages don't scroll-lock). Remove ANY such overlay that isn't page chrome — many
|
||||
// modals/drawers carry no consent/modal keyword and an icon-only close, so a keyword/aria
|
||||
// allowlist misses them. PROTECTED guards real chrome (header/nav/footer) only.
|
||||
const PROTECTED = /header|navbar|nav-|site-nav|topbar|masthead|footer/i;
|
||||
const sig = (el: HTMLElement): string => `${el.id} ${el.className}`.toString();
|
||||
|
||||
const removedLabels: string[] = [];
|
||||
let removed = 0;
|
||||
let remaining = bigOverlays();
|
||||
if (remaining.length && locked) {
|
||||
for (const el of remaining) {
|
||||
const s = sig(el);
|
||||
const z = effectiveZ(el);
|
||||
// Scroll-locked + full-viewport ⇒ blocking modal; remove unless it's page chrome. Always
|
||||
// remove iframes (cross-origin close, unclickable), max-z wrappers, and the overlay unit:
|
||||
// when the layer IS or CONTAINS a full-viewport fixed iframe, remove the whole subtree
|
||||
// (rule c — the 0-size wrapper + its iframe come out together as one node).
|
||||
const isShadowHost = !!(el as HTMLElement).shadowRoot;
|
||||
const containsIframe = el.tagName === "IFRAME" || !!el.querySelector("iframe") ||
|
||||
(isShadowHost && !!(el as HTMLElement).shadowRoot!.querySelector("iframe"));
|
||||
const removable = !PROTECTED.test(s) || el.getAttribute("aria-modal") === "true" ||
|
||||
containsIframe || isShadowHost || z >= 100;
|
||||
if (removable) { el.remove(); removed++; removedLabels.push((el.id || el.className || el.tagName).toString().slice(0, 40)); }
|
||||
}
|
||||
if (removed) {
|
||||
document.body.style.overflow = "visible"; document.documentElement.style.overflow = "visible";
|
||||
document.body.style.overflowY = "visible"; document.documentElement.style.overflowY = "visible";
|
||||
document.body.style.position = "static";
|
||||
}
|
||||
remaining = bigOverlays();
|
||||
}
|
||||
// Loud failure (rule d): a persisting lock is blocking whether or not a specific overlay was
|
||||
// still detectable — a scroll-locked capture is polluted, so surface it to the pollution gate.
|
||||
const stillLocked = isLocked();
|
||||
return { overlaysRemaining: remaining.length, removed, blocking: stillLocked && (remaining.length > 0 || locked), removedLabels };
|
||||
}
|
||||
|
||||
async function finalizeOverlays(page: import("playwright").Page): Promise<FinalizeOverlaysResult> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
page.evaluate(() => {
|
||||
const vw = window.innerWidth, vh = window.innerHeight;
|
||||
const bigOverlays = (): HTMLElement[] => {
|
||||
const out: HTMLElement[] = [];
|
||||
for (const el of Array.from(document.body.querySelectorAll("*"))) {
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.position !== "fixed" && cs.position !== "sticky") continue;
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") === 0) continue;
|
||||
const r = (el as HTMLElement).getBoundingClientRect();
|
||||
const z = parseInt(cs.zIndex || "0", 10) || 0;
|
||||
const area = (r.width * r.height) / (vw * vh);
|
||||
if (area >= 0.5 && z >= 100 && r.width >= vw * 0.7 && r.height >= vh * 0.5) out.push(el as HTMLElement);
|
||||
}
|
||||
return out.filter((el) => !out.some((o) => o !== el && o.contains(el)));
|
||||
};
|
||||
const isLocked = (): boolean => {
|
||||
const b = document.body, h = document.documentElement;
|
||||
return getComputedStyle(b).overflow === "hidden" || getComputedStyle(h).overflow === "hidden" ||
|
||||
getComputedStyle(b).position === "fixed";
|
||||
};
|
||||
// A scroll-locked page behind a full-viewport overlay IS a blocking modal by
|
||||
// definition (legit pages don't scroll-lock). So remove ANY such overlay that
|
||||
// isn't page chrome — many modals/drawers carry no consent/modal keyword and
|
||||
// an icon-only close, so a keyword/aria allowlist misses them (ruggable's
|
||||
// z-[1001] drawer). PROTECTED guards real chrome (header/nav/footer) only.
|
||||
const PROTECTED = /header|navbar|nav-|site-nav|topbar|masthead|footer/i;
|
||||
const sig = (el: HTMLElement): string => `${el.id} ${el.className}`.toString();
|
||||
|
||||
const removedLabels: string[] = [];
|
||||
let removed = 0;
|
||||
let remaining = bigOverlays();
|
||||
if (remaining.length && isLocked()) {
|
||||
for (const el of remaining) {
|
||||
const s = sig(el);
|
||||
const z = parseInt(getComputedStyle(el).zIndex || "0", 10) || 0;
|
||||
// Scroll-locked + full-viewport ⇒ blocking modal; remove unless it's page
|
||||
// chrome. Always remove iframes (cross-origin close, unclickable) and the
|
||||
// max-z-index popup trick.
|
||||
const removable = !PROTECTED.test(s) || el.getAttribute("aria-modal") === "true" ||
|
||||
el.tagName === "IFRAME" || z >= 2_000_000_000;
|
||||
if (removable) { el.remove(); removed++; removedLabels.push((el.id || el.className || el.tagName).toString().slice(0, 40)); }
|
||||
}
|
||||
if (removed) { document.body.style.overflow = "visible"; document.documentElement.style.overflow = "visible"; document.body.style.position = "static"; }
|
||||
remaining = bigOverlays();
|
||||
}
|
||||
return { overlaysRemaining: remaining.length, removed, blocking: remaining.length > 0 && isLocked(), removedLabels };
|
||||
}),
|
||||
new Promise<{ overlaysRemaining: number; removed: number; blocking: boolean; removedLabels: string[] }>((res) => setTimeout(() => res({ overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }), 6000)),
|
||||
page.evaluate(finalizeOverlaysInPage),
|
||||
new Promise<FinalizeOverlaysResult>((res) => setTimeout(() => res({ overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] }), 6000)),
|
||||
]);
|
||||
} catch {
|
||||
return { overlaysRemaining: 0, removed: 0, blocking: false, removedLabels: [] };
|
||||
@@ -475,10 +743,102 @@ async function captureVideoStills(page: import("playwright").Page): Promise<Vide
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page screenshot with robustness for heavy/animated pages. The default 30s
|
||||
* timeout is exceeded by tall SaaS pages (Playwright also waits for web fonts);
|
||||
* use a long timeout, freeze animations (also improves determinism), and retry.
|
||||
* As a last resort take a viewport-only shot so the file exists (the capture gate
|
||||
* Stage 2 — canvas raster fallback. A visible <canvas> (animated background, chart,
|
||||
* WebGL scene) is a runtime-drawn surface the clone cannot reproduce as DOM, so it
|
||||
* would render as an empty box. Rasterize each meaningful canvas to a PNG still under
|
||||
* a synthetic URL (the normal asset pipeline rewrites it to a local file); the node is
|
||||
* then marked with a `src` attr so generation emits the still as an <img> filling the
|
||||
* canvas's box. `toDataURL` is exact but THROWS for tainted canvases and for WebGL
|
||||
* contexts without preserveDrawingBuffer — those return a shot plan for the node-side
|
||||
* element-screenshot fallback (composited pixels, works regardless). A WebGL canvas may
|
||||
* also toDataURL to a blank image when the drawing buffer was already presented; that
|
||||
* blank is accepted as-is (detecting blankness would be heuristic, not deterministic).
|
||||
* The in-page part is exported for the fixture tests (page.evaluate'd directly).
|
||||
*/
|
||||
export type CanvasStillPlan = { stills: Array<{ url: string; dataUrl: string; sel: string }>; shots: Array<{ url: string; sel: string }> };
|
||||
export function captureCanvasStillsInPage(): CanvasStillPlan {
|
||||
const stills: CanvasStillPlan["stills"] = [];
|
||||
const shots: CanvasStillPlan["shots"] = [];
|
||||
const hash = (s: string): string => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0; return h.toString(36); };
|
||||
const canvases = Array.from(document.querySelectorAll("canvas"));
|
||||
let i = 0;
|
||||
for (const c of canvases) {
|
||||
const r = c.getBoundingClientRect();
|
||||
if (r.width < 48 || r.height < 48) continue; // not a meaningful painted surface
|
||||
const cs = getComputedStyle(c);
|
||||
if (cs.display === "none" || cs.visibility === "hidden" || parseFloat(cs.opacity || "1") < 0.05) continue;
|
||||
const idx = i++;
|
||||
c.setAttribute("data-clone-canvas", String(idx));
|
||||
const sel = `canvas[data-clone-canvas="${idx}"]`;
|
||||
const url = `https://clone-canvas.local/${idx}-${hash(c.id + "|" + c.width + "|" + c.height + "|" + idx)}.png`;
|
||||
let ok = false;
|
||||
try {
|
||||
const dataUrl = c.toDataURL("image/png"); // throws if tainted / WebGL buffer unreadable
|
||||
if (dataUrl.startsWith("data:image/png")) { stills.push({ url, dataUrl, sel }); ok = true; }
|
||||
} catch { /* fall through to the element-screenshot plan */ }
|
||||
if (!ok) shots.push({ url, sel });
|
||||
}
|
||||
return { stills, shots };
|
||||
}
|
||||
|
||||
async function captureCanvasStills(page: import("playwright").Page): Promise<CanvasStillPlan> {
|
||||
try {
|
||||
return await Promise.race([
|
||||
page.evaluate(captureCanvasStillsInPage),
|
||||
new Promise<CanvasStillPlan>((res) => setTimeout(() => res({ stills: [], shots: [] }), 12_000)),
|
||||
]);
|
||||
} catch {
|
||||
return { stills: [], shots: [] };
|
||||
}
|
||||
}
|
||||
|
||||
// Chromium refuses/truncates screenshots past a texture-size cap; keep clip dimensions
|
||||
// under a conservative bound so captureFullPageViaCDP fails cleanly (→ Playwright fallback)
|
||||
// instead of returning a truncated image on pathologically tall pages.
|
||||
const CDP_MAX_SHOT_DIMENSION = 16_384;
|
||||
|
||||
/**
|
||||
* Full-page screenshot via CDP `Page.captureScreenshot` with `captureBeyondViewport:true`.
|
||||
* Unlike Playwright's `fullPage:true` (which scroll-stitches — scrolling the page to render
|
||||
* each band, FIRING scroll events that drive scroll-linked animations, e.g. IX2 grow-on-scroll
|
||||
* or WAAPI scroll-timelines), CDP renders the whole page in ONE shot WITHOUT scrolling and
|
||||
* never fires scroll events. So the resulting still is the genuine at-rest (unscrolled) page,
|
||||
* matching the DOM snapshot the walk grades. Writes a PNG to `path`. Throws on any failure so
|
||||
* the caller can fall back to the Playwright path.
|
||||
*/
|
||||
export async function captureFullPageViaCDP(page: import("playwright").Page, path: string): Promise<void> {
|
||||
const client = await page.context().newCDPSession(page);
|
||||
try {
|
||||
const metrics = await client.send("Page.getLayoutMetrics") as {
|
||||
cssContentSize?: { width: number; height: number };
|
||||
contentSize?: { width: number; height: number };
|
||||
};
|
||||
const size = metrics.cssContentSize ?? metrics.contentSize;
|
||||
if (!size || !(size.width > 0) || !(size.height > 0)) throw new Error("cdp: empty content size");
|
||||
const width = Math.ceil(size.width);
|
||||
const height = Math.ceil(size.height);
|
||||
if (width > CDP_MAX_SHOT_DIMENSION || height > CDP_MAX_SHOT_DIMENSION) {
|
||||
throw new Error(`cdp: content ${width}x${height} exceeds max shot dimension`);
|
||||
}
|
||||
const shot = await client.send("Page.captureScreenshot", {
|
||||
format: "png",
|
||||
captureBeyondViewport: true,
|
||||
clip: { x: 0, y: 0, width, height, scale: 1 },
|
||||
}) as { data: string };
|
||||
if (!shot?.data) throw new Error("cdp: no screenshot data");
|
||||
writeBytes(path, Buffer.from(shot.data, "base64"));
|
||||
} finally {
|
||||
await client.detach().catch(() => { /* ignore */ });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-page screenshot with robustness for heavy/animated pages. Prefers CDP capture
|
||||
* (captureFullPageViaCDP — no scroll-stitch, so scroll-linked animations aren't scrubbed
|
||||
* and the still is the true at-rest page). On ANY CDP failure, falls back to Playwright's
|
||||
* `fullPage:true` (which scroll-stitches but freezes animations); the default 30s timeout
|
||||
* is exceeded by tall pages (Playwright also waits for web fonts), so use a long timeout and
|
||||
* retry. As a last resort take a viewport-only shot so the file exists (the capture gate
|
||||
* checks presence; a partial image still beats none).
|
||||
*/
|
||||
async function captureScreenshot(
|
||||
@@ -487,6 +847,13 @@ async function captureScreenshot(
|
||||
vw: number,
|
||||
log: (e: Record<string, unknown>) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await captureFullPageViaCDP(page, path);
|
||||
log({ event: "screenshot_cdp", viewport: vw });
|
||||
return;
|
||||
} catch (eCdp) {
|
||||
log({ event: "screenshot_cdp_fallback", viewport: vw, error: String(eCdp).slice(0, 200) });
|
||||
}
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
await page.screenshot({ path, fullPage: true, timeout: 90_000, animations: "disabled" });
|
||||
@@ -505,6 +872,131 @@ async function captureScreenshot(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause every <video> and seek it to time 0, then wait for that seek to actually PAINT, so a
|
||||
* screenshot taken right after is deterministic. A video's playback time is runtime state: a one-shot
|
||||
* animation ends on its last frame, an autoplaying loop sits at an arbitrary offset, and the time a
|
||||
* given viewport's shot happens to catch is nondeterministic. Without normalization the SOURCE and the
|
||||
* (frame-0, non-playing) CLONE can show different frames of the same element — a phantom perceptual
|
||||
* diff that no CSS change can close. Seeking BOTH sides to frame 0 before EVERY viewport screenshot
|
||||
* removes it. `seeked` fires once the new frame is decoded; we still yield two rAFs so the compositor
|
||||
* has painted it before the screenshot reads pixels. Bounded so a stalled/unseekable video can't hang.
|
||||
*
|
||||
* FIRST it re-runs `<source media>` resource selection for THIS viewport. The capture pipeline loads
|
||||
* the page once at the canonical width and reaches every other viewport by resizing — but the HTML
|
||||
* media resource-selection algorithm evaluates `<source media>` only at load, so an aspect/orientation
|
||||
* -gated hero video stays frozen on whatever variant matched at load width across all resized shots.
|
||||
* The validator fresh-loads per viewport and correctly re-selects, so the two channels disagree on
|
||||
* which variant is on screen (a large phantom diff on full-bleed portrait/landscape hero videos).
|
||||
* Re-selecting here keeps the channels symmetric; it is a no-op on the fresh-loading validator side
|
||||
* and — the common case — a no-op whenever the current selection is still the right one, so the fast
|
||||
* path never touches `video.load()`.
|
||||
*/
|
||||
export async function normalizeVideoTime(
|
||||
page: import("playwright").Page,
|
||||
log?: (event: Record<string, unknown>) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const reselections = await page.evaluate(async () => {
|
||||
const vids = Array.from(document.querySelectorAll("video"));
|
||||
const raf = () => new Promise<void>((r) => requestAnimationFrame(() => r()));
|
||||
|
||||
// Re-select the <source> the resource-selection algorithm would pick at the CURRENT viewport,
|
||||
// mirroring selectVideoSourceIndex (Node-side, unit-tested): first source in document order
|
||||
// whose media matches (missing media matches unconditionally) and whose type is playable
|
||||
// (missing type is never disqualifying). Only reload when the winner differs from what is
|
||||
// currently loaded — the fast path leaves untouched videos alone.
|
||||
//
|
||||
// Track which videos we reloaded. A reload resets the element's *show-poster flag* to true and
|
||||
// sets currentTime to 0; the follow-up pause() cancels the pending autoplay, so the one thing
|
||||
// that would have cleared that flag (playback beginning) never happens. Left uncleared, the
|
||||
// element renders its `poster` image — which for an aspect-gated hero is a frame of the WRONG
|
||||
// variant — instead of the freshly-selected source's frame 0. A real seek is the other
|
||||
// flag-clearing path, but the fast-skip below bails at t=0. So force a genuine seek (to a tiny
|
||||
// epsilon, not to the already-current 0) on exactly the reloaded videos to clear the flag and
|
||||
// present the new source's decoded frame. Non-reloaded videos keep the existing fast skip.
|
||||
const reloaded = new WeakSet<HTMLVideoElement>();
|
||||
const reselections: { from: string; to: string; readyState: number }[] = [];
|
||||
const reloadWaits: Promise<void>[] = [];
|
||||
for (const v of vids) {
|
||||
try {
|
||||
const sources = Array.from(v.querySelectorAll("source"));
|
||||
if (sources.length === 0) continue; // src attribute (no <source> children): nothing to re-select
|
||||
let winner: HTMLSourceElement | null = null;
|
||||
for (const s of sources) {
|
||||
const media = (s.getAttribute("media") ?? "").trim();
|
||||
if (media && !window.matchMedia(media).matches) continue;
|
||||
const type = (s.getAttribute("type") ?? "").trim();
|
||||
if (type && !v.canPlayType(type)) continue;
|
||||
winner = s;
|
||||
break;
|
||||
}
|
||||
if (!winner || !winner.src) continue;
|
||||
// Compare resolved URLs; currentSrc is absolute, winner.src is already resolved.
|
||||
if (v.currentSrc === winner.src) continue; // no change — fast path, do not reload
|
||||
const from = v.currentSrc;
|
||||
const to = winner.src;
|
||||
v.load();
|
||||
reloaded.add(v);
|
||||
reloadWaits.push(new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const done = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
v.removeEventListener("loadeddata", done);
|
||||
v.removeEventListener("seeked", done);
|
||||
reselections.push({ from, to, readyState: v.readyState });
|
||||
resolve();
|
||||
};
|
||||
if (v.readyState >= 2 /* HAVE_CURRENT_DATA */) { done(); return; }
|
||||
// A post-reload `seeked` implies a decoded frame is ready too, so treat it as an
|
||||
// additional readiness signal: on slow CDNs the epsilon seek (below) can land its
|
||||
// `seeked` before `loadeddata`, which unblocks the FIRST viewport instead of waiting
|
||||
// out the 4s bound and shooting the poster.
|
||||
v.addEventListener("loadeddata", done, { once: true });
|
||||
v.addEventListener("seeked", done, { once: true });
|
||||
setTimeout(done, 4000); // bound: a stalled reload resolves anyway so the shot never hangs
|
||||
}));
|
||||
} catch { /* a malformed <source>/media query must not block the others */ }
|
||||
}
|
||||
|
||||
const waits: Promise<void>[] = [];
|
||||
const seekTo = (v: HTMLVideoElement, t: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const done = () => { if (settled) return; settled = true; v.removeEventListener("seeked", done); resolve(); };
|
||||
v.addEventListener("seeked", done, { once: true });
|
||||
try { v.currentTime = t; } catch { done(); }
|
||||
setTimeout(done, 400); // bound: a stalled/unseekable video resolves anyway
|
||||
});
|
||||
for (const v of vids) {
|
||||
try { v.pause(); } catch { /* ignore */ }
|
||||
if (reloaded.has(v)) {
|
||||
// A just-reloaded video sits at t=0 with the show-poster flag set. Seek to a tiny epsilon
|
||||
// (not 0, which is already the current time and would fire no `seeked`) to force a genuine
|
||||
// seek: it clears the flag and paints the new source's frame 0. Start it eagerly so its
|
||||
// `seeked` can also satisfy the reload wait above on slow networks.
|
||||
waits.push(seekTo(v, 1e-4));
|
||||
continue;
|
||||
}
|
||||
// Seek to 0 only when not already there (a fresh seek to the current time may not fire `seeked`).
|
||||
if (Math.abs(v.currentTime) < 1e-3) continue;
|
||||
waits.push(seekTo(v, 0));
|
||||
}
|
||||
// Await the reload settles and the seeks together; the reload wait can be released by an
|
||||
// epsilon seek's `seeked`, so both sets must be in flight before we block on either.
|
||||
await Promise.all([...reloadWaits, ...waits]);
|
||||
await raf(); await raf(); // let the decoded frame composite before the screenshot reads pixels
|
||||
return reselections;
|
||||
});
|
||||
if (log && reselections && reselections.length) {
|
||||
for (const r of reselections) {
|
||||
log({ event: "video_source_reselected", from: r.from, to: r.to, readyState: r.readyState });
|
||||
}
|
||||
}
|
||||
} catch { /* a page with no videos / an eval hiccup must never block the screenshot */ }
|
||||
}
|
||||
|
||||
export async function captureSite(opts: {
|
||||
url: string;
|
||||
outDir: string; // source/ directory
|
||||
@@ -577,9 +1069,24 @@ export async function captureSite(opts: {
|
||||
// page stored under first-stored-wins ships a corrupt file). Left unstored, the
|
||||
// asset surfaces in visual_assets_missing instead.
|
||||
if (type === "video" && !looksLikeVideoFile(bytes)) return;
|
||||
// Same guard for fonts: a 200+HTML body (SPA router answering an unknown /media/* path) must not
|
||||
// be stored as a .woff2. Left unstored, the face surfaces as failed, so the font graph can fall
|
||||
// back to a correctly-resolved duplicate url instead of shipping an impostor the browser rejects.
|
||||
if (type === "font" && !looksLikeFontFile(bytes)) return;
|
||||
const a = assetMap.get(url) ?? recordAsset(url, type, null, null, "network");
|
||||
if (a.storedAs) return;
|
||||
const ext = extFromUrl(url) || extFromContentType(a.contentType) ||
|
||||
// A `.lottie` (dotLottie) asset is a ZIP archive, not bare lottie-web JSON. lottie-web's
|
||||
// `path:` loader does a JSON.parse and throws on the ZIP bytes, blanking the container. So
|
||||
// unwrap it here: extract the default animation JSON and store THAT, materializing every
|
||||
// lottie source as plain JSON regardless of the container it arrived in.
|
||||
let extOverride: string | null = null;
|
||||
if (type === "lottie" && isZipArchive(bytes)) {
|
||||
const json = extractDotLottieJson(bytes);
|
||||
if (!json) return; // unreadable dotLottie — leave unstored rather than ship a broken ZIP
|
||||
bytes = json;
|
||||
extOverride = "json";
|
||||
}
|
||||
const ext = extOverride || extFromUrl(url) || extFromContentType(a.contentType) ||
|
||||
(type === "css" ? "css" : type === "font" ? "woff2" : type === "svg" ? "svg" :
|
||||
type === "video" ? "mp4" : type === "lottie" ? "json" : "png");
|
||||
const name = `${sha1_12(url)}.${ext}`;
|
||||
@@ -870,6 +1377,47 @@ export async function captureSite(opts: {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
|
||||
// Stage 2: canvas raster fallback — same synthetic-URL mechanism as the video
|
||||
// stills above (captureCanvasStills). Bytes ride the normal asset pipeline; the
|
||||
// canvas node gets a `src` attr (whitelisted → survives the IR) that generation
|
||||
// reads to emit the still as an <img> carrying the canvas's cid/box. The attr is
|
||||
// stamped only AFTER bytes are stored, so a failed capture leaves the canvas
|
||||
// rendering as an empty box, same as before.
|
||||
const cplan = await captureCanvasStills(page);
|
||||
const stampCanvasSrc = (sel: string, u: string): Promise<void> =>
|
||||
page.evaluate(({ s, u: uu }) => { document.querySelector(s)?.setAttribute("src", uu); }, { s: sel, u }).catch(() => { /* ignore */ });
|
||||
for (const s of cplan.stills) {
|
||||
const comma = s.dataUrl.indexOf(",");
|
||||
if (comma < 0) continue;
|
||||
try {
|
||||
const buf = Buffer.from(s.dataUrl.slice(comma + 1), "base64");
|
||||
recordAsset(s.url, "image", "image/png", 200, "canvas-still");
|
||||
storeBytes(s.url, "image", buf);
|
||||
await stampCanvasSrc(s.sel, s.url);
|
||||
log({ event: "canvas_still", viewport: vw, sel: s.sel });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
for (const s of cplan.shots) {
|
||||
// Same reveal discipline as the video shots: a hidden canvas (entrance
|
||||
// animation not yet fired) would time out locator.screenshot's visibility wait.
|
||||
try {
|
||||
await page.evaluate(forceRevealForShot, s.sel);
|
||||
const buf = await page.locator(s.sel).first().screenshot({ type: "png", timeout: 5000, animations: "disabled" });
|
||||
recordAsset(s.url, "image", "image/png", 200, "canvas-still");
|
||||
storeBytes(s.url, "image", buf);
|
||||
await stampCanvasSrc(s.sel, s.url);
|
||||
log({ event: "canvas_still", viewport: vw, sel: s.sel });
|
||||
} catch (e) {
|
||||
log({ event: "canvas_still_error", viewport: vw, sel: s.sel, error: String(e).slice(0, 200) });
|
||||
} finally {
|
||||
await page.evaluate(restoreRevealForShot).catch(() => { /* ignore */ });
|
||||
}
|
||||
}
|
||||
if (cplan.shots.length) {
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(80);
|
||||
}
|
||||
}
|
||||
|
||||
// Stage 4/5: stamp capture-ids before the canonical snapshot so the IR carries
|
||||
@@ -973,6 +1521,21 @@ export async function captureSite(opts: {
|
||||
const scrollAnimsCanceled = await neutralizeScrollTimelineAnimations(page);
|
||||
if (scrollAnimsCanceled) log({ event: "scroll_timeline_anims_canceled", viewport: vw, count: scrollAnimsCanceled });
|
||||
|
||||
// Final scroll-state reset immediately before the walk: every preceding pass (dwell
|
||||
// scroll, carousel settle, element screenshots) can leave the page scrolled, which bakes
|
||||
// scroll-linked transforms/offsets into this viewport's computed styles. Scroll to top and
|
||||
// let scroll-linked effects settle so the snapshot records the genuine at-rest values.
|
||||
await settleScrollTopBeforeSnapshot(page);
|
||||
|
||||
// Late-mounting dialogs (a CCPA opt-out that opens between the canonical and last
|
||||
// viewport; a Recart/Attentive promo that fires on a timer) mount AFTER the load /
|
||||
// post-scroll passes and would otherwise pollute this viewport's DOM snapshot. Re-run
|
||||
// the dismissal pass immediately before the walk. Cheap when nothing matches — the
|
||||
// settle only runs when a control was actually clicked or an overlay removed.
|
||||
await applyDismiss("pre-snapshot");
|
||||
dismissUnion.overlaysRemaining = Math.max(dismissUnion.overlaysRemaining, overlaysRemaining);
|
||||
dismissUnion.blocking = dismissUnion.blocking || blocking;
|
||||
|
||||
// Bound the in-page DOM walk: page.evaluate has no default timeout, so a
|
||||
// pathologically large/animated DOM (e.g. asana.com) could hang forever.
|
||||
const snapshot: PageSnapshot = await Promise.race([
|
||||
@@ -1020,7 +1583,20 @@ export async function captureSite(opts: {
|
||||
|
||||
// Persist DOM snapshot, and (unless skipped for a production clone) the full-page screenshot.
|
||||
writeJSONCompact(join(captureDir, `dom-${vw}.json`), snapshot);
|
||||
if (opts.screenshots !== false) await captureScreenshot(page, join(screenshotsDir, `${vw}.png`), vw, log);
|
||||
if (opts.screenshots !== false) {
|
||||
// A promo popup can mount in the window between the DOM walk and the screenshot (Recart
|
||||
// fires into a shadow root on a short timer). The screenshot is the ground-truth channel
|
||||
// the perceptual gate compares against, so re-run dismissal right before it — otherwise a
|
||||
// late popup paints over every shot even though the DOM snapshot came out clean. Cheap
|
||||
// when nothing matches.
|
||||
await applyDismiss("pre-screenshot");
|
||||
dismissUnion.blocking = dismissUnion.blocking || blocking;
|
||||
// Normalize every video to frame 0 (paused) at THIS viewport before the shot — the clone is
|
||||
// always at frame 0, so pinning the source there too makes the two channels comparable
|
||||
// regardless of the playback time the viewport happened to catch.
|
||||
await normalizeVideoTime(page, log);
|
||||
await captureScreenshot(page, join(screenshotsDir, `${vw}.png`), vw, log);
|
||||
}
|
||||
|
||||
// Stage 4: drive recognized affordances at the canonical viewport (opt-in).
|
||||
if (opts.interactions && vw === canonical) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { inflateRawSync } from "node:zlib";
|
||||
|
||||
/**
|
||||
* dotLottie (.lottie) extraction.
|
||||
*
|
||||
* A `.lottie` file is NOT bare lottie-web JSON — it is a ZIP archive (the "dotLottie"
|
||||
* container) holding `manifest.json` plus one or more `animations/*.json` documents. Feeding
|
||||
* the raw ZIP bytes to lottie-web via `path:` makes it try to JSON.parse a ZIP, which throws
|
||||
* an InvalidStateError at runtime and leaves the container blank. So at materialization time
|
||||
* we detect the ZIP, pick the default/first animation, and rewrite the stored asset as plain
|
||||
* lottie JSON that lottie-web can parse directly.
|
||||
*
|
||||
* Node ships `zlib` (raw DEFLATE) but no ZIP reader, and pulling a zip dependency for this
|
||||
* one shape is overkill — so this implements the minimal slice of the ZIP spec needed:
|
||||
* walking the End-Of-Central-Directory + central directory to locate entries, then reading
|
||||
* each local file header to decompress a stored (method 0) or deflated (method 8) entry.
|
||||
* Deterministic: no timestamps, no randomness, entries resolved by name.
|
||||
*/
|
||||
|
||||
/** ZIP local file header signature (`PK\x03\x04`). dotLottie archives always start with it. */
|
||||
export function isZipArchive(bytes: Buffer): boolean {
|
||||
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
|
||||
}
|
||||
|
||||
const EOCD_SIG = 0x06054b50; // End Of Central Directory
|
||||
const CEN_SIG = 0x02014b50; // Central directory file header
|
||||
const LOC_SIG = 0x04034b50; // Local file header
|
||||
|
||||
type ZipEntry = { name: string; method: number; compSize: number; uncompSize: number; localOffset: number };
|
||||
|
||||
/** Locate + read the End-Of-Central-Directory record, then walk the central directory. */
|
||||
function readCentralDirectory(buf: Buffer): ZipEntry[] {
|
||||
// The EOCD lives at the tail; it is >=22 bytes and may carry a trailing comment, so scan
|
||||
// backwards for its signature within the max comment window (64KiB) + record size.
|
||||
const minEocd = 22;
|
||||
if (buf.length < minEocd) return [];
|
||||
let eocd = -1;
|
||||
const scanFrom = Math.max(0, buf.length - (0xffff + minEocd));
|
||||
for (let i = buf.length - minEocd; i >= scanFrom; i--) {
|
||||
if (buf.readUInt32LE(i) === EOCD_SIG) { eocd = i; break; }
|
||||
}
|
||||
if (eocd < 0) return [];
|
||||
|
||||
const entryCount = buf.readUInt16LE(eocd + 10);
|
||||
let ptr = buf.readUInt32LE(eocd + 16); // central directory offset
|
||||
const entries: ZipEntry[] = [];
|
||||
for (let i = 0; i < entryCount; i++) {
|
||||
if (ptr + 46 > buf.length || buf.readUInt32LE(ptr) !== CEN_SIG) break;
|
||||
const method = buf.readUInt16LE(ptr + 10);
|
||||
const compSize = buf.readUInt32LE(ptr + 20);
|
||||
const uncompSize = buf.readUInt32LE(ptr + 24);
|
||||
const nameLen = buf.readUInt16LE(ptr + 28);
|
||||
const extraLen = buf.readUInt16LE(ptr + 30);
|
||||
const commentLen = buf.readUInt16LE(ptr + 32);
|
||||
const localOffset = buf.readUInt32LE(ptr + 42);
|
||||
const name = buf.toString("utf8", ptr + 46, ptr + 46 + nameLen);
|
||||
entries.push({ name, method, compSize, uncompSize, localOffset });
|
||||
ptr += 46 + nameLen + extraLen + commentLen;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/** Decompress one central-directory entry by reading its local header for the data offset. */
|
||||
function readEntry(buf: Buffer, e: ZipEntry): Buffer | null {
|
||||
const p = e.localOffset;
|
||||
if (p + 30 > buf.length || buf.readUInt32LE(p) !== LOC_SIG) return null;
|
||||
// The central directory's name/extra lengths can differ from the local header's, so read the
|
||||
// local header's own lengths to find where the file data begins.
|
||||
const nameLen = buf.readUInt16LE(p + 26);
|
||||
const extraLen = buf.readUInt16LE(p + 28);
|
||||
const dataStart = p + 30 + nameLen + extraLen;
|
||||
const dataEnd = dataStart + e.compSize;
|
||||
if (dataEnd > buf.length) return null;
|
||||
const comp = buf.subarray(dataStart, dataEnd);
|
||||
if (e.method === 0) return Buffer.from(comp); // stored
|
||||
if (e.method === 8) {
|
||||
try { return inflateRawSync(comp); } catch { return null; }
|
||||
}
|
||||
return null; // unsupported compression method
|
||||
}
|
||||
|
||||
/** Read a named entry (exact path match) from a ZIP buffer, or null if absent/unreadable. */
|
||||
export function readZipEntry(buf: Buffer, name: string): Buffer | null {
|
||||
const entry = readCentralDirectory(buf).find((e) => e.name === name);
|
||||
return entry ? readEntry(buf, entry) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the lottie animation JSON from a dotLottie ZIP. Picks the manifest's first/default
|
||||
* animation (falling back to the first `animations/*.json` entry), returning it as a Buffer of
|
||||
* plain lottie JSON ready to hand to lottie-web via `path:`. Returns null when the bytes are
|
||||
* not a dotLottie ZIP or no animation JSON is present.
|
||||
*/
|
||||
export function extractDotLottieJson(bytes: Buffer): Buffer | null {
|
||||
if (!isZipArchive(bytes)) return null;
|
||||
const entries = readCentralDirectory(bytes);
|
||||
if (!entries.length) return null;
|
||||
|
||||
const animEntries = entries
|
||||
.filter((e) => /^animations\/.+\.json$/i.test(e.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)); // deterministic ordering
|
||||
if (!animEntries.length) return null;
|
||||
|
||||
// Prefer the animation the manifest names first (its default), when the manifest is readable.
|
||||
let chosen = animEntries[0]!;
|
||||
const manifestBuf = readZipEntry(bytes, "manifest.json");
|
||||
if (manifestBuf) {
|
||||
try {
|
||||
const manifest = JSON.parse(manifestBuf.toString("utf8")) as { animations?: Array<{ id?: string }> };
|
||||
const firstId = manifest.animations?.[0]?.id;
|
||||
if (firstId) {
|
||||
const match = animEntries.find((e) => e.name === `animations/${firstId}.json`);
|
||||
if (match) chosen = match;
|
||||
}
|
||||
} catch { /* manifest unreadable — keep the name-sorted first animation */ }
|
||||
}
|
||||
|
||||
const json = readEntry(bytes, chosen);
|
||||
if (!json) return null;
|
||||
// Validate it parses as JSON (lottie-web's `path:` loader does a bare JSON.parse).
|
||||
try { JSON.parse(json.toString("utf8")); } catch { return null; }
|
||||
return json;
|
||||
}
|
||||
@@ -29,8 +29,14 @@ export const MIN_FRAME_DIM = 48;
|
||||
export type FramePlan = "skip" | "still" | "graft";
|
||||
|
||||
// Ad/analytics/consent plumbing frames render nothing a visitor values — skip entirely.
|
||||
// The trailing group is email-capture / promo POPUP CREATIVE hosts: a vendor whose creative
|
||||
// iframe IS a full-viewport interstitial ("Enjoy 15% off" over a dimmed backdrop). Grafting it
|
||||
// pours the popup's copy into the DOM/text channel and paints the modal over the real page.
|
||||
// CAUTION: these hosts serve OVERLAY creatives, distinct from the vendor's INLINE-form embed
|
||||
// hosts (e.g. static-forms.klaviyo.com), which stay graftable — inline signup forms are a
|
||||
// deliberately-grafted feature (see iframeGraft tests). Match creative/overlay hosts only.
|
||||
const FRAME_SKIP_RE =
|
||||
/(?:doubleclick\.net|googlesyndication\.com|googletagmanager\.com|google-analytics\.com|googleadservices\.com|adservice\.google|facebook\.com\/tr|connect\.facebook\.net|\brecaptcha\b|hcaptcha\.com|challenges\.cloudflare\.com|adsrvr\.org|amazon-adsystem\.com)/i;
|
||||
/(?:doubleclick\.net|googlesyndication\.com|googletagmanager\.com|google-analytics\.com|googleadservices\.com|adservice\.google|facebook\.com\/tr|connect\.facebook\.net|\brecaptcha\b|hcaptcha\.com|challenges\.cloudflare\.com|adsrvr\.org|amazon-adsystem\.com|creatives?\.attn\.tv|\.attentivemobile\.com|bounceexchange\.com|bouncex\.net|\.wunderkind\.co|wknd\.ai|cdn\.justuno\.com\/mkjs|privy\.com\/.*popup|widget\.privy\.com|\.recart\.com|recart\.io)/i;
|
||||
// Media players are JS-built canvases/videos whose DOM graft is meaningless; an element
|
||||
// screenshot (the poster frame + play chrome) is the faithful static paint.
|
||||
const FRAME_STILL_RE =
|
||||
@@ -165,6 +171,21 @@ export function graftFrameIntoSnapshot(
|
||||
x: Math.round((n.bbox.x + frame.contentX) * 100) / 100,
|
||||
y: Math.round((n.bbox.y + frame.contentY) * 100) / 100,
|
||||
};
|
||||
// Rebase viewport-relative positioning into the frame's box. Inside a real iframe,
|
||||
// `position: fixed` pins to the FRAME's viewport (its content box) and `sticky` scrolls
|
||||
// against the FRAME's scroll container. Grafted in as plain <div> children, those
|
||||
// containing blocks no longer exist, so `fixed` would resolve against the MAIN page
|
||||
// viewport — escaping the iframe's clip box (fixed ignores `overflow:hidden` ancestors)
|
||||
// and painting frame chrome (e.g. a `fixed inset-0 z-[-1]` dark backdrop) across the
|
||||
// whole clone. Demote to box-relative positioning: `fixed`→`absolute` (contained and
|
||||
// clipped by the host div, whose bboxes are already rebased to page coords) and
|
||||
// `sticky`→`relative` (stays in flow instead of sticking to the page scroll). The host
|
||||
// is made a containing block below so these absolutes anchor to the frame box.
|
||||
if (n.computed) {
|
||||
const pos = n.computed.position;
|
||||
if (pos === "fixed") n.computed = { ...n.computed, position: "absolute" };
|
||||
else if (pos === "sticky") n.computed = { ...n.computed, position: "relative" };
|
||||
}
|
||||
const a = n.attrs ?? {};
|
||||
delete a["data-cid-cap"]; // capture-ids belong to the main document only
|
||||
if (a.id) a.id = prefix + a.id;
|
||||
@@ -184,8 +205,13 @@ export function graftFrameIntoSnapshot(
|
||||
|
||||
host.children = [wrapper];
|
||||
// A real frame viewport clips its document; the replacement <div> must too, at every
|
||||
// captured viewport (each per-viewport snapshot is grafted independently).
|
||||
// captured viewport (each per-viewport snapshot is grafted independently). It must also
|
||||
// be a containing block: `visit` demoted the frame's `position: fixed` chrome (which was
|
||||
// pinned to the frame viewport) to `absolute`, and those absolutes must anchor to — and
|
||||
// be clipped by — this box, not some positioned ancestor further up the page. A `static`
|
||||
// host wouldn't establish that containing block, so promote it to `relative`.
|
||||
host.computed = { ...host.computed, overflow: "hidden", overflowX: "hidden", overflowY: "hidden" };
|
||||
if ((host.computed.position || "static") === "static") host.computed.position = "relative";
|
||||
// An iframe is a REPLACED element: `display:inline` (the default) still honors its
|
||||
// width/height. The <div> that replaces it at generation is not — inline would collapse
|
||||
// the box — so translate to the behavior-equivalent inline-block.
|
||||
|
||||
@@ -24,6 +24,13 @@ const PSEUDO_PROPS = [
|
||||
|
||||
export type StyleDelta = Record<string, string>;
|
||||
|
||||
/** Changed-properties delta: every key in `b` whose value differs from `a`. Pure (unit-tested). */
|
||||
export function diffStyle(a: StyleDelta, b: StyleDelta): StyleDelta {
|
||||
const d: StyleDelta = {};
|
||||
for (const k of Object.keys(b)) if (a[k] !== b[k]) d[k] = b[k]!;
|
||||
return d;
|
||||
}
|
||||
|
||||
// Properties that distinguish a panel's shown/hidden state and a trigger's
|
||||
// active/inactive state. Captured per discrete state so the generated controller
|
||||
// can toggle between them faithfully (M2: tabs + accordion).
|
||||
@@ -363,11 +370,42 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
|
||||
return o;
|
||||
}, { capId, props: PSEUDO_PROPS as unknown as string[] });
|
||||
|
||||
const diff = (a: StyleDelta, b: StyleDelta): StyleDelta => {
|
||||
const d: StyleDelta = {};
|
||||
for (const k of Object.keys(b)) if (a[k] !== b[k]) d[k] = b[k]!;
|
||||
return d;
|
||||
const diff = diffStyle;
|
||||
|
||||
// Pseudo-state driver via CDP `CSS.forcePseudoState`. Pointer-based hovering (page.hover)
|
||||
// moves the REAL cursor to the element's box centre, so the browser applies `:hover` to
|
||||
// whatever sits under that point — on a page with a transparent full-viewport overlay (a
|
||||
// fixed page-wrapper / scroll layer, common on modern builder stacks) the point lands on the
|
||||
// overlay and the target never enters `:hover`, silently capturing ZERO authored hover states
|
||||
// even though page.hover throws nothing. Forcing the pseudo-class on the node itself is
|
||||
// geometry-independent (occlusion-immune), applies pure-CSS `:hover` rules directly, and — for
|
||||
// `.card:hover .overlay` — also styles descendants of the forced node, so reveals still show.
|
||||
// Resolves each cap → CDP nodeId; a live map is rebuilt per element (cheap) so it survives
|
||||
// any DOM churn. Falls back to no forcing if a CDP session can't be opened (then hover/focus
|
||||
// capture is simply empty rather than wrong).
|
||||
const client = await page.context().newCDPSession(page).catch(() => null);
|
||||
if (client) {
|
||||
try { await client.send("DOM.enable"); await client.send("CSS.enable"); await client.send("DOM.getDocument", {}); }
|
||||
catch { /* CDP unavailable — force() below no-ops */ }
|
||||
}
|
||||
const nodeIdFor = async (capId: string): Promise<number | null> => {
|
||||
if (!client) return null;
|
||||
let objectId: string | undefined;
|
||||
try {
|
||||
const ev = await client.send("Runtime.evaluate", { expression: `document.querySelector('[data-cid-cap="${capId}"]')` }) as { result?: { objectId?: string } };
|
||||
objectId = ev.result?.objectId; if (!objectId) return null;
|
||||
const r = await client.send("DOM.requestNode", { objectId }) as { nodeId?: number };
|
||||
return r.nodeId ?? null;
|
||||
} catch { return null; }
|
||||
finally { if (objectId) await client.send("Runtime.releaseObject", { objectId }).catch(() => {}); }
|
||||
};
|
||||
// Force (or clear, with []) a pseudo-class on a node. Deterministic and reversible.
|
||||
const force = async (nodeId: number | null, classes: string[]): Promise<boolean> => {
|
||||
if (!client || nodeId == null) return false;
|
||||
try { await client.send("CSS.forcePseudoState", { nodeId, forcedPseudoClasses: classes }); return true; }
|
||||
catch { return false; }
|
||||
};
|
||||
const settlePseudo = () => page.waitForTimeout(180); // let a `:hover`/`:focus` transition land
|
||||
|
||||
// Reveal-relevant props for descendants that appear on hover (overlay glow + CTA). Only
|
||||
// clean show/hide props — NOT transform/filter, whose mid-animation values bake a janky
|
||||
@@ -413,21 +451,21 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
|
||||
const hoverDesc: Record<string, Record<string, StyleDelta>> = {};
|
||||
|
||||
for (const capId of candidates) {
|
||||
const sel = `[data-cid-cap="${capId}"]`;
|
||||
const base = await read(capId);
|
||||
if (!base) continue;
|
||||
const nodeId = await nodeIdFor(capId);
|
||||
const hiddenBase = await readHiddenDesc(capId); // hidden descendants → hover-reveal candidates
|
||||
const hiddenCaps = Object.keys(hiddenBase);
|
||||
// :hover — settle before reading: framer-style hover is driven by JS (Framer Motion)
|
||||
// with a short transition, so an immediate read catches the pre-transition frame and
|
||||
// sees no delta (the nav-link hover was missed this way). Wait for the transition to land.
|
||||
try {
|
||||
await page.hover(sel, { timeout: 1200, force: true });
|
||||
await page.waitForTimeout(180);
|
||||
// :hover — force the pseudo-class on the node (geometry-independent; see `force` above),
|
||||
// then settle before reading: an authored `:hover`/JS transition animates from the resting
|
||||
// frame, so an immediate read catches the pre-transition value and sees no delta. Wait for it.
|
||||
if (await force(nodeId, ["hover"])) {
|
||||
await settlePseudo();
|
||||
const h = await read(capId);
|
||||
if (h) { const d = diff(base, h); if (Object.keys(d).length) hover[capId] = d; }
|
||||
// Descendant reveals (while still hovered): a hidden child shown on hover — the card's
|
||||
// OWN style is unchanged, so this is the only signal (framer's "Read story" overlay).
|
||||
// OWN style is unchanged, so this is the only signal (a "Read story" overlay). Forcing
|
||||
// `:hover` on the card also matches `.card:hover .overlay` rules on its descendants.
|
||||
if (hiddenCaps.length) {
|
||||
await page.waitForTimeout(380); // let the reveal transition fully finish before reading
|
||||
const after = await readDescProps(hiddenCaps);
|
||||
@@ -444,16 +482,17 @@ export async function captureInteractions(page: Page, opts?: { maxCandidates?: n
|
||||
}
|
||||
if (Object.keys(revealed).length) hoverDesc[capId] = revealed;
|
||||
}
|
||||
} catch { /* not hoverable (covered/offscreen) — skip */ }
|
||||
await page.mouse.move(1, 1).catch(() => {});
|
||||
// :focus (only for focusable-ish; cheap to attempt, guarded)
|
||||
try {
|
||||
await page.focus(sel, { timeout: 800 });
|
||||
await force(nodeId, []); // clear :hover before probing :focus
|
||||
}
|
||||
// :focus — force the pseudo-class the same way (also occlusion-independent).
|
||||
if (await force(nodeId, ["focus"])) {
|
||||
await settlePseudo();
|
||||
const f = await read(capId);
|
||||
if (f) { const d = diff(base, f); if (Object.keys(d).length) focus[capId] = d; }
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
|
||||
} catch { /* not focusable — skip */ }
|
||||
await force(nodeId, []); // restore the resting state
|
||||
}
|
||||
}
|
||||
await client?.detach().catch(() => {});
|
||||
|
||||
log({ event: "interactions_hover_focus", candidates: candidates.length, hover: Object.keys(hover).length, focus: Object.keys(focus).length, hoverDesc: Object.keys(hoverDesc).length });
|
||||
|
||||
|
||||
+195
-15
@@ -60,6 +60,83 @@ export type MarqueeSpec = {
|
||||
periodPx: number; // distance per seamless loop (one duplicated copy ≈ scrollWidth/2)
|
||||
};
|
||||
|
||||
// ---- Pure marquee discriminators (extracted so the in-browser sampling logic is unit
|
||||
// testable). These are duplicated verbatim inside `detectMarquees`' page.evaluate body —
|
||||
// module-scope functions do NOT cross the serialization boundary — so any change here must
|
||||
// be mirrored there (and vice versa). They are deterministic: no randomness, no clock reads.
|
||||
|
||||
/**
|
||||
* Median signed velocity (px/s) from a series of per-sample translateX deltas taken at a
|
||||
* fixed cadence. Median (not mean) ignores the single per-loop wrap-reset outlier, which
|
||||
* is a large jump opposite the travel direction when the track seamlessly restarts.
|
||||
*/
|
||||
export function medianVelocityPxPerSec(deltas: number[], sampleMs: number): number {
|
||||
if (!deltas.length || sampleMs <= 0) return 0;
|
||||
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
|
||||
return Math.round((med / sampleMs) * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator 2 — sustained-constant-velocity test. A real marquee holds a steady
|
||||
* velocity across time; a scroll-settle lerp (still easing toward its scroll-target after
|
||||
* `scrollIntoView`) decays, so its velocity in a later observation window is a small
|
||||
* fraction of the earlier one. Two windows of per-sample deltas separated by ≥1.5s; pass
|
||||
* only if the later window's speed is BOTH non-trivial AND close to the earlier window's
|
||||
* (within a relative tolerance) — i.e. it did not decay and did not reverse.
|
||||
*
|
||||
* @param window1Deltas per-sample translateX deltas from the first observation window
|
||||
* @param window2Deltas per-sample translateX deltas from the second (later) window
|
||||
* @param sampleMs cadence between samples within a window
|
||||
* @param minPxPerSec minimum sustained |velocity| to be considered moving at all
|
||||
* @param relTol fractional tolerance: |v2| must be ≥ (1-relTol)·|v1| and same sign
|
||||
*/
|
||||
export function classifyVelocitySamples(
|
||||
window1Deltas: number[],
|
||||
window2Deltas: number[],
|
||||
sampleMs: number,
|
||||
minPxPerSec = 4,
|
||||
relTol = 0.5,
|
||||
): { isMarquee: boolean; pxPerSec: number; v1: number; v2: number } {
|
||||
const v1 = medianVelocityPxPerSec(window1Deltas, sampleMs);
|
||||
const v2 = medianVelocityPxPerSec(window2Deltas, sampleMs);
|
||||
// The reported velocity is the first window's (measured closest to a clean, post-settle
|
||||
// marquee; also what the old code reported), kept for output determinism vs. the old path.
|
||||
const pxPerSec = v1;
|
||||
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
|
||||
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 }; // reversed → not a steady ticker
|
||||
// v2 must NOT have decayed relative to v1 (a lerp settling toward target loses speed).
|
||||
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
|
||||
return { isMarquee: sustained, pxPerSec, v1, v2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Discriminator 4 — genuine duplicated content. A marquee duplicates its content ≥2×
|
||||
* (that is how a seamless loop works); "≥2 children" alone is far too weak (any flex row
|
||||
* of distinct logos passes). Require actual repetition: ≥2 CONSECUTIVE children whose
|
||||
* shape repeats — equal outerHTML hash (exact duplicate) or, as a looser structural
|
||||
* fallback, an equal consecutive width sequence (some marquees clone then tweak attributes
|
||||
* so hashes differ but the geometry repeats).
|
||||
*
|
||||
* @param hashes per-child outerHTML hash (cheap 32-bit), index-aligned with `widths`
|
||||
* @param widths per-child rounded offsetWidth
|
||||
*/
|
||||
export function hasRepeatedChildren(hashes: number[], widths: number[]): boolean {
|
||||
const n = Math.min(hashes.length, widths.length);
|
||||
if (n < 2) return false;
|
||||
// (a) two consecutive children with an identical hash — a literal cloned copy.
|
||||
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
|
||||
// (b) structural fallback: the first half's width sequence repeats in the second half
|
||||
// (content duplicated as a block, e.g. [A B C A B C]). Require an even count and a real
|
||||
// width so a row of zero-width nodes can't spuriously "repeat".
|
||||
if (n >= 4 && n % 2 === 0) {
|
||||
const half = n / 2;
|
||||
let repeats = true;
|
||||
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
|
||||
if (repeats) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export type MotionCapture = {
|
||||
waapi: WaapiAnim[];
|
||||
rotators: RotatorSpec[];
|
||||
@@ -132,6 +209,25 @@ export async function probeReveals(page: Page): Promise<void> {
|
||||
* and record its signed velocity + seamless-loop period (one duplicated copy ≈ half the
|
||||
* scroll width) so the clone can replay the loop. Read-only beyond scrolling (restores
|
||||
* scroll to top); does not touch the captured snapshot/IR.
|
||||
*
|
||||
* Discriminators (ALL must pass — a candidate is only classified as a marquee if every one
|
||||
* holds), added to defeat scroll-LINKED easing false positives (a static logo row on a
|
||||
* scroll-eased page is still lerping toward its scroll-target right after `scrollIntoView`,
|
||||
* which reads as a constant velocity over a single short window):
|
||||
* 1. Content overflow — scrollWidth > 1.35·clientWidth (a marquee has content to scroll;
|
||||
* a static row that fits its box, scrollWidth ≈ clientWidth, cannot be a marquee).
|
||||
* 2. Sustained constant velocity — two observation windows separated by ≥1.5s; a settle
|
||||
* lerp decays (v2 ≪ v1), a real marquee holds v2 ≈ v1 (see classifyVelocitySamples).
|
||||
* 3. Scroll independence (jiggle) — nudge scroll by ±50px and re-sample; if the velocity
|
||||
* responds to the nudge the motion is scroll-linked, not a self-driven ticker.
|
||||
* 4. Genuine duplication — ≥2 consecutive children repeat (equal outerHTML hash or an
|
||||
* equal consecutive width sequence), the shape real marquees use for a seamless loop.
|
||||
*
|
||||
* Added latency is bounded: the expensive per-candidate windows/jiggle run ONLY for the
|
||||
* few candidates that already pass the cheap synchronous gates (overflow + translated +
|
||||
* clip + duplication), and the candidate list is capped at 8. Per surviving candidate the
|
||||
* async cost is ≈ 300ms settle + 2×~600ms windows + ~1.4s inter-window gap + 2×~250ms
|
||||
* jiggle ≈ 3.4s; with the cap the whole pass is bounded regardless of page size.
|
||||
*/
|
||||
async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
|
||||
try {
|
||||
@@ -143,32 +239,113 @@ async function detectMarquees(page: Page): Promise<MarqueeSpec[]> {
|
||||
while (p && depth < 6) { const ox = getComputedStyle(p).overflowX; if (ox === "hidden" || ox === "clip") return true; p = p.parentElement; depth++; }
|
||||
return false;
|
||||
};
|
||||
// cheap 32-bit string hash (djb2-ish) — deterministic, for the duplication test.
|
||||
const hashStr = (s: string): number => { let h = 5381; for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; return h; };
|
||||
|
||||
// ---- Pure discriminators (mirror of the exported module-scope functions; kept inline
|
||||
// because module scope does not cross the page.evaluate serialization boundary). ----
|
||||
const medianVelocityPxPerSec = (deltas: number[], sampleMs: number): number => {
|
||||
if (!deltas.length || sampleMs <= 0) return 0;
|
||||
const med = [...deltas].sort((a, b) => a - b)[Math.floor(deltas.length / 2)]!;
|
||||
return Math.round((med / sampleMs) * 1000);
|
||||
};
|
||||
const classifyVelocitySamples = (w1: number[], w2: number[], sampleMs: number, minPxPerSec = 4, relTol = 0.5) => {
|
||||
const v1 = medianVelocityPxPerSec(w1, sampleMs);
|
||||
const v2 = medianVelocityPxPerSec(w2, sampleMs);
|
||||
const pxPerSec = v1;
|
||||
if (Math.abs(v1) < minPxPerSec || Math.abs(v2) < minPxPerSec) return { isMarquee: false, pxPerSec, v1, v2 };
|
||||
if (Math.sign(v1) !== Math.sign(v2)) return { isMarquee: false, pxPerSec, v1, v2 };
|
||||
const sustained = Math.abs(v2) >= Math.abs(v1) * (1 - relTol);
|
||||
return { isMarquee: sustained, pxPerSec, v1, v2 };
|
||||
};
|
||||
const hasRepeatedChildren = (hashes: number[], widths: number[]): boolean => {
|
||||
const n = Math.min(hashes.length, widths.length);
|
||||
if (n < 2) return false;
|
||||
for (let i = 1; i < n; i++) if (hashes[i] === hashes[i - 1] && hashes[i] !== 0) return true;
|
||||
if (n >= 4 && n % 2 === 0) {
|
||||
const half = n / 2;
|
||||
let repeats = true;
|
||||
for (let i = 0; i < half; i++) { if (widths[i] === 0 || widths[i] !== widths[i + half]) { repeats = false; break; } }
|
||||
if (repeats) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// sample per-child outerHTML hash + width for the duplication test.
|
||||
const childSignature = (el: Element): { hashes: number[]; widths: number[] } => {
|
||||
const hashes: number[] = [], widths: number[] = [];
|
||||
const kids = Array.from(el.children).slice(0, 24) as HTMLElement[];
|
||||
for (const k of kids) { hashes.push(hashStr(k.outerHTML)); widths.push(Math.round(k.getBoundingClientRect().width)); }
|
||||
return { hashes, widths };
|
||||
};
|
||||
// one observation window: `count` deltas at `sampleMs` cadence.
|
||||
const sampleWindow = async (el: Element, count: number, sampleMs: number): Promise<number[]> => {
|
||||
const xs: number[] = [txOf(el)];
|
||||
for (let i = 0; i < count; i++) { await sleep(sampleMs); xs.push(txOf(el)); }
|
||||
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
|
||||
return dxs;
|
||||
};
|
||||
|
||||
const SAMPLE_MS = 120;
|
||||
const WINDOW_SAMPLES = 5; // 5 deltas ≈ 600ms per window
|
||||
const INTER_WINDOW_MS = 1500; // ≥1.5s between windows (discriminator 2)
|
||||
const JIGGLE_PX = 50; // scroll nudge for the independence test (discriminator 3)
|
||||
|
||||
// Candidates: tagged, already translated (paused tickers keep their offset), with
|
||||
// duplicated content (≥2 children) inside an overflow-clip viewport — the marquee shape.
|
||||
// GENUINE duplicated content inside an overflow-clip viewport, AND real content
|
||||
// overflow (scrollWidth > 1.35·clientWidth) — the marquee shape. All cheap/synchronous
|
||||
// so the expensive async windows only run for the few survivors.
|
||||
const cand: Element[] = [];
|
||||
for (const el of Array.from(document.querySelectorAll("[data-cid-cap]"))) {
|
||||
if (Math.abs(txOf(el)) <= 0.5) continue;
|
||||
if (el.children.length < 2) continue;
|
||||
if (!inClip(el)) continue;
|
||||
// Discriminator 1: content overflow. scrollWidth must meaningfully exceed clientWidth;
|
||||
// a static row that fits (scrollWidth ≈ clientWidth) has nothing to scroll.
|
||||
if (el.scrollWidth <= el.clientWidth * 1.35) continue;
|
||||
// Discriminator 4: genuine repetition, not merely ≥2 distinct children.
|
||||
const sig = childSignature(el);
|
||||
if (!hasRepeatedChildren(sig.hashes, sig.widths)) continue;
|
||||
cand.push(el);
|
||||
if (cand.length >= 16) break;
|
||||
if (cand.length >= 8) break;
|
||||
}
|
||||
const out: Array<{ cap: string; axis: "x"; pxPerSec: number; periodPx: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
for (const el of cand) {
|
||||
const cap = el.getAttribute("data-cid-cap"); if (!cap || seen.has(cap)) continue;
|
||||
try { el.scrollIntoView({ block: "center" }); } catch { /* ignore */ }
|
||||
await sleep(450); // wake the off-screen-paused ticker + let the scroll settle
|
||||
const xs: number[] = [];
|
||||
for (let i = 0; i < 6; i++) { xs.push(txOf(el)); await sleep(120); }
|
||||
const dxs: number[] = []; for (let i = 1; i < xs.length; i++) dxs.push(xs[i]! - xs[i - 1]!);
|
||||
if (dxs.length < 3) continue;
|
||||
// velocity = median per-120ms delta (median ignores the single wrap-reset outlier).
|
||||
const med = [...dxs].sort((a, b) => a - b)[Math.floor(dxs.length / 2)]!;
|
||||
const pxPerSec = Math.round((med / 120) * 1000);
|
||||
if (Math.abs(pxPerSec) < 4) continue; // not actually moving (e.g. a frozen scroll-scrub offset)
|
||||
// direction must be consistent in all but (at most) the one wrap step.
|
||||
if (dxs.filter((d) => Math.sign(d) === Math.sign(med)).length < dxs.length - 1) continue;
|
||||
await sleep(300); // wake the off-screen-paused ticker + let the scroll settle
|
||||
|
||||
// Discriminator 2: two velocity windows ≥1.5s apart. A scroll-settle lerp decays;
|
||||
// a real marquee holds constant velocity.
|
||||
const w1 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
|
||||
await sleep(INTER_WINDOW_MS);
|
||||
const w2 = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
|
||||
if (w1.length < 3 || w2.length < 3) continue;
|
||||
// direction must be consistent within window 1, but for (at most) the one wrap step.
|
||||
const med1 = [...w1].sort((a, b) => a - b)[Math.floor(w1.length / 2)]!;
|
||||
if (w1.filter((d) => Math.sign(d) === Math.sign(med1)).length < w1.length - 1) continue;
|
||||
const cls = classifyVelocitySamples(w1, w2, SAMPLE_MS);
|
||||
if (!cls.isMarquee) continue;
|
||||
const pxPerSec = cls.pxPerSec;
|
||||
|
||||
// Discriminator 3: scroll independence (jiggle). Nudge the scroll ±50px and re-sample
|
||||
// velocity; a self-driven ticker is unaffected, a scroll-linked animation changes.
|
||||
const baseY = window.scrollY;
|
||||
const jiggle = async (dy: number): Promise<number> => {
|
||||
try { window.scrollTo(0, Math.max(0, baseY + dy)); } catch { /* ignore */ }
|
||||
await sleep(250); // let any scroll-linked easing react and settle at the new offset
|
||||
const dxs = await sampleWindow(el, WINDOW_SAMPLES, SAMPLE_MS);
|
||||
return medianVelocityPxPerSec(dxs, SAMPLE_MS);
|
||||
};
|
||||
const vDown = await jiggle(JIGGLE_PX);
|
||||
const vUp = await jiggle(-JIGGLE_PX);
|
||||
try { window.scrollTo(0, baseY); } catch { /* ignore */ }
|
||||
// Scroll-linked motion responds to the nudge (velocity reverses, dies, or spikes as
|
||||
// it re-lerps to the new target). A real marquee holds ~pxPerSec through both nudges.
|
||||
const stableUnderJiggle = (v: number): boolean =>
|
||||
Math.sign(v) === Math.sign(pxPerSec) && Math.abs(v) >= Math.abs(pxPerSec) * 0.5 && Math.abs(v) <= Math.abs(pxPerSec) * 2;
|
||||
if (!stableUnderJiggle(vDown) || !stableUnderJiggle(vUp)) continue;
|
||||
|
||||
const periodPx = Math.round(el.scrollWidth / 2);
|
||||
if (periodPx < 40) continue;
|
||||
seen.add(cap);
|
||||
@@ -305,8 +482,11 @@ export async function captureMotion(page: Page, opts?: { observeMs?: number; log
|
||||
const cap = el && (el as Element).getAttribute?.("data-cid-cap");
|
||||
if (!cap) continue;
|
||||
const elem = el as Element;
|
||||
// only track small text-bearing elements (a rotating word/phrase, not a big block)
|
||||
if (elem.querySelector("[data-cid-cap]")) continue; // has element children → not a leaf word
|
||||
// only track small text-bearing elements (a rotating word/phrase, not a big block).
|
||||
// Reject ANY element child, capped or not: rows injected at runtime (after cid-cap
|
||||
// tagging, so uncapped) would otherwise defeat a capped-only check and let a
|
||||
// multi-element panel pass as a "leaf word".
|
||||
if (elem.firstElementChild) continue; // has element children → not a leaf word
|
||||
const txt = norm(elem.textContent || "");
|
||||
if (!txt || txt.length > 80) continue;
|
||||
const e = changes.get(cap) ?? { texts: [], times: [] };
|
||||
|
||||
+267
-16
@@ -44,6 +44,18 @@ export type RawNode = {
|
||||
computed: RawStyle;
|
||||
bbox: RawBBox;
|
||||
visible: boolean;
|
||||
// A font-metric / measurement scratch node injected by the SOURCE site's own JS (typography
|
||||
// libraries, FontFaceObserver): absolutely positioned, parked far off-screen, and non-painting.
|
||||
// Never user-visible; excluded from emission so it doesn't ship as page markup.
|
||||
probe?: boolean;
|
||||
// This element hosts an OPEN shadow root: its serialized children are the composed (flattened)
|
||||
// shadow tree — the shadowRoot's children with each <slot> replaced by its assigned light-DOM
|
||||
// nodes — NOT the host's light-DOM children. Custom elements (`<product-info>`) render all their
|
||||
// swatches/title/price inside this shadow tree, so a childNodes-only walk captured them empty.
|
||||
shadowHost?: boolean;
|
||||
// This node was serialized from inside a shadow tree (a shadowRoot descendant, or the shadow
|
||||
// subtree of a slotted light node). Tagged so generation can emit it as ordinary light DOM.
|
||||
inShadow?: boolean;
|
||||
sizing?: RawSizing;
|
||||
before?: RawStyle;
|
||||
after?: RawStyle;
|
||||
@@ -51,6 +63,10 @@ export type RawNode = {
|
||||
// clone renders the browser's default gray, losing the authored placeholder color/type.
|
||||
placeholder?: RawStyle;
|
||||
rawHTML?: string; // set for inline <svg>
|
||||
// Computed paint of an inline <svg> root (fill/stroke/color). A raw `fill="none"` attribute may
|
||||
// still resolve to a real paint via site CSS (`fill: currentColor`); these resolved values let
|
||||
// codegen recover a paint the extraction stripped. Set only for svg roots.
|
||||
svgPaint?: { fill: string; stroke: string; color: string };
|
||||
children: RawChild[];
|
||||
};
|
||||
|
||||
@@ -64,6 +80,10 @@ export type FontFace = {
|
||||
display?: string;
|
||||
unicodeRange?: string;
|
||||
stretch?: string;
|
||||
// Url of the stylesheet this face was declared in (undefined for an inline <style>, or for faces
|
||||
// parsed out-of-band where the base is already baked into `src`). The src descriptor's relative
|
||||
// url()s resolve against this, not the document — see readRules and parseSrcUrls.
|
||||
baseHref?: string;
|
||||
};
|
||||
|
||||
export type PageSnapshot = {
|
||||
@@ -134,6 +154,11 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
"letterSpacing", "wordSpacing", "textAlign", "textTransform",
|
||||
"textDecorationLine", "textDecorationColor", "textDecorationStyle",
|
||||
"whiteSpace", "wordBreak", "overflowWrap", "textOverflow", "textIndent",
|
||||
// Modern line-wrapping: `text-wrap: balance/pretty` rebalances heading line breaks
|
||||
// (getComputedStyle reports the shorthand — "balance"/"pretty"/"wrap"). Without it a
|
||||
// balanced heading wraps differently in the clone (an even two-line title collapses
|
||||
// to a lopsided break). Default "wrap" is elided downstream.
|
||||
"textWrap",
|
||||
"textShadow", "fontVariantCaps", "fontFeatureSettings",
|
||||
// Line clamping (`display:-webkit-box; -webkit-box-orient:vertical; -webkit-line-clamp:N`):
|
||||
// the mechanism that keeps cards equal height regardless of text length. Without it the engine
|
||||
@@ -286,7 +311,24 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
return true;
|
||||
};
|
||||
|
||||
const serializeElement = (el: Element): RawNode | null => {
|
||||
// A measurement/probe scratch node: out-of-flow (absolute/fixed), parked far off-screen
|
||||
// (≥10000px beyond any edge — real drawers/sr-only content never live out there), AND
|
||||
// non-painting (visibility:hidden / collapse / opacity:0). The two together are exclusive to
|
||||
// font-metric / measurement scratch elements the source's own JS injects (a11y sr-only text
|
||||
// stays visibility:visible so AT can read it, so it never matches). Tagged so emission drops it.
|
||||
const OFFSCREEN_PROBE_PX = 10000;
|
||||
const isProbe = (cs: CSSStyleDeclaration, bbox: RawBBox): boolean => {
|
||||
if (cs.position !== "absolute" && cs.position !== "fixed") return false;
|
||||
const nonPainting = cs.visibility === "hidden" || cs.visibility === "collapse" || parseFloat(cs.opacity || "1") === 0;
|
||||
if (!nonPainting) return false;
|
||||
const rightEdge = bbox.x + bbox.width;
|
||||
const bottomEdge = bbox.y + bbox.height;
|
||||
const pageH = round2(scrollEl.scrollHeight);
|
||||
return rightEdge <= -OFFSCREEN_PROBE_PX || bottomEdge <= -OFFSCREEN_PROBE_PX
|
||||
|| bbox.x >= OFFSCREEN_PROBE_PX + vpW || bbox.y >= OFFSCREEN_PROBE_PX + pageH;
|
||||
};
|
||||
|
||||
const serializeElement = (el: Element, inShadow = false): RawNode | null => {
|
||||
if (nodeCount >= MAX_NODES) { truncated = true; return null; }
|
||||
const tag = el.tagName.toLowerCase();
|
||||
nodeCount++;
|
||||
@@ -359,9 +401,36 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
const hf = el.getBoundingClientRect().height;
|
||||
// Tight 0.5px: only call a dimension "reproduced" when auto/100% lands essentially exactly,
|
||||
// so a drop can't accumulate a visible shift across many elements (favours fidelity).
|
||||
let hAuto = Math.abs(ha - r.height) <= 0.5;
|
||||
let hFill = Math.abs(hf - r.height) <= 0.5;
|
||||
// Circular-height guard: a box whose fill child (height:100%) pins it back up makes BOTH
|
||||
// `height:auto` and `height:100%` reproduce the box, so the raw verdict reads hAuto (drop) —
|
||||
// even though the height is authored (e.g. `100vh` on a hero, an explicit px section). Both
|
||||
// sides then wait on each other and the box collapses. When this element's own cascade/inline
|
||||
// style declares an explicit definite height, trust that declaration: the height is authored,
|
||||
// so it is neither content-sized (hAuto) nor a parent fill (hFill). Only overrides when auto
|
||||
// actually reproduced — a genuine explicit height that auto already shrinks stays hAuto:false.
|
||||
if ((hAuto || hFill) && r.height > 2 && authorsExplicitHeight(el, sh)) {
|
||||
hAuto = false;
|
||||
hFill = false;
|
||||
}
|
||||
let wAuto = Math.abs(wa - r.width) <= 0.5;
|
||||
let wFill = Math.abs(wf - r.width) <= 0.5;
|
||||
// Circular-WIDTH guard (the mirror of the height guard above): an authored `width:24px` inside a
|
||||
// SHRINK-TO-FIT parent (a color-swatch link in a span that shrink-wraps to it) makes BOTH
|
||||
// `width:auto` and `width:100%` reproduce the box — the parent's width still holds while the
|
||||
// child re-measures — so the raw verdict reads wAuto/wFill (drop) and the clone collapses the
|
||||
// child to 0 content width (and the shrink-wrap span to its borders). When this element's own
|
||||
// cascade/inline style declares an explicit definite width, trust it: the width is authored, so
|
||||
// it is neither content-sized (wAuto) nor a parent fill (wFill). Only overrides when a probe
|
||||
// actually reproduced — a genuine explicit width that auto already shrinks stays wAuto:false.
|
||||
if ((wAuto || wFill) && r.width > 2 && authorsExplicitWidth(el, sw)) {
|
||||
wAuto = false;
|
||||
wFill = false;
|
||||
}
|
||||
sizing = {
|
||||
wAuto: Math.abs(wa - r.width) <= 0.5, wFill: Math.abs(wf - r.width) <= 0.5, hAuto: Math.abs(ha - r.height) <= 0.5,
|
||||
hFill: Math.abs(hf - r.height) <= 0.5,
|
||||
wAuto, wFill, hAuto,
|
||||
hFill,
|
||||
wMin: Math.round(wmin * 100) / 100, wMax: Math.round(wmax * 100) / 100,
|
||||
};
|
||||
} finally {
|
||||
@@ -401,6 +470,8 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
computed,
|
||||
bbox,
|
||||
visible: isVisible(el, cs, bbox),
|
||||
...(isProbe(cs, bbox) ? { probe: true } : {}),
|
||||
...(inShadow ? { inShadow: true } : {}),
|
||||
...(sizing ? { sizing } : {}),
|
||||
children: [],
|
||||
};
|
||||
@@ -429,6 +500,14 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
// Inline SVG → raw markup, no recursion.
|
||||
if (tag === "svg") {
|
||||
node.rawHTML = el.outerHTML;
|
||||
// Capture the svg root's COMPUTED paint (fill/stroke/color) separately from the general
|
||||
// computed-prop list. A raw `fill="none"` presentation attribute paints nothing on its own; a
|
||||
// wordmark/icon is visible on the source only because site CSS (a `fill: currentColor` class,
|
||||
// an inherited `color`) overrides it. Extraction strips that CSS, so the raw attribute alone is
|
||||
// misleading — codegen consults these resolved values to decide whether the root truly paints.
|
||||
try {
|
||||
node.svgPaint = { fill: cs.fill, stroke: cs.stroke, color: cs.color };
|
||||
} catch { /* getComputedStyle already read above; guard against exotic UAs */ }
|
||||
return node;
|
||||
}
|
||||
|
||||
@@ -436,8 +515,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
// code blocks with highlighted spans separated by newlines).
|
||||
const preserveWs = /^(pre|pre-wrap|break-spaces)/.test(cs.whiteSpace || "");
|
||||
|
||||
// Composed (flattened) child list — the tree the user actually sees, matching what
|
||||
// getComputedStyle/getBoundingClientRect already report for every node:
|
||||
// - An open shadow HOST renders its shadow tree, not its light children: iterate the
|
||||
// shadowRoot's children and tag them (and the host) so generation emits them as light DOM.
|
||||
// - A <slot> is a placeholder for the light-DOM nodes distributed into it: replace it with its
|
||||
// assignedNodes (or its own fallback children when nothing is assigned). Assigned nodes are
|
||||
// the author's real light content, so they are NOT tagged inShadow — only genuine shadow
|
||||
// descendants are. Walking the shadow tree (never the host's raw light children) means a
|
||||
// slotted child is serialized once, at the slot position, with no double-emission.
|
||||
const sr = (el as HTMLElement).shadowRoot; // open roots only; closed roots are null here
|
||||
let childInShadow = inShadow;
|
||||
let childNodes: Node[];
|
||||
if (sr) {
|
||||
node.shadowHost = true;
|
||||
childInShadow = true;
|
||||
childNodes = Array.from(sr.childNodes);
|
||||
} else {
|
||||
childNodes = Array.from(el.childNodes);
|
||||
}
|
||||
|
||||
// Recurse children (elements + text nodes), preserving order.
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
for (const child of childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const t = child.textContent || "";
|
||||
if (preserveWs && t.length > 0) {
|
||||
@@ -457,8 +556,28 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
const childEl = child as Element;
|
||||
if (SKIP_TAGS.has(childEl.tagName.toLowerCase())) continue;
|
||||
const sn = serializeElement(childEl);
|
||||
const childTag = childEl.tagName.toLowerCase();
|
||||
if (SKIP_TAGS.has(childTag)) continue;
|
||||
// A slot nested deeper inside a shadow tree: expand its assigned light nodes in place. The
|
||||
// assigned nodes are light DOM (author content), so they drop back to inShadow=false.
|
||||
if (childTag === "slot" && childInShadow) {
|
||||
const assigned = (childEl as HTMLSlotElement).assignedNodes({ flatten: true });
|
||||
const slotKids = assigned.length ? assigned : Array.from(childEl.childNodes);
|
||||
for (const sk of slotKids) {
|
||||
if (sk.nodeType === Node.TEXT_NODE) {
|
||||
const t = sk.textContent || "";
|
||||
if (t.trim().length > 0) node.children.push({ text: t });
|
||||
continue;
|
||||
}
|
||||
if (sk.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
const skEl = sk as Element;
|
||||
if (SKIP_TAGS.has(skEl.tagName.toLowerCase())) continue;
|
||||
const skn = serializeElement(skEl, assigned.length ? false : true);
|
||||
if (skn) node.children.push(skn);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const sn = serializeElement(childEl, childInShadow);
|
||||
if (sn) node.children.push(sn);
|
||||
}
|
||||
|
||||
@@ -470,22 +589,107 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
const cssUrlSet = new Set<string>();
|
||||
const keyframes: string[] = [];
|
||||
const cssVars: Record<string, string> = {};
|
||||
// Selectors that AUTHOR an explicit, definite height/min-height (px/rem/em/vh/vw/…,
|
||||
// NOT auto, NOT a percentage, NOT a keyword). Harvested once from the cascade below and
|
||||
// consulted by the sizing probe to break circular parent/child height verdicts: when a box
|
||||
// and its fill child mutually justify each other's height, `height:auto` reproduces the box
|
||||
// for a reason that is NOT "content-sized", so the probe alone reads hAuto:true and the
|
||||
// authored dimension gets dropped downstream. A declared explicit length is ground truth that
|
||||
// the height is load-bearing, so we trust the declaration over the reflow verdict.
|
||||
const explicitHeightSelectors: string[] = [];
|
||||
// The WIDTH twin of explicitHeightSelectors: selectors that author an explicit definite `width`/
|
||||
// `min-width` (px/rem/em/…, not auto/%/keyword). Consulted by the sizing probe to break a CIRCULAR
|
||||
// width verdict — an authored `width:24px` inside a shrink-to-fit parent reproduces at both auto and
|
||||
// 100% (the parent's width still holds), so the probe alone reads wAuto and the authored width gets
|
||||
// dropped, collapsing the box in the clone.
|
||||
const explicitWidthSelectors: string[] = [];
|
||||
|
||||
const absUrl = (u: string): string => {
|
||||
try { return new URL(u, document.baseURI).href; } catch { return u; }
|
||||
const absUrl = (u: string, base?: string): string => {
|
||||
try { return new URL(u, base || document.baseURI).href; } catch { return u; }
|
||||
};
|
||||
|
||||
const harvestUrlsFromText = (text: string): void => {
|
||||
// A relative url() inside a stylesheet resolves against THAT STYLESHEET'S url, not the document —
|
||||
// `url("../media/x.woff2")` in `/a/b/css/sheet.css` points at `/a/b/media/x.woff2`, which is a
|
||||
// different file from the document-relative `/media/x.woff2`. Resolving font/image srcs against
|
||||
// `document.baseURI` fetches the wrong path (often the SPA router's 200+HTML shell), so a harvested
|
||||
// face src must carry its owning sheet's base. Walk up through `@import` nesting (parentStyleSheet
|
||||
// via ownerRule) to the nearest sheet that actually has an href; an inline `<style>` has none, so
|
||||
// it correctly falls back to the document base.
|
||||
const sheetBaseHref = (sheet: CSSStyleSheet | null | undefined): string | undefined => {
|
||||
let s: CSSStyleSheet | null | undefined = sheet;
|
||||
// Bound the walk defensively; @import chains are shallow in practice.
|
||||
for (let i = 0; s && i < 32; i++) {
|
||||
if (s.href) return s.href;
|
||||
s = s.ownerRule?.parentStyleSheet ?? null;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const harvestUrlsFromText = (text: string, base?: string): void => {
|
||||
const re = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const raw = m[2];
|
||||
if (!raw || raw.startsWith("data:")) continue;
|
||||
cssUrlSet.add(absUrl(raw));
|
||||
cssUrlSet.add(absUrl(raw, base));
|
||||
}
|
||||
};
|
||||
|
||||
const readRules = (rules: CSSRuleList): void => {
|
||||
// True when a `height`/`min-height` value is an explicit, definite length the browser resolves
|
||||
// to a fixed px box (px/rem/em/vh/vw/vmin/vmax/ch/…, or a calc/min/max/clamp over them). False for
|
||||
// `auto`, an empty value, a pure percentage (resolves against the parent — that's the fill case the
|
||||
// hFill probe already handles), `0`, and intrinsic keywords (fit-/min-/max-content). A definite
|
||||
// authored height is load-bearing and must survive even when the reflow probe reads hAuto:true.
|
||||
const isExplicitHeight = (raw: string): boolean => {
|
||||
const v = (raw || "").trim().toLowerCase();
|
||||
if (!v || v === "auto" || v === "0" || v === "0px" || v === "none") return false;
|
||||
if (v === "fit-content" || v === "min-content" || v === "max-content" || v === "inherit" ||
|
||||
v === "initial" || v === "unset" || v === "revert" || v === "revert-layer") return false;
|
||||
// A bare percentage resolves against the parent (fill), not an authored definite length.
|
||||
if (/^[\d.]+%$/.test(v)) return false;
|
||||
// A definite length unit (or a calc()/min()/max()/clamp() that contains one) anchors the box.
|
||||
return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|vh|vw|vmin|vmax|svh|lvh|dvh|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v);
|
||||
};
|
||||
|
||||
// Does this element author an explicit definite height — via its own inline style (passed as
|
||||
// `inlineHeight`, already read by the probe) or via any matched cascade rule harvested into
|
||||
// `explicitHeightSelectors`? getComputedStyle resolves height to used px (so `100vh` reads as a
|
||||
// plain number and is indistinguishable from a content height there); the specified value is only
|
||||
// recoverable from the inline declaration and the cascade, which is why we consult both.
|
||||
const authorsExplicitHeight = (el: Element, inlineHeight: string): boolean => {
|
||||
if (isExplicitHeight(inlineHeight)) return true;
|
||||
try {
|
||||
const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-height") || "";
|
||||
if (isExplicitHeight(inlineMin)) return true;
|
||||
} catch { /* ignore */ }
|
||||
for (const sel of explicitHeightSelectors) {
|
||||
try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// The WIDTH twin of authorsExplicitHeight: does this element author an explicit definite width via
|
||||
// its own inline style (`inlineWidth`, already read by the probe) or any matched harvested rule?
|
||||
// `isExplicitHeight` is unit-agnostic (it only rejects auto/%/keywords and matches definite lengths),
|
||||
// so it doubles as the width predicate.
|
||||
const authorsExplicitWidth = (el: Element, inlineWidth: string): boolean => {
|
||||
if (isExplicitHeight(inlineWidth)) return true;
|
||||
try {
|
||||
const inlineMin = (el as HTMLElement).style?.getPropertyValue("min-width") || "";
|
||||
if (isExplicitHeight(inlineMin)) return true;
|
||||
} catch { /* ignore */ }
|
||||
for (const sel of explicitWidthSelectors) {
|
||||
try { if (el.matches(sel)) return true; } catch { /* invalid/unsupported selector */ }
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// `base` is the url of the stylesheet these rules live in (undefined for an inline <style>, which
|
||||
// resolves against the document). It is threaded so every harvested url() — @font-face src and
|
||||
// ordinary style-rule url() alike — resolves relative to its OWNING sheet, not the page. The src
|
||||
// string is left verbatim on the FontFace (parseSrcUrls re-absolutizes it downstream against the
|
||||
// same base), but the base still drives the cssUrlSet entry that triggers the download.
|
||||
const readRules = (rules: CSSRuleList, base?: string): void => {
|
||||
for (const rule of Array.from(rules)) {
|
||||
const type = rule.constructor.name;
|
||||
if (type === "CSSFontFaceRule") {
|
||||
@@ -502,26 +706,40 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
display: s.getPropertyValue("font-display") || undefined,
|
||||
unicodeRange: s.getPropertyValue("unicode-range") || undefined,
|
||||
stretch: s.getPropertyValue("font-stretch") || undefined,
|
||||
baseHref: base,
|
||||
});
|
||||
harvestUrlsFromText(src);
|
||||
harvestUrlsFromText(src, base);
|
||||
}
|
||||
} else if (type === "CSSKeyframesRule") {
|
||||
keyframes.push((rule as CSSKeyframesRule).cssText);
|
||||
} else if (type === "CSSStyleRule") {
|
||||
const r = rule as CSSStyleRule;
|
||||
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText);
|
||||
if (r.style && r.style.cssText.includes("url(")) harvestUrlsFromText(r.style.cssText, base);
|
||||
if (r.selectorText && r.style &&
|
||||
(isExplicitHeight(r.style.getPropertyValue("height")) ||
|
||||
isExplicitHeight(r.style.getPropertyValue("min-height")))) {
|
||||
explicitHeightSelectors.push(r.selectorText);
|
||||
}
|
||||
if (r.selectorText && r.style &&
|
||||
(isExplicitHeight(r.style.getPropertyValue("width")) ||
|
||||
isExplicitHeight(r.style.getPropertyValue("min-width")))) {
|
||||
explicitWidthSelectors.push(r.selectorText);
|
||||
}
|
||||
} else if (type === "CSSMediaRule" || type === "CSSSupportsRule") {
|
||||
try { readRules((rule as CSSGroupingRule).cssRules); } catch { /* ignore */ }
|
||||
// Nested grouping rules live in the same sheet — keep the base.
|
||||
try { readRules((rule as CSSGroupingRule).cssRules, base); } catch { /* ignore */ }
|
||||
} else if (type === "CSSImportRule") {
|
||||
// The imported sheet is a separate file: its rules resolve against ITS url, falling back to
|
||||
// the importing sheet's base only if the imported sheet reports none.
|
||||
const imp = rule as CSSImportRule;
|
||||
try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules); } catch { /* cross-origin */ }
|
||||
try { if (imp.styleSheet) readRules(imp.styleSheet.cssRules, sheetBaseHref(imp.styleSheet) ?? base); } catch { /* cross-origin */ }
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const sheet of Array.from(document.styleSheets)) {
|
||||
try {
|
||||
readRules(sheet.cssRules);
|
||||
readRules(sheet.cssRules, sheetBaseHref(sheet));
|
||||
} catch {
|
||||
// Cross-origin sheet — record its href so the capture layer can fetch the
|
||||
// raw text out-of-band and parse font-faces/urls from it.
|
||||
@@ -529,6 +747,39 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
}
|
||||
}
|
||||
|
||||
// Custom elements (web components) carry their styles INSIDE their open shadow root — via
|
||||
// `adoptedStyleSheets` (constructable sheets) and/or `<style>`/`<link>` in the shadow tree
|
||||
// (`shadowRoot.styleSheets`). Those sheets never appear in `document.styleSheets`, so an authored
|
||||
// rule like `color-swatch a { width: 24px }` is invisible to the harvest above and the sizing probe
|
||||
// can't see the explicit width. Walk every open shadow root (composed-tree sweep, matching the
|
||||
// serializer) and read its sheets too — same `readRules` (font-faces, url()s, explicit width/height
|
||||
// selectors). Closed roots return null and are skipped, exactly as elsewhere.
|
||||
const shadowRoots: ShadowRoot[] = [];
|
||||
const collectShadowRoots = (): void => {
|
||||
const stack: Element[] = [];
|
||||
const pushChildren = (parent: Element | Document | ShadowRoot): void => {
|
||||
let c = parent.firstElementChild;
|
||||
while (c) { stack.push(c); c = c.nextElementSibling; }
|
||||
};
|
||||
pushChildren(document);
|
||||
// Depth-first over the composed element tree, descending into every open shadow root.
|
||||
while (stack.length) {
|
||||
const node = stack.pop()!;
|
||||
const sr = (node as HTMLElement).shadowRoot;
|
||||
if (sr) { shadowRoots.push(sr); pushChildren(sr); }
|
||||
pushChildren(node);
|
||||
}
|
||||
};
|
||||
try { collectShadowRoots(); } catch { /* ignore */ }
|
||||
for (const sr of shadowRoots) {
|
||||
const sheets: CSSStyleSheet[] = [];
|
||||
try { sheets.push(...(sr.adoptedStyleSheets || [])); } catch { /* ignore */ }
|
||||
try { sheets.push(...Array.from(sr.styleSheets || [])); } catch { /* ignore */ }
|
||||
for (const sheet of sheets) {
|
||||
try { readRules(sheet.cssRules, sheetBaseHref(sheet)); } catch { if ((sheet as CSSStyleSheet).href) cssUrlSet.add(absUrl((sheet as CSSStyleSheet).href!)); }
|
||||
}
|
||||
}
|
||||
|
||||
// CSS custom properties declared on :root.
|
||||
try {
|
||||
const rootCs = window.getComputedStyle(document.documentElement);
|
||||
|
||||
+294
-43
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { rmSync } from "node:fs";
|
||||
import { writeText } from "../util/fsx.js";
|
||||
import type { IR, IRNode, IRChild, IRTextNode } from "../normalize/ir.js";
|
||||
import type { IR, IRNode, IRChild, IRTextNode, StyleMap } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import { generateCss, RESET_CSS } from "./css.js";
|
||||
import { generateInteractionCss } from "./interactionCss.js";
|
||||
@@ -17,7 +17,7 @@ import { buildClassMap } from "./classMap.js";
|
||||
import { buildTailwind, tailwindGlobalsCss } from "./tailwind.js";
|
||||
import { planSections, type SectionPlan } from "./sectionSplit.js";
|
||||
import type { RecipeReport } from "../infer/recipes.js";
|
||||
import { emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, viewportExport, type SeoInventory } from "./seo.js";
|
||||
import { emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, siteOriginImportLine, SITE_ORIGIN_LAYOUT_IMPORT, SITE_ORIGIN_MODULE, viewportExport, type SeoInventory } from "./seo.js";
|
||||
import { emitGeneratedDocs } from "./docs.js";
|
||||
|
||||
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
|
||||
@@ -282,6 +282,17 @@ function jsxText(raw: string): string {
|
||||
return `{${escapeText(raw)}}`;
|
||||
}
|
||||
|
||||
/** Collapse a text run under `white-space: normal` the way CSS renders it: every run of
|
||||
* whitespace (captured \n\t indentation, doubled spaces) becomes a single space, so the
|
||||
* markup carries content — not the source file's frozen formatting. Leading/trailing single
|
||||
* spaces are KEPT (JSX-significant for inline flow); the boundary is preserved so `word <b>x</b>`
|
||||
* keeps its space. A run that is entirely whitespace collapses to a single space. The text gate
|
||||
* compares whitespace-normalized (gates.ts:normText), so this stays faithful. */
|
||||
function collapseWs(raw: string): string {
|
||||
const collapsed = raw.replace(/\s+/g, " ");
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
/** The ordered [propKey, valueExpr] list a node emits — rendered to clean JSX attributes
|
||||
* by renderAttrs. Each valueExpr is ready-to-emit JS source (a JSON literal,
|
||||
* `true`, or a `{ __html: … }` object). Component extraction reuses this so the
|
||||
@@ -358,11 +369,20 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
|
||||
if (kept.length === 0) continue;
|
||||
value = kept.join(", ");
|
||||
} else if (key === "href") {
|
||||
// Preserve in-page anchors. For multi-route sites a linkRewrite maps internal
|
||||
// links to the generated clone routes (and collapsed-collection links to their
|
||||
// representative); otherwise absolutize so it never 404s inside the clone
|
||||
// (external navigation is allowed by the rubric).
|
||||
if (!value.startsWith("#")) value = ctx?.linkRewrite ? ctx.linkRewrite(value) : resolveUrl(value, sourceUrl);
|
||||
// A `javascript:*` href (announcement bars, JS-driven buttons authored as links) is a
|
||||
// script trigger, not a navigable URL. React refuses to render one: it rewrites the
|
||||
// attribute to a long `javascript:throw new Error('React has blocked a javascript: URL…')`
|
||||
// string, which no longer matches the source href in the link gate. The behaviour is
|
||||
// script we don't reproduce anyway, so emit an inert `#` and keep the anchor navigable-inert.
|
||||
if (/^\s*javascript:/i.test(value)) {
|
||||
value = "#";
|
||||
} else if (!value.startsWith("#")) {
|
||||
// Preserve in-page anchors. For multi-route sites a linkRewrite maps internal
|
||||
// links to the generated clone routes (and collapsed-collection links to their
|
||||
// representative); otherwise absolutize so it never 404s inside the clone
|
||||
// (external navigation is allowed by the rubric).
|
||||
value = ctx?.linkRewrite ? ctx.linkRewrite(value) : resolveUrl(value, sourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
let reactName = isCustom ? key : (ATTR_RENAME[key] ?? key);
|
||||
@@ -380,24 +400,44 @@ export function propsList(node: IRNode, assetMap: Map<string, string>, sourceUrl
|
||||
|
||||
if (isVideo && !videoLocal && !props.some(([k]) => k === "preload")) props.push(["preload", JSON.stringify("none")]);
|
||||
|
||||
// A canvas emitted as its raster still (<img> — see resolveTag) is decorative
|
||||
// painted surface with no captured alt text; emit alt="" so the img is valid.
|
||||
if (node.tag === "canvas" && node.attrs.src && !props.some(([k]) => k === "alt")) props.push(["alt", JSON.stringify("")]);
|
||||
|
||||
if (node.rawHTML && node.tag === "svg") {
|
||||
// Strip the Stage-4 capture-id (`data-cid-cap`) the interaction pass stamps on
|
||||
// elements: it's internal instrumentation, render-inert, and would otherwise
|
||||
// surface verbatim in the markup and (post-extraction) as a bogus data field.
|
||||
const inner = svgInnerForNode(node, ctx);
|
||||
const svgAttrs = extractSvgAttrs(node.rawHTML);
|
||||
let hasFillAttr = false;
|
||||
let rawFill: string | undefined;
|
||||
for (const [k, v] of svgAttrs) {
|
||||
if (k.toLowerCase() === "fill") hasFillAttr = true;
|
||||
if (k.toLowerCase() === "fill") { rawFill = v; continue; } // the root fill is resolved below, not copied verbatim
|
||||
if (k === "class" || k === "style" || k === "data-cid-cap" || k.includes(":")) continue;
|
||||
const reactName = SVG_ATTR_RENAME[k] ?? k;
|
||||
const propKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(reactName) ? reactName : JSON.stringify(reactName);
|
||||
if (!props.some(([pk]) => pk === propKey)) props.push([propKey, JSON.stringify(v)]);
|
||||
}
|
||||
// Raw SVG icons often rely on site CSS (`svg { fill: currentColor }` or a class) that is
|
||||
// stripped during extraction. If the root didn't declare a fill, inherit the surrounding text
|
||||
// color so monochrome wordmarks/icons don't fall back to the browser's black default.
|
||||
if (!hasFillAttr && !props.some(([pk]) => pk === "fill")) props.push(["fill", JSON.stringify("currentColor")]);
|
||||
// Reconcile the root fill against the captured COMPUTED paint. A raw `fill="none"` only looks
|
||||
// unfilled: site CSS (`fill: currentColor` on the wordmark, an inherited color) may actually
|
||||
// paint it, and that CSS is stripped during extraction. resolveSvgRootFill recovers the real
|
||||
// paint — as currentColor when the computed fill tracks the element's color (emit that color
|
||||
// too), else the literal value — and leaves a genuinely unfilled svg as fill="none". When the
|
||||
// root declared no fill at all we fall back to currentColor so monochrome icons don't drop to
|
||||
// the browser's black default.
|
||||
if (!props.some(([pk]) => pk === "fill")) {
|
||||
const resolved = resolveSvgRootFill(rawFill, node.svgPaint);
|
||||
if (resolved.mode === "keep") {
|
||||
if (rawFill !== undefined) props.push(["fill", JSON.stringify(rawFill)]);
|
||||
} else if (resolved.mode === "emit") {
|
||||
props.push(["fill", JSON.stringify(resolved.value!)]);
|
||||
if (resolved.emitColor && !props.some(([pk]) => pk === "color")) {
|
||||
props.push(["color", JSON.stringify(resolved.emitColor)]);
|
||||
}
|
||||
} else {
|
||||
props.push(["fill", JSON.stringify("currentColor")]);
|
||||
}
|
||||
}
|
||||
props.push(["dangerouslySetInnerHTML", `{ __html: ${JSON.stringify(inner)} }`]);
|
||||
}
|
||||
|
||||
@@ -445,6 +485,50 @@ function extractSvgAttrs(outerHTML: string): Array<[string, string]> {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A resolved paint value is "real" (paints something) when it is neither absent nor an explicit
|
||||
* non-paint (`none`) nor fully transparent. `currentColor` counts as real (it paints the inherited
|
||||
* color). Case/whitespace-insensitive. */
|
||||
export function isRealPaint(value: string | null | undefined): boolean {
|
||||
const v = (value ?? "").trim().toLowerCase();
|
||||
if (!v || v === "none" || v === "transparent") return false;
|
||||
if (v === "rgba(0, 0, 0, 0)" || v === "rgba(0,0,0,0)") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what `fill` an inline <svg> ROOT should emit, reconciling the raw `fill` attribute against
|
||||
* the svg's COMPUTED paint (captured from the live source). Pure and deterministic.
|
||||
*
|
||||
* - Raw `fill` present and itself a real paint → keep it (`keep`): the author meant that fill.
|
||||
* - Raw `fill="none"` but the computed fill IS a real paint → the root only looked unfilled because
|
||||
* `fill="none"` is a presentation attribute the site's CSS overrode. Recover the real paint:
|
||||
* emit `currentColor` when the computed fill equals the computed `color` (a `fill: currentColor`
|
||||
* class — the common wordmark case; caller must also emit `color`), else the literal computed fill.
|
||||
* - Raw `fill="none"` and computed fill also not a real paint → genuinely unfilled: keep `none`.
|
||||
* - Raw `fill` absent → `fallback`: caller applies its existing currentColor default.
|
||||
*/
|
||||
export function resolveSvgRootFill(
|
||||
rawFill: string | null | undefined,
|
||||
computed?: { fill?: string; color?: string } | null,
|
||||
): { mode: "keep" | "fallback" | "emit"; value?: string; emitColor?: string } {
|
||||
const raw = (rawFill ?? "").trim();
|
||||
if (raw && raw.toLowerCase() !== "none") return { mode: "keep" };
|
||||
if (raw.toLowerCase() === "none") {
|
||||
const cf = computed?.fill;
|
||||
if (isRealPaint(cf)) {
|
||||
const color = (computed?.color ?? "").trim();
|
||||
// fill:currentColor resolves to the element's color; recover it as currentColor so the clone
|
||||
// tracks whatever color the surrounding CSS supplies, and signal the caller to emit that color.
|
||||
if (color && cf!.trim().toLowerCase() === color.toLowerCase() && isRealPaint(color)) {
|
||||
return { mode: "emit", value: "currentColor", emitColor: color };
|
||||
}
|
||||
return { mode: "emit", value: cf!.trim() };
|
||||
}
|
||||
return { mode: "keep" }; // genuinely unfilled — leave fill="none"
|
||||
}
|
||||
return { mode: "fallback" };
|
||||
}
|
||||
|
||||
// SVG presentation attributes that React requires in camelCase (kebab in JSX is silently
|
||||
// dropped — and `fill-rule`/`clip-rule` being dropped changes how a path fills). Anything not
|
||||
// here that's already camel/lowercase (d, cx, fill, viewBox, opacity, transform, offset…) or a
|
||||
@@ -546,6 +630,12 @@ export function resolveTag(node: IRNode, insideInteractive: boolean, insideTable
|
||||
// children inside <iframe> are unrendered fallback content, so emit a <div> container
|
||||
// carrying the iframe's box/styles (CSS is keyed by cid, so the geometry is identical).
|
||||
if (tag === "iframe" && node.children.some((c) => !isTextChild(c))) tag = "div";
|
||||
// A canvas is runtime-drawn surface the clone cannot reproduce; when capture
|
||||
// rasterized it (canvas-still synthetic URL stamped as `src` — see
|
||||
// captureCanvasStillsInPage) emit the still as an <img> filling the canvas's box
|
||||
// (CSS is keyed by cid, so the geometry is identical). A canvas with no still
|
||||
// keeps rendering as an empty <canvas> box, same as before.
|
||||
if (tag === "canvas" && node.attrs.src) tag = "img";
|
||||
if (TABLE_SCOPED.has(tag) && !insideTable) tag = "div"; // orphan table element → neutral box
|
||||
if (violatesContentModel(node, tag)) tag = "div";
|
||||
return tag;
|
||||
@@ -591,7 +681,7 @@ function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: stri
|
||||
return `${pad}<${tag}${attrs} />`;
|
||||
}
|
||||
|
||||
const childParts = emitChildren(node.children, tag, assetMap, sourceUrl, indent + 1, childInteractive, ctx, childTable);
|
||||
const childParts = emitChildren(node.children, tag, assetMap, sourceUrl, indent + 1, childInteractive, ctx, childTable, preservesWhitespace(node));
|
||||
if (childParts.length === 0) {
|
||||
return `${pad}<${tag}${attrs} />`;
|
||||
}
|
||||
@@ -602,7 +692,7 @@ function renderNode(node: IRNode, assetMap: Map<string, string>, sourceUrl: stri
|
||||
* `{Name_data.map(...)}` call for any run of extracted-component instances. Shared
|
||||
* by renderNode (parentTag is the element tag) and the body/chrome fragment
|
||||
* renderers (parentTag null → no element-only-parent whitespace rule). */
|
||||
function emitChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, indent: number, childInteractive: boolean, ctx?: RenderCtx, insideTable = false): string[] {
|
||||
function emitChildren(children: IRChild[], parentTag: string | null, assetMap: Map<string, string>, sourceUrl: string, indent: number, childInteractive: boolean, ctx?: RenderCtx, insideTable = false, preserveWs = false): string[] {
|
||||
const pad = " ".repeat(indent);
|
||||
const parts: string[] = [];
|
||||
// Coalesce consecutive text children into one. Emitting them as separate JSX
|
||||
@@ -612,7 +702,12 @@ function emitChildren(children: IRChild[], parentTag: string | null, assetMap: M
|
||||
let textBuf = "";
|
||||
const flushText = () => {
|
||||
if (parentTag && ELEMENT_ONLY_PARENTS.has(parentTag) && textBuf.trim() === "") { textBuf = ""; return; }
|
||||
if (textBuf.length) parts.push(`${pad}${jsxText(textBuf)}`);
|
||||
// Under white-space:normal, collapse the captured formatting (\n\t runs, doubled/leading
|
||||
// multi-space) to CSS-equivalent single spaces so markup ships content, not source-file
|
||||
// indentation. A whitespace-only run collapses to a single significant space ({" "}) — the
|
||||
// huge `{" "}` literals disappear. Preserve verbatim under pre/pre-wrap/pre-line.
|
||||
const out = preserveWs ? textBuf : collapseWs(textBuf);
|
||||
if (out.length) parts.push(`${pad}${jsxText(out)}`);
|
||||
textBuf = "";
|
||||
};
|
||||
const reg = ctx?.components;
|
||||
@@ -708,12 +803,20 @@ function takeCid(coll: CidCollector, instances: IRNode[]): number {
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** A self-contained class-name merger emitted into each component module that needs it
|
||||
* (kept import-free so component files stay standalone). Skips falsy parts and joins —
|
||||
* exact for our output because a node's baked base classes and its per-instance overrides
|
||||
* are disjoint token sets. Swap in `tailwind-merge` if you want conflict-aware merging
|
||||
* when hand-editing the ./_styles overrides. */
|
||||
const CN_HELPER = `function cn(...parts: Array<string | false | null | undefined>) {\n return parts.filter(Boolean).join(" ");\n}`;
|
||||
/** The single shared `cn()` module (`src/lib/utils.ts`), imported by every module that
|
||||
* merges class names — one definition per clone instead of a copy per component file.
|
||||
* Skips falsy parts and joins — exact for our output because a node's baked base classes
|
||||
* and its per-instance overrides are disjoint token sets. Swap in `tailwind-merge` if you
|
||||
* want conflict-aware merging when hand-editing the ./_styles overrides. */
|
||||
export const CN_UTILS_MODULE = `export function cn(...parts: Array<string | false | null | undefined>) {\n return parts.filter(Boolean).join(" ");\n}\n`;
|
||||
|
||||
/** The `import { cn } from "…/lib/utils"` line a module emits when it references `cn(`.
|
||||
* `depth` is how many directory levels the consuming file sits BELOW `src` (page.tsx in
|
||||
* `src/app` → 1; a component in `src/app/components` → 2), so the relative path always
|
||||
* resolves to the single `src/lib/utils`. */
|
||||
export function cnImportLine(depth: number): string {
|
||||
return `import { cn } from "${"../".repeat(Math.max(1, depth))}lib/utils";`;
|
||||
}
|
||||
|
||||
/** Split a className value SOURCE (a JSON string literal as emitted by propsList) into its
|
||||
* whitespace-separated tokens. Returns [] for a non-string / unparseable source. */
|
||||
@@ -852,6 +955,15 @@ function canonicalViewportFor(n: IRNode): number {
|
||||
return keys[0] ?? 1280;
|
||||
}
|
||||
|
||||
/** Whether a node's `white-space` preserves captured whitespace verbatim (pre/pre-wrap/pre-line/
|
||||
* break-spaces) — in which case emission must NOT collapse its text runs. `<pre>`/`<textarea>`
|
||||
* default to pre even without an explicit declaration. Normal/nowrap collapse per CSS. */
|
||||
function preservesWhitespace(n: IRNode): boolean {
|
||||
const ws = (n.computedByVp[canonicalViewportFor(n)] ?? 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";
|
||||
}
|
||||
|
||||
type TextLeaf = { text: string; node: IRNode; index: number; ancestorTags: string[] };
|
||||
|
||||
function normalizeTextValue(text: string): string {
|
||||
@@ -1594,7 +1706,8 @@ export function componentPreamble(reg: ComponentRegistry | undefined): string {
|
||||
const cids = reg.cidDecls.map((c) => `const ${c.varName}: string[][] = ${c.body};`);
|
||||
const styles = reg.styleDecls.map((s) => `const ${s.varName} = ${s.body};`);
|
||||
const fns = [...reg.funcDefs.values()];
|
||||
const cn = fns.some((f) => f.includes("cn(")) ? [CN_HELPER] : [];
|
||||
// Inlined into a chrome module at src/app (layout.tsx) or src/ (Chrome.tsx) — depth 1.
|
||||
const cn = fns.some((f) => f.includes("cn(")) ? [cnImportLine(1)] : [];
|
||||
const parts = [...cn, ...fns, ...data, ...cids, ...styles];
|
||||
return parts.length ? parts.join("\n\n") : "";
|
||||
}
|
||||
@@ -1818,13 +1931,13 @@ function describeComponent(name: string): string {
|
||||
return map[base] ?? `${base.replace(/([a-z])([A-Z])/g, "$1 $2").toLowerCase()} component.`;
|
||||
}
|
||||
|
||||
export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgRegistry): Array<{ name: string; module: string }> {
|
||||
export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgRegistry, depth = 2): Array<{ name: string; module: string }> {
|
||||
if (!reg || reg.funcDefs.size === 0) return [];
|
||||
return [...reg.funcDefs].map(([name, src]) => {
|
||||
const agg = reg.byName.get(name);
|
||||
void agg;
|
||||
const header = `/** ${describeComponent(name)} */`;
|
||||
const cnDef = src.includes("cn(") ? CN_HELPER + "\n" : "";
|
||||
const cnImport = src.includes("cn(") ? cnImportLine(depth) : "";
|
||||
// The component's props-data type is DEFINED here (colocated), not imported from a central content
|
||||
// file — and exported so the section that supplies the data can type its array. Per-instance class
|
||||
// overrides still come from _styles.ts (pure styling plumbing).
|
||||
@@ -1842,9 +1955,9 @@ export function componentFiles(reg: ComponentRegistry | undefined, svgs?: SvgReg
|
||||
for (const c of scanRefs(src).comps) {
|
||||
if (svgs?.defs.has(c)) svgImports.push(`import ${c} from "../svgs/${svgFileBase(c)}";`);
|
||||
}
|
||||
const imports = [...typeImports, ...svgImports];
|
||||
const imports = [...typeImports, ...svgImports, ...(cnImport ? [cnImport] : [])];
|
||||
const importBlock = imports.length ? imports.join("\n") + "\n" : "";
|
||||
return { name, module: `${importBlock}${typeDef}${header}\n${cnDef}export default ${src}\n` };
|
||||
return { name, module: `${importBlock}${typeDef}${header}\nexport default ${src}\n` };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2082,6 +2195,7 @@ export function sectionFiles(sreg: SectionRegistry | undefined, reg: ComponentRe
|
||||
];
|
||||
const params = paramParts.length ? `{ ${paramParts.join(", ")} } = {}` : "";
|
||||
const header = `/** ${describeSection(name)} */`;
|
||||
if (/\bcn\(/.test(body)) lines.push(cnImportLine(2)); // sections live at src/app/sections
|
||||
const importBlock = lines.length ? lines.join("\n") + "\n" : "";
|
||||
const allConsts = consts;
|
||||
const constBlock = allConsts.length ? allConsts.join("\n") + "\n" : "";
|
||||
@@ -2121,6 +2235,7 @@ export function generatePageTsx(ir: IR, assetMap: Map<string, string>, sourceUrl
|
||||
hasMotion ? `import DittoMotion from "${dittoMotionImportPath(0)}";` : "",
|
||||
hasLottie ? `import DittoLottie from "${dittoLottieImportPath(0)}";` : "",
|
||||
hasMenus ? `import DropdownMenu from "${dropdownMenuImportPath(0)}";` : "",
|
||||
/\bcn\(/.test(body) ? cnImportLine(1) : "", // page.tsx lives at src/app
|
||||
...compImports,
|
||||
].filter(Boolean).join("\n");
|
||||
const importBlock = imports ? imports + "\n\n" : "";
|
||||
@@ -2137,31 +2252,33 @@ ${body}${wiresBlock}${accordionBlock}${motionBlock}${lottieBlock}${menusBlock}
|
||||
/** SEO scaffolding (Next App Router): robots.ts + sitemap.ts + an llms.txt route, generated
|
||||
* from the captured URL / title / description — the discovery files a real site ships. */
|
||||
function seoFiles(ir: IR): Array<[string, string]> {
|
||||
let origin = "https://example.com";
|
||||
try { origin = new URL(ir.doc.sourceUrl).origin; } catch { /* keep default */ }
|
||||
const url = ir.doc.sourceUrl || origin + "/";
|
||||
const title = ir.doc.title || "Home";
|
||||
const desc = ir.doc.head?.description || "";
|
||||
// robots.ts / sitemap.ts live at src/app → depth 1 below src. URLs resolve against the
|
||||
// clone's own origin (SITE_ORIGIN, relative by default), never the source domain.
|
||||
const siteImport = siteOriginImportLine(1);
|
||||
const robots = `import type { MetadataRoute } from "next";
|
||||
${siteImport}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: ${JSON.stringify(origin + "/sitemap.xml")},
|
||||
sitemap: SITE_ORIGIN + "/sitemap.xml",
|
||||
};
|
||||
}
|
||||
`;
|
||||
const sitemap = `import type { MetadataRoute } from "next";
|
||||
${siteImport}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [{ url: ${JSON.stringify(url)}, changeFrequency: "weekly", priority: 1 }];
|
||||
return [{ url: SITE_ORIGIN + "/", changeFrequency: "weekly", priority: 1 }];
|
||||
}
|
||||
`;
|
||||
const llmsText = [`# ${title}`, ...(desc ? ["", `> ${desc}`] : []), "", "## Pages", "", `- [${title}](${url})`, ""].join("\n");
|
||||
const llmsText = [`# ${title}`, ...(desc ? ["", `> ${desc}`] : []), "", "## Pages", "", `- [${title}](/)`, ""].join("\n");
|
||||
const llms = `export const dynamic = "force-static";
|
||||
|
||||
export function GET() {
|
||||
@@ -2183,10 +2300,11 @@ function generateLayoutTsx(ir: IR, bodyClass?: string, seo?: SeoInventory): stri
|
||||
const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`;
|
||||
const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
|
||||
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
|
||||
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
|
||||
return `import "./globals.css";
|
||||
import "./ditto.css";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
${siteImport}
|
||||
${metadata}${viewport}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
@@ -2201,10 +2319,36 @@ ${head} <body${bodyAttrs}>
|
||||
`;
|
||||
}
|
||||
|
||||
const TRANSPARENT_BG = "rgba(0, 0, 0, 0)";
|
||||
|
||||
/** Decide what background (if any) the clone's `html` element should paint.
|
||||
*
|
||||
* Per CSS 2.1 §14.2, when `html` is transparent the `body` background propagates
|
||||
* to the canvas and paints *beneath* every descendant — including negative-z-index
|
||||
* layers (full-bleed hero video/image/canvas backdrops). The moment `html` paints
|
||||
* an opaque background, `body` stops propagating and paints its own in-flow block
|
||||
* background, which sits *above* negative-z descendants and buries them.
|
||||
*
|
||||
* So we only give `html` a background when the SOURCE `html` actually painted one.
|
||||
* A transparent source `html` stays transparent (returns null → no rule emitted), so
|
||||
* the captured body background propagates to the canvas exactly as in the source.
|
||||
* The white fallback applies only when BOTH html and body are transparent, otherwise
|
||||
* the UA-default canvas would show through. Deterministic: pure function of the IR. */
|
||||
export function resolveHtmlBg(pv: { htmlBg?: string; bodyBg?: string } | undefined): string | null {
|
||||
const srcHtmlBg = pv?.htmlBg && pv.htmlBg !== TRANSPARENT_BG ? pv.htmlBg : null;
|
||||
const srcBodyBg = pv?.bodyBg && pv.bodyBg !== TRANSPARENT_BG ? pv.bodyBg : null;
|
||||
return srcHtmlBg ?? (srcBodyBg ? null : "#ffffff");
|
||||
}
|
||||
|
||||
/** `html { background: … }` rule, or empty string when html should stay transparent. */
|
||||
export function htmlBgRule(htmlBg: string | null): string {
|
||||
return htmlBg !== null ? `html { background: ${htmlBg}; }\n` : "";
|
||||
}
|
||||
|
||||
function generateGlobalsCss(ir: IR, fontGraph: FontGraph, tokensCss: string): string {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const pv = ir.doc.perViewport[cw];
|
||||
const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff");
|
||||
const htmlBg = resolveHtmlBg(pv);
|
||||
// If the SOURCE page never scrolls horizontally (its scrollWidth fits the
|
||||
// viewport at every captured width), neither should the clone. JS-driven
|
||||
// widgets (custom-element carousels, sliders) position children off-axis via
|
||||
@@ -2222,8 +2366,7 @@ ${fontGraph.css}
|
||||
${tokensCss}
|
||||
|
||||
/* page base */
|
||||
html { background: ${htmlBg}; }
|
||||
body { font-family: ${SYSTEM_FALLBACK}; }${clip}
|
||||
${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2345,17 +2488,113 @@ ${input}
|
||||
`;
|
||||
}
|
||||
|
||||
function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined {
|
||||
// Number of explicit column tracks in a computed `grid-template-columns` value. The capture
|
||||
// resolves the property to a used track list (e.g. `284px 284px 284px`), so track count is the
|
||||
// whitespace-separated token count; `none`/empty means the element is not a track grid.
|
||||
function computedTrackCount(value: string | undefined): number {
|
||||
if (!value || value === "none") return 0;
|
||||
return value.trim().split(/\s+/).filter(Boolean).length;
|
||||
}
|
||||
|
||||
// Resolved px widths of the explicit column tracks in a computed `grid-template-columns` value. The
|
||||
// capture resolves the property to a used track list (`260px 1020px`), so each token is a definite
|
||||
// px length. Non-px tokens (`auto`, `min-content`, a leftover `1fr`) make the track set unquantifiable
|
||||
// and yield null — the plan then cannot claim the tracks are uniform.
|
||||
function computedTrackWidths(value: string | undefined): number[] | null {
|
||||
if (!value || value === "none") return null;
|
||||
const toks = value.trim().split(/\s+/).filter(Boolean);
|
||||
const out: number[] = [];
|
||||
for (const t of toks) {
|
||||
const m = /^(-?\d*\.?\d+)px$/.exec(t);
|
||||
if (!m) return null;
|
||||
out.push(parseFloat(m[1]!));
|
||||
}
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
// A `grid-cols-N` plan rewrites the container to `repeat(N, minmax(0, 1fr))` — N EQUAL tracks. It is
|
||||
// only a faithful replacement for the authored template when the computed tracks actually are ~equal
|
||||
// width. An asymmetric template (`260px 1fr` → a 260/1020 sidebar layout) has the same track COUNT as
|
||||
// the heuristic column count but a different geometry; collapsing it to equal halves destroys the
|
||||
// authored layout, so such templates always keep their computed tracks.
|
||||
function tracksNearEqual(widths: number[]): boolean {
|
||||
if (widths.length <= 1) return true;
|
||||
const max = Math.max(...widths);
|
||||
const min = Math.min(...widths);
|
||||
if (!(max > 0)) return false;
|
||||
// Tolerance: a couple of px (gap/rounding) or 2% of the widest track, whichever is larger.
|
||||
return max - min <= Math.max(1.5, 0.02 * max);
|
||||
}
|
||||
|
||||
// Track span of a grid item from its resolved `grid-column-start`/`grid-column-end`. A spanning
|
||||
// item (`span 2`, or an explicit line pair `1 / 3`) means row-length item-counting under-reports
|
||||
// the real track count, so the column-count heuristic is not trustworthy for this container.
|
||||
function computedColumnSpan(cs: StyleMap | undefined): number {
|
||||
if (!cs) return 1;
|
||||
const end = cs["gridColumnEnd"];
|
||||
const start = cs["gridColumnStart"];
|
||||
const span = /^span\s+(\d+)$/.exec(end ?? "") ?? /^span\s+(\d+)$/.exec(start ?? "");
|
||||
if (span) return Math.max(1, Number(span[1]));
|
||||
if (start && end && /^-?\d+$/.test(start) && /^-?\d+$/.test(end)) {
|
||||
const diff = Number(end) - Number(start);
|
||||
if (Number.isFinite(diff) && diff > 1) return diff;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function recipeResponsiveClassCleaner(ir: IR, recipes: RecipeReport | undefined, opts: { tailwind: boolean }): (cid: string, className: string | undefined) => string | undefined {
|
||||
const recipeParents = new Set<string>();
|
||||
const repeatedGridParents = new Set<string>();
|
||||
const fillGridParents = new Set<string>();
|
||||
// Containers whose computed geometry agreed with the item-count heuristic — a genuinely uniform
|
||||
// repeated grid the recipe may re-flow. Row-track utilities are only stripped here; on containers
|
||||
// where the geometry disagreed (spanning items / differing track count) the authored row and
|
||||
// column tracks are ground truth and must survive.
|
||||
const uniformGridParents = new Set<string>();
|
||||
const columnPlans = new Map<string, string[]>();
|
||||
const nodeByCid = new Map<string, IRNode>();
|
||||
const index = (n: IRNode): void => { nodeByCid.set(n.id, n); for (const c of n.children) if (!isTextChild(c)) index(c); };
|
||||
index(ir.root);
|
||||
// The captured/computed grid geometry is ground truth. A recipe may add semantic structure, but
|
||||
// its column count is inferred by grouping item bounding boxes into rows — which under-reports the
|
||||
// real track count whenever an item spans multiple columns. Before letting a recipe's synthesized
|
||||
// column plan override the authored grid, cross-check it against the computed `grid-template-columns`
|
||||
// track count and per-item `grid-column` spans at each regime viewport. On disagreement, keep the
|
||||
// emitted (computed) geometry and drop the plan so spans and track count survive.
|
||||
const planAgreesWithComputed = (c: RecipeReport["candidates"][number], regimeVps: number[]): boolean => {
|
||||
const parent = c.itemParentCid ? nodeByCid.get(c.itemParentCid) : undefined;
|
||||
if (!parent) return false;
|
||||
const itemCids = (c.repeatedItems ?? []).map((i) => i.cid);
|
||||
for (const vp of regimeVps) {
|
||||
const gtc = parent.computedByVp[vp]?.["gridTemplateColumns"];
|
||||
const tracks = computedTrackCount(gtc);
|
||||
// Only trust the heuristic where the computed grid actually is a track grid at this viewport.
|
||||
if (tracks === 0) continue;
|
||||
const regime = c.responsiveRegimes.find((r) => r.viewport === vp);
|
||||
const heuristicCols = regime?.columns ?? 0;
|
||||
if (heuristicCols > 0 && heuristicCols !== tracks) return false;
|
||||
// A `grid-cols-N` plan means N EQUAL tracks. If the computed tracks are asymmetric (a sidebar
|
||||
// template like `260px 1020px`), the count agrees but the geometry does not — collapsing to
|
||||
// equal columns would destroy the authored layout. Keep the authored template in that case.
|
||||
if (tracks >= 2) {
|
||||
const widths = computedTrackWidths(gtc);
|
||||
if (!widths || !tracksNearEqual(widths)) return false;
|
||||
}
|
||||
for (const cid of itemCids) {
|
||||
if (computedColumnSpan(nodeByCid.get(cid)?.computedByVp[vp]) > 1) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const gridColumnTokens = (c: RecipeReport["candidates"][number]): string[] | null => {
|
||||
if (c.kind !== "card-grid" && c.kind !== "feature-grid" && c.kind !== "product-grid") return null;
|
||||
const regimes = c.responsiveRegimes
|
||||
.filter((r) => (r.visibleItems ?? 0) > 0 && (r.columns ?? 0) > 0)
|
||||
.sort((a, b) => a.viewport - b.viewport);
|
||||
if (regimes.length < 2) return null;
|
||||
// The computed track geometry disagrees with the item-count heuristic (spanning items or a
|
||||
// differing track count) — defer to the authored grid rather than synthesize a column plan.
|
||||
if (!planAgreesWithComputed(c, regimes.map((r) => r.viewport))) return null;
|
||||
const prefixFor = (vp: number): string => vp >= 1536 ? "2xl:" : vp >= 1024 ? "lg:" : vp >= 768 ? "md:" : "";
|
||||
const tokens: string[] = [];
|
||||
let last: number | undefined;
|
||||
@@ -2377,6 +2616,12 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
|
||||
for (const c of recipes?.candidates ?? []) {
|
||||
if ((c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase" || c.kind === "logo-cloud") && c.confidence >= 0.86 && c.itemParentCid) {
|
||||
recipeParents.add(c.itemParentCid);
|
||||
// A container is a uniform repeated grid only if its computed track geometry agrees with the
|
||||
// item-count heuristic at every sampled regime viewport (no spanning items, matching track
|
||||
// count). Non-grid containers (flex/stack) have no computed tracks to disagree with, so they
|
||||
// stay eligible; a track grid whose geometry disagrees is excluded and keeps its authored rows.
|
||||
const sampledVps = c.responsiveRegimes.map((r) => r.viewport);
|
||||
if (planAgreesWithComputed(c, sampledVps)) uniformGridParents.add(c.itemParentCid);
|
||||
if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid" || c.kind === "gallery-showcase") repeatedGridParents.add(c.itemParentCid);
|
||||
if (c.kind === "card-grid" || c.kind === "feature-grid" || c.kind === "product-grid") fillGridParents.add(c.itemParentCid);
|
||||
const columns = gridColumnTokens(c);
|
||||
@@ -2390,8 +2635,8 @@ function recipeResponsiveClassCleaner(recipes: RecipeReport | undefined, opts: {
|
||||
const columnPlan = opts.tailwind ? columnPlans.get(cid) : undefined;
|
||||
const keep = tokens.filter((token) => {
|
||||
if (columnPlan && /^(?:[a-z0-9-]+:)*grid-cols-(?:\d+|\[[^\]]+\])$/.test(token)) return false;
|
||||
if (/^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false;
|
||||
if (repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false;
|
||||
if (uniformGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\[(?:auto_?)+\]$/.test(token)) return false;
|
||||
if (uniformGridParents.has(cid) && repeatedGridParents.has(cid) && /^(?:[a-z0-9-]+:)*grid-rows-\d+$/.test(token)) return false;
|
||||
const initialCols = /^(?:(.*):)?grid-cols-\[initial\]$/.exec(token);
|
||||
if (initialCols) {
|
||||
const prefix = initialCols[1] ? `${initialCols[1]}:` : "";
|
||||
@@ -2436,9 +2681,12 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
||||
const mode = input.humanizeMode ?? "tailwind";
|
||||
// Tailwind mode (default): translate each node's exact decls to utility classes.
|
||||
// CSS mode: dedup into shared semantic CSS classes. Both fidelity-neutral.
|
||||
const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow }) : undefined;
|
||||
// Lottie mount boxes are pinned to their captured height (replaced-like) so the runtime player's
|
||||
// aspect-sized svg fills a definite box instead of inflating. The spec item cid IS the mount cid.
|
||||
const lottieMounts = new Set(lottieSpec.items.map((it) => it.cid));
|
||||
const tw = humanize && mode === "tailwind" ? buildTailwind(ir, assetMap, input.colorVar, { interaction: input.interaction, reflow: input.reflow, lottieMounts }) : undefined;
|
||||
const classMap = humanize && mode === "css" ? buildClassMap(ir, assetMap, input.colorVar, input.primitives, input.tokenResolver) : undefined;
|
||||
const cleanRecipeClass = recipeResponsiveClassCleaner(input.recipeReport, { tailwind: !!tw });
|
||||
const cleanRecipeClass = recipeResponsiveClassCleaner(ir, input.recipeReport, { tailwind: !!tw });
|
||||
const classOf = tw ? (cid: string) => cleanRecipeClass(cid, tw.classOf.get(cid)) : classMap ? (cid: string) => classMap.classOf.get(cid) : undefined;
|
||||
const styleOf = tw ? (cid: string) => tw.styleOf.get(cid) : undefined;
|
||||
// Section split (single-page humanized): plan section roots, render each into its own
|
||||
@@ -2527,7 +2775,7 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
||||
if (tw) {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const pv = ir.doc.perViewport[cw];
|
||||
const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff");
|
||||
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 globals = tailwindGlobalsCss({
|
||||
@@ -2541,6 +2789,9 @@ export function generateApp(input: GenerateInput, tokensCss: string): { pageTsx:
|
||||
writeText(join(rootDir, "globals.css"), framework === "vite" ? viteGlobalsCss(globals) : globals);
|
||||
}
|
||||
writeText(join(rootDir, "ditto.css"), cloneCss);
|
||||
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
|
||||
// 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 (wires.length) writeText(join(rootDir, "ditto", "DittoWire.tsx"), DITTO_WIRE_TSX);
|
||||
if (accordions.length) writeText(join(rootDir, "ditto", "Accordion.tsx"), ACCORDION_TSX);
|
||||
if (motionHasContent(motionSpec)) writeText(join(rootDir, "ditto", "DittoMotion.tsx"), DITTO_MOTION_TSX);
|
||||
|
||||
+718
-62
File diff suppressed because it is too large
Load Diff
@@ -151,7 +151,7 @@ export function accordionJsx(specs: AccordionRuntimeSpec[], indent: number): str
|
||||
}
|
||||
|
||||
export const ACCORDION_TSX = `"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type CapStyle = Record<string, string>;
|
||||
type RTAcc = { trigger: string; region: string; expanded: boolean; triggerOn: CapStyle; triggerOff: CapStyle; regionShown: CapStyle; regionHidden: CapStyle };
|
||||
@@ -167,10 +167,9 @@ function applyStyle(el: HTMLElement | null, s: CapStyle) {
|
||||
/** Wires captured accordion rows with small explicit state.
|
||||
* Hydration initializes the captured base state, then clicks toggle only the target row. */
|
||||
export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
|
||||
const wired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wired.current) return;
|
||||
wired.current = true;
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
for (const spec of specs) {
|
||||
const state = spec.items.map((it) => it.expanded);
|
||||
const renderItem = (i: number) => {
|
||||
@@ -193,10 +192,11 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
|
||||
e.preventDefault();
|
||||
state[i] = !state[i];
|
||||
renderItem(i);
|
||||
});
|
||||
}, { signal });
|
||||
renderItem(i);
|
||||
});
|
||||
}
|
||||
return () => ac.abort();
|
||||
}, [specs]);
|
||||
return null;
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export default function Accordion({ specs }: { specs: AccordionSpec[] }) {
|
||||
|
||||
/** The fixed DittoWire client component, written once per generated app. */
|
||||
export const DITTO_WIRE_TSX = `"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type CapStyle = Record<string, string>;
|
||||
type RTTab = { trigger: string; panel: string; triggerOn: CapStyle; triggerOff: CapStyle; panelShown: CapStyle; panelHidden: CapStyle; descendants?: Record<string, CapStyle> };
|
||||
@@ -235,10 +235,9 @@ function applyDesc(d?: Record<string, CapStyle>) {
|
||||
* on mount: the server-rendered markup + per-node CSS already reproduce the captured
|
||||
* base state exactly, so state styles are applied only on user interaction. */
|
||||
export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
const wired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wired.current) return;
|
||||
wired.current = true;
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
if (spec.kind === "tabs") {
|
||||
let active = spec.active;
|
||||
const render = () => spec.tabs.forEach((t, i) => {
|
||||
@@ -254,7 +253,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
spec.tabs.forEach((t, i) => {
|
||||
const trig = byCid(t.trigger);
|
||||
if (!trig) return;
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); active = i; render(); });
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); active = i; render(); }, { signal });
|
||||
trig.addEventListener("keydown", (e) => {
|
||||
const k = (e as KeyboardEvent).key;
|
||||
if (k === "ArrowRight" || k === "ArrowLeft") {
|
||||
@@ -263,7 +262,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
render();
|
||||
byCid(spec.tabs[active].trigger)?.focus();
|
||||
}
|
||||
});
|
||||
}, { signal });
|
||||
});
|
||||
// No initial render() — the static base state is already correct.
|
||||
} else if (spec.kind === "accordion") {
|
||||
@@ -278,7 +277,7 @@ export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
};
|
||||
spec.items.forEach((it, i) => {
|
||||
const trig = byCid(it.trigger);
|
||||
if (trig) trig.addEventListener("click", (e) => { e.preventDefault(); state[i] = !state[i]; renderItem(i); });
|
||||
if (trig) trig.addEventListener("click", (e) => { e.preventDefault(); state[i] = !state[i]; renderItem(i); }, { signal });
|
||||
});
|
||||
// No initial renderItem — the static base state is already correct.
|
||||
} else if (spec.kind === "carousel") {
|
||||
@@ -293,9 +292,9 @@ export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
};
|
||||
const nextEl = spec.next ? byCid(spec.next) : null;
|
||||
const prevEl = spec.prev ? byCid(spec.prev) : null;
|
||||
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); });
|
||||
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); });
|
||||
spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); }));
|
||||
nextEl?.addEventListener("click", (e) => { e.preventDefault(); go(index + 1); }, { signal });
|
||||
prevEl?.addEventListener("click", (e) => { e.preventDefault(); go(index - 1); }, { signal });
|
||||
spec.bullets.forEach((b, bi) => byCid(b)?.addEventListener("click", (e) => { e.preventDefault(); go(bi); }, { signal }));
|
||||
// No initial go() — the static base state is already correct.
|
||||
} else {
|
||||
// Disclosure: dropdown / mega-menu / modal — a trigger reveals a hidden overlay.
|
||||
@@ -311,18 +310,19 @@ export default function DittoWire({ spec }: { spec: Spec }) {
|
||||
if (o) applyDesc(it.descendants);
|
||||
if (o) panel.removeAttribute("hidden"); else panel.setAttribute("hidden", "");
|
||||
};
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); set(it.isDialog ? true : !open); });
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); set(it.isDialog ? true : !open); }, { signal });
|
||||
if (it.hoverOpen) {
|
||||
const root = trig.parentElement ?? trig;
|
||||
root.addEventListener("mouseenter", () => set(true));
|
||||
root.addEventListener("mouseleave", () => set(false));
|
||||
root.addEventListener("mouseenter", () => set(true), { signal });
|
||||
root.addEventListener("mouseleave", () => set(false), { signal });
|
||||
}
|
||||
it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); }));
|
||||
if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); });
|
||||
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); });
|
||||
it.closes.forEach((c) => byCid(c)?.addEventListener("click", (e) => { e.preventDefault(); set(false); }, { signal }));
|
||||
if (it.backdropClose) panel.addEventListener("click", (e) => { if (e.target === panel) set(false); }, { signal });
|
||||
document.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Escape" && open) set(false); }, { signal });
|
||||
});
|
||||
// No initial set() — the static base state is already correct.
|
||||
}
|
||||
return () => ac.abort();
|
||||
}, [spec]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data
|
||||
export default function DittoLottie({ spec }: { spec: LottieSpec }) {
|
||||
useEffect(() => {
|
||||
const stopped = (window as any).__dittoMotionStopped === true;
|
||||
const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void }> = [];
|
||||
const anims: Array<{ destroy: () => void; goToAndStop: (value: number, isFrame?: boolean) => void; addEventListener: (name: string, cb: () => void) => void }> = [];
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const lottie = (await import("lottie-web")).default;
|
||||
@@ -134,17 +134,59 @@ export default function DittoLottie({ spec }: { spec: LottieSpec }) {
|
||||
for (const it of spec.items) {
|
||||
const el = byCid(it.cid);
|
||||
if (!el) continue;
|
||||
// clear the captured placeholder frame so it never stacks with the live render
|
||||
el.innerHTML = "";
|
||||
try {
|
||||
// Mount the live animation into an OVERLAY child, keeping the captured placeholder
|
||||
// frame in the DOM until lottie signals a successful load — a failed load (bad JSON,
|
||||
// network) then leaves the placeholder intact instead of erasing the container to blank.
|
||||
// The overlay stays position:absolute;inset:0 for its WHOLE life (not just pre-swap):
|
||||
// the host box carries the captured per-viewport height, and only an absolutely-filled
|
||||
// overlay inherits that definite box. A static-flow overlay has indefinite height, so
|
||||
// the player's svg (style height:100%) collapses to auto and inflates to the source
|
||||
// aspect (a portrait viewBox then oversizes far past the captured, letterboxed box).
|
||||
const mount = document.createElement("div");
|
||||
mount.style.position = "absolute";
|
||||
mount.style.inset = "0";
|
||||
mount.style.opacity = "0";
|
||||
const cs = getComputedStyle(el);
|
||||
if (cs.position === "static") el.style.position = "relative";
|
||||
// Mark the host so emitted CSS can force the runtime svg/canvas to fit the pinned box.
|
||||
el.setAttribute("data-ditto-lottie", "");
|
||||
el.appendChild(mount);
|
||||
const anim = lottie.loadAnimation({
|
||||
container: el,
|
||||
container: mount,
|
||||
renderer: it.renderer === "canvas" ? "canvas" : "svg",
|
||||
loop: it.loop,
|
||||
autoplay: it.autoplay && !stopped,
|
||||
...(it.path ? { path: it.path } : { animationData: it.animationData as object }),
|
||||
rendererSettings: { preserveAspectRatio: "xMidYMid meet" },
|
||||
});
|
||||
// Swap only once the animation is genuinely ready: reveal the live render and remove
|
||||
// every original child (the placeholder) so the two never stack. Before discarding the
|
||||
// placeholder, forward its data-cid onto the runtime-rendered svg/canvas so the media
|
||||
// node stays addressable by cid after the swap (it would otherwise mount without one).
|
||||
const reveal = () => {
|
||||
mount.style.opacity = "1";
|
||||
// The captured placeholder is an svg/canvas (or wraps one) carrying its own data-cid.
|
||||
let placeholderCid: string | null = null;
|
||||
for (const child of Array.from(el.childNodes)) {
|
||||
if (child === mount || placeholderCid !== null || !(child instanceof Element)) continue;
|
||||
const media = child.matches("svg, canvas") ? child : child.querySelector("svg, canvas");
|
||||
placeholderCid = (media ?? child).getAttribute("data-cid");
|
||||
}
|
||||
for (const child of Array.from(el.childNodes)) if (child !== mount) el.removeChild(child);
|
||||
if (placeholderCid) {
|
||||
const rendered = mount.querySelector("svg, canvas");
|
||||
if (rendered) rendered.setAttribute("data-cid", placeholderCid);
|
||||
}
|
||||
// Do NOT clear position/inset: the overlay must keep filling the host's pinned box
|
||||
// (see above). Stripping them reverts it to static flow → indefinite height → the
|
||||
// player's height:100% svg inflates to its aspect and escapes the captured bbox.
|
||||
};
|
||||
// DOMLoaded fires only after the JSON parsed and the first frame rendered — the
|
||||
// right moment to reveal the live render and drop the placeholder. data_failed
|
||||
// (bad JSON / fetch error) tears the empty mount back out, leaving the placeholder.
|
||||
anim.addEventListener("DOMLoaded", reveal);
|
||||
anim.addEventListener("data_failed", () => { try { el.removeChild(mount); } catch {} });
|
||||
if (stopped) { try { anim.goToAndStop(0, true); } catch {} }
|
||||
anims.push(anim);
|
||||
} catch {
|
||||
|
||||
@@ -56,6 +56,13 @@ export function buildManifest(args: {
|
||||
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
|
||||
},
|
||||
components: { count: componentCount },
|
||||
// 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
|
||||
// clone shows the canonical-viewport children at those widths (faithful-at-canonical) instead
|
||||
// of an empty shell; a perceptual delta against the source at those widths is expected.
|
||||
divergence: {
|
||||
contentDrift: (ir.doc.contentDrift ?? []).map((d) => ({ id: d.id, tag: d.tag, viewports: d.viewports })),
|
||||
},
|
||||
// Stage 2: capture-sanity audit — what overlays were dismissed, whether any
|
||||
// still covered the page, video stills materialized, and per-viewport quiescence.
|
||||
capture: {
|
||||
|
||||
@@ -94,7 +94,7 @@ export function dropdownMenuImportPath(depth: number): string {
|
||||
|
||||
/** The fixed DropdownMenu client component, written once per generated app. */
|
||||
export const DROPDOWN_MENU_TSX = `"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
|
||||
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
|
||||
@@ -103,10 +103,10 @@ const byCid = (cid: string): HTMLElement | null => document.querySelector('[data
|
||||
* user interaction does it inject the captured panel fragment under its trigger. The base
|
||||
* render is therefore unchanged. */
|
||||
export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
|
||||
const wired = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wired.current) return;
|
||||
wired.current = true;
|
||||
const ac = new AbortController();
|
||||
const { signal } = ac;
|
||||
const openPanels: HTMLElement[] = [];
|
||||
for (const m of menus) {
|
||||
const trig = byCid(m.trigger);
|
||||
if (!trig) continue;
|
||||
@@ -127,27 +127,41 @@ export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
|
||||
panel = wrap.firstElementChild as HTMLElement | null;
|
||||
if (!panel) return;
|
||||
document.body.appendChild(panel);
|
||||
openPanels.push(panel);
|
||||
place();
|
||||
trig.setAttribute("aria-expanded", "true");
|
||||
};
|
||||
const close = () => { if (panel) { panel.remove(); panel = null; } trig.setAttribute("aria-expanded", "false"); };
|
||||
const close = () => {
|
||||
if (panel) {
|
||||
const i = openPanels.indexOf(panel);
|
||||
if (i !== -1) openPanels.splice(i, 1);
|
||||
panel.remove();
|
||||
panel = null;
|
||||
}
|
||||
trig.setAttribute("aria-expanded", "false");
|
||||
};
|
||||
const toggle = () => (panel ? close() : open());
|
||||
if (m.hoverOpen) {
|
||||
const root = trig.parentElement ?? trig;
|
||||
root.addEventListener("mouseenter", open);
|
||||
root.addEventListener("mouseleave", close);
|
||||
root.addEventListener("mouseenter", open, { signal });
|
||||
root.addEventListener("mouseleave", close, { signal });
|
||||
} else {
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); });
|
||||
trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); }, { signal });
|
||||
}
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
|
||||
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }, { signal });
|
||||
document.addEventListener("click", (e) => {
|
||||
const t = e.target as Node;
|
||||
if (panel && !trig.contains(t) && !panel.contains(t)) close();
|
||||
});
|
||||
window.addEventListener("resize", place);
|
||||
window.addEventListener("scroll", place, { passive: true });
|
||||
}, { signal });
|
||||
window.addEventListener("resize", place, { signal });
|
||||
window.addEventListener("scroll", place, { passive: true, signal });
|
||||
}
|
||||
(window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically
|
||||
return () => {
|
||||
ac.abort();
|
||||
// Remove any still-open panels appended to document.body so unmount leaves no orphan nodes.
|
||||
for (const p of openPanels.splice(0)) p.remove();
|
||||
};
|
||||
}, [menus]);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,12 +41,31 @@ function capToCid(ir: IR): Map<string, string> {
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Map every IR node's cid → node, so an emission guard can inspect the resolved target. */
|
||||
function cidToNode(ir: IR): Map<string, IRNode> {
|
||||
const m = new Map<string, IRNode>();
|
||||
const walk = (n: IRNode): void => {
|
||||
m.set(n.id, n);
|
||||
for (const c of n.children) if (!isTextChild(c)) walk(c);
|
||||
};
|
||||
walk(ir.root);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** True when an IR node has ≥1 element child. A genuine rotating word/phrase is a text leaf
|
||||
* (no element children); a container captured as a rotator would have the runtime flatten its
|
||||
* whole subtree to one text node, destroying every descendant element. */
|
||||
function hasElementChild(n: IRNode | undefined): boolean {
|
||||
return !!n && n.children.some((c) => !isTextChild(c));
|
||||
}
|
||||
|
||||
/** Resolve cap-keyed motion specs to cids that survived into this IR (optionally
|
||||
* scoped by an include filter, for multi-route body/chrome splitting). Specs whose
|
||||
* element was pruned are dropped (left static). */
|
||||
export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, include?: (cid: string) => boolean): MotionSpec {
|
||||
if (!motion) return { waapi: [], rotators: [], reveals: [], marquees: [] };
|
||||
const map = capToCid(ir);
|
||||
const nodeByCid = cidToNode(ir);
|
||||
const ok = (cid: string | undefined): cid is string => !!cid && (!include || include(cid));
|
||||
const waapi: RTWaapi[] = [];
|
||||
for (const w of motion.waapi as WaapiAnim[]) {
|
||||
@@ -58,6 +77,11 @@ export function buildMotionSpec(ir: IR, motion: MotionCapture | undefined, inclu
|
||||
for (const r of motion.rotators as RotatorSpec[]) {
|
||||
const cid = map.get(r.cap);
|
||||
if (!ok(cid)) continue;
|
||||
// A rotator cycles the text of a single leaf word/phrase. If the resolved target has element
|
||||
// children, capture misclassified a structural container (e.g. one whose rows were injected at
|
||||
// runtime, uncapped, after cid-cap tagging) as a rotator. Emitting it would let the runtime
|
||||
// flatten every descendant element to one text node; drop it so the static subtree survives.
|
||||
if (hasElementChild(nodeByCid.get(cid))) continue;
|
||||
rotators.push({ cid, texts: r.texts, intervalMs: r.intervalMs });
|
||||
}
|
||||
const reveals: RTReveal[] = [];
|
||||
@@ -157,7 +181,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
||||
useEffect(() => {
|
||||
if ((window as any).__dittoMotionStopped) return; // measurement mode — apply nothing
|
||||
const intervals: ReturnType<typeof setInterval>[] = [];
|
||||
const rotators: Array<{ el: HTMLElement; original: string | null }> = [];
|
||||
const rotators: Array<{ el: HTMLElement; original: Node[] }> = [];
|
||||
const anims: Animation[] = [];
|
||||
// per-reveal "show now" fns (also the cleanup); animate=false jumps to the settled frame
|
||||
const revealed: Array<(animate: boolean) => void> = [];
|
||||
@@ -196,8 +220,15 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
||||
for (const r of spec.rotators) {
|
||||
const el = byCid(r.cid);
|
||||
if (!el || r.texts.length < 2) continue;
|
||||
const original = el.textContent;
|
||||
const start = r.texts.findIndex((t) => t === (original || "").replace(/\\s+/g, " ").trim());
|
||||
// Defense in depth: a genuine rotating word is a text leaf. If the target has element
|
||||
// children, it was misclassified (its subtree would be destroyed by a text write) — skip it
|
||||
// so the static structure survives even if the emission guard was bypassed.
|
||||
if (el.childElementCount > 0) continue;
|
||||
// Save the ORIGINAL child nodes (cloned) rather than a flattened textContent string, so the
|
||||
// settle/restore path rebuilds the exact subtree via replaceChildren — never a lossy text
|
||||
// node. For a real text leaf this is just the single text node round-tripped intact.
|
||||
const original = Array.from(el.childNodes).map((n) => n.cloneNode(true));
|
||||
const start = r.texts.findIndex((t) => t === (el.textContent || "").replace(/\\s+/g, " ").trim());
|
||||
let i = start < 0 ? 0 : start;
|
||||
rotators.push({ el, original });
|
||||
intervals.push(setInterval(() => { i = (i + 1) % r.texts.length; el.textContent = r.texts[i]!; }, Math.max(400, r.intervalMs)));
|
||||
@@ -274,7 +305,7 @@ export default function DittoMotion({ spec }: { spec: MotionSpec }) {
|
||||
const stopAll = () => {
|
||||
(window as any).__dittoMotionStopped = true;
|
||||
for (const id of intervals) clearInterval(id);
|
||||
for (const r of rotators) r.el.textContent = r.original;
|
||||
for (const r of rotators) r.el.replaceChildren(...r.original.map((n) => n.cloneNode(true)));
|
||||
for (const a of anims) { try { a.cancel(); } catch { /* ignore */ } }
|
||||
if (io) io.disconnect();
|
||||
if (forceTimer) clearTimeout(forceTimer);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import type { RecipeCandidate, RecipeKind, RecipeReport } from "../infer/recipes.js";
|
||||
import { detectSectionNodes, heroLikeHeader } from "../infer/sections.js";
|
||||
import { subtreeSignature } from "../site/sharedLayout.js";
|
||||
|
||||
export type SectionPlan = {
|
||||
@@ -23,7 +24,7 @@ export type SectionPlan = {
|
||||
};
|
||||
|
||||
const MIN_SECTION_H = 56;
|
||||
const MAX_SECTIONS = 24;
|
||||
const MAX_SECTIONS = 32;
|
||||
|
||||
function box(n: IRNode, cw: number): { width: number; height: number } | undefined {
|
||||
return n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0];
|
||||
@@ -31,15 +32,9 @@ function box(n: IRNode, cw: number): { width: number; height: number } | undefin
|
||||
function yOf(n: IRNode, cw: number): number {
|
||||
return (n.bboxByVp[cw] ?? Object.values(n.bboxByVp)[0])?.y ?? 0;
|
||||
}
|
||||
function visible(n: IRNode, cw: number): boolean {
|
||||
return !!(n.visibleByVp[cw] ?? Object.values(n.visibleByVp)[0]);
|
||||
}
|
||||
function elementChildren(n: IRNode): IRNode[] {
|
||||
return n.children.filter((c): c is IRNode => !isTextChild(c));
|
||||
}
|
||||
function significantChildren(n: IRNode, cw: number): IRNode[] {
|
||||
return elementChildren(n).filter((c) => visible(c, cw) && (box(c, cw)?.height ?? 0) >= MIN_SECTION_H);
|
||||
}
|
||||
|
||||
function subtreeHasTag(n: IRNode, tag: string, depth = 4): boolean {
|
||||
if (depth < 0) return false;
|
||||
@@ -105,6 +100,83 @@ function identName(name: string): string {
|
||||
return /^[A-Za-z_$]/.test(name) ? name : "S" + name;
|
||||
}
|
||||
|
||||
// Generic structural words that carry no section identity — dropped from source-derived names.
|
||||
const GENERIC_NAME_WORDS = new Set([
|
||||
"section", "sections", "wrapper", "container", "block", "blocks", "template", "templates",
|
||||
"group", "inner", "outer", "content", "contents", "row", "col", "column", "grid", "layout",
|
||||
"shopify", "js", "tvg", "elementor", "wp", "widget", "module", "region", "main", "page",
|
||||
"component", "components", "el", "root", "body", "area", "box", "item", "items", "wrap",
|
||||
]);
|
||||
|
||||
/** A trailing token looks like a build hash (mixed-case or long alnum entropy, e.g.
|
||||
* `JtTWTt`, `RbEALJ`, `x19f2a`, `dDMm2q`) rather than a real word — strip it. A token is
|
||||
* hashy when it is ≥4 chars and either mixes upper+lower case or is a long digit-bearing run. */
|
||||
function looksHashy(tok: string): boolean {
|
||||
if (tok.length < 4) return false;
|
||||
const hasUpper = /[A-Z]/.test(tok), hasLower = /[a-z]/.test(tok), hasDigit = /\d/.test(tok);
|
||||
if (hasUpper && hasLower) return true; // camel/entropy hash: JtTWTt, dDMm2q
|
||||
if (hasDigit && tok.length >= 6) return true; // long id run: 19797275672650
|
||||
if (!/[aeiou]/i.test(tok) && tok.length >= 5) return true; // vowelless run: bcdfgh
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Turn a source id/class token stream into ≤3 semantic PascalCase words, or "" when the
|
||||
* evidence is all-generic or hashy. Strips CMS prefixes (shopify-section-…__), leading
|
||||
* numeric template ids, generic structural words, and trailing hash suffixes. Exported for
|
||||
* tests (the hashy-suffix stripping + generic-word filtering is the load-bearing part). */
|
||||
export function nameFromSourceToken(raw: string): string {
|
||||
// Peel known CMS section-id prefixes, keeping the semantic slug after `__` / the hook name.
|
||||
// Case is preserved through prefix-stripping + tokenizing so `looksHashy` can see the
|
||||
// mixed-case entropy of build hashes (RbEALJ, JtTWTt) BEFORE we normalize to lowercase.
|
||||
const s = raw.trim()
|
||||
.replace(/^shopify-section-(?:template|sections)--\d+__/i, "")
|
||||
.replace(/^shopify-(?:section|block)-/i, "")
|
||||
.replace(/^(?:js|tvg|elementor|wp|et|elementor-element)[-_]/i, "");
|
||||
const tokens = s.split(/[\s\-_]+/).filter(Boolean);
|
||||
const kept: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (kept.length >= 3) break;
|
||||
if (/^\d+$/.test(t)) continue; // pure numeric template ids
|
||||
if (GENERIC_NAME_WORDS.has(t.toLowerCase())) continue; // structural noise
|
||||
if (looksHashy(t)) continue; // trailing entropy suffix (mixed-case aware)
|
||||
if (t.length < 2) continue;
|
||||
const lc = t.toLowerCase();
|
||||
kept.push(lc[0]!.toUpperCase() + lc.slice(1));
|
||||
}
|
||||
return kept.join("");
|
||||
}
|
||||
|
||||
// A source `id` (or `data-section-type`) is a DELIBERATE, developer-authored block name only
|
||||
// when it carries a recognized CMS section prefix — Shopify's `shopify-section-…__split_callout`,
|
||||
// or a generic `section-…`/`…-section`. Arbitrary utility classes (`g_section_space`,
|
||||
// `duraldar-cta_section`) are styling noise, so we do NOT mine class names — a truncated heading
|
||||
// slug reads better than a made-up name. `js-*` behaviour hooks ARE intentional and allowed.
|
||||
const CMS_SECTION_ID = /shopify-section|^section[-_]|[-_]section(?:[-_]|$)|data-section/i;
|
||||
|
||||
/** Best semantic name derivable from a section subtree's source *ids* + `js-*` hooks — the
|
||||
* only source signals reliable enough to beat a heading slug. Scans the root + shallow
|
||||
* descendants. Returns "" when no trustworthy semantic evidence exists. */
|
||||
function sourceNameForSection(sec: IRNode): string {
|
||||
const candidates: string[] = [];
|
||||
const collect = (n: IRNode, depth: number): void => {
|
||||
const id = n.attrs.id;
|
||||
if (id && CMS_SECTION_ID.test(id)) candidates.push(id);
|
||||
const sectionType = n.attrs["data-section-type"] ?? n.attrs["data-section"];
|
||||
if (sectionType) candidates.push(sectionType);
|
||||
if (n.srcClass) {
|
||||
// Only explicit `js-…` behaviour hooks — a deliberate semantic handle, not a utility class.
|
||||
for (const cls of n.srcClass.split(/\s+/)) if (/^js[-_][a-z]/i.test(cls)) candidates.push(cls);
|
||||
}
|
||||
if (depth > 0) for (const c of elementChildren(n)) collect(c, depth - 1);
|
||||
};
|
||||
collect(sec, 2);
|
||||
for (const c of candidates) {
|
||||
const name = nameFromSourceToken(c);
|
||||
if (name && name.length >= 3) return name;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function looksLikeNav(n: IRNode, cw: number): boolean {
|
||||
if (n.tag === "nav" || n.tag === "header") return true;
|
||||
if (subtreeHasTag(n, "nav")) return true;
|
||||
@@ -225,18 +297,11 @@ function recipeFallbackSections(ir: IR, recipes?: RecipeReport): { sections: IRN
|
||||
|
||||
export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
// Descend through the wrapper chain (single significant child) to the container
|
||||
// whose children are the actual sections.
|
||||
let node = ir.root;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const sig = significantChildren(node, cw);
|
||||
if (sig.length >= 2) break;
|
||||
if (sig.length === 1) { node = sig[0]!; continue; }
|
||||
const kids = elementChildren(node);
|
||||
if (kids.length === 1) { node = kids[0]!; continue; }
|
||||
break;
|
||||
}
|
||||
// Exclude any child that is part of a REPEATED run (≥3 same-signature siblings): that's
|
||||
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
|
||||
// The shared recursive band decomposition (infer/sections) — the same roots the
|
||||
// section gate validates, so each emitted file corresponds to a gate section.
|
||||
const bands = detectSectionNodes(ir).filter((n) => n.id !== ir.root.id);
|
||||
// Exclude any band that is part of a REPEATED run (≥3 same-signature siblings): that's
|
||||
// a component cluster (a card/logo grid), which component extraction should turn into a
|
||||
// `.map()` over a data array — not a wall of near-identical "section" files. Only the
|
||||
// distinct, one-off blocks become sections.
|
||||
@@ -246,29 +311,14 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
|
||||
return list.filter((s) => (count.get(subtreeSignature(s)) ?? 0) < 3);
|
||||
};
|
||||
|
||||
let candidates = significantChildren(node, cw);
|
||||
let sections = distinctOf(candidates);
|
||||
const fallback = recipeFallbackSections(ir, recipes);
|
||||
// If the container yields too few sections, a dominant child is a wrapper (e.g. <main>)
|
||||
// holding the real sections — expand such oversized children into their own significant
|
||||
// children, keeping the other siblings (e.g. a sibling <footer>). Gated on <3 so a page
|
||||
// that already splits cleanly is untouched.
|
||||
if (sections.length < 3) {
|
||||
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
|
||||
const expanded: IRNode[] = [];
|
||||
for (const c of candidates) {
|
||||
const inner = significantChildren(c, cw);
|
||||
const h = box(c, cw)?.height ?? 0;
|
||||
if (inner.length >= 3 && pageH > 0 && h >= pageH * 0.4) expanded.push(...inner);
|
||||
else expanded.push(c);
|
||||
}
|
||||
candidates = expanded;
|
||||
sections = distinctOf(candidates);
|
||||
}
|
||||
let sections = distinctOf(bands);
|
||||
let recipeNameHints = new Map<string, string>();
|
||||
if (sections.length < 3 && fallback.sections.length >= 3) {
|
||||
sections = fallback.sections;
|
||||
recipeNameHints = fallback.names;
|
||||
if (sections.length < 3) {
|
||||
const fallback = recipeFallbackSections(ir, recipes);
|
||||
if (fallback.sections.length >= 3) {
|
||||
sections = fallback.sections;
|
||||
recipeNameHints = fallback.names;
|
||||
}
|
||||
}
|
||||
const roots = new Map<string, string>();
|
||||
if (sections.length < 3 || sections.length > MAX_SECTIONS) return { roots };
|
||||
@@ -280,6 +330,10 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
|
||||
return n === 1 ? base : `${base}${n}`;
|
||||
};
|
||||
|
||||
// Hero must actually start near the top of the page; without the gate a
|
||||
// mid-page band inherits the name when the true hero was excluded (e.g. as a
|
||||
// repeated run), which is worse than an honest content-derived name.
|
||||
const heroMaxY = Math.max(900, pageH * 0.25);
|
||||
let heroAssigned = false;
|
||||
sections.forEach((sec, i) => {
|
||||
const isLast = i === sections.length - 1;
|
||||
@@ -287,18 +341,31 @@ export function planSections(ir: IR, recipes?: RecipeReport): SectionPlan {
|
||||
let name: string;
|
||||
if (sec.tag === "footer" || (isLast && looksLikeNav(sec, cw) === false && subtreeHasTag(sec, "a") && (box(sec, cw)?.height ?? 0) < 700)) {
|
||||
name = sec.tag === "footer" ? "Footer" : (titleText(sec) ? slugWords(titleText(sec)) + "Section" : "Footer");
|
||||
} else if (i === 0 && looksLikeNav(sec, cw)) {
|
||||
} else if (sec.tag === "nav"
|
||||
|| (i === 0 && looksLikeNav(sec, cw) && !heroLikeHeader(sec, cw))
|
||||
// a thin fixed bar wrapping the real <nav> (often a styled <div>, sorted after
|
||||
// the hero it overlays) is still the navbar
|
||||
|| (yOf(sec, cw) <= 160 && (box(sec, cw)?.height ?? 0) <= 160 && subtreeHasTag(sec, "nav"))) {
|
||||
name = "Navbar";
|
||||
} else if (sec.tag === "header") {
|
||||
} else if (sec.tag === "header" && !heroLikeHeader(sec, cw)) {
|
||||
// a <header> carrying the page h1 at real height is the hero, not chrome
|
||||
name = "Header";
|
||||
} else if (!heroAssigned) {
|
||||
} else if (!heroAssigned && yOf(sec, cw) <= heroMaxY) {
|
||||
heroAssigned = true;
|
||||
name = "HeroSection";
|
||||
} else if (recipeName) {
|
||||
name = recipeName;
|
||||
} else {
|
||||
// Prefer a clean semantic name from the source markup (Shopify/CMS section ids +
|
||||
// `js-*` hooks, hash suffixes stripped) over a truncated heading slug — the source
|
||||
// slug (`split_callout` → SplitCalloutSection) reads more like a hand-authored name.
|
||||
const sourceName = sourceNameForSection(sec);
|
||||
const slug = slugWords(titleText(sec));
|
||||
name = slug ? `${slug}Section` : `Section${i + 1}`;
|
||||
name = sourceName ? `${sourceName}Section`
|
||||
: slug ? `${slug}Section`
|
||||
: subtreeHasTag(sec, "form", 6) ? "ContactSection"
|
||||
: subtreeHasTag(sec, "video", 6) || subtreeHasTag(sec, "iframe", 6) ? "MediaSection"
|
||||
: `Section${i + 1}`;
|
||||
}
|
||||
roots.set(sec.id, dedupe(identName(name)));
|
||||
});
|
||||
|
||||
@@ -289,8 +289,15 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
if (keywords) metadata.keywords = keywords;
|
||||
if (report.robots) metadata.robots = report.robots;
|
||||
if (report.referrer) metadata.referrer = report.referrer;
|
||||
const sourceOrigin = originOf(report.sourceUrl);
|
||||
const alternates: Record<string, unknown> = {};
|
||||
if (report.canonicalUrl) alternates.canonical = report.canonicalUrl;
|
||||
// Canonical is relativized to the source origin path so metadataBase (the clone's own
|
||||
// origin) resolves it — never hard-coding the source domain. Off-origin canonicals are rare;
|
||||
// keep them verbatim.
|
||||
if (report.canonicalUrl) {
|
||||
const c = relativizeToSource(report.canonicalUrl, sourceOrigin);
|
||||
alternates.canonical = c.path;
|
||||
}
|
||||
if (report.alternates.length) {
|
||||
const languages: Record<string, string> = {};
|
||||
for (const alt of report.alternates) languages[alt.hrefLang] = alt.href;
|
||||
@@ -314,7 +321,7 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
else other["og:type"] = ogType;
|
||||
}
|
||||
if (ogSiteName) og.siteName = ogSiteName;
|
||||
if (ogUrl) og.url = ogUrl;
|
||||
if (ogUrl) og.url = relativizeToSource(ogUrl, sourceOrigin).path;
|
||||
if (ogImages.length) og.images = ogImages;
|
||||
if (Object.keys(og).length) metadata.openGraph = og;
|
||||
|
||||
@@ -352,8 +359,23 @@ function metadataObject(report: SeoInventory): Record<string, unknown> {
|
||||
return metadata;
|
||||
}
|
||||
|
||||
// A sentinel object value that metadataExport rewrites into the runtime `metadataBase`
|
||||
// expression (JSON can't hold `new URL(...)`). Chosen so it never collides with real content.
|
||||
const METADATA_BASE_SENTINEL = "__DITTO_METADATA_BASE__";
|
||||
|
||||
// The metadata/JSON-LD layout module references SITE_ORIGIN — callers must hoist this import.
|
||||
export const SITE_ORIGIN_LAYOUT_IMPORT = siteOriginImportLine(1);
|
||||
|
||||
export function metadataExport(report: SeoInventory): string {
|
||||
return `export const metadata = ${JSON.stringify(metadataObject(report), null, 2)};\n`;
|
||||
const obj = metadataObject(report);
|
||||
// metadataBase lets Next resolve the (now relative) canonical/openGraph URLs against the
|
||||
// clone's OWN origin — SITE_ORIGIN when set, localhost in dev. Placed first for readability.
|
||||
const withBase = { metadataBase: METADATA_BASE_SENTINEL, ...obj };
|
||||
const body = JSON.stringify(withBase, null, 2).replace(
|
||||
JSON.stringify(METADATA_BASE_SENTINEL),
|
||||
`new URL(SITE_ORIGIN || "http://localhost:3000")`,
|
||||
);
|
||||
return `export const metadata = ${body};\n`;
|
||||
}
|
||||
|
||||
export function viewportExport(report: SeoInventory): string {
|
||||
@@ -367,13 +389,31 @@ function safeJsonLd(text: string): string {
|
||||
return text.replace(/<\/script/gi, "<\\/script").replace(/<!--/g, "<\\!--");
|
||||
}
|
||||
|
||||
/** Rewrite a JSON-LD block's on-origin @id/url/contentUrl/target values off the SOURCE domain.
|
||||
* The frozen JSON escapes slashes (`https:\/\/host\/…`), so we split on both the escaped and
|
||||
* plain origin forms and rejoin with SITE_ORIGIN at RUNTIME — the source domain never appears
|
||||
* in output, and setting NEXT_PUBLIC_SITE_ORIGIN re-hosts the ids under the clone's own origin.
|
||||
* Returns a JS expression string. Off-origin links (genuinely external) are left untouched. */
|
||||
function jsonLdHtmlExpr(text: string, sourceOrigin: string): string {
|
||||
const safe = safeJsonLd(text);
|
||||
if (!sourceOrigin) return JSON.stringify(safe);
|
||||
const escaped = sourceOrigin.replace(/\//g, "\\/");
|
||||
// Split on whichever origin form appears; only one form is present in a given block.
|
||||
const forms = safe.includes(escaped) ? escaped : (safe.includes(sourceOrigin) ? sourceOrigin : null);
|
||||
if (!forms) return JSON.stringify(safe);
|
||||
const segments = safe.split(forms);
|
||||
if (segments.length === 1) return JSON.stringify(safe);
|
||||
return `[${segments.map((s) => JSON.stringify(s)).join(", ")}].join(SITE_ORIGIN)`;
|
||||
}
|
||||
|
||||
export function jsonLdHeadMarkup(report: SeoInventory, indent = 8): string {
|
||||
if (!report.jsonLd.length) return "";
|
||||
const pad = " ".repeat(indent);
|
||||
const sourceOrigin = originOf(report.sourceUrl);
|
||||
return report.jsonLd.map((entry, index) => `${pad}<script
|
||||
${pad} key="ditto-json-ld-${index}"
|
||||
${pad} type="application/ld+json"
|
||||
${pad} dangerouslySetInnerHTML={{ __html: ${JSON.stringify(safeJsonLd(entry.text))} }}
|
||||
${pad} dangerouslySetInnerHTML={{ __html: ${jsonLdHtmlExpr(entry.text, sourceOrigin)} }}
|
||||
${pad}/>`).join("\n");
|
||||
}
|
||||
|
||||
@@ -397,6 +437,27 @@ function originOf(url: string): string {
|
||||
try { return new URL(url).origin; } catch { return "https://example.com"; }
|
||||
}
|
||||
|
||||
/** The single origin constant every generated SEO/metadata route resolves against. Empty by
|
||||
* default so canonical/sitemap/JSON-LD URLs render RELATIVE to wherever the clone is served
|
||||
* (never the source domain); set NEXT_PUBLIC_SITE_ORIGIN to make them absolute to the clone's
|
||||
* own origin. Emitted once as src/lib/site.ts. */
|
||||
export const SITE_ORIGIN_MODULE = `export const SITE_ORIGIN = (process.env.NEXT_PUBLIC_SITE_ORIGIN ?? "").replace(/\\/$/, "");\n`;
|
||||
|
||||
/** The `import { SITE_ORIGIN } from "…/lib/site"` line; `depth` mirrors cnImportLine. */
|
||||
export function siteOriginImportLine(depth: number): string {
|
||||
return `import { SITE_ORIGIN } from "${"../".repeat(Math.max(1, depth))}lib/site";`;
|
||||
}
|
||||
|
||||
/** Path portion of a URL that sits on the source origin (so it can be re-hosted under
|
||||
* SITE_ORIGIN); returns the input unchanged for off-origin (genuinely external) URLs. */
|
||||
function relativizeToSource(url: string, sourceOrigin: string): { onOrigin: boolean; path: string } {
|
||||
if (sourceOrigin && url.startsWith(sourceOrigin)) {
|
||||
const path = url.slice(sourceOrigin.length) || "/";
|
||||
return { onOrigin: true, path: path.startsWith("/") ? path : "/" + path };
|
||||
}
|
||||
return { onOrigin: false, path: url };
|
||||
}
|
||||
|
||||
function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string {
|
||||
const title = report.title || routes[0]?.title || "Generated Clone";
|
||||
const lines: string[] = [`# ${title}`, ""];
|
||||
@@ -422,15 +483,17 @@ function generatedLlms(report: SeoInventory, routes: SeoRouteSummary[]): string
|
||||
|
||||
export function seoRouteFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
|
||||
const origin = originOf(report.sourceUrl);
|
||||
const sitemapUrl = origin + "/sitemap.xml";
|
||||
// robots.ts / sitemap.ts live at src/app → depth 1 below src.
|
||||
const siteImport = siteOriginImportLine(1);
|
||||
const robots = `import type { MetadataRoute } from "next";
|
||||
${siteImport}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: { userAgent: "*", allow: "/" },
|
||||
sitemap: ${JSON.stringify(sitemapUrl)},
|
||||
sitemap: SITE_ORIGIN + "/sitemap.xml",
|
||||
};
|
||||
}
|
||||
`;
|
||||
@@ -441,13 +504,21 @@ export default function robots(): MetadataRoute.Robots {
|
||||
title: report.title || "Home",
|
||||
description: report.description,
|
||||
excerpt: "",
|
||||
}]).map((route, index) => ({ url: route.url, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 }));
|
||||
}]).map((route, index) => ({ path: relativizeToSource(route.url, origin).path, changeFrequency: "weekly", priority: index === 0 ? 1 : 0.7 }));
|
||||
// Emit `url: SITE_ORIGIN + "<path>"` so URLs resolve to the clone's own origin (relative by
|
||||
// default), never the source domain.
|
||||
const entryLines = sitemapEntries.map((e) =>
|
||||
` {\n url: SITE_ORIGIN + ${JSON.stringify(e.path)},\n changeFrequency: ${JSON.stringify(e.changeFrequency)},\n priority: ${e.priority},\n }`
|
||||
).join(",\n");
|
||||
const sitemap = `import type { MetadataRoute } from "next";
|
||||
${siteImport}
|
||||
|
||||
export const dynamic = "force-static";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return ${JSON.stringify(sitemapEntries, null, 2)};
|
||||
return [
|
||||
${entryLines},
|
||||
];
|
||||
}
|
||||
`;
|
||||
const sourceLlms = resourceText(report, "llms");
|
||||
@@ -472,11 +543,12 @@ function xmlEscape(value: string): string {
|
||||
|
||||
export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]): Array<[string, string]> {
|
||||
const origin = originOf(report.sourceUrl);
|
||||
const sitemapUrl = origin + "/sitemap.xml";
|
||||
// Static output (Vite) has no runtime env: emit the clone's own paths RELATIVE to the source
|
||||
// origin so the source domain is never baked in (the requirement's relative default).
|
||||
const robots = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
`Sitemap: ${sitemapUrl}`,
|
||||
"Sitemap: /sitemap.xml",
|
||||
"",
|
||||
].join("\n");
|
||||
const sitemapRoutes = routes.length ? routes : [{
|
||||
@@ -492,7 +564,7 @@ export function seoStaticFiles(report: SeoInventory, routes: SeoRouteSummary[]):
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
...sitemapRoutes.map((route, index) => [
|
||||
" <url>",
|
||||
` <loc>${xmlEscape(route.url)}</loc>`,
|
||||
` <loc>${xmlEscape(relativizeToSource(route.url, origin).path)}</loc>`,
|
||||
" <changefreq>weekly</changefreq>",
|
||||
` <priority>${index === 0 ? "1.0" : "0.7"}</priority>`,
|
||||
" </url>",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import { collectNodeRules, computeBands, keyframesCss, type NodeRule } from "./css.js";
|
||||
import { colorClusterKey } from "../infer/semanticTokens.js";
|
||||
import type { InteractionCapture, StyleDelta } from "../capture/interactions.js";
|
||||
|
||||
// ---- arbitrary-value escaping ----
|
||||
@@ -28,6 +29,25 @@ import type { InteractionCapture, StyleDelta } from "../capture/interactions.js"
|
||||
function arb(v: string): string {
|
||||
return v.replace(/"/g, "'").replace(/_/g, "\\_").replace(/ /g, "_");
|
||||
}
|
||||
|
||||
/** Round a single simple `<number>px|rem` length, killing frozen sub-pixel noise: snap to an
|
||||
* integer when the value is within 0.1px of one (measurement/rounding jitter — `204.9994px` was
|
||||
* meant to be `205px`), otherwise keep at most 1 decimal (`204.797px` → `204.8px`, a genuine
|
||||
* fraction). `627px` is unchanged. Only touches a lone number+unit token: multi-token values,
|
||||
* calc()/clamp()/var(), percentages, and unitless numbers pass through untouched, so nothing that
|
||||
* needs exact sub-pixel precision (borders, transforms — handled on their own branches) is moved. */
|
||||
export function snapLen(value: string): string {
|
||||
const m = /^(-?\d*\.?\d+)(px|rem)$/.exec(value.trim());
|
||||
if (!m) return value;
|
||||
const n = parseFloat(m[1]!);
|
||||
const unit = m[2]!;
|
||||
// rem thresholds scale by the 16px root so "within 0.1px" is unit-consistent.
|
||||
const pxPerUnit = unit === "rem" ? 16 : 1;
|
||||
const rounded = Math.abs(n - Math.round(n)) * pxPerUnit < 0.1
|
||||
? Math.round(n)
|
||||
: Math.round(n * 10) / 10;
|
||||
return `${rounded}${unit}`;
|
||||
}
|
||||
function arbList(v: string): string {
|
||||
return arb(v).replace(/,_/g, ",");
|
||||
}
|
||||
@@ -53,6 +73,7 @@ const KW: Record<string, Record<string, string>> = {
|
||||
"font-weight": { "100": "font-thin", "200": "font-extralight", "300": "font-light", "400": "font-normal", "500": "font-medium", "600": "font-semibold", "700": "font-bold", "800": "font-extrabold", "900": "font-black" },
|
||||
"text-decoration-line": { underline: "underline", "line-through": "line-through", overline: "overline", none: "no-underline" },
|
||||
"white-space": { nowrap: "whitespace-nowrap", normal: "whitespace-normal", pre: "whitespace-pre", "pre-line": "whitespace-pre-line", "pre-wrap": "whitespace-pre-wrap", "break-spaces": "whitespace-break-spaces" },
|
||||
"text-wrap": { wrap: "text-wrap", nowrap: "text-nowrap", balance: "text-balance", pretty: "text-pretty" },
|
||||
"overflow-x": { hidden: "overflow-x-hidden", auto: "overflow-x-auto", scroll: "overflow-x-scroll", visible: "overflow-x-visible", clip: "overflow-x-clip" },
|
||||
"overflow-y": { hidden: "overflow-y-hidden", auto: "overflow-y-auto", scroll: "overflow-y-scroll", visible: "overflow-y-visible", clip: "overflow-y-clip" },
|
||||
"object-fit": { contain: "object-contain", cover: "object-cover", fill: "object-fill", none: "object-none", "scale-down": "object-scale-down" },
|
||||
@@ -243,7 +264,14 @@ export function declToUtil(prop: string, value: string): string {
|
||||
if (value === "0" || value === "0px" || value === "0rem") {
|
||||
return ZERO_NAMED.has(prop) ? `${ARB[prop]}-0` : `${ARB[prop]}-[0px]`;
|
||||
}
|
||||
return `${ARB[prop]}-[${arb(value)}]`;
|
||||
// `snapLen`'s 0.1px integer snap is calibrated for BOX lengths (killing measurement jitter like
|
||||
// `204.9994px`→`205px`). letter-spacing is authored at a far finer scale — a real `-0.08px`/
|
||||
// `-0.0375px` tracking is WITHIN 0.1px of zero, so snapLen would collapse it to `0px`. Chromium
|
||||
// then serializes computed `letter-spacing:0` as the keyword `normal`, which the style gate's
|
||||
// numeric compare can't parse → a false exact-string mismatch. Skip the integer snap for tracking;
|
||||
// `snapBase` rounds it to 2 decimals (`tracking-[-0.08px]`), the right precision for this axis.
|
||||
if (prop === "letter-spacing") return `${ARB[prop]}-[${arb(value)}]`;
|
||||
return `${ARB[prop]}-[${arb(snapLen(value))}]`;
|
||||
}
|
||||
if (/^border-(top|right|bottom|left)-width$/.test(prop)) {
|
||||
const side = prop.split("-")[1]![0]!; // t/r/b/l
|
||||
@@ -353,7 +381,7 @@ function parseSide(b: string): SidePart | null {
|
||||
for (const h of COLLAPSE_HEADS) if (body.startsWith(h + "-")) return { neg, head: h, suf: body.slice(h.length + 1) };
|
||||
return null;
|
||||
}
|
||||
function collapseBases(bases: string[]): string[] {
|
||||
export function collapseBases(bases: string[]): string[] {
|
||||
const byHead = new Map<string, SidePart>(); // head → its parsed part (sides are unique within a group)
|
||||
for (const b of bases) { const p = parseSide(b); if (p) byHead.set(p.head, p); }
|
||||
const origOf = new Map<string, string>(); // head → original base string (to drop/replace by identity)
|
||||
@@ -410,6 +438,18 @@ function collapseBases(bases: string[]): string[] {
|
||||
if (wv !== null) { const sfx = wv ? `-${wv}` : ""; replace.set(`border-t${sfx}`, `border${sfx}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}${sfx}`); }
|
||||
const cv = allEqSide(bcSuf);
|
||||
if (cv !== null) { replace.set(`border-t-${cv}`, `border-${cv}`); for (const s of ["r", "b", "l"]) drop.add(`border-${s}-${cv}`); }
|
||||
// flex:1 1 0% → the idiomatic `flex-1` (Tailwind `flex-1` compiles to `flex: 1 1 0%`, EXACTLY these
|
||||
// longhands). Fold when this band sets flex-grow:1 (`grow-[1]`) AND a zero flex-basis (`basis-[0%]`,
|
||||
// or its already-shortened `basis-0` — reached from a band delta). flex-shrink is the CSS default 1
|
||||
// (elided) unless an explicit `shrink-0` is present, which would break the equivalence — so require
|
||||
// its absence. Emitting `flex-1` rather than `grow basis-[0%]` also sidesteps the `basis-[0%]`→`basis
|
||||
// -0` prettify hazard (0% content-sizes against an indefinite main axis; 0px is a definite zero).
|
||||
const hasGrow1 = bases.includes("grow-[1]");
|
||||
const zeroBasis = bases.includes("basis-[0%]") ? "basis-[0%]" : bases.includes("basis-0") ? "basis-0" : null;
|
||||
if (hasGrow1 && zeroBasis && !bases.includes("shrink-0")) {
|
||||
replace.set("grow-[1]", "flex-1");
|
||||
drop.add(zeroBasis);
|
||||
}
|
||||
const out: string[] = [];
|
||||
for (const b of bases) { if (drop.has(b)) continue; out.push(replace.get(b) ?? b); }
|
||||
return out;
|
||||
@@ -490,6 +530,14 @@ export function prettifyBase(base: string): string {
|
||||
const pm = /^(w|h|min-w|min-h|basis|inset|inset-x|inset-y|top|right|bottom|left)-\[(\d*\.?\d+)%\]$/.exec(base);
|
||||
if (pm) {
|
||||
const v = parseFloat(pm[2]!);
|
||||
// A percentage on a MAIN-SIZE axis whose containing block may be indefinite is NOT equal to the
|
||||
// same length in px. `flex-basis:0%` against an auto-sized (indefinite) flex container falls back
|
||||
// to CONTENT sizing per the flexbox spec, whereas `flex-basis:0` (definite zero) gives a zero base
|
||||
// size — collapsing a `flex:1 1 0%` item to 0 in an auto-height column. `height`/`min-height` in %
|
||||
// resolve to `auto` when the containing block's height is indefinite, the same hazard. So never
|
||||
// rewrite `[0%]` to the definite `-0` for these prefixes; keep the literal `basis-[0%]`/`h-[0%]`.
|
||||
// (Width/inset percentages resolve against the always-definite containing-block WIDTH — safe.)
|
||||
if (v === 0 && (pm[1] === "basis" || pm[1] === "h" || pm[1] === "min-h")) return base;
|
||||
const frac = FRACTIONS.find(([p]) => Math.abs(p - v) < 0.4);
|
||||
return frac ? `${pm[1]}-${frac[1]}` : base;
|
||||
}
|
||||
@@ -544,7 +592,6 @@ function namedText(base: string): string | null {
|
||||
// nearest scale step / integer px when comfortably INSIDE that budget (ε well under the gate tol), so
|
||||
// the markup reads hand-authored. Bounded ε + the layout & perceptual gates backstop any accumulation;
|
||||
// values not near a step stay arbitrary because they are genuine one-offs.
|
||||
const SNAP_SPACE_PX = 1.0; // padding/margin/gap/inset/size → nearest 0.25rem scale step within this
|
||||
const SNAP_TYPE_PX = 0.6; // font-size/line-height/radius → nearest integer px within this
|
||||
const NAMED_K_SORTED = [...NAMED_K].sort((a, b) => a - b);
|
||||
function nearestNamedK(k: number): number {
|
||||
@@ -552,7 +599,7 @@ function nearestNamedK(k: number): number {
|
||||
for (const c of NAMED_K_SORTED) if (Math.abs(c - k) < Math.abs(best - k)) best = c;
|
||||
return best;
|
||||
}
|
||||
function snapBase(base: string): string {
|
||||
export function snapBase(base: string): string {
|
||||
const m = /^(-?)([a-z][a-z-]*)-\[(-?\d*\.?\d+)(px|rem)\]$/.exec(base);
|
||||
if (!m) return base;
|
||||
const neg = m[1]!, prefix = m[2]!, px = parseFloat(m[3]!) * (m[4] === "rem" ? 16 : 1);
|
||||
@@ -568,10 +615,16 @@ function snapBase(base: string): string {
|
||||
if (Math.abs(r - px) <= SNAP_TYPE_PX && r in RADIUS_SUFFIX) return `${prefix}${RADIUS_SUFFIX[r]}`;
|
||||
return base;
|
||||
}
|
||||
// spacing / size → nearest named scale step (0.25rem grid)
|
||||
// spacing / size → nearest named scale step (0.25rem grid). Only snap a value that lands ESSENTIALLY
|
||||
// ON a step (≤0.25px): the scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so a captured value ≥0.25px
|
||||
// off the nearest step is a genuine sub-step measurement (3.5px is BETWEEN 2px and 4px — not on the
|
||||
// scale). Snapping such a value up by +0.5px per side accumulates across the links of a fixed-width
|
||||
// flex row until the items overflow and wrap to a second line; the exact `p-[3.5px]` cannot. The wider
|
||||
// ±1px budget the gate tolerates in isolation is unsafe here because the error is systematic, not
|
||||
// random. Values genuinely on a step (2px→`p-0.5`, 4px→`p-1`) still snap.
|
||||
if (SCALE_PREFIXES.has(prefix)) {
|
||||
const k = nearestNamedK(px / 4);
|
||||
if (Math.abs(k * 4 - px) <= SNAP_SPACE_PX) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`;
|
||||
if (Math.abs(k * 4 - px) <= 0.25) return k === 0 ? `${neg}${prefix}-0` : `${neg}${prefix}-${k}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
@@ -694,6 +747,25 @@ function sourceVariantActive(variant: string, vp: number): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Tailwind cascade specificity of an ACTIVE variant at a viewport: the effective min-width its media
|
||||
// query opens at, so a more-specific (higher) breakpoint wins over a lower one. Tailwind emits its
|
||||
// utilities sorted by breakpoint (min-width ascending), NOT by class-attribute order, so when both
|
||||
// `md:` and `lg:` apply at 1280 the `lg:` rule wins regardless of which appears first in the class
|
||||
// string. `min-[Npx]:` embeds N; a bare min breakpoint uses its threshold; `max-*` variants are
|
||||
// upper-bounded windows that a plain min variant out-specifies, so they score below any min. The
|
||||
// unprefixed base scores 0. Only meaningful for variants already known active at `vp`.
|
||||
function sourceVariantSpecificity(variant: string, vp: number): number {
|
||||
if (!variant) return 0;
|
||||
let score = 0;
|
||||
for (const part of variant.split(":").filter(Boolean)) {
|
||||
const minArb = /^min-\[(\d+)px\]$/.exec(part);
|
||||
if (minArb) { score = Math.max(score, +minArb[1]!); continue; }
|
||||
if (part in SOURCE_BP) { score = Math.max(score, SOURCE_BP[part]!); continue; }
|
||||
// max-* windows are less specific than any min breakpoint; leave score as-is (a min wins).
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
function sourceArbitraryInner(suffix: string): string | null {
|
||||
return suffix.startsWith("[") && suffix.endsWith("]") ? suffix.slice(1, -1) : null;
|
||||
}
|
||||
@@ -709,6 +781,118 @@ function sourceFluidLengthSuffix(suffix: string): boolean {
|
||||
return /(%|vw|vh|svw|lvw|dvw|svh|lvh|dvh|fr|min-content|max-content|fit-content)/.test(inner);
|
||||
}
|
||||
|
||||
// A FIXED, definite authored length suffix on a `w-`/`h-` utility: an arbitrary `[<px|rem|em|…>]`, the
|
||||
// `px` token (1px), or a numeric spacing-scale token (`24`, `2.5` → fixed rem). Unlike
|
||||
// sourceFluidLengthSuffix these do NOT re-derive from the container, so the source-intent pass must
|
||||
// re-emit them as the CAPTURED computed px (the clone's root font-size may differ from the source's, so
|
||||
// echoing `h-[4rem]` verbatim would mis-size), and only where geometry corroborates the resolved box.
|
||||
function sourceFixedLengthSuffix(suffix: string): boolean {
|
||||
const inner = sourceArbitraryInner(suffix);
|
||||
if (inner !== null) {
|
||||
const v = inner.trim().toLowerCase();
|
||||
if (/^var\(/.test(v)) return false; // handled by the var-length path
|
||||
if (/%|vw|vh|vmin|vmax|svw|lvw|dvw|svh|lvh|dvh|\bfr\b|min-content|max-content|fit-content/.test(v)) return false;
|
||||
return /(?:^|[\s(*/+-])[\d.]+(?:px|rem|em|cm|mm|in|pt|pc|ex|ch|q)\b/.test(v);
|
||||
}
|
||||
if (suffix === "px") return true;
|
||||
return /^\d+(?:\.\d+)?$/.test(suffix);
|
||||
}
|
||||
|
||||
// A `w-`/`h-` utility carrying a fixed authored length (as recognised by sourceFixedLengthSuffix).
|
||||
function isFixedLengthUtil(util: string): boolean {
|
||||
const m = /^([wh])-(.+)$/.exec(util);
|
||||
return !!m && sourceFixedLengthSuffix(m[2]!);
|
||||
}
|
||||
|
||||
// The captured computed px on this axis is definite and matches the measured bbox — i.e. the authored
|
||||
// fixed length resolved to the box the capture recorded. Geometry corroboration for a fixed w/h util.
|
||||
function fixedLengthResolvesToBox(node: IRNode, axis: "w" | "h", vp: number): boolean {
|
||||
const cs = node.computedByVp[vp]; const b = node.bboxByVp[vp];
|
||||
if (!cs || !b) return false;
|
||||
const val = axis === "h" ? cs.height : cs.width;
|
||||
const ext = axis === "h" ? b.height : b.width;
|
||||
if (!val || val === "auto" || !/px$/.test(val)) return false;
|
||||
if (!(ext > 0)) return false;
|
||||
return Math.abs(pf(val) - ext) <= Math.max(1.5, 0.01 * ext);
|
||||
}
|
||||
|
||||
// Rewrite a fixed-length `w-`/`h-` source utility to the node's CAPTURED computed px at a viewport,
|
||||
// preserving any variant prefix. `h-[4rem]` → `h-[60px]` (the source resolved 4rem against a 15px root;
|
||||
// the clone's root may differ, so bake the measured px). Returns the utility unchanged when it is not a
|
||||
// fixed-length util or the computed px is unavailable.
|
||||
function fixedLengthUtilAsPx(util: string, node: IRNode, vp: number): string {
|
||||
const m = VARIANT_PREFIX.exec(util);
|
||||
const prefix = m ? m[1]! : "";
|
||||
const core = m ? m[2]! : util;
|
||||
const sm = /^([wh])-(.+)$/.exec(core);
|
||||
if (!sm || !sourceFixedLengthSuffix(sm[2]!)) return util;
|
||||
const val = sm[1] === "h" ? node.computedByVp[vp]?.height : node.computedByVp[vp]?.width;
|
||||
if (!val || !/px$/.test(val)) return util;
|
||||
const px = Math.round(pf(val) * 100) / 100;
|
||||
return `${prefix}${sm[1]}-[${px}px]`;
|
||||
}
|
||||
|
||||
// Tailwind v4's `max-w-*` / `w-*` / `h-*` / `max-h-*` NAMED size scale (the `--container-*` theme
|
||||
// values), in px at a 16px root. A source built on an OLDER Tailwind authored these names against a
|
||||
// DIFFERENT scale (e.g. v0–v2 `max-w-md` = 640px vs v4's 448px), so re-emitting the name verbatim
|
||||
// silently re-sizes the box. The source-intent pass validates the modern px below and falls back to
|
||||
// the captured computed px when they disagree.
|
||||
const CONTAINER_NAMED_PX: Record<string, number> = {
|
||||
"3xs": 256, "2xs": 288, xs: 320, sm: 384, md: 448, lg: 512, xl: 576,
|
||||
"2xl": 672, "3xl": 768, "4xl": 896, "5xl": 1024, "6xl": 1152, "7xl": 1280,
|
||||
};
|
||||
|
||||
// The px a `w-`/`h-`/`max-w-`/`max-h-` NAMED or numeric-scale suffix resolves to under THIS clone's
|
||||
// Tailwind v4 (16px root). Covers the `--container-*` names (`md`→448) and the numeric spacing scale
|
||||
// (`24`→96px, `2.5`→10px). Returns null for relative/keyword/arbitrary suffixes (`full`, `1/2`,
|
||||
// `screen`, `[…]`, `prose`) — those re-derive from context and are not px-fixed, so they need no
|
||||
// value validation and are re-emitted verbatim.
|
||||
function namedLengthPx(suffix: string): number | null {
|
||||
if (suffix in CONTAINER_NAMED_PX) return CONTAINER_NAMED_PX[suffix]!;
|
||||
if (suffix === "px") return 1;
|
||||
if (/^\d+(?:\.\d+)?$/.test(suffix)) return parseFloat(suffix) * 4; // spacing scale: n × 0.25rem
|
||||
return null;
|
||||
}
|
||||
|
||||
// A source utility on a length axis whose suffix is a NAMED/numeric-scale token (px-fixed under
|
||||
// v4) rather than a relative/keyword/arbitrary one. Only these need modern-value validation; e.g.
|
||||
// `max-w-md`, `w-24`, `h-px` — but not `max-w-full`, `w-1/2`, `h-[4rem]`.
|
||||
function namedScaleCore(core: string): { axis: "w" | "h" | "max-w" | "max-h"; px: number } | null {
|
||||
const m = /^(max-w|max-h|w|h)-(.+)$/.exec(core);
|
||||
if (!m) return null;
|
||||
const px = namedLengthPx(m[2]!);
|
||||
return px === null ? null : { axis: m[1] as "w" | "h" | "max-w" | "max-h", px };
|
||||
}
|
||||
|
||||
// Captured computed px on the axis at a viewport, when definite (`…px`, not `auto`/`none`). The
|
||||
// property a length axis constrains: `max-width` / `max-height` / `width` / `height`.
|
||||
function computedAxisPx(node: IRNode, axis: SourceAxis, vp: number): number | null {
|
||||
const cs = node.computedByVp[vp];
|
||||
if (!cs) return null;
|
||||
const raw = axis === "max-w" ? cs.maxWidth : axis === "max-h" ? cs.maxHeight
|
||||
: axis === "w" ? cs.width : axis === "h" ? cs.height : undefined;
|
||||
if (!raw || raw === "auto" || raw === "none" || !/px$/.test(raw)) return null;
|
||||
return pf(raw);
|
||||
}
|
||||
|
||||
// Re-emit a source length utility for one viewport. When the suffix is a NAMED/numeric-scale token
|
||||
// (px-fixed under v4) and its modern px does NOT match the captured computed px (within tolerance),
|
||||
// the authored name meant a different length on the source's older Tailwind — emit the captured px
|
||||
// as an arbitrary value instead of the mis-resolving name. Fluid/keyword/fixed-arbitrary suffixes
|
||||
// (and the already-handled fixed w/h path) pass through unchanged.
|
||||
function namedLengthUtilChecked(util: string, node: IRNode, axis: SourceAxis, vp: number): string {
|
||||
const m = VARIANT_PREFIX.exec(util);
|
||||
const prefix = m ? m[1]! : "";
|
||||
const core = m ? m[2]! : util;
|
||||
const named = namedScaleCore(core);
|
||||
if (!named) return util;
|
||||
const computed = computedAxisPx(node, axis, vp);
|
||||
if (computed === null) return util; // no definite computed px to check against — keep authored name
|
||||
if (Math.abs(named.px - computed) <= Math.max(1.5, 0.02 * computed)) return util; // modern value agrees
|
||||
const rem = pxToRem(computed);
|
||||
return `${prefix}${named.axis}-[${rem ?? computed + "px"}]`;
|
||||
}
|
||||
|
||||
function sourceAspectUtility(core: string): string | null {
|
||||
let m = /^aspect-(\d+)\/(\d+)-box$/.exec(core);
|
||||
if (m) return m[1] === m[2] ? "aspect-square" : `aspect-[${m[1]}/${m[2]}]`;
|
||||
@@ -740,7 +924,7 @@ function sourceAxisForCore(core0: string): { axis: SourceAxis; utility: string }
|
||||
const maxH = /^max-h-(.+)$/.exec(core);
|
||||
if (maxH && sourceFluidLengthSuffix(maxH[1]!)) return { axis: "max-h", utility: core };
|
||||
const size = /^([wh])-(.+)$/.exec(core);
|
||||
if (size && sourceFluidLengthSuffix(size[2]!)) return { axis: size[1] as SourceAxis, utility: core };
|
||||
if (size && (sourceFluidLengthSuffix(size[2]!) || sourceFixedLengthSuffix(size[2]!))) return { axis: size[1] as SourceAxis, utility: core };
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -904,6 +1088,12 @@ function sourceAxisCompatible(node: IRNode, parent: IRNode | undefined, axis: So
|
||||
if (axis === "h" && v === "h-full") return !!s?.hFill;
|
||||
if (axis === "h" && v === "h-auto") return !!s?.hAuto;
|
||||
if (axis === "h" && v && sourceVarName(v)) return /px$/.test(node.computedByVp[vp]?.height || "");
|
||||
// A FIXED authored length (`h-[4rem]`, `w-24`, `h-px`) — the source-intent pass will re-emit it as
|
||||
// the captured computed px. Compatible when that px is definite and equals the measured bbox extent
|
||||
// at this viewport (the authored length actually resolved to the captured box). This recovers a
|
||||
// banded authored fixed height/width the sizing probe dropped through the fill↔content cycle
|
||||
// (h-full) or the content/fill drop (auto), without depending on the clone's root font-size.
|
||||
if (v && isFixedLengthUtil(v)) return fixedLengthResolvesToBox(node, axis, vp);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
@@ -932,6 +1122,13 @@ function sourceIntentVarCss(node: IRNode, axis: SourceAxis, values: Map<number,
|
||||
|
||||
function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewports: number[], canonical: number): SourceIntent {
|
||||
const perAxis = new Map<SourceAxis, Map<number, string>>();
|
||||
// Per (axis, vp): the cascade specificity of the currently-chosen active token, so a later token in
|
||||
// the class string can only override an earlier one when it is at least as specific. Tailwind sorts
|
||||
// its emitted rules by breakpoint (min-width), so when both `md:` and `lg:` apply at a wide viewport
|
||||
// the `lg:` value wins — regardless of the class-attribute order. Picking the LAST active token
|
||||
// instead (class-string order) silently takes `md:grid-cols-2` at 1280 for `lg:grid-cols-3
|
||||
// md:grid-cols-2`, dropping the desktop column count.
|
||||
const specAt = new Map<SourceAxis, Map<number, number>>();
|
||||
for (const tok of parseSourceClass(node.srcClass)) {
|
||||
const parsed = sourceAxisForCore(tok.core);
|
||||
if (!parsed) continue;
|
||||
@@ -939,7 +1136,12 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
|
||||
if (!sourceVariantActive(tok.variant, vp)) continue;
|
||||
let m = perAxis.get(parsed.axis);
|
||||
if (!m) { m = new Map<number, string>(); perAxis.set(parsed.axis, m); }
|
||||
m.set(vp, parsed.utility);
|
||||
let s = specAt.get(parsed.axis);
|
||||
if (!s) { s = new Map<number, number>(); specAt.set(parsed.axis, s); }
|
||||
const spec = sourceVariantSpecificity(tok.variant, vp);
|
||||
// `>=` so a later token at EQUAL specificity still wins (matches source order for same-breakpoint
|
||||
// duplicates), but a lower breakpoint can never displace a higher one already chosen.
|
||||
if (!m.has(vp) || spec >= (s.get(vp) ?? -1)) { m.set(vp, parsed.utility); s.set(vp, spec); }
|
||||
}
|
||||
}
|
||||
const axes = new Set<SourceAxis>();
|
||||
@@ -947,15 +1149,47 @@ function sourceIntentUtilities(node: IRNode, parent: IRNode | undefined, viewpor
|
||||
const css: string[] = [];
|
||||
const bands = computeBands(viewports, canonical);
|
||||
for (const [axis, byVp] of perAxis) {
|
||||
if (!viewports.every((vp) => byVp.has(vp))) continue; // avoid partial custom-class inference for now
|
||||
if (!viewports.every((vp) => byVp.has(vp))) {
|
||||
// Partial-coverage escape hatch for SUBGRID. `grid-rows-subgrid`/`grid-cols-subgrid` is a
|
||||
// structural keyword authored as a variant-only utility (`max-lg:grid-rows-subgrid`, with the
|
||||
// axis resolving to explicit tracks at ≥lg), so the map covers only a subset of viewports and
|
||||
// the full-coverage bail below would throw it away. Subgrid is safe to band partially: emit it
|
||||
// exactly at the covered viewports as banded variants (and as the base only when canonical is
|
||||
// covered). Adding the axis lets the redundant computed-derived subgrid bands drop in favour of
|
||||
// this authored intent. Other partial axes still bail (partial fluid inference is unsafe).
|
||||
const subgridOnly = (axis === "grid-rows" || axis === "grid-cols") &&
|
||||
[...byVp.values()].every((u) => /-subgrid$/.test(u));
|
||||
if (!subgridOnly) continue;
|
||||
if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue;
|
||||
axes.add(axis);
|
||||
const canon = byVp.get(canonical);
|
||||
if (canon) utilities.push(canon);
|
||||
for (const b of bands) {
|
||||
if (!b.media) continue;
|
||||
const v = byVp.get(b.vp);
|
||||
if (v && v !== canon) utilities.push(prefixFor(b.media) + v);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!sourceAxisCompatible(node, parent, axis, byVp, viewports)) continue;
|
||||
const base = byVp.get(canonical)!;
|
||||
// A fixed-length w/h axis re-emits the CAPTURED computed px per band (root-font-size independent),
|
||||
// not the source rem token. A NAMED/numeric-scale length token (`max-w-md`, `w-24`) is re-emitted
|
||||
// only when its modern Tailwind-v4 px matches the captured computed px; otherwise the authored
|
||||
// name meant a different length on the source's older Tailwind and the captured px is emitted as an
|
||||
// arbitrary value. Every other axis keeps its authored utility verbatim.
|
||||
const asEmit = (vp: number): string => {
|
||||
const u = byVp.get(vp)!;
|
||||
if ((axis === "w" || axis === "h") && isFixedLengthUtil(u)) return fixedLengthUtilAsPx(u, node, vp);
|
||||
if (axis === "w" || axis === "h" || axis === "max-w" || axis === "max-h") return namedLengthUtilChecked(u, node, axis, vp);
|
||||
return u;
|
||||
};
|
||||
const base = asEmit(canonical);
|
||||
axes.add(axis);
|
||||
if (axis === "aspect") axes.add("grid-rows");
|
||||
utilities.push(base);
|
||||
for (const b of bands) {
|
||||
if (!b.media) continue;
|
||||
const v = byVp.get(b.vp)!;
|
||||
const v = asEmit(b.vp);
|
||||
if (v !== base) utilities.push(prefixFor(b.media) + v);
|
||||
}
|
||||
css.push(...sourceIntentVarCss(node, axis, byVp, viewports, canonical));
|
||||
@@ -988,11 +1222,12 @@ export type TailwindOutput = {
|
||||
export type ColorInterner = {
|
||||
defs: Map<string, string>; // minted token name → literal value
|
||||
byValue: Map<string, string>; // literal value → minted token name
|
||||
byKey: Map<string, string>; // rounded-sRGB cluster key → minted token name (visual dedup)
|
||||
tokens: Set<string>; // ALL referenced color token names (palette + minted)
|
||||
seq: { n: number }; // monotonic counter for clr-N names
|
||||
};
|
||||
export function createColorInterner(): ColorInterner {
|
||||
return { defs: new Map(), byValue: new Map(), tokens: new Set(), seq: { n: 0 } };
|
||||
return { defs: new Map(), byValue: new Map(), byKey: new Map(), tokens: new Set(), seq: { n: 0 } };
|
||||
}
|
||||
/** `:root { --clr-N: <literal>; … }` for the interner's minted tokens (empty if none). */
|
||||
export function colorDefsCssOf(it: ColorInterner): string {
|
||||
@@ -1053,11 +1288,11 @@ function interactionUtilities(
|
||||
return { byCid, groups };
|
||||
}
|
||||
|
||||
export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?: (v: string) => string | null, opts?: { interner?: ColorInterner; includeNode?: (id: string) => boolean; interaction?: InteractionCapture; reflow?: boolean }): TailwindOutput {
|
||||
export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?: (v: string) => string | null, opts?: { interner?: ColorInterner; includeNode?: (id: string) => boolean; interaction?: InteractionCapture; reflow?: boolean; lottieMounts?: ReadonlySet<string> }): TailwindOutput {
|
||||
// Colors tokenized (var(--…)); typography/geometry kept RAW (text-[16px] reads cleaner
|
||||
// than a token ref). The full tokenResolver is deliberately NOT passed — only colors are
|
||||
// tokenized below, so spacing/type stay as readable arbitrary values.
|
||||
const rules = collectNodeRules(ir, assetMap, opts?.includeNode, colorVar, undefined, opts?.reflow);
|
||||
const rules = collectNodeRules(ir, assetMap, opts?.includeNode, colorVar, undefined, opts?.reflow, opts?.lottieMounts);
|
||||
const classOf = new Map<string, string>();
|
||||
const styleOf = new Map<string, Map<string, string>>(); // cid → inline style (base-only gradients/url)
|
||||
const extraParts: string[] = []; // pseudo rules + url()-bearing decls, keyed by [data-cid]
|
||||
@@ -1075,12 +1310,31 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
|
||||
indexNode(ir.root);
|
||||
|
||||
// Color interner: every distinct color value → a stable theme token referenced as
|
||||
// var(--…). Palette colors already arrive as var(--color-*) (kept, semantic); any other
|
||||
// color is minted a numbered token (--clr-N) so raw rgb/hex NEVER lands in markup.
|
||||
// The token holds the literal value, minted in deterministic first-encounter order.
|
||||
// var(--…). A color the semantic palette recognizes (exactly OR within the grader's ±2
|
||||
// sRGB tolerance — the same tolerance css.ts already trusts for color/bg/border) reuses
|
||||
// that SEMANTIC name (--primary, --surface, --color-001…), so oklab/lch colours reached
|
||||
// only through decoration/gradient/shadow props no longer each mint a fresh opaque
|
||||
// --clr-N. Only colours with NO palette role fall through to a numbered --clr-N token,
|
||||
// minted in deterministic first-encounter order (the literal is kept, byte-exact).
|
||||
const internColor = (literal: string): string => {
|
||||
const semantic = colorVar?.(literal);
|
||||
if (semantic) { const n = tokenName(semantic); if (n) colorTokens.add(n); return semantic; }
|
||||
let name = interner.byValue.get(literal);
|
||||
if (!name) { name = `clr-${interner.seq.n++}`; interner.byValue.set(literal, name); interner.defs.set(name, literal); }
|
||||
if (!name) {
|
||||
// Dedup visually-identical literals (many oklab()/lch() forms round to the same sRGB):
|
||||
// reuse the token minted for that colour rather than a fresh --clr-N. Fidelity-neutral —
|
||||
// the grader compares in sRGB and the shared token holds the FIRST literal (within ±0 of
|
||||
// its own colour). Values that don't parse fall back to exact-literal keying.
|
||||
const key = colorClusterKey(literal);
|
||||
const existing = key ? interner.byKey.get(key) : undefined;
|
||||
if (existing) { name = existing; interner.byValue.set(literal, name); }
|
||||
else {
|
||||
name = `clr-${interner.seq.n++}`;
|
||||
interner.byValue.set(literal, name);
|
||||
interner.defs.set(name, literal);
|
||||
if (key) interner.byKey.set(key, name);
|
||||
}
|
||||
}
|
||||
colorTokens.add(name);
|
||||
return `var(--${name})`;
|
||||
};
|
||||
@@ -1131,7 +1385,11 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
|
||||
// one-off — emit it as an inline `style={{…}}` (exact, no Tailwind-escape mangling) so the node
|
||||
// needs no `[data-cid]` ditto.css rule and the shipped data-cid is stripped. If the prop IS
|
||||
// banded, it must stay in ditto.css: an inline style would out-specify the @media override.
|
||||
const bandedRawProps = new Set<string>(bandRaws.flatMap((b) => [...b.raw.keys()]));
|
||||
// Count EVERY band-touched prop (raw or not), not just the raw ones: a band that RESETS the prop
|
||||
// to a non-raw value (e.g. `max-lg:bg-[none]` turning a gradient off on mobile) becomes a utility
|
||||
// rather than a raw decl, so a raw-only set misses it — and inlining the base gradient then
|
||||
// out-specifies the `@media` reset, painting the gradient where the source turned it off.
|
||||
const bandedRawProps = new Set<string>(nr.bands.flatMap((b) => [...b.decls.keys()]));
|
||||
const inlineStyle = new Map<string, string>();
|
||||
for (const [p, v] of [...baseRaw]) {
|
||||
if (!bandedRawProps.has(p)) { inlineStyle.set(p, tokenizeColors(p, v)); baseRaw.delete(p); }
|
||||
@@ -1222,7 +1480,7 @@ export function buildTailwind(ir: IR, assetMap: Map<string, string>, colorVar?:
|
||||
* + breakpoint bindings, our token :root, and our reset/fonts/page-base inside @layer base
|
||||
* so utilities override them. */
|
||||
export function tailwindGlobalsCss(opts: {
|
||||
reset: string; fontCss: string; tokensCss: string; htmlBg: string; bodyFont: string;
|
||||
reset: string; fontCss: string; tokensCss: string; htmlBg: string | null; bodyFont: string;
|
||||
clip: string; colorTokens: string[]; viewports: number[]; canonical: number;
|
||||
}): string {
|
||||
const screens = [
|
||||
@@ -1250,8 +1508,7 @@ ${opts.tokensCss}
|
||||
${opts.reset}
|
||||
/* fonts */
|
||||
${opts.fontCss}
|
||||
html { background: ${opts.htmlBg}; }
|
||||
body { font-family: ${opts.bodyFont}; }${opts.clip}
|
||||
${opts.htmlBg !== null ? `html { background: ${opts.htmlBg}; }\n` : ""}body { font-family: ${opts.bodyFont}; }${opts.clip}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
+47
-15
@@ -61,19 +61,14 @@ export function buildFontGraph(fontFaces: FontFace[], assetGraph: AssetGraph, so
|
||||
}
|
||||
}
|
||||
|
||||
const entries: FontEntry[] = [];
|
||||
const cssBlocks: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const ff of fontFaces) {
|
||||
const weight = (ff.weight || "400").trim();
|
||||
const style = (ff.style || "normal").trim();
|
||||
const display = (ff.display || "swap").trim();
|
||||
const key = `${ff.family}|${weight}|${style}|${ff.unicodeRange ?? ""}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
const srcs = parseSrcUrls(ff.src, sourceUrl);
|
||||
// Resolve one face's src descriptor against the downloaded-asset graph. A face harvested from
|
||||
// CSSOM carries the url of its owning sheet in `baseHref`; its relative src url()s must resolve
|
||||
// against THAT, not the document, or `../media/x` clamps to the wrong path (commonly the SPA
|
||||
// router's HTML shell). Faces parsed out-of-band already have absolute srcs baked in, so
|
||||
// `baseHref` is absent and the document url is a harmless fallback.
|
||||
const order = ["woff2", "woff", "truetype", "opentype", "embedded-opentype"];
|
||||
const resolveFace = (ff: FontFace): { resolved: Array<{ localPath: string; format: string }>; dataUris: string[] } => {
|
||||
const srcs = parseSrcUrls(ff.src, ff.baseHref || sourceUrl);
|
||||
const resolved: Array<{ localPath: string; format: string }> = [];
|
||||
const dataUris: string[] = [];
|
||||
for (const s of srcs) {
|
||||
@@ -84,10 +79,47 @@ export function buildFontGraph(fontFaces: FontFace[], assetGraph: AssetGraph, so
|
||||
resolved.push({ localPath: entry.localPath, format: s.format || FORMAT_BY_EXT[ext] || "woff2" });
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer woff2 > woff > others for ordering.
|
||||
const order = ["woff2", "woff", "truetype", "opentype", "embedded-opentype"];
|
||||
resolved.sort((a, b) => order.indexOf(a.format) - order.indexOf(b.format));
|
||||
return { resolved, dataUris };
|
||||
};
|
||||
|
||||
// Deduplicate faces by family|weight|style|unicodeRange. Two sources can supply the same face
|
||||
// with DIFFERENT (correctly- vs wrongly-resolved) src urls — the CSSOM harvest and the css-text
|
||||
// parse both land in `fontFaces`. Keeping the first-inserted face lets a src that resolves to
|
||||
// nothing (a rejected impostor, or a mis-based path) win the slot, so choose validity-aware:
|
||||
// the first face whose src resolves to a downloaded/data-uri source wins; only if NONE in the
|
||||
// group resolves do we fall back to the first-seen face (recorded as unavailable). Ties (more
|
||||
// than one resolving) keep insertion order — determinism preserved.
|
||||
const chosen = new Map<string, { ff: FontFace; res: ReturnType<typeof resolveFace> }>();
|
||||
const orderKeys: string[] = [];
|
||||
for (const ff of fontFaces) {
|
||||
const weight = (ff.weight || "400").trim();
|
||||
const style = (ff.style || "normal").trim();
|
||||
const key = `${ff.family}|${weight}|${style}|${ff.unicodeRange ?? ""}`;
|
||||
const res = resolveFace(ff);
|
||||
const resolves = res.resolved.length > 0 || res.dataUris.length > 0;
|
||||
const prev = chosen.get(key);
|
||||
if (!prev) {
|
||||
chosen.set(key, { ff, res });
|
||||
orderKeys.push(key);
|
||||
} else if (resolves && !(prev.res.resolved.length > 0 || prev.res.dataUris.length > 0)) {
|
||||
// Upgrade: the incumbent resolved to nothing, this candidate resolves — replace it in place
|
||||
// (keeping its original emission position).
|
||||
chosen.set(key, { ff, res });
|
||||
}
|
||||
// Otherwise keep the incumbent (first-resolving wins; ties hold insertion order).
|
||||
}
|
||||
|
||||
const entries: FontEntry[] = [];
|
||||
const cssBlocks: string[] = [];
|
||||
|
||||
for (const key of orderKeys) {
|
||||
const { ff, res } = chosen.get(key)!;
|
||||
const weight = (ff.weight || "400").trim();
|
||||
const style = (ff.style || "normal").trim();
|
||||
const display = (ff.display || "swap").trim();
|
||||
const { resolved, dataUris } = res;
|
||||
|
||||
if (resolved.length > 0 || dataUris.length > 0) {
|
||||
const srcParts: string[] = [];
|
||||
|
||||
+154
-87
@@ -4,10 +4,19 @@ import { round } from "../util/canonical.js";
|
||||
|
||||
/**
|
||||
* Deterministic section detection. Splits the page into visually coherent
|
||||
* top-level blocks using semantic tags and geometry (we do not rely on class
|
||||
* names — those are intentionally dropped from the IR). Sections are metadata:
|
||||
* stable ids + per-viewport bboxes for the layout/section gate. Role guesses are
|
||||
* advisory and never affect fidelity.
|
||||
* top-level bands using semantic tags and geometry (we do not rely on class
|
||||
* names — those are intentionally dropped from the IR).
|
||||
*
|
||||
* The core is a recursive descent: a container that covers most of the page
|
||||
* (<body> → <div id=root> → <main>) is a WRAPPER, not a band — descend into it
|
||||
* and re-collect among its children, repeating until every candidate is
|
||||
* band-like. A descent only replaces the container when its children actually
|
||||
* tile it (stacked full-width blocks with near-complete coverage), so a page
|
||||
* that truly is one tall band legally stays a single section.
|
||||
*
|
||||
* Sections are metadata: stable ids + per-viewport bboxes for the layout/section
|
||||
* gate, and the shared root set for section-per-file emission (sectionSplit).
|
||||
* Role guesses are advisory and never affect fidelity.
|
||||
*/
|
||||
|
||||
export type Section = {
|
||||
@@ -19,104 +28,162 @@ export type Section = {
|
||||
};
|
||||
|
||||
const SEMANTIC = new Set(["header", "nav", "main", "section", "article", "footer", "aside"]);
|
||||
const MIN_BAND_H = 64;
|
||||
const MIN_SEMANTIC_H = 24; // a 62px navbar is still the navbar
|
||||
const MIN_BAND_W_FRAC = 0.55; // a band spans most of the viewport
|
||||
const WRAPPER_FRAC = 0.5; // taller than this fraction of the page → wrapper, try descending
|
||||
const MIN_CHILD_COVERAGE = 0.8; // children must tile the container to replace it
|
||||
const MAX_STACK_OVERLAP = 0.3; // consecutive bands may overlap at most this much of the smaller
|
||||
const MAX_BANDS_PER_SPLIT = 20; // a split into more pieces than this is fragmentation, not bands
|
||||
const MAX_DEPTH = 12;
|
||||
|
||||
type Cand = { node: IRNode; path: string; y: number; width: number; height: number };
|
||||
type Cand = { node: IRNode; y: number; width: number; height: number };
|
||||
type Ctx = { cw: number; pageH: number };
|
||||
|
||||
/** The ordered section-root nodes of the page (top to bottom). Shared by the
|
||||
* validator (detectSections) and the generator's section splitter, so the gate's
|
||||
* section list and the emitted section components describe the same bands.
|
||||
* Falls back to `[ir.root]` when the page has no decomposable structure. */
|
||||
export function detectSectionNodes(ir: IR): IRNode[] {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const ctx: Ctx = { cw, pageH: ir.doc.perViewport[cw]?.scrollHeight ?? 0 };
|
||||
const bands = bandsOf(ir.root, 0, ctx);
|
||||
// Stable sort by top edge; ties keep document order (a fixed nav stays before
|
||||
// the hero it overlays).
|
||||
bands.sort((a, b) => a.y - b.y);
|
||||
// Nested wrappers can repeat the same box from disjoint subtrees — keep the first.
|
||||
const final: Cand[] = [];
|
||||
for (const c of bands) {
|
||||
if (!final.some((f) => Math.abs(f.y - c.y) < 4 && Math.abs(f.height - c.height) < 4)) final.push(c);
|
||||
}
|
||||
if (final.length === 0) return [ir.root];
|
||||
return final.map((c) => c.node);
|
||||
}
|
||||
|
||||
export function detectSections(ir: IR): Section[] {
|
||||
const cw = ir.doc.canonicalViewport;
|
||||
const vpWidth = cw;
|
||||
|
||||
const candidates: Cand[] = [];
|
||||
const walk = (node: IRNode, path: string): void => {
|
||||
const bbox = node.bboxByVp[cw];
|
||||
const visible = node.visibleByVp[cw];
|
||||
if (bbox && visible) {
|
||||
const isSemantic = SEMANTIC.has(node.tag);
|
||||
const isBigBlock = bbox.width >= vpWidth * 0.55 && bbox.height >= 64;
|
||||
const display = node.computedByVp[cw]?.display ?? "";
|
||||
const blockish = !/^(inline|inline-block|none)$/.test(display);
|
||||
if ((isSemantic || isBigBlock) && blockish) {
|
||||
candidates.push({ node, path, y: bbox.y, width: bbox.width, height: bbox.height });
|
||||
}
|
||||
}
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
walk(c, `${path}/${c.id}`);
|
||||
}
|
||||
};
|
||||
walk(ir.root, ir.root.id);
|
||||
|
||||
// Outermost wins: drop candidates nested inside another candidate.
|
||||
const byShallow = candidates.slice().sort((a, b) => a.path.split("/").length - b.path.split("/").length);
|
||||
let kept: Cand[] = [];
|
||||
for (const c of byShallow) {
|
||||
if (kept.some((k) => c.path.startsWith(k.path + "/"))) continue;
|
||||
kept.push(c);
|
||||
}
|
||||
|
||||
// If the only survivor is a giant wrapper, expand into its section-like children.
|
||||
const pageHeight = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
|
||||
kept = expandOversized(kept, vpWidth, pageHeight, cw);
|
||||
|
||||
// Sort by Y, then assign ids/roles.
|
||||
kept.sort((a, b) => a.y - b.y || b.height - a.height);
|
||||
// Deduplicate near-identical y/height wrappers (keep the first/shallowest).
|
||||
const final: Cand[] = [];
|
||||
for (const c of kept) {
|
||||
const dup = final.find((f) => Math.abs(f.y - c.y) < 4 && Math.abs(f.height - c.height) < 4);
|
||||
if (!dup) final.push(c);
|
||||
}
|
||||
|
||||
return final.map((c, i) => ({
|
||||
const pageH = ir.doc.perViewport[cw]?.scrollHeight ?? 0;
|
||||
const nodes = detectSectionNodes(ir);
|
||||
const roles = guessRoles(nodes, cw, pageH);
|
||||
return nodes.map((node, i) => ({
|
||||
id: `section-${String(i + 1).padStart(3, "0")}`,
|
||||
nodeId: c.node.id,
|
||||
role: guessRole(c.node, i, final.length),
|
||||
nodeId: node.id,
|
||||
role: roles[i]!,
|
||||
order: i,
|
||||
bboxByVp: bboxesFor(c.node),
|
||||
bboxByVp: bboxesFor(node),
|
||||
}));
|
||||
}
|
||||
|
||||
function expandOversized(cands: Cand[], vpWidth: number, pageHeight: number, cw: number): Cand[] {
|
||||
if (cands.length > 2) return cands;
|
||||
const threshold = Math.max(pageHeight * 0.55, 1200);
|
||||
/** Landmark evidence that a bar is site chrome: an ARIA landmark role on the node
|
||||
* itself, or a <nav> within a shallow wrapper chain. Lets a thin fixed bar (often a
|
||||
* 62px styled <div> around the real <nav>) clear the semantic height bar. */
|
||||
export function navEvidence(node: IRNode): boolean {
|
||||
const role = node.attrs.role ?? "";
|
||||
if (role === "banner" || role === "navigation") return true;
|
||||
return subtreeHas(node, (n) => n.tag === "nav" || n.attrs.role === "navigation", 3);
|
||||
}
|
||||
|
||||
function candOf(node: IRNode, ctx: Ctx): Cand | null {
|
||||
const bbox = node.bboxByVp[ctx.cw];
|
||||
if (!bbox || !node.visibleByVp[ctx.cw]) return null;
|
||||
const display = node.computedByVp[ctx.cw]?.display ?? "";
|
||||
if (/^(inline|inline-block|none)$/.test(display)) return null;
|
||||
if (bbox.width < ctx.cw * MIN_BAND_W_FRAC) return null;
|
||||
const minH = SEMANTIC.has(node.tag) || navEvidence(node) ? MIN_SEMANTIC_H : MIN_BAND_H;
|
||||
if (bbox.height < minH) return null;
|
||||
return { node, y: bbox.y, width: bbox.width, height: bbox.height };
|
||||
}
|
||||
|
||||
/** Collect the band candidates among `container`'s children. A band-sized child is
|
||||
* kept; a page-covering child is descended into (its children replace it only when
|
||||
* they tile it — see acceptSplit); anything else is a transparent wrapper we look
|
||||
* through. Output is in document order. */
|
||||
function bandsOf(container: IRNode, depth: number, ctx: Ctx): Cand[] {
|
||||
if (depth > MAX_DEPTH) return [];
|
||||
const out: Cand[] = [];
|
||||
for (const c of cands) {
|
||||
if (c.height < threshold) { out.push(c); continue; }
|
||||
const inner: Cand[] = [];
|
||||
const collect = (node: IRNode, path: string, depth: number): void => {
|
||||
if (depth > 6) return;
|
||||
for (const ch of node.children) {
|
||||
if (isTextChild(ch)) continue;
|
||||
const bbox = ch.bboxByVp[cw];
|
||||
if (bbox && ch.visibleByVp[cw] && bbox.width >= vpWidth * 0.5 && bbox.height >= 64) {
|
||||
inner.push({ node: ch, path: `${path}/${ch.id}`, y: bbox.y, width: bbox.width, height: bbox.height });
|
||||
} else {
|
||||
collect(ch, `${path}/${ch.id}`, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
collect(c.node, c.path, 0);
|
||||
// outermost wins within inner
|
||||
const byShallow = inner.slice().sort((a, b) => a.path.split("/").length - b.path.split("/").length);
|
||||
const keptInner: Cand[] = [];
|
||||
for (const ic of byShallow) {
|
||||
if (keptInner.some((k) => ic.path.startsWith(k.path + "/"))) continue;
|
||||
keptInner.push(ic);
|
||||
for (const child of container.children) {
|
||||
if (isTextChild(child)) continue;
|
||||
const cand = candOf(child, ctx);
|
||||
if (!cand) {
|
||||
out.push(...bandsOf(child, depth + 1, ctx));
|
||||
continue;
|
||||
}
|
||||
if (keptInner.length >= 3) out.push(...keptInner);
|
||||
else out.push(c);
|
||||
if (ctx.pageH > 0 && cand.height > ctx.pageH * WRAPPER_FRAC) {
|
||||
const inner = bandsOf(child, depth + 1, ctx);
|
||||
if (acceptSplit(inner, cand)) {
|
||||
out.push(...inner);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(cand);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function guessRole(node: IRNode, index: number, total: number): string {
|
||||
if (node.tag === "header") return "header";
|
||||
if (node.tag === "nav") return "nav";
|
||||
if (node.tag === "footer") return "footer";
|
||||
if (node.tag === "main") return "main";
|
||||
if (index === 0) return "hero";
|
||||
if (index === total - 1) return "footer";
|
||||
return "section";
|
||||
/** May `inner` replace its containing candidate? Only when it reads as a stack of
|
||||
* bands: at least two, not a fragmentation explosion, vertically stacked (side-by-side
|
||||
* columns or overlaid layers must not be split), and tiling the container with
|
||||
* near-complete coverage so no substantial content is silently dropped. */
|
||||
function acceptSplit(inner: Cand[], parent: Cand): boolean {
|
||||
if (inner.length < 2 || inner.length > MAX_BANDS_PER_SPLIT) return false;
|
||||
const sorted = inner.slice().sort((a, b) => a.y - b.y);
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1]!, cur = sorted[i]!;
|
||||
const overlap = prev.y + prev.height - cur.y;
|
||||
if (overlap > Math.min(prev.height, cur.height) * MAX_STACK_OVERLAP) return false;
|
||||
}
|
||||
let covered = 0, cursor = -Infinity;
|
||||
for (const c of sorted) {
|
||||
const top = Math.max(c.y, cursor), bot = c.y + c.height;
|
||||
if (bot > top) covered += bot - top;
|
||||
cursor = Math.max(cursor, bot);
|
||||
}
|
||||
return covered >= parent.height * MIN_CHILD_COVERAGE;
|
||||
}
|
||||
|
||||
function subtreeHas(node: IRNode, pred: (n: IRNode) => boolean, depth = 6): boolean {
|
||||
if (depth < 0) return false;
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
if (pred(c) || subtreeHas(c, pred, depth - 1)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A <header> that carries the page's h1 and real height is the hero band, not
|
||||
* site chrome — "header → chrome" only holds for the thin bar variant. */
|
||||
export function heroLikeHeader(node: IRNode, cw: number): boolean {
|
||||
const h = node.bboxByVp[cw]?.height ?? 0;
|
||||
return h >= 200 && subtreeHas(node, (n) => n.tag === "h1");
|
||||
}
|
||||
|
||||
/** Advisory roles, one pass top-to-bottom: tag evidence first, then the first
|
||||
* near-top content band claims "hero" (once), then content evidence. */
|
||||
function guessRoles(nodes: IRNode[], cw: number, pageH: number): string[] {
|
||||
let heroClaimed = false;
|
||||
return nodes.map((node, index) => {
|
||||
if (node.tag === "nav") return "navbar";
|
||||
if (node.tag === "header") {
|
||||
if (!heroLikeHeader(node, cw)) return "header";
|
||||
heroClaimed = true;
|
||||
return "hero";
|
||||
}
|
||||
if (node.tag === "footer") return "footer";
|
||||
if (node.tag === "aside") return "aside";
|
||||
if (node.tag === "main") return "main";
|
||||
const bbox = node.bboxByVp[cw];
|
||||
const h = bbox?.height ?? 0, y = bbox?.y ?? 0;
|
||||
// thin top bar without the <nav> tag (first band, or a fixed bar with landmark evidence)
|
||||
if (h <= 140 && y <= 160 && (index === 0 || navEvidence(node))) return "navbar";
|
||||
if (!heroClaimed && (bbox?.y ?? 0) <= Math.max(900, pageH * 0.25)) {
|
||||
heroClaimed = true;
|
||||
return "hero";
|
||||
}
|
||||
if (index === nodes.length - 1) return "footer";
|
||||
if (subtreeHas(node, (n) => n.tag === "form")) return "contact";
|
||||
if (subtreeHas(node, (n) => n.tag === "video" || n.tag === "iframe")) return "media";
|
||||
return "section";
|
||||
});
|
||||
}
|
||||
|
||||
function bboxesFor(node: IRNode): Section["bboxByVp"] {
|
||||
|
||||
@@ -27,12 +27,117 @@ export type ColorPalette = {
|
||||
const TRANSPARENT = new Set(["rgba(0, 0, 0, 0)", "transparent", "rgba(0,0,0,0)", ""]);
|
||||
|
||||
type RGBA = [number, number, number, number];
|
||||
|
||||
/** Parse the numeric args of a `fn(a b c / d)` / `fn(a,b,c,d)` color into [a,b,c,alpha].
|
||||
* Percentages resolve against `pctBase` (255 for rgb channels, 1 for lab/lch/oklab/oklch
|
||||
* L and the c/ab axes where the caller passes their own scale). `none` → 0. */
|
||||
function parseColorArgs(inner: string): { nums: number[]; alpha: number } | null {
|
||||
const parts = inner.split("/");
|
||||
const main = parts[0]!.trim().split(/[\s,]+/).filter(Boolean);
|
||||
const nums = main.map((s) => (s === "none" ? 0 : parseFloat(s)));
|
||||
if (nums.length < 3 || nums.slice(0, 3).some(Number.isNaN)) return null;
|
||||
let alpha = 1;
|
||||
const alphaTok = parts.length > 1 ? parts[1]!.trim() : main[3];
|
||||
if (alphaTok !== undefined && alphaTok !== "none") {
|
||||
const a = alphaTok.endsWith("%") ? parseFloat(alphaTok) / 100 : parseFloat(alphaTok);
|
||||
if (!Number.isNaN(a)) alpha = a;
|
||||
}
|
||||
return { nums, alpha };
|
||||
}
|
||||
|
||||
function pctOr(tok: string, base: number): number {
|
||||
return tok.endsWith("%") ? (parseFloat(tok) / 100) * base : parseFloat(tok);
|
||||
}
|
||||
|
||||
/** Linear-light sRGB channel → gamma-encoded 0–255. */
|
||||
function lin2srgb(c: number): number {
|
||||
const v = c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
||||
return Math.round(Math.min(1, Math.max(0, v)) * 255);
|
||||
}
|
||||
|
||||
/** OKLab → sRGB (CSS Color 4 reference matrices). Returns gamma-encoded 0–255. */
|
||||
function oklabToRgb(L: number, a: number, b: number): [number, number, number] {
|
||||
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
||||
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
||||
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
||||
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
||||
const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
||||
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
||||
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
|
||||
return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)];
|
||||
}
|
||||
|
||||
/** CIE Lab (D50) → sRGB, gamma-encoded 0–255. */
|
||||
function labToRgb(L: number, a: number, b: number): [number, number, number] {
|
||||
const fy = (L + 16) / 116, fx = fy + a / 500, fz = fy - b / 200;
|
||||
const d = 6 / 29;
|
||||
const inv = (t: number): number => (t > d ? t ** 3 : 3 * d * d * (t - 4 / 29));
|
||||
// D50 white point.
|
||||
const X = 0.9642956 * inv(fx), Y = 1.0 * inv(fy), Z = 0.8251046 * inv(fz);
|
||||
// XYZ(D50) → linear sRGB (Bradford-adapted D50→D65 folded in).
|
||||
const r = +3.1341359 * X - 1.6172206 * Y - 0.4906860 * Z;
|
||||
const g = -0.9787684 * X + 1.9161415 * Y + 0.0334540 * Z;
|
||||
const bl = +0.0719453 * X - 0.2289914 * Y + 1.4052427 * Z;
|
||||
return [lin2srgb(r), lin2srgb(g), lin2srgb(bl)];
|
||||
}
|
||||
|
||||
/** Parse any CSS color value (`rgb`/`rgba`/`hsl`/`hsla`/`oklab`/`oklch`/`lab`/`lch`/hex/
|
||||
* named-ish) into rounded sRGB `[r,g,b,alpha]`, else null. The literal value is kept
|
||||
* verbatim in emitted CSS; this RGB is used ONLY for clustering (grader works in sRGB
|
||||
* ±2), so oklab/lch colours cluster + earn semantic roles instead of falling to --clr-N. */
|
||||
function parseColor(v: string): RGBA | null {
|
||||
const s = v.trim().toLowerCase();
|
||||
if (s === "white") return [255, 255, 255, 1];
|
||||
if (s === "black") return [0, 0, 0, 1];
|
||||
const hex = /^#([0-9a-f]{3,8})$/i.exec(s);
|
||||
if (hex) {
|
||||
let h = hex[1]!;
|
||||
if (h.length === 3 || h.length === 4) h = h.split("").map((c) => c + c).join("");
|
||||
const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
|
||||
const a = h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
||||
return [r, g, b, a];
|
||||
}
|
||||
const fn = /^(rgba?|hsla?|oklab|oklch|lab|lch)\(([^)]*)\)$/i.exec(s);
|
||||
if (!fn) return null;
|
||||
const kind = fn[1]!, parsed = parseColorArgs(fn[2]!);
|
||||
if (!parsed) return null;
|
||||
const { nums, alpha } = parsed;
|
||||
const raw = fn[2]!.split("/")[0]!.trim().split(/[\s,]+/).filter(Boolean);
|
||||
if (kind.startsWith("rgb")) {
|
||||
return [Math.round(pctOr(raw[0]!, 255)), Math.round(pctOr(raw[1]!, 255)), Math.round(pctOr(raw[2]!, 255)), alpha];
|
||||
}
|
||||
if (kind.startsWith("hsl")) {
|
||||
const H = ((nums[0]! % 360) + 360) % 360, S = pctOr(raw[1]!, 1), Lh = pctOr(raw[2]!, 1);
|
||||
const c = (1 - Math.abs(2 * Lh - 1)) * S, x = c * (1 - Math.abs(((H / 60) % 2) - 1)), m = Lh - c / 2;
|
||||
const [r1, g1, b1] = H < 60 ? [c, x, 0] : H < 120 ? [x, c, 0] : H < 180 ? [0, c, x] : H < 240 ? [0, x, c] : H < 300 ? [x, 0, c] : [c, 0, x];
|
||||
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255), alpha];
|
||||
}
|
||||
if (kind === "oklab") { const [r, g, b] = oklabToRgb(pctOr(raw[0]!, 1), nums[1]!, nums[2]!); return [r, g, b, alpha]; }
|
||||
if (kind === "oklch") {
|
||||
const L = pctOr(raw[0]!, 1), C = nums[1]!, h = (nums[2]! * Math.PI) / 180;
|
||||
const [r, g, b] = oklabToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha];
|
||||
}
|
||||
if (kind === "lab") { const [r, g, b] = labToRgb(pctOr(raw[0]!, 100), nums[1]!, nums[2]!); return [r, g, b, alpha]; }
|
||||
if (kind === "lch") {
|
||||
const L = pctOr(raw[0]!, 100), C = nums[1]!, h = (nums[2]! * Math.PI) / 180;
|
||||
const [r, g, b] = labToRgb(L, C * Math.cos(h), C * Math.sin(h)); return [r, g, b, alpha];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Back-compat alias: the palette parses every colour space now, not just rgb(). */
|
||||
function parseRgb(v: string): RGBA | null {
|
||||
const m = v.match(/rgba?\(([^)]+)\)/i);
|
||||
if (!m) return null;
|
||||
const p = m[1]!.split(/[,\/]/).map((s) => parseFloat(s.trim()));
|
||||
if (p.length < 3 || p.slice(0, 3).some(Number.isNaN)) return null;
|
||||
return [Math.round(p[0]!), Math.round(p[1]!), Math.round(p[2]!), p.length >= 4 && !Number.isNaN(p[3]!) ? p[3]! : 1];
|
||||
return parseColor(v);
|
||||
}
|
||||
|
||||
/** A stable clustering key for a color literal: its rounded sRGB (+ alpha), so two literals
|
||||
* that render the same colour (`oklab(0.988242 …)` and `oklab(0.988371 …)` → rgb(251,251,251))
|
||||
* collapse to ONE minted token instead of a wall of near-identical --clr-N. Null when the
|
||||
* value can't be parsed (keeps the raw literal as its own key). Alpha bucketed to ±0.02. */
|
||||
export function colorClusterKey(v: string): string | null {
|
||||
const c = parseColor(v);
|
||||
if (!c) return null;
|
||||
return `${c[0]},${c[1]},${c[2]},${Math.round(c[3] * 50)}`;
|
||||
}
|
||||
function within2(a: RGBA, b: RGBA): boolean {
|
||||
return Math.abs(a[0] - b[0]) <= 2 && Math.abs(a[1] - b[1]) <= 2 && Math.abs(a[2] - b[2]) <= 2 && Math.abs(a[3] - b[3]) <= 0.04;
|
||||
@@ -140,33 +245,57 @@ function paletteFrom(usage: Map<string, Usage>, roleIr: IR, minCount: number): C
|
||||
const fgCluster = findCluster(pv?.bodyColor);
|
||||
if (fgCluster) assign("--foreground", fgCluster);
|
||||
|
||||
// 2) Primary/accent: most-used chromatic color, weighted toward interactive usage.
|
||||
const remaining = clusters.filter((c) => !c.members.some((m) => valueToName.has(m)));
|
||||
// Luminance (0–1) for deterministic tiebreaks (Rec.709). Ties in every ranking below
|
||||
// break by count, then luminance (dark→light), then the literal value — so the palette
|
||||
// is byte-stable for a given capture (determinism gate 6).
|
||||
const lum = (c: Usage): number => (0.2126 * c.rgba[0] + 0.7152 * c.rgba[1] + 0.0722 * c.rgba[2]) / 255;
|
||||
const unnamed = (c: { canon: Usage; members: string[] }): boolean => !c.members.some((m) => valueToName.has(m));
|
||||
const byCount = (score: (c: Usage) => number) => (a: { canon: Usage }, b: { canon: Usage }): number =>
|
||||
score(b.canon) - score(a.canon) || b.canon.count - a.canon.count || lum(a.canon) - lum(b.canon) || a.canon.value.localeCompare(b.canon.value);
|
||||
|
||||
// 2) Brand + accent: the most-used chromatic colours, weighted toward interactive usage
|
||||
// (buttons/links). `--primary` (a.k.a. brand) is the strongest; `--accent` the next.
|
||||
// A colour is "chromatic" only with real hue: HSL saturation ≥ 0.25 AND an absolute
|
||||
// channel spread ≥ 24, so near-neutral off-whites/creams (max−min ≈ 10) that merely
|
||||
// tip over the ratio threshold stay neutrals → --surface, not a fake --accent.
|
||||
const chroma = (c: RGBA): number => Math.max(c[0], c[1], c[2]) - Math.min(c[0], c[1], c[2]);
|
||||
const remaining = clusters.filter(unnamed);
|
||||
const chromatic = remaining
|
||||
.filter((c) => { const { s } = satLight(c.canon.rgba); return s >= 0.25; })
|
||||
.sort((a, b) => (b.canon.interactive * 3 + b.canon.count) - (a.canon.interactive * 3 + a.canon.count));
|
||||
.filter((c) => satLight(c.canon.rgba).s >= 0.25 && chroma(c.canon.rgba) >= 24)
|
||||
.sort(byCount((c) => c.interactive * 3 + c.count));
|
||||
if (chromatic[0]) assign("--primary", chromatic[0]);
|
||||
if (chromatic[1]) assign("--accent", chromatic[1]);
|
||||
|
||||
// 3) Border: most-used color that appears predominantly as a border.
|
||||
// 3) Border: most-used colour that appears predominantly as a border.
|
||||
const borderC = remaining
|
||||
.filter((c) => !c.members.some((m) => valueToName.has(m)) && c.canon.border > 0)
|
||||
.sort((a, b) => b.canon.border - a.canon.border)[0];
|
||||
.filter((c) => unnamed(c) && c.canon.border > 0)
|
||||
.sort(byCount((c) => c.border))[0];
|
||||
if (borderC && borderC.canon.border >= Math.max(2, minCount - 1)) assign("--border", borderC);
|
||||
|
||||
// 4) Neutrals: a light neutral used as a background → surface; a mid neutral text → muted.
|
||||
for (const c of remaining) {
|
||||
if (c.members.some((m) => valueToName.has(m))) continue;
|
||||
if (c.canon.count < minCount) continue;
|
||||
const { s, l } = satLight(c.canon.rgba);
|
||||
if (s < 0.15 && c.canon.bg >= c.canon.text && l > 0.85 && !taken.has("--surface")) assign("--surface", c);
|
||||
else if (s < 0.2 && c.canon.text >= c.canon.bg && l > 0.35 && l < 0.7 && !taken.has("--muted-foreground")) assign("--muted-foreground", c);
|
||||
// 4) Neutrals, ranked so the most-used earns the cleanest name. Light neutrals used as
|
||||
// backgrounds → --surface, --surface-2, … (section/card/footer alt backgrounds);
|
||||
// mid/dark neutrals used as text → --muted-foreground then --muted. "Neutral" is the
|
||||
// complement of "chromatic" above (no strong hue: low saturation OR tiny channel spread),
|
||||
// so no colour falls through the gap between the two thresholds.
|
||||
const neutrals = remaining
|
||||
.filter((c) => unnamed(c) && c.canon.count >= minCount && (satLight(c.canon.rgba).s < 0.25 || chroma(c.canon.rgba) < 24))
|
||||
.sort(byCount((c) => c.count));
|
||||
let surfaceN = 0;
|
||||
for (const c of neutrals) {
|
||||
if (!unnamed(c)) continue;
|
||||
const { l } = satLight(c.canon.rgba);
|
||||
if (c.canon.bg >= c.canon.text && l > 0.85) {
|
||||
assign(surfaceN === 0 ? "--surface" : `--surface-${surfaceN + 1}`, c);
|
||||
surfaceN++;
|
||||
} else if (c.canon.text >= c.canon.bg && l > 0.2 && l < 0.7) {
|
||||
assign(!taken.has("--muted-foreground") ? "--muted-foreground" : "--muted", c);
|
||||
}
|
||||
}
|
||||
|
||||
// 5) Everything else used >= minCount → numbered.
|
||||
// 5) Everything else used >= minCount → numbered, in a deterministic order.
|
||||
let ci = 1;
|
||||
for (const c of remaining) {
|
||||
if (c.members.some((m) => valueToName.has(m))) continue;
|
||||
for (const c of [...remaining].sort(byCount(() => 0))) {
|
||||
if (!unnamed(c)) continue;
|
||||
if (c.canon.count < minCount) continue;
|
||||
assign(`--color-${String(ci++).padStart(3, "0")}`, c);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,10 @@ import type { InteractionCapture } from "../capture/interactions.js";
|
||||
/**
|
||||
* Normalized Render IR. Merges the per-viewport capture snapshots into a single
|
||||
* tree whose structure comes from the canonical (1280) capture; each node carries
|
||||
* per-viewport computed styles, bounding boxes, and visibility. This is the single
|
||||
* per-viewport computed styles, bounding boxes, and visibility. Children that exist
|
||||
* ONLY at non-canonical viewports are grafted in as siblings (visible only in their
|
||||
* source bands); a container whose children are a wholly different set at some width
|
||||
* is recorded as content drift instead (see childDriftVps). This is the single
|
||||
* source of truth for section/token inference, generation, and validation.
|
||||
*/
|
||||
|
||||
@@ -25,6 +28,9 @@ export type IRNode = {
|
||||
// element had no class.
|
||||
srcClass?: string;
|
||||
rawHTML?: string; // inline svg
|
||||
// Computed paint of an inline <svg> root (fill/stroke/color), carried from capture. Lets codegen
|
||||
// recover a paint that a raw `fill="none"` attribute hides but site CSS actually supplied.
|
||||
svgPaint?: { fill: string; stroke: string; color: string };
|
||||
visibleByVp: Record<number, boolean>;
|
||||
bboxByVp: Record<number, BBox>;
|
||||
computedByVp: Record<number, StyleMap>;
|
||||
@@ -37,6 +43,14 @@ export type IRNode = {
|
||||
// ::placeholder computed style (input/textarea with placeholder text) — emitted as a
|
||||
// `::placeholder` rule so form controls keep their authored placeholder color.
|
||||
placeholderByVp?: Record<number, StyleMap>;
|
||||
// Band viewports where this container's element children were a DIFFERENT SET than the
|
||||
// canonical capture's (mutual whole-set mismatch — the source deterministically served
|
||||
// different content at that width, e.g. a rotator picking other items per breakpoint).
|
||||
// Policy: FAITHFUL-AT-CANONICAL — emission shows the canonical children there (it skips
|
||||
// the display:none band it would otherwise emit for a child with no counterpart) instead
|
||||
// of rendering an empty shell; the other set is NOT grafted (no duplication). Surfaced in
|
||||
// the manifest via doc.contentDrift.
|
||||
childDriftVps?: number[];
|
||||
children: IRChild[];
|
||||
};
|
||||
|
||||
@@ -72,6 +86,11 @@ export type IRDoc = {
|
||||
// re-emit the keyframes that the per-node `animation-name` declarations reference —
|
||||
// without these the animation properties are inert (the half-plumbed pre-Stage-5 gap).
|
||||
keyframes: string[];
|
||||
// Containers whose children diverged as a WHOLE SET at some band viewport(s) (see
|
||||
// IRNode.childDriftVps). Recorded post-renumber so ids are final; carried into the
|
||||
// generated manifest as a fidelity note ("the source served different content there").
|
||||
// Omitted when no drift was detected.
|
||||
contentDrift?: Array<{ id: string; tag: string; viewports: number[] }>;
|
||||
};
|
||||
|
||||
export type IR = {
|
||||
@@ -83,6 +102,33 @@ export function isTextChild(c: IRChild): c is IRTextNode {
|
||||
return (c as IRTextNode).text !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-flow content extent at a viewport: the largest border-box bottom (`bbox.y + bbox.height`)
|
||||
* over every VISIBLE, IN-FLOW descendant. This is the true height the real page laid out to,
|
||||
* independent of a scroll-lock that clips the root to one viewport (a popup vendor that sets
|
||||
* `body{overflow:hidden;height:100vh}` collapses `document.scrollHeight` to the viewport, but
|
||||
* the in-flow sections still carry their real coordinates). Out-of-flow (absolute/fixed) and
|
||||
* floated boxes are excluded — a fixed overlay/footer badge or a floated aside is not part of
|
||||
* the document flow that determines page height. Pure + deterministic (reads only the IR).
|
||||
*/
|
||||
export function irContentExtent(root: IRNode, vp: number): number {
|
||||
let maxBottom = 0;
|
||||
const visit = (n: IRNode): void => {
|
||||
for (const c of n.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
const cs = c.computedByVp[vp];
|
||||
const bb = c.bboxByVp[vp];
|
||||
if (!cs || !bb || !c.visibleByVp[vp]) { visit(c); continue; }
|
||||
const pos = cs.position || "static";
|
||||
const inFlow = pos !== "absolute" && pos !== "fixed" && (cs.float || "none") === "none";
|
||||
if (inFlow) maxBottom = Math.max(maxBottom, bb.y + bb.height);
|
||||
visit(c);
|
||||
}
|
||||
};
|
||||
visit(root);
|
||||
return maxBottom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover lazy-loaded CSS backgrounds dropped by uneven per-viewport capture.
|
||||
* If an element has exactly one URL background at some sampled widths and
|
||||
@@ -175,9 +221,24 @@ const NOISE_TAGS = new Set(["next-route-announcer"]);
|
||||
// width where capture happened to read it open → an empty 400×700 white rectangle at 2xl). Drop
|
||||
// the whole subtree so it never reaches generation.
|
||||
const THIRD_PARTY_OVERLAY = /(?:^|[\s_-])(?:intercom|drift-|hubspot-messages|zendesk|zd-|onetrust|ot-sdk-|usercentrics|grecaptcha|crisp-client|tawk-|livechat|helpscout|beacon-container|cookiebot|cky-)/;
|
||||
|
||||
/**
|
||||
* Email-capture / promo POPUP overlay containers (Attentive, Wunderkind/Bounce Exchange,
|
||||
* Justuno, Privy, Omnisend, Sailthru, Wisepops, etc.). These vendors inject a full-viewport
|
||||
* fixed overlay + backdrop that scroll-locks the page — third-party chrome, not site content.
|
||||
*
|
||||
* CAUTION: these vendors ALSO ship inline, embedded signup forms that ARE real page content
|
||||
* and are deliberately grafted (see iframeGraft tests). So this matches ONLY the OVERLAY
|
||||
* CONTAINER markers each vendor uses for its popup (`attentive_overlay`, `bx-wrapper` /
|
||||
* `wk-`, `justuno`-`container`, `privy-`popup, …) — never a bare vendor name that an inline
|
||||
* form embed would also carry. Overlay-container tokens only.
|
||||
*/
|
||||
export const POPUP_OVERLAY_CONTAINER =
|
||||
/(?:^|[\s_-])(?:attentive_overlay|attentive_creative|attn-|bx-wrapper|bx-window|bounce-?exchange|wknd-|wunderkind|justuno_container|jw-overlay|privy-popup|privy-container|klaviyo-form-.*overlay|sailthru-overlay|wisepops-root|recart-popup|recart-modal)/;
|
||||
function isThirdPartyOverlay(raw: RawNode): boolean {
|
||||
const idClass = `${raw.attrs?.id ?? ""} ${raw.attrs?.class ?? ""}`.toLowerCase();
|
||||
if (THIRD_PARTY_OVERLAY.test(idClass)) return true;
|
||||
if (POPUP_OVERLAY_CONTAINER.test(idClass)) return true;
|
||||
// INT_MAX band: overlay libraries stack above everything. Real content should not reach here,
|
||||
// so this cannot swallow site chrome under normal captures.
|
||||
const zi = parseInt(raw.computed?.zIndex ?? "", 10);
|
||||
@@ -188,6 +249,121 @@ function elementChildren(n: RawNode): RawNode[] {
|
||||
return n.children.filter((c) => (c as { text?: string }).text === undefined) as RawNode[];
|
||||
}
|
||||
|
||||
/** True for a transform value that is visually the identity (no offset/rotation/scale):
|
||||
* the `none` keyword or an identity matrix/matrix3d the browser reports as noise. */
|
||||
export function isIdentityTransform(value: string | undefined): boolean {
|
||||
if (!value || value === "none") return true;
|
||||
const m = /^matrix\(([^)]*)\)$/.exec(value.trim());
|
||||
if (m) {
|
||||
const n = m[1]!.split(",").map((s) => parseFloat(s.trim()));
|
||||
return n.length === 6 && n[0] === 1 && n[1] === 0 && n[2] === 0 && n[3] === 1 && n[4] === 0 && n[5] === 0;
|
||||
}
|
||||
const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim());
|
||||
if (m3) {
|
||||
const n = m3[1]!.split(",").map((s) => parseFloat(s.trim()));
|
||||
const id = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
|
||||
return n.length === 16 && n.every((v, i) => v === id[i]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Parse a CSS length to px (number), 0 for `auto`/empty/non-px. */
|
||||
function pfLen(v: string | undefined): number {
|
||||
if (!v) return 0;
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
/** The horizontal translate (matrix `e`) of a computed transform, or null if the transform is not a
|
||||
* pure-2D matrix/matrix3d with a definite tx (rotation/scale/skew or 3D perspective → null: we only
|
||||
* re-anchor plain horizontal track offsets). `matrix(a,b,c,d,e,f)` → e; `matrix3d(...)` → m41. */
|
||||
function translateXOf(value: string | undefined): number | null {
|
||||
if (!value || value === "none") return null;
|
||||
const m = /^matrix\(([^)]*)\)$/.exec(value.trim());
|
||||
if (m) {
|
||||
const n = m[1]!.split(",").map((s) => parseFloat(s.trim()));
|
||||
if (n.length !== 6 || n.some((x) => !Number.isFinite(x))) return null;
|
||||
if (n[1] !== 0 || n[2] !== 0) return null; // has rotation/skew → not a plain slide
|
||||
return n[4]!;
|
||||
}
|
||||
const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim());
|
||||
if (m3) {
|
||||
const n = m3[1]!.split(",").map((s) => parseFloat(s.trim()));
|
||||
if (n.length !== 16 || n.some((x) => !Number.isFinite(x))) return null;
|
||||
// Require an otherwise-identity 3D matrix apart from the translate column (indices 12/13/14).
|
||||
const idExceptTranslate = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, /*12*/ 0, /*13*/ 0, /*14*/ 0, 1];
|
||||
for (let i = 0; i < 16; i++) { if (i === 12 || i === 13 || i === 14) continue; if (n[i] !== idExceptTranslate[i]) return null; }
|
||||
return n[12]!;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Return `value` with its horizontal translate replaced by `tx` (keeps the rest of the matrix). */
|
||||
function setTranslateX(value: string, tx: number): string {
|
||||
const m = /^matrix\(([^)]*)\)$/.exec(value.trim());
|
||||
if (m) {
|
||||
const n = m[1]!.split(",").map((s) => s.trim());
|
||||
n[4] = String(tx);
|
||||
return `matrix(${n.join(", ")})`;
|
||||
}
|
||||
const m3 = /^matrix3d\(([^)]*)\)$/.exec(value.trim());
|
||||
if (m3) {
|
||||
const n = m3[1]!.split(",").map((s) => s.trim());
|
||||
n[12] = String(tx);
|
||||
return `matrix3d(${n.join(", ")})`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize per-viewport transform values so identity is represented uniformly as the literal
|
||||
* `none`. Two problems this closes, both in the per-viewport delta emission downstream:
|
||||
* 1. The browser reports "no transform" inconsistently — `none` at some widths, an identity
|
||||
* `matrix(1,0,0,1,0,0)` at others (composited-layer noise). The generator's default-skip
|
||||
* only recognizes `none`, so an identity matrix at the base viewport is emitted as a real
|
||||
* transform and then cascades across bands.
|
||||
* 2. When ANY viewport carries a genuine (non-identity) transform, the identity value at the
|
||||
* other viewports must stay observable — canonicalizing it to `none` (not dropping it) lets
|
||||
* the generator emit the explicit reset so the non-identity transform can't leak into a band
|
||||
* where the source had none.
|
||||
* Deterministic, in place; only touches the `transform` slot.
|
||||
*/
|
||||
export function canonicalizeTransforms(computedByVp: Record<number, StyleMap>): void {
|
||||
for (const k of Object.keys(computedByVp)) {
|
||||
const cs = computedByVp[Number(k)];
|
||||
if (cs && isIdentityTransform(cs.transform)) cs.transform = "none";
|
||||
}
|
||||
}
|
||||
|
||||
/** True when an INFINITE CSS animation is active at this viewport. Such an animation perpetually
|
||||
* drives its animated properties (opacity/transform), so the captured value is a frozen phase of
|
||||
* the loop, not authored design. */
|
||||
function hasInfiniteAnimation(cs: StyleMap | undefined): boolean {
|
||||
if (!cs || (cs.animationName || "none") === "none") return false;
|
||||
return /infinite/.test(cs.animationIterationCount || "1");
|
||||
}
|
||||
|
||||
/**
|
||||
* Neutralize the transform of a node that carries an INFINITE animation at ANY captured viewport.
|
||||
* The capture shutter froze the marquee/spinner mid-loop, and — critically — a CSS animation gated
|
||||
* to a breakpoint (Webflow's `max-lg` logo/big-text tracks) reads `animation:none` at the widths
|
||||
* where it does NOT run, yet the browser still reports the last frozen `translateX` there. Banding
|
||||
* those frozen values bakes a mid-scroll offset that shifts content offscreen-left AT REST (rows
|
||||
* starting mid-glyph). The runtime `@keyframes` owns the transform and starts at translateX(0), so
|
||||
* the faithful at-rest value is `none` at every width; generation's `animOwnedProps` then keeps the
|
||||
* base holding while the animation drives it live. Only fires when a genuine infinite animation is
|
||||
* present at some viewport — a statically-offset design element (no animation) is untouched.
|
||||
* Deterministic, in place; only touches the `transform` slot.
|
||||
*/
|
||||
export function neutralizeAnimatedTransforms(computedByVp: Record<number, StyleMap>): void {
|
||||
const vps = Object.keys(computedByVp).map(Number);
|
||||
if (!vps.some((vp) => hasInfiniteAnimation(computedByVp[vp]))) return;
|
||||
for (const vp of vps) {
|
||||
const cs = computedByVp[vp];
|
||||
if (cs && cs.transform && cs.transform !== "none") cs.transform = "none";
|
||||
}
|
||||
}
|
||||
|
||||
/** Full identity signature (tag + id + class). */
|
||||
function sigFull(n: RawNode): string {
|
||||
return `${n.tag}#${n.attrs?.id ?? ""}.${(n.attrs?.class ?? "").trim()}`;
|
||||
@@ -259,6 +435,39 @@ function alignChildren(canon: RawNode[], other: RawNode[]): (RawNode | undefined
|
||||
return out;
|
||||
}
|
||||
|
||||
/** True when this raw node or any element descendant painted at its capture viewport. */
|
||||
function rawSubtreeVisible(n: RawNode): boolean {
|
||||
if (n.visible) return true;
|
||||
for (const c of elementChildren(n)) if (rawSubtreeVisible(c)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Most frequent tag in a sibling group (ties broken alphabetically — deterministic). */
|
||||
function dominantTag(nodes: RawNode[]): string {
|
||||
const counts = new Map<string, number>();
|
||||
for (const n of nodes) counts.set(n.tag, (counts.get(n.tag) ?? 0) + 1);
|
||||
let best = "", bestC = -1;
|
||||
for (const [t, c] of [...counts.entries()].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
|
||||
if (c > bestC) { best = t; bestC = c; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// A sibling appearing ONLY at non-canonical viewport(s) is grafted into the IR (visible only in
|
||||
// its source band(s)). Cap per parent per viewport so a pathological capture (a virtualized list
|
||||
// re-keying hundreds of rows) can't balloon the tree; the whole-set drift path below covers the
|
||||
// legitimate large-mismatch case.
|
||||
const GRAFT_MAX_PER_VP = 24;
|
||||
// Whole-set content-identity drift: at least this many children UNMATCHED on BOTH sides before a
|
||||
// container is treated as "the source served different content at this width" (vs a couple of
|
||||
// responsive-only extras, which are grafted instead).
|
||||
const DRIFT_MIN_UNMATCHED = 3;
|
||||
|
||||
/** A per-parent graft group: one non-canonical-only child, matched across the non-canonical
|
||||
* viewports it appears at. `rep` is the raw node from the LOWEST viewport (structural identity
|
||||
* for aligning later viewports into the group). */
|
||||
type GraftGroup = { rep: RawNode; rawByVp: Record<number, RawNode> };
|
||||
|
||||
/** Capture-ids of recognized-pattern panels/regions to force-keep through pruning
|
||||
* (they are display:none in the inactive/collapsed base state). Empty when no
|
||||
* interactions were captured, so non-interactive runs prune exactly as before. */
|
||||
@@ -289,6 +498,9 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
}
|
||||
const canonical = viewports.includes(1280) ? 1280 : viewports[Math.floor(viewports.length / 2)]!;
|
||||
const canonSnap = snapshots[canonical]!;
|
||||
// Deterministic iteration order for the cross-viewport graft grouping below.
|
||||
const vpsAsc = [...viewports].sort((a, b) => a - b);
|
||||
const bandVpSet = new Set(bandVps);
|
||||
|
||||
// Stage 4: recognized interactive panels (tabs/accordion) are often display:none
|
||||
// at base (the inactive/collapsed state). Their subtrees must survive pruning so
|
||||
@@ -304,6 +516,10 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
const convert = (raw: RawNode, matched: Record<number, RawNode | undefined>): IRNode | null => {
|
||||
if (NOISE_TAGS.has(raw.tag)) return null;
|
||||
if (isThirdPartyOverlay(raw)) return null;
|
||||
// Font-metric / measurement scratch nodes the source's own JS injects (tagged by the walker):
|
||||
// absolutely positioned, parked far off-screen, non-painting. Never user-visible — drop so they
|
||||
// don't ship as page markup (e.g. the `<div … -top-[6249rem] invisible>Mgy</div>` probe).
|
||||
if (raw.probe) return null;
|
||||
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
@@ -324,6 +540,14 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
if (match.after) afterByVp[vw] = match.after;
|
||||
if (match.placeholder) placeholderByVp[vw] = match.placeholder;
|
||||
}
|
||||
// Canonicalize identity transforms (none / identity matrix) to `none` at every viewport so
|
||||
// the generator's per-band delta treats them uniformly and a scroll/composite-noise transform
|
||||
// at one width can't leak across bands.
|
||||
canonicalizeTransforms(computedByVp);
|
||||
// Drop transforms frozen mid-loop by an infinite animation (marquees/spinners) at every viewport —
|
||||
// including the breakpoints where the animation is gated off but the browser still reports the last
|
||||
// frozen offset. Prevents a baked mid-scroll translateX from clipping content offscreen at rest.
|
||||
neutralizeAnimatedTransforms(computedByVp);
|
||||
|
||||
const node: IRNode = {
|
||||
id: nextId(),
|
||||
@@ -335,6 +559,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
children: [],
|
||||
};
|
||||
if (raw.rawHTML) node.rawHTML = raw.rawHTML;
|
||||
if (raw.svgPaint) node.svgPaint = raw.svgPaint;
|
||||
const srcClass = (raw.attrs?.class ?? "").trim();
|
||||
if (srcClass) node.srcClass = srcClass;
|
||||
if (Object.keys(sizingByVp).length) node.sizingByVp = sizingByVp;
|
||||
@@ -346,25 +571,131 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
const canonKids = elementChildren(raw);
|
||||
// Align this node's canonical element children to each viewport's children.
|
||||
const aligned: Record<number, (RawNode | undefined)[]> = {};
|
||||
const kidsByVp: Record<number, RawNode[]> = {};
|
||||
for (const vw of viewports) {
|
||||
if (vw === canonical) continue;
|
||||
const m = matched[vw];
|
||||
const vwKids = m && m.tag === raw.tag ? elementChildren(m) : [];
|
||||
kidsByVp[vw] = vwKids;
|
||||
aligned[vw] = alignChildren(canonKids, vwKids);
|
||||
}
|
||||
|
||||
// ---- Per-viewport DOM divergence (canonical-rooted subtrees only) ----
|
||||
// Two inverse gaps closed here:
|
||||
// • GRAFT: a child that exists ONLY at non-canonical viewport(s) (destroyed/never built at
|
||||
// the canonical width) would otherwise never enter the IR — the clone then misses it at
|
||||
// the widths where the source shows it (e.g. mobile-only carousel pagination bullets).
|
||||
// Graft it as a sibling at its source position, carrying per-viewport data ONLY for the
|
||||
// viewports it appeared at; emission hides it at base and reveals it in its band(s).
|
||||
// • DRIFT: when a container's children at some viewport are a DIFFERENT SET on BOTH sides
|
||||
// (mutual whole-set mismatch — the source deterministically served other content there),
|
||||
// grafting would duplicate the component. Prefer faithful-at-canonical: record the drift
|
||||
// so emission shows the canonical children there instead of banding them all display:none
|
||||
// (an empty shell). Recorded in childDriftVps → doc.contentDrift → the manifest.
|
||||
const driftVps = new Set<number>();
|
||||
const graftsByAnchor = new Map<number, GraftGroup[]>();
|
||||
if (matched[canonical]) {
|
||||
for (const vw of vpsAsc) {
|
||||
if (vw === canonical) continue;
|
||||
const vwKids = kidsByVp[vw]!;
|
||||
if (vwKids.length === 0) continue;
|
||||
const matchedOther = new Set<RawNode>();
|
||||
for (const mo of aligned[vw]!) if (mo) matchedOther.add(mo);
|
||||
const unmatchedCanon = canonKids.filter((_, i) => !aligned[vw]![i]);
|
||||
const unmatchedOther = vwKids.filter((k) => !matchedOther.has(k));
|
||||
// Whole-set identity drift: many unmatched on BOTH sides, few matched, and both leftover
|
||||
// groups are the same kind of element (same dominant tag — one component family serving
|
||||
// different items, not a structurally different widget swapped in at this width).
|
||||
if (
|
||||
unmatchedCanon.length >= DRIFT_MIN_UNMATCHED &&
|
||||
unmatchedOther.length >= DRIFT_MIN_UNMATCHED &&
|
||||
matchedOther.size * 2 < Math.min(canonKids.length, vwKids.length) &&
|
||||
dominantTag(unmatchedCanon) === dominantTag(unmatchedOther)
|
||||
) {
|
||||
driftVps.add(vw);
|
||||
continue; // faithful-at-canonical: do NOT graft the other set (no duplication)
|
||||
}
|
||||
if (unmatchedOther.length === 0 || unmatchedOther.length > GRAFT_MAX_PER_VP) continue;
|
||||
// Graft position: each unmatched child anchors AFTER the last canonical child matched
|
||||
// before it in this viewport's sibling order (-1 = before every canonical child).
|
||||
const canonIdxOf = new Map<RawNode, number>();
|
||||
aligned[vw]!.forEach((mo, i) => { if (mo) canonIdxOf.set(mo, i); });
|
||||
let lastCanonIdx = -1;
|
||||
const newByAnchor = new Map<number, RawNode[]>();
|
||||
for (const k of vwKids) {
|
||||
const ci = canonIdxOf.get(k);
|
||||
if (ci !== undefined) { lastCanonIdx = ci; continue; }
|
||||
let list = newByAnchor.get(lastCanonIdx);
|
||||
if (!list) newByAnchor.set(lastCanonIdx, (list = []));
|
||||
list.push(k);
|
||||
}
|
||||
// Merge this viewport's unmatched children into the anchor's existing graft groups by
|
||||
// structural signature (the same node present at several widths becomes ONE graft with
|
||||
// per-viewport data), preserving sibling order; leftovers open new groups in order.
|
||||
for (const [anchor, newcomers] of [...newByAnchor.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const groups = graftsByAnchor.get(anchor) ?? [];
|
||||
const al = alignChildren(groups.map((g) => g.rep), newcomers);
|
||||
const groupOfNew = new Map<RawNode, number>();
|
||||
al.forEach((r, idx) => { if (r) groupOfNew.set(r, idx); });
|
||||
const merged: GraftGroup[] = [];
|
||||
let gi = 0;
|
||||
for (const k of newcomers) {
|
||||
const gIdx = groupOfNew.get(k);
|
||||
if (gIdx !== undefined) {
|
||||
while (gi <= gIdx) merged.push(groups[gi++]!);
|
||||
groups[gIdx]!.rawByVp[vw] = k;
|
||||
} else {
|
||||
merged.push({ rep: k, rawByVp: { [vw]: k } });
|
||||
}
|
||||
}
|
||||
while (gi < groups.length) merged.push(groups[gi++]!);
|
||||
graftsByAnchor.set(anchor, merged);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Materialize graft groups → IR nodes. Keep only groups that actually PAINT at a band
|
||||
// viewport (a graft visible solely at a dense sample width could never be shown — bands are
|
||||
// emitted only at the standard breakpoints — so it would be dead markup).
|
||||
const graftedByAnchor = new Map<number, IRNode[]>();
|
||||
for (const [anchor, groups] of [...graftsByAnchor.entries()].sort((a, b) => a[0] - b[0])) {
|
||||
const out: IRNode[] = [];
|
||||
for (const g of groups) {
|
||||
const graftVps = Object.keys(g.rawByVp).map(Number).sort((a, b) => a - b);
|
||||
if (!graftVps.some((vw) => bandVpSet.has(vw) && rawSubtreeVisible(g.rawByVp[vw]!))) continue;
|
||||
const gm: Record<number, RawNode | undefined> = {};
|
||||
for (const vw of viewports) gm[vw] = g.rawByVp[vw];
|
||||
const gn = convert(g.rawByVp[graftVps[0]!]!, gm);
|
||||
if (gn) out.push(gn);
|
||||
}
|
||||
if (out.length) graftedByAnchor.set(anchor, out);
|
||||
}
|
||||
if (driftVps.size) {
|
||||
const bandDrift = [...driftVps].filter((v) => bandVpSet.has(v)).sort((a, b) => a - b);
|
||||
if (bandDrift.length) node.childDriftVps = bandDrift;
|
||||
}
|
||||
|
||||
let ei = 0;
|
||||
let pushedLeading = false;
|
||||
const pushGrafts = (anchor: number): void => {
|
||||
for (const gn of graftedByAnchor.get(anchor) ?? []) node.children.push(gn);
|
||||
};
|
||||
for (const c of raw.children) {
|
||||
if ((c as IRTextNode).text !== undefined) {
|
||||
node.children.push({ text: (c as IRTextNode).text });
|
||||
continue;
|
||||
}
|
||||
if (!pushedLeading) { pushGrafts(-1); pushedLeading = true; }
|
||||
const child = c as RawNode;
|
||||
const childMatched: Record<number, RawNode | undefined> = {};
|
||||
for (const vw of viewports) childMatched[vw] = vw === canonical ? child : aligned[vw]![ei];
|
||||
ei++;
|
||||
// A grafted subtree has no canonical counterpart: its own children must not be treated as
|
||||
// canonical-matched either (they carry data only at the graft's source viewports).
|
||||
for (const vw of viewports) childMatched[vw] = vw === canonical ? (matched[canonical] ? child : undefined) : aligned[vw]![ei];
|
||||
const converted = convert(child, childMatched);
|
||||
if (converted) node.children.push(converted);
|
||||
pushGrafts(ei);
|
||||
ei++;
|
||||
}
|
||||
if (!pushedLeading) pushGrafts(-1);
|
||||
}
|
||||
return node;
|
||||
};
|
||||
@@ -381,6 +712,8 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
// interactive panel so its whole (possibly display:none) subtree is retained.
|
||||
const keepAll = forced || preserveCaps.has(node.attrs["data-cid-cap"] ?? "");
|
||||
const keptChildren: IRChild[] = [];
|
||||
// Per element child, in original order: was it kept? (for track-transform re-anchoring below.)
|
||||
const elemKept: Array<{ child: IRNode; kept: boolean }> = [];
|
||||
let hasVisibleDescendant = false;
|
||||
let hasText = false;
|
||||
for (const c of node.children) {
|
||||
@@ -397,16 +730,81 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
if (keepAll) {
|
||||
prune(c, true);
|
||||
keptChildren.push(c);
|
||||
elemKept.push({ child: c, kept: true });
|
||||
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
|
||||
} else if (prune(c, false) || keepSource) {
|
||||
keptChildren.push(c);
|
||||
elemKept.push({ child: c, kept: true });
|
||||
if (Object.values(c.visibleByVp).some(Boolean) || childHasVisible(c)) hasVisibleDescendant = true;
|
||||
} else {
|
||||
elemKept.push({ child: c, kept: false });
|
||||
}
|
||||
}
|
||||
node.children = keptChildren;
|
||||
reanchorTrackTransform(node, elemKept);
|
||||
if (keepAll) return true;
|
||||
const selfVisible = Object.values(node.visibleByVp).some(Boolean);
|
||||
return selfVisible || hasVisibleDescendant || hasText || !!node.rawHTML;
|
||||
return selfVisible || hasVisibleDescendant || hasText || !!node.rawHTML || isSizedInvisibleSpacer(node);
|
||||
};
|
||||
// An in-flow `visibility:hidden` box with a nonzero border box is LOAD-BEARING geometry: it is
|
||||
// invisible (so `visibleByVp` is false and the visibility prune would drop it), but it still takes
|
||||
// up its captured width/height in normal flow — a spacer/ghost column that reserves the row height
|
||||
// its absolutely-positioned siblings paint into. Dropping it collapses the column and shifts
|
||||
// everything below it up. Keep it as an empty sized placeholder (generation emits its w/h with
|
||||
// `visibility:hidden` and no content). `display:none`-everywhere nodes (never in flow, zero box)
|
||||
// and out-of-flow probes stay pruned; font-metric probes are already dropped before pruning.
|
||||
const isSizedInvisibleSpacer = (node: IRNode): boolean => {
|
||||
for (const vp of Object.keys(node.computedByVp).map(Number)) {
|
||||
const cs = node.computedByVp[vp]; const bb = node.bboxByVp[vp];
|
||||
if (!cs || !bb) continue;
|
||||
if ((cs.display || "") === "none") continue; // not in flow here
|
||||
const vis = cs.visibility || "visible";
|
||||
if (vis !== "hidden" && vis !== "collapse") continue; // only invisible-but-laid-out boxes
|
||||
const pos = cs.position || "static";
|
||||
if (pos !== "static" && pos !== "relative") continue; // in-flow geometry only (not absolute/fixed)
|
||||
if (bb.width > 0 && bb.height > 0) return true; // reserves real space
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// Re-anchor a horizontally-translated TRACK container after pruning removed leading in-flow
|
||||
// children. A settled loop carousel (Splide/Swiper/Slick) parks its track with a baked
|
||||
// `translateX(-N)` and prepends invisible clone slides that occupy exactly [-N, 0]; the first REAL
|
||||
// slide then paints at x=0. The visibility prune drops the off-screen clones but the baked
|
||||
// translateX survives verbatim, so every kept slide sits N px offscreen-left (an empty band). When
|
||||
// emission drops the leading in-flow children of such a translated track, subtract their aggregate
|
||||
// outer width from the baked translateX per viewport so the first KEPT child lands where it was
|
||||
// captured. NON-animated baked offsets only — animation-owned transforms are already neutralized to
|
||||
// `none` upstream (neutralizeAnimatedTransforms), so a still-present translate here is a static bake.
|
||||
const reanchorTrackTransform = (node: IRNode, elemKept: Array<{ child: IRNode; kept: boolean }>): void => {
|
||||
if (elemKept.length === 0) return;
|
||||
// Only act when a leading RUN of element children was dropped (the clone slides precede the reals).
|
||||
const firstKeptIdx = elemKept.findIndex((e) => e.kept);
|
||||
if (firstKeptIdx <= 0) return; // nothing dropped ahead of the first kept child (or nothing kept)
|
||||
for (const vp of Object.keys(node.computedByVp).map(Number)) {
|
||||
const cs = node.computedByVp[vp];
|
||||
if (!cs || !cs.transform) continue;
|
||||
const tx = translateXOf(cs.transform);
|
||||
if (tx === null || tx === 0) continue; // no baked horizontal offset to re-anchor at this vp
|
||||
// Aggregate the outer (margin-box) width of the dropped leading in-flow siblings at this vp.
|
||||
let droppedW = 0;
|
||||
for (let i = 0; i < firstKeptIdx; i++) {
|
||||
const { child } = elemKept[i]!;
|
||||
const ccs = child.computedByVp[vp]; const cb = child.bboxByVp[vp];
|
||||
if (!ccs || !cb) continue;
|
||||
if ((ccs.display || "") === "none") continue;
|
||||
const cpos = ccs.position || "static";
|
||||
if (cpos !== "static" && cpos !== "relative") continue; // out-of-flow slides don't advance the track
|
||||
droppedW += cb.width + pfLen(ccs.marginLeft) + pfLen(ccs.marginRight);
|
||||
}
|
||||
if (droppedW === 0) continue;
|
||||
// translateX is negative (track pulled left); dropping the leading clones that filled [tx, 0]
|
||||
// means the reals shift right by droppedW → add it back. Re-anchor toward 0.
|
||||
const next = tx + droppedW;
|
||||
// Only re-anchor when it moves the track TOWARD the origin and doesn't overshoot past it — a
|
||||
// guard so a partial mismatch can't push content the wrong way. Round to match capture precision.
|
||||
if (Math.abs(next) >= Math.abs(tx)) continue;
|
||||
cs.transform = setTranslateX(cs.transform, Math.round(next * 100) / 100);
|
||||
}
|
||||
};
|
||||
const childHasVisible = (n: IRNode): boolean => {
|
||||
for (const c of n.children) {
|
||||
@@ -427,6 +825,16 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
};
|
||||
renumber(root);
|
||||
|
||||
// Content-identity drift notes (containers whose children were a different set at some band
|
||||
// viewport — see childDriftVps). Collected AFTER renumbering so the recorded ids are final;
|
||||
// deterministic (pre-order walk of the pruned tree).
|
||||
const contentDrift: NonNullable<IRDoc["contentDrift"]> = [];
|
||||
const collectDrift = (n: IRNode): void => {
|
||||
if (n.childDriftVps?.length) contentDrift.push({ id: n.id, tag: n.tag, viewports: [...n.childDriftVps] });
|
||||
for (const c of n.children) if (!isTextChild(c)) collectDrift(c);
|
||||
};
|
||||
collectDrift(root);
|
||||
|
||||
const perViewport: IRDoc["perViewport"] = {};
|
||||
for (const vw of viewports) {
|
||||
const d = snapshots[vw]!.doc;
|
||||
@@ -456,6 +864,7 @@ export function buildIR(sourceDir: string, viewports: number[], opts?: { motion?
|
||||
// benchmark + all multi-page, which don't capture motion) ⇒ none emitted, so the
|
||||
// clone stays byte-identical to the pre-Stage-5 frozen output.
|
||||
keyframes: opts?.motion ? [...new Set(canonSnap.keyframes ?? [])].sort() : [],
|
||||
...(contentDrift.length ? { contentDrift } : {}),
|
||||
};
|
||||
|
||||
// Repair transient root scroll-locks. A site that locks scrolling during an intro
|
||||
|
||||
+117
-118
@@ -1,142 +1,141 @@
|
||||
/**
|
||||
* Code-quality audit — count the "bad code" tells that make a generated clone read robotic, for any
|
||||
* number of app source trees, side by side. Repeatable: point it at shipped deliverables and it
|
||||
* prints one column per tree.
|
||||
* Code-quality audit — an honest, human-readable report over one or more generated app
|
||||
* trees. Built directly on the dimension scorer in ./qualityScore, so the audit and the
|
||||
* shipped `code-quality.md` quality number never disagree.
|
||||
*
|
||||
* npm run audit # auto: every output/<site>/app
|
||||
* npm run audit -- <dir> [<dir> ...] # explicit trees; .clone resolves to sibling app/
|
||||
* npm run audit -- output/example/app
|
||||
* npm run audit # auto: every runs/<site>/latest/generated/app
|
||||
* npm run audit -- <appDir> [<appDir> ...] # explicit app trees
|
||||
* npm run audit -- runs/example/latest/generated/app
|
||||
* npm run audit -- <dir> --json # machine-readable
|
||||
*
|
||||
* Scans .tsx/.jsx/.ts/.css under each dir (skips node_modules/.next/out/dotfiles). Every metric is a
|
||||
* COUNT where lower is better, except the two "good" context rows (fluid width / standard scale).
|
||||
* The decisive "decimal" tell is non-integer-PX: each arbitrary px/rem is converted to px and flagged
|
||||
* if it isn't a whole pixel (a frozen measurement), so a clean 12.5rem (=200px) does NOT count.
|
||||
* For each tree it prints:
|
||||
* • the LETTER GRADE + numeric score (and any hard-cap reason),
|
||||
* • a per-DIMENSION table (payload / decomposition / duplication / semantics / hygiene
|
||||
* / runtime) with the visible sub-metrics behind each score,
|
||||
* • the TOP OFFENDERS (file, metric, value) — the worst individual tells.
|
||||
* When several trees are passed it also prints a side-by-side grade comparison.
|
||||
*
|
||||
* Pure static analysis over generated source (.tsx/.jsx/.ts/.astro/.css). No builds, no
|
||||
* browser. See qualityScore.ts for the rubric + the (calibration-guide) thresholds.
|
||||
*/
|
||||
import { readdirSync, statSync, readFileSync, existsSync } from "node:fs";
|
||||
import { join, resolve, basename, dirname, sep } from "node:path";
|
||||
import { readdirSync, statSync, existsSync } from "node:fs";
|
||||
import { join, resolve, basename } from "node:path";
|
||||
import { scoreApp, type QualityReport } from "./qualityScore.js";
|
||||
|
||||
type Row = { label: string; lowerBetter: boolean; values: number[] };
|
||||
type Target = { inputDir: string; scanDir: string; validationDir?: string };
|
||||
// ---------------------------------------------------------------------------
|
||||
// Target resolution — accept an app dir directly, or discover deliverables.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function readTree(dir: string): { tsx: string; css: string; all: string } {
|
||||
const tsxParts: string[] = [], cssParts: string[] = [];
|
||||
const walk = (d: string): void => {
|
||||
for (const n of readdirSync(d)) {
|
||||
if (n === "node_modules" || n === ".next" || n === "out" || n.startsWith(".")) continue;
|
||||
const p = join(d, n);
|
||||
const st = statSync(p);
|
||||
if (st.isDirectory()) walk(p);
|
||||
else if (/\.(tsx|jsx|ts)$/.test(n)) tsxParts.push(readFileSync(p, "utf8"));
|
||||
else if (/\.css$/.test(n)) cssParts.push(readFileSync(p, "utf8"));
|
||||
/** Is `dir` a scannable app tree (has a src/ with code in it, or is itself full of code)? */
|
||||
function isAppDir(dir: string): boolean {
|
||||
if (!existsSync(dir)) return false;
|
||||
if (existsSync(join(dir, "src"))) return true;
|
||||
try { return readdirSync(dir).some((n) => /\.(tsx|jsx|ts|css|astro)$/.test(n)); } catch { return false; }
|
||||
}
|
||||
|
||||
/** Discover every runs/<site>/latest/generated/app deliverable, newest layout first.
|
||||
* Checks both the cwd and its parent, so `npm run audit` works whether invoked from the
|
||||
* repo root or from compiler/ (where runs/ lives one level up). */
|
||||
function discoverTargets(): string[] {
|
||||
const out: string[] = [];
|
||||
const roots = ["runs", "output", "../runs", "../output"].map((r) => resolve(r)).filter(existsSync);
|
||||
for (const root of roots) {
|
||||
let sites: string[];
|
||||
try { sites = readdirSync(root); } catch { continue; }
|
||||
for (const site of sites) {
|
||||
for (const app of [
|
||||
join(root, site, "latest", "generated", "app"),
|
||||
join(root, site, "app"),
|
||||
]) {
|
||||
if (isAppDir(app)) { out.push(app); break; }
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(dir);
|
||||
const tsx = tsxParts.join("\n"), css = cssParts.join("\n");
|
||||
return { tsx, css, all: tsx + "\n" + css };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** All metric counts for one tree. */
|
||||
function audit(target: Target): Record<string, number> {
|
||||
const dir = target.scanDir;
|
||||
const { tsx, css, all } = readTree(dir);
|
||||
const n = (re: RegExp, s = all): number => (s.match(re) || []).length;
|
||||
const validationDataCid = target.validationDir && existsSync(target.validationDir)
|
||||
? (readTree(target.validationDir).all.match(/data-cid/g) || []).length
|
||||
: 0;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Arbitrary px/rem values + the non-integer-px ("decimal") subset, the decisive measurement tell.
|
||||
let arb = 0, decimal = 0;
|
||||
for (const m of all.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) {
|
||||
arb++;
|
||||
const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1);
|
||||
if (Math.abs(px - Math.round(px)) > 0.02) decimal++;
|
||||
}
|
||||
|
||||
return {
|
||||
"files (tsx+css)": (tsx ? 1 : 0), // overwritten below with real file counts
|
||||
"── BAD (lower=better) ──": -1,
|
||||
"fixed width w-[Npx/rem]": n(/\bw-\[-?[0-9.]+(?:px|rem)\]/g),
|
||||
"fixed height h-[Npx/rem]": n(/\bh-\[-?[0-9.]+(?:px|rem)\]/g),
|
||||
"breakpoint utilities": n(/\b(?:sm|md|lg|xl|2xl|max-sm|max-md|max-lg|max-xl):/g),
|
||||
"arbitrary bands min/max-[Npx]:": n(/\b(?:min|max)-\[[0-9]+px\]:/g),
|
||||
"arbitrary […px/rem] total": arb,
|
||||
"decimal (non-integer px)": decimal,
|
||||
"baked position top/left/inset-[N]": n(/\b(?:top|left|right|bottom|inset(?:-x|-y)?)-\[-?[0-9.]+(?:px|rem)\]/g),
|
||||
"raw color literal [#hex/rgb]": n(/\[(?:#[0-9a-fA-F]{3,8}|rgba?\([^\]]*)\]/g),
|
||||
"per-side border longhand": n(/\[border-(?:top|right|bottom|left)-(?:style|color):/g),
|
||||
"data-cid (shipped)": n(/data-cid/g),
|
||||
"data-cid (validation-only)": validationDataCid,
|
||||
"dangerouslySetInnerHTML": n(/dangerouslySetInnerHTML/g),
|
||||
"': any' props": n(/:\s*any\b/g),
|
||||
"robotic 'Generated by' comments": n(/Generated by clone|clone-static/g),
|
||||
"── GOOD (higher=better) ──": -1,
|
||||
"fluid width (w-full/auto/fraction)": n(/\bw-(?:full|auto|fit|screen|\d{1,2}\/\d{1,2})\b/g),
|
||||
"standard scale (gap-2/w-10/p-4…)": n(/\b(?:gap|p|px|py|pt|pb|pl|pr|m|mx|my|mt|mb|w|h)-(?:0|0\.5|1|1\.5|2|2\.5|3|3\.5|4|5|6|7|8|9|10|11|12|14|16|20|24|28|32|36|40|44|48|52|56|60|64|72|80|96|px)\b/g),
|
||||
};
|
||||
/** A readable column label for a target (its site/run name where possible). */
|
||||
function labelFor(dir: string): string {
|
||||
const parts = resolve(dir).split("/");
|
||||
const runsIdx = parts.lastIndexOf("runs");
|
||||
const outIdx = parts.lastIndexOf("output");
|
||||
const i = runsIdx >= 0 ? runsIdx : outIdx;
|
||||
if (i >= 0 && parts[i + 1]) return parts[i + 1]!.slice(0, 22);
|
||||
return (parts[parts.length - 2] ?? basename(dir)).slice(0, 22);
|
||||
}
|
||||
|
||||
function fileCount(dir: string): number {
|
||||
let c = 0;
|
||||
const walk = (d: string): void => { for (const x of readdirSync(d)) { if (x === "node_modules" || x === ".next" || x === "out" || x.startsWith(".")) continue; const p = join(d, x); statSync(p).isDirectory() ? walk(p) : (/\.(tsx|jsx|ts|css)$/.test(x) && c++); } };
|
||||
walk(dir);
|
||||
return c;
|
||||
const pad = (s: string, w: number): string => s + " ".repeat(Math.max(0, w - s.length));
|
||||
const padL = (s: string, w: number): string => " ".repeat(Math.max(0, w - s.length)) + s;
|
||||
|
||||
function renderReport(label: string, rep: QualityReport): string {
|
||||
const L: string[] = [];
|
||||
L.push("");
|
||||
L.push(`━━━ ${label} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
||||
L.push(` GRADE ${rep.grade} (${rep.total}/100)`);
|
||||
if (rep.caps.length) {
|
||||
L.push(` HARD CAP → grade limited to D-range:`);
|
||||
for (const c of rep.caps) L.push(` • ${c}`);
|
||||
}
|
||||
L.push("");
|
||||
L.push(` ${pad("dimension", 15)}${padL("score", 9)} sub-metrics`);
|
||||
L.push(` ${"─".repeat(66)}`);
|
||||
for (const [dim, cat] of Object.entries(rep.categories)) {
|
||||
const metrics = Object.entries(cat.metrics).map(([k, v]) => `${k}=${v}`).join(" ");
|
||||
L.push(` ${pad(dim, 15)}${padL(`${cat.score}/${cat.max}`, 9)} ${metrics}`);
|
||||
}
|
||||
if (rep.offenders.length) {
|
||||
L.push("");
|
||||
L.push(` top offenders`);
|
||||
L.push(` ${pad("metric", 34)}${padL("value", 12)} file`);
|
||||
L.push(` ${"─".repeat(66)}`);
|
||||
for (const o of rep.offenders.slice(0, 10)) {
|
||||
L.push(` ${pad(o.metric, 34)}${padL(String(o.value), 12)} ${o.file}`);
|
||||
}
|
||||
}
|
||||
return L.join("\n");
|
||||
}
|
||||
|
||||
function normalizeTarget(input: string): Target {
|
||||
const d = resolve(input);
|
||||
const asApp = (appDir: string, validationDir?: string): Target => ({ inputDir: d, scanDir: appDir, validationDir });
|
||||
if (basename(d) === ".clone") {
|
||||
const app = join(dirname(d), "app");
|
||||
const validation = join(d, "generated", "app");
|
||||
if (existsSync(join(app, "src"))) return asApp(app, existsSync(validation) ? validation : undefined);
|
||||
function renderComparison(labels: string[], reps: QualityReport[]): string {
|
||||
const L: string[] = [];
|
||||
const colW = Math.max(12, ...labels.map((l) => l.length + 2));
|
||||
L.push("");
|
||||
L.push(`━━━ comparison ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
||||
L.push(" " + pad("dimension", 15) + labels.map((l) => padL(l, colW)).join(""));
|
||||
const dims = Object.keys(reps[0]!.categories);
|
||||
for (const d of dims) {
|
||||
L.push(" " + pad(d, 15) + reps.map((r) => padL(String(r.categories[d]!.score), colW)).join(""));
|
||||
}
|
||||
const generatedSuffix = `${sep}.clone${sep}generated${sep}app`;
|
||||
if (d.endsWith(generatedSuffix)) {
|
||||
const cloneDir = d.slice(0, -`${sep}generated${sep}app`.length);
|
||||
const app = join(dirname(cloneDir), "app");
|
||||
if (existsSync(join(app, "src"))) return asApp(app, d);
|
||||
}
|
||||
if (basename(d) === "generated" && existsSync(join(d, "app", "src"))) {
|
||||
const cloneDir = dirname(d);
|
||||
const app = join(dirname(cloneDir), "app");
|
||||
if (basename(cloneDir) === ".clone" && existsSync(join(app, "src"))) return asApp(app, join(d, "app"));
|
||||
}
|
||||
return { inputDir: d, scanDir: d };
|
||||
L.push(" " + pad("GRADE", 15) + reps.map((r) => padL(`${r.grade} (${r.total})`, colW)).join(""));
|
||||
return L.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function main(): void {
|
||||
const args = process.argv.slice(2).filter((a) => !a.startsWith("--"));
|
||||
let targets: Target[];
|
||||
if (args.length) targets = args.map(normalizeTarget);
|
||||
else {
|
||||
// Auto: every output/<site>/app deliverable.
|
||||
const outRoot = resolve("output");
|
||||
const sites = existsSync(outRoot)
|
||||
? readdirSync(outRoot).map((s) => join(outRoot, s, "app")).filter((p) => existsSync(join(p, "src")))
|
||||
: [];
|
||||
targets = sites.map(normalizeTarget);
|
||||
}
|
||||
targets = targets.filter((t) => existsSync(t.scanDir));
|
||||
if (!targets.length) { console.error("no app trees found — pass dirs explicitly"); process.exit(1); }
|
||||
const asJson = process.argv.includes("--json");
|
||||
const targets = (args.length ? args.map((a) => resolve(a)) : discoverTargets()).filter(isAppDir);
|
||||
if (!targets.length) { console.error("no app trees found — pass app dirs explicitly (e.g. runs/<site>/latest/generated/app)"); process.exit(1); }
|
||||
|
||||
// A readable column label: the deliverable dir's parent name, or the scanned dir name.
|
||||
const label = (t: Target): string => (basename(dirname(t.scanDir)) || basename(t.scanDir)).slice(0, 16);
|
||||
const labels = targets.map(label);
|
||||
const audits = targets.map(audit);
|
||||
audits.forEach((a, i) => { a["files (tsx+css)"] = fileCount(targets[i]!.scanDir); });
|
||||
const labels = targets.map(labelFor);
|
||||
const reports = targets.map((t) => scoreApp(t));
|
||||
|
||||
const keys = Object.keys(audits[0]!);
|
||||
const w0 = Math.max(...keys.map((k) => k.length));
|
||||
const colW = Math.max(11, ...labels.map((l) => l.length));
|
||||
const pad = (s: string, w: number) => s + " ".repeat(Math.max(0, w - s.length));
|
||||
console.log("\n" + pad("metric", w0) + " " + labels.map((l) => pad(l, colW)).join(""));
|
||||
console.log("─".repeat(w0 + 2 + colW * labels.length));
|
||||
for (const k of keys) {
|
||||
if (audits[0]![k] === -1) { console.log(pad(k, w0)); continue; } // section header
|
||||
const cells = audits.map((a) => pad(String(a[k] ?? 0), colW)).join("");
|
||||
console.log(pad(k, w0) + " " + cells);
|
||||
if (asJson) {
|
||||
console.log(JSON.stringify(reports.map((r, i) => ({ label: labels[i], ...r })), null, 2));
|
||||
return;
|
||||
}
|
||||
console.log("\n(counts; lower is better in the BAD block, higher in the GOOD block. 'decimal' = the\n arbitrary value's px isn't a whole pixel — the frozen-measurement tell.)\n");
|
||||
|
||||
for (let i = 0; i < reports.length; i++) console.log(renderReport(labels[i]!, reports[i]!));
|
||||
if (reports.length > 1) console.log(renderComparison(labels, reports));
|
||||
console.log("\n(scores are out of each dimension's max; grade is the weighted blend, capped to\n D-range if any single file/line/blob is in catastrophic payload territory.)\n");
|
||||
}
|
||||
|
||||
main();
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
|
||||
+493
-232
@@ -1,23 +1,93 @@
|
||||
/**
|
||||
* Output-quality rubric (deterministic, framework-agnostic).
|
||||
* Output-quality rubric — HONEST edition (deterministic, framework-agnostic).
|
||||
*
|
||||
* The fidelity gates (validate/gates.ts) measure whether the clone *looks and
|
||||
* behaves* like the source. They say nothing about whether the generated CODE is
|
||||
* good — componentized, semantically named, styled through a reusable token/class
|
||||
* system, editable, and well organized. That "developer-facing quality" is exactly
|
||||
* where a deterministic converter is judged once fidelity is a given.
|
||||
* good — small files, decomposed into real components, semantic and accessible,
|
||||
* free of capture artifacts, and safe at runtime. That "developer-facing quality"
|
||||
* is what a deterministic converter is judged on once fidelity is a given.
|
||||
*
|
||||
* This module statically analyzes a generated app directory and scores it 0–100 on
|
||||
* six categories. It reads only source text (`.tsx/.jsx/.ts/.astro/.css`). The
|
||||
* metrics are chosen to be framework-agnostic: e.g. "style reuse" rewards shared
|
||||
* classes whether they're Tailwind utilities or semantic clone classes, and
|
||||
* penalizes per-node unique rules (`.c1{...} .c2{...}`) regardless of framework.
|
||||
* This module statically analyzes a generated app directory (source text only —
|
||||
* `.tsx/.jsx/.ts/.astro/.css`) and scores it 0–100 across SIX dimensions, each
|
||||
* with visible subscores. The score is a weighted blend of the dimensions EXCEPT
|
||||
* that a single dimension in catastrophic territory (a multi-megabyte source file,
|
||||
* a 100KB+ single line) HARD-CAPS the overall grade into D-range no matter how
|
||||
* clean everything else is — because such a file is unopenable, un-diffable, and
|
||||
* un-editable, which is the whole point of "good code".
|
||||
*
|
||||
* The signals are deliberately general (payload bytes, LOC distribution, repeated
|
||||
* definitions, semantic-tag ratios, whitespace/capture artifacts, uncleaned
|
||||
* listeners) so the scorer is not tuned to any specific site — it measures the
|
||||
* failure *modes* a robotic converter falls into, not any one output.
|
||||
*
|
||||
* Pure + deterministic: same directory ⇒ same score.
|
||||
*/
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join, extname, basename, relative } from "node:path";
|
||||
|
||||
// ===========================================================================
|
||||
// CALIBRATION CONSTANTS
|
||||
// ---------------------------------------------------------------------------
|
||||
// These are CALIBRATION GUIDES, expected to be tuned as the generator and our
|
||||
// taste evolve — they are NOT compliance targets or contractual thresholds. A
|
||||
// number here answers "where does a human reviewer start wincing?", set from
|
||||
// observed good vs. bad outputs, and should be re-fit (not litigated) whenever
|
||||
// calibration drifts. Every knob lives in this one block on purpose.
|
||||
// ===========================================================================
|
||||
const K = {
|
||||
// ---- payload discipline (a file/line so big it is not human-readable) ----
|
||||
FILE_BYTES_GOOD: 30_000, // ≤30KB source file: comfortable to open
|
||||
FILE_BYTES_BAD: 160_000, // ≥160KB: a reviewer scrolls forever; scores ~0
|
||||
LINE_CHARS_GOOD: 2_000, // a formatted line, allowing for a dense data/JSX row
|
||||
LINE_CHARS_BAD: 30_000, // a giant one-liner (minified / HTML-as-string prop)
|
||||
INLINE_BYTES_GOOD: 50_000, // total base64 / inline-HTML bytes that's forgivable
|
||||
INLINE_BYTES_BAD: 2_000_000, // 2MB+ of embedded blobs: catastrophic payload
|
||||
// Catastrophe caps: any ONE of these forces the whole app into D-range.
|
||||
CATASTROPHE_FILE_BYTES: 1_000_000, // a >1MB source file
|
||||
CATASTROPHE_LINE_CHARS: 100_000, // a >100KB single line
|
||||
CATASTROPHE_INLINE_BYTES: 5_000_000, // >5MB of embedded base64/HTML
|
||||
CAP_GRADE_D: 68, // ceiling applied when a catastrophe fires — top of the D band, so a
|
||||
// catastrophic payload can grade no better than D+ regardless of other dimensions
|
||||
// Softer cap for "very bad but not unopenable" payloads.
|
||||
WARN_FILE_BYTES: 350_000,
|
||||
WARN_LINE_CHARS: 40_000,
|
||||
CAP_GRADE_C: 78, // ceiling for the softer warning band (top of C+)
|
||||
|
||||
// ---- component decomposition (is the page a monolith?) ----
|
||||
MONOLITH_DOMINANCE_GOOD: 0.3, // biggest file ≤30% of all tags → well spread
|
||||
MONOLITH_DOMINANCE_BAD: 0.75, // one file holds ≥75% of the page → monolith
|
||||
BIG_SECTION_LINES: 600, // a "section" this long is really a whole page
|
||||
COMPONENTS_GOOD: 12, // this many real components → full decomposition credit
|
||||
|
||||
// ---- duplication ----
|
||||
DUP_HELPER_RATIO_BAD: 0.4, // ≥40% of helper defs are copy-paste duplicates
|
||||
DUP_SVG_PATH_RATIO_BAD: 0.85, // ≥85% of inline <path> strings are repeats (shared
|
||||
// icon sets legitimately repeat, so only near-total duplication is a real tell)
|
||||
NEAR_DUP_COMPONENT_BAD: 14, // this many near-identical NON-trivial component pairs → 0
|
||||
NEAR_DUP_MIN_TAGS: 8, // ignore tiny components (icons/logos) in near-dup detection
|
||||
|
||||
// ---- semantics / a11y ----
|
||||
DIV_RATIO_GOOD: 0.6, // ≤60% of elements are bare div/span → healthy
|
||||
DIV_RATIO_BAD: 0.9, // ≥90% divs → div soup
|
||||
ALT_COVERAGE_GOOD: 0.9, // ≥90% of <img> carry alt=
|
||||
H1_REQUIRED: 1, // a page really should have exactly one <h1>
|
||||
|
||||
// ---- hygiene ----
|
||||
// {" "} literals and sub-pixel arbitraries are a KNOWN baseline quirk of this
|
||||
// converter present in even good output — only pathological volumes should bite,
|
||||
// so these BADs sit well above what a "decent" tree emits.
|
||||
WS_LITERAL_PER_KTAG_GOOD: 100, // per 1000 tags a converter routinely emits some
|
||||
WS_LITERAL_PER_KTAG_BAD: 1200, // this many → capture-whitespace noise dominates
|
||||
SUBPIXEL_PER_KTAG_GOOD: 60, // some frozen measurements are unavoidable
|
||||
SUBPIXEL_PER_KTAG_BAD: 260, // a wall of frozen sub-pixels → machine replay
|
||||
OPAQUE_TOKEN_RATIO_BAD: 0.6, // ≥60% of CSS custom props are opaque --clr-N / hashes
|
||||
PROBE_ARTIFACTS_BAD: 8, // off-screen capture-probe text leaks
|
||||
|
||||
// ---- runtime discipline ----
|
||||
LEAK_LISTENERS_GOOD: 8, // a small shared runtime bundle carries a few listeners
|
||||
LEAK_LISTENERS_BAD: 30, // this many uncleaned adds → real leak territory
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File collection
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -51,13 +121,43 @@ export function collectFiles(root: string): SrcFile[] {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive extraction
|
||||
// Small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x));
|
||||
/** Linear ramp: v in [lo,hi] → [0,1]. Handles lo>hi (descending / "lower is better"). */
|
||||
function ramp(v: number, lo: number, hi: number): number {
|
||||
if (lo === hi) return v >= hi ? 1 : 0;
|
||||
return clamp01((v - lo) / (hi - lo));
|
||||
}
|
||||
/** A value where HIGHER is worse: good at `good`, zero at `bad`. */
|
||||
const penalize = (v: number, good: number, bad: number): number => 1 - ramp(v, good, bad);
|
||||
const r1 = (n: number): number => Math.round(n * 10) / 10;
|
||||
|
||||
/** Longest single line (in chars) in a file. */
|
||||
function maxLineLen(text: string): number {
|
||||
let max = 0, start = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
if (text.charCodeAt(i) === 10) { if (i - start > max) max = i - start; start = i + 1; }
|
||||
}
|
||||
if (text.length - start > max) max = text.length - start;
|
||||
return max;
|
||||
}
|
||||
|
||||
/** Count JSX/HTML opening element tags — a framework-agnostic page-size proxy. */
|
||||
function countTags(text: string): number {
|
||||
const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g);
|
||||
return m ? m.length : 0;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Structural classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CONFIG_NAME_RE = /(config|tsconfig|next-env|site-config|content\.config|\.config\.)/i;
|
||||
const ROOT_NAME_RE = /(^|\/)(layout|_app|_document|root-layout)\.[jt]sx$/i;
|
||||
/** Files that compose the whole page (the "entry"): page.tsx (Next), index/page in a
|
||||
* pages/ dir, home.tsx, or an .astro route shell. Used for the dominance metric. */
|
||||
const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"];
|
||||
|
||||
function isEntryFile(rel: string): boolean {
|
||||
const b = basename(rel).toLowerCase();
|
||||
if (/^(page|index|home|app)\.[jt]sx$/.test(b)) return true;
|
||||
@@ -66,54 +166,8 @@ function isEntryFile(rel: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const SECTION_WORDS = ["hero", "footer", "navbar", "nav", "header", "about", "cta", "feature", "pricing", "faq", "logo", "logocloud", "testimonial", "gallery", "stories", "spotlight", "knowledge", "news", "apply", "contact", "banner", "team", "stats", "partners", "alumni", "founder", "section"];
|
||||
|
||||
/** Count JSX/HTML opening element tags — a framework-agnostic proxy for page size
|
||||
* (how many DOM nodes the output describes). Excludes React fragments + closing tags. */
|
||||
function countTags(text: string): number {
|
||||
const m = text.match(/<[a-zA-Z][a-zA-Z0-9.]*(\s|\/|>)/g);
|
||||
return m ? m.length : 0;
|
||||
}
|
||||
|
||||
/** All className / class string-literal tokens used on elements (space split).
|
||||
* Catches JSX attribute form (className="…"), object/spread form (className: "…" —
|
||||
* what our generator emits), and hoisted className consts. A dynamic per-node class
|
||||
* (className: "c" + d._cid / className={"c"+…}) renders a UNIQUE opaque class per
|
||||
* instance, so each occurrence is recorded as its own opaque token (keeping the
|
||||
* reuse + semantic metrics honest for the per-node-CSS strategy). */
|
||||
function classTokens(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
// className="..." / className: "..." / "class": "..." (attr + object form)
|
||||
const re = /\b(?:className|class)\s*(?:=|:)\s*"([^"]*)"/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
for (const t of m[1]!.split(/\s+/)) if (t) out.push(t);
|
||||
}
|
||||
// hoisted className consts: const xClassName = "..."
|
||||
const re2 = /\b(?:const|let)\s+\w*[Cc]lassName\w*\s*=\s*"([^"]*)"/g;
|
||||
while ((m = re2.exec(text)) !== null) {
|
||||
for (const t of m[1]!.split(/\s+/)) if (t) out.push(t);
|
||||
}
|
||||
// dynamic per-node class: className: "c" + d._cid / className={"c"+ ...}
|
||||
const dyn = (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?"\s*\+/g) ?? []).length
|
||||
+ (text.match(/\bclassName\s*(?:=\s*\{|:)\s*"c[a-z]?\d*"\s*\+/g) ?? []).length;
|
||||
for (let i = 0; i < dyn; i++) out.push(`c__dyn${out.length}`); // unique opaque per occurrence
|
||||
return out;
|
||||
}
|
||||
|
||||
const CID_CLASS_RE = /^c[a-z]?\d+$/; // our legacy per-node class: c12 / cn12
|
||||
/** A class token that carries no human meaning (pure id / hash). */
|
||||
function isOpaqueClass(t: string): boolean {
|
||||
if (CID_CLASS_RE.test(t)) return true;
|
||||
if (/^[a-z]?\d+$/.test(t)) return true;
|
||||
// hashed CSS-module-ish tokens: _foo_ab12 or 6-hex tails
|
||||
if (/_[a-z0-9]{5,}$/i.test(t) && /\d/.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
const GENERIC_COMP_RE = /^(Item|Card|List|ListItem|Link|LinkItem|Wrapper|Box|Div|El|Node|Group|Block|Comp|Component|Row|Col|Cell|Thing|Unit|Part|Chunk)\d*$/;
|
||||
/** Exported / defined component names in a file. */
|
||||
function componentNames(text: string): string[] {
|
||||
export function componentNames(text: string): string[] {
|
||||
const names = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
const re1 = /export\s+default\s+function\s+([A-Z]\w*)/g;
|
||||
@@ -125,221 +179,428 @@ function componentNames(text: string): string[] {
|
||||
return [...names];
|
||||
}
|
||||
|
||||
/** Data/content field names declared in a content or data module. */
|
||||
function contentFieldNames(text: string): string[] {
|
||||
const out: string[] = [];
|
||||
// type Foo = { a: string; b?: number } — capture keys
|
||||
const typeBlocks = text.match(/(?:type|interface)\s+\w+\s*=?\s*\{([^}]*)\}/g) ?? [];
|
||||
for (const blk of typeBlocks) {
|
||||
const re = /(\w+)\s*\??\s*:/g; let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(blk)) !== null) out.push(m[1]!);
|
||||
// ===========================================================================
|
||||
// METRIC EXTRACTORS (exported for unit tests — each is a pure text→number/obj)
|
||||
// ===========================================================================
|
||||
|
||||
/** Bytes of embedded base64 blobs + HTML passed as string props/dangerous html. */
|
||||
export function inlineBlobBytes(text: string): number {
|
||||
let bytes = 0;
|
||||
// base64 data URIs — the payload after the comma.
|
||||
for (const m of text.matchAll(/data:[^;,'"`)\s]*;base64,([A-Za-z0-9+/=]+)/g)) bytes += m[1]!.length;
|
||||
// dangerouslySetInnerHTML / html-string props: a big string literal handed to markup.
|
||||
for (const m of text.matchAll(/dangerouslySetInnerHTML/g)) void m;
|
||||
// A very long string literal that is actually markup ("<div ...</div>" as a prop).
|
||||
for (const m of text.matchAll(/"((?:[^"\\]|\\.){2000,})"/g)) {
|
||||
const s = m[1]!;
|
||||
if (/<[a-z][a-z0-9]*[\s>]/i.test(s) && (s.match(/</g)?.length ?? 0) > 20) bytes += s.length;
|
||||
}
|
||||
return out;
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Payload facts about one file. */
|
||||
export function filePayload(f: SrcFile): { bytes: number; maxLine: number; inlineBytes: number } {
|
||||
return { bytes: f.text.length, maxLine: maxLineLen(f.text), inlineBytes: inlineBlobBytes(f.text) };
|
||||
}
|
||||
|
||||
/** {" "} / {' '} whitespace-only JSX expression literals (a capture artifact). */
|
||||
export function whitespaceLiterals(text: string): number {
|
||||
return (text.match(/\{\s*["'`]\s+["'`]\s*\}/g) ?? []).length
|
||||
+ (text.match(/\{["'`]\\u00a0["'`]\}/gi) ?? []).length;
|
||||
}
|
||||
|
||||
/** Frozen sub-pixel arbitrary values: `[12.5px]`, `w-[713.938px]` where px isn't whole. */
|
||||
export function subpixelArbitraries(text: string): number {
|
||||
let n = 0;
|
||||
for (const m of text.matchAll(/\[(-?[0-9]+\.?[0-9]*)(px|rem)\]/g)) {
|
||||
const px = parseFloat(m[1]!) * (m[2] === "rem" ? 16 : 1);
|
||||
if (Math.abs(px - Math.round(px)) > 0.02) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** CSS custom-property definitions split into opaque (--clr-7, --c12, hashes) vs named. */
|
||||
export function customPropTokens(text: string): { total: number; opaque: number } {
|
||||
let total = 0, opaque = 0;
|
||||
for (const m of text.matchAll(/(^|[^\w-])(--[\w-]+)\s*:/g)) {
|
||||
const name = m[2]!.slice(2); // strip leading --
|
||||
total++;
|
||||
if (/^(clr|c|color|var|token|t|v|n|x)?-?\d+$/i.test(name) || /^[a-f0-9]{6,}$/i.test(name)) opaque++;
|
||||
}
|
||||
return { total, opaque };
|
||||
}
|
||||
|
||||
/** Off-screen capture-probe text leaks (measurement scaffolding shipped into markup):
|
||||
* probe data-attrs, __probe__ markers, and clip:rect(0…) off-screen clipping. */
|
||||
export function probeArtifacts(text: string): number {
|
||||
return (text.match(/(?:data-(?:probe|measure|ditto-probe)|__probe__|offscreen-probe|clip:\s*["'`]?\s*rect\(\s*0)/g) ?? []).length;
|
||||
}
|
||||
|
||||
/** addEventListener calls with no matching removeEventListener / cleanup return. */
|
||||
export function uncleanedListeners(text: string): number {
|
||||
const adds = (text.match(/\.addEventListener\s*\(/g) ?? []).length;
|
||||
const removes = (text.match(/\.removeEventListener\s*\(/g) ?? []).length;
|
||||
// A cleanup return (useEffect teardown / AbortController) neutralizes some adds.
|
||||
const hasCleanupReturn = /return\s*\(\s*\)\s*=>/.test(text) || /new AbortController/.test(text) || /\{\s*signal\s*\}/.test(text);
|
||||
const covered = hasCleanupReturn ? Math.max(removes, adds) : removes;
|
||||
return Math.max(0, adds - covered);
|
||||
}
|
||||
|
||||
/** Element-tag histogram for a whole tree of markup text. */
|
||||
export function tagHistogram(text: string): Record<string, number> {
|
||||
const h: Record<string, number> = {};
|
||||
for (const m of text.matchAll(/<([a-z][a-z0-9]*)(?:\s|\/|>)/g)) {
|
||||
const t = m[1]!;
|
||||
h[t] = (h[t] ?? 0) + 1;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/** aria-hidden applied to a focusable/interactive element in the same tag. */
|
||||
export function ariaHiddenFocusables(text: string): number {
|
||||
let n = 0;
|
||||
for (const m of text.matchAll(/<(a|button|input|select|textarea)\b[^>]*aria-hidden\s*=\s*["'{]?\s*true/gi)) void m;
|
||||
n += (text.match(/<(?:a|button|input|select|textarea)\b[^>]*aria-hidden\s*=\s*["'{]?\s*true/gi) ?? []).length;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Repeated helper/function definitions with identical bodies (copy-paste, not shared). */
|
||||
export function duplicateHelpers(text: string): { defs: number; dups: number } {
|
||||
const bodies = new Map<string, number>();
|
||||
// named function / arrow-const helper definitions with a brace body.
|
||||
const re = /(?:function\s+\w+\s*\([^)]*\)|const\s+\w+\s*=\s*(?:\([^)]*\)|\w+)\s*=>)\s*\{([\s\S]{40,600}?)\}/g;
|
||||
let m: RegExpExecArray | null;
|
||||
let defs = 0;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
defs++;
|
||||
const key = m[1]!.replace(/\s+/g, " ").trim();
|
||||
bodies.set(key, (bodies.get(key) ?? 0) + 1);
|
||||
}
|
||||
let dups = 0;
|
||||
for (const c of bodies.values()) if (c > 1) dups += c - 1;
|
||||
return { defs, dups };
|
||||
}
|
||||
|
||||
/** Inline SVG <path d="…"> strings, and how many are exact repeats. */
|
||||
export function svgPathDuplication(text: string): { total: number; repeats: number } {
|
||||
const seen = new Map<string, number>();
|
||||
let total = 0;
|
||||
for (const m of text.matchAll(/\bd\s*=\s*["']([Mm][^"']{20,})["']/g)) {
|
||||
total++;
|
||||
const key = m[1]!;
|
||||
seen.set(key, (seen.get(key) ?? 0) + 1);
|
||||
}
|
||||
let repeats = 0;
|
||||
for (const c of seen.values()) if (c > 1) repeats += c - 1;
|
||||
return { total, repeats };
|
||||
}
|
||||
|
||||
/** Near-identical component pairs: same tag-histogram signature across component files. */
|
||||
export function nearDuplicateComponents(sigs: string[]): number {
|
||||
const counts = new Map<string, number>();
|
||||
for (const s of sigs) counts.set(s, (counts.get(s) ?? 0) + 1);
|
||||
let pairs = 0;
|
||||
for (const c of counts.values()) if (c > 1) pairs += c - 1;
|
||||
return pairs;
|
||||
}
|
||||
const PLUMBING_FIELD_RE = /^(_cid\d*|cid\d*|value\d*|val\d*|f\d+|d\d+|field\d+|key\d*|prop\d+|arg\d+)$/;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scoring
|
||||
// Grading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type LetterGrade = "A" | "A-" | "B+" | "B" | "B-" | "C+" | "C" | "C-" | "D+" | "D" | "D-" | "F";
|
||||
export function toLetter(total: number): LetterGrade {
|
||||
if (total >= 93) return "A";
|
||||
if (total >= 90) return "A-";
|
||||
if (total >= 87) return "B+";
|
||||
if (total >= 83) return "B";
|
||||
if (total >= 80) return "B-";
|
||||
if (total >= 77) return "C+";
|
||||
if (total >= 73) return "C";
|
||||
if (total >= 70) return "C-";
|
||||
if (total >= 66) return "D+";
|
||||
if (total >= 63) return "D";
|
||||
if (total >= 60) return "D-";
|
||||
return "F";
|
||||
}
|
||||
|
||||
export type CategoryScore = { score: number; max: number; metrics: Record<string, number | string> };
|
||||
export type Offender = { file: string; metric: string; value: number };
|
||||
export type QualityReport = {
|
||||
dir: string;
|
||||
total: number;
|
||||
grade: LetterGrade;
|
||||
caps: string[];
|
||||
categories: Record<string, CategoryScore>;
|
||||
offenders: Offender[];
|
||||
raw: Record<string, number>;
|
||||
};
|
||||
|
||||
const clamp01 = (x: number): number => Math.max(0, Math.min(1, x));
|
||||
/** Linear ramp: value v mapped from [lo,hi] → [0,1]. */
|
||||
const ramp = (v: number, lo: number, hi: number): number => clamp01((v - lo) / (hi - lo));
|
||||
const r1 = (n: number): number => Math.round(n * 10) / 10;
|
||||
/** Dimension weights (sum = 100). Guiding, not exact — see CALIBRATION note.
|
||||
* Payload + decomposition carry the most weight because an unopenable, monolithic
|
||||
* file is the defect a reviewer notices first; hygiene/duplication are lighter
|
||||
* because this converter's baseline (some {" "}, shared icons) is tolerable. */
|
||||
const WEIGHTS = {
|
||||
payload: 26,
|
||||
decomposition: 18,
|
||||
duplication: 12,
|
||||
semantics: 18,
|
||||
hygiene: 14,
|
||||
runtime: 12,
|
||||
} as const;
|
||||
|
||||
export function scoreApp(root: string): QualityReport {
|
||||
const files = collectFiles(root);
|
||||
const tsx = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro");
|
||||
const markup = files.filter((f) => f.ext === ".tsx" || f.ext === ".jsx" || f.ext === ".astro");
|
||||
const css = files.filter((f) => f.ext === ".css");
|
||||
const runtimeFiles = files.filter((f) => f.ext === ".ts" || f.ext === ".tsx" || f.ext === ".jsx");
|
||||
|
||||
// Component modules = JSX-bearing files that aren't config/root-layout.
|
||||
const componentModules = tsx.filter((f) =>
|
||||
const componentModules = markup.filter((f) =>
|
||||
!CONFIG_NAME_RE.test(f.rel) && !ROOT_NAME_RE.test(f.rel) && countTags(f.text) > 0);
|
||||
const nonEntry = componentModules.filter((f) => !isEntryFile(f.rel));
|
||||
const allText = files.map((f) => f.text).join("\n");
|
||||
const markupText = markup.map((f) => f.text).join("\n");
|
||||
|
||||
const totalTags = componentModules.reduce((s, f) => s + countTags(f.text), 0) || 1;
|
||||
const entryFiles = componentModules.filter((f) => isEntryFile(f.rel));
|
||||
const maxFileTags = Math.max(0, ...componentModules.map((f) => countTags(f.text)));
|
||||
const dominance = maxFileTags / totalTags; // 1.0 = one giant file
|
||||
const kTags = totalTags / 1000;
|
||||
|
||||
// Section-named components (semantic top-level blocks). Tokenize the file path +
|
||||
// component names into words (splitting camelCase, kebab, snake, slashes) so both
|
||||
// `hero-section.tsx` and `HeroSection` are credited.
|
||||
const isSectionFile = (f: SrcFile): boolean => {
|
||||
const raw = f.rel + " " + componentNames(f.text).join(" ");
|
||||
const words = new Set(raw.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
||||
return SECTION_WORDS.some((w) => words.has(w));
|
||||
const offenders: Offender[] = [];
|
||||
const caps: string[] = [];
|
||||
|
||||
// =========================================================================
|
||||
// 1. PAYLOAD DISCIPLINE — no file/line/blob a human cannot open.
|
||||
// =========================================================================
|
||||
let worstFileBytes = 0, worstFileRel = "";
|
||||
let worstLine = 0, worstLineRel = "";
|
||||
let totalInlineBytes = 0, worstInlineRel = "", worstInlineBytes = 0;
|
||||
for (const f of files) {
|
||||
const p = filePayload(f);
|
||||
if (p.bytes > worstFileBytes) { worstFileBytes = p.bytes; worstFileRel = f.rel; }
|
||||
if (p.maxLine > worstLine) { worstLine = p.maxLine; worstLineRel = f.rel; }
|
||||
totalInlineBytes += p.inlineBytes;
|
||||
if (p.inlineBytes > worstInlineBytes) { worstInlineBytes = p.inlineBytes; worstInlineRel = f.rel; }
|
||||
}
|
||||
if (worstFileRel) offenders.push({ file: worstFileRel, metric: "file bytes", value: worstFileBytes });
|
||||
if (worstLineRel) offenders.push({ file: worstLineRel, metric: "max line chars", value: worstLine });
|
||||
if (worstInlineBytes > 0) offenders.push({ file: worstInlineRel, metric: "inline blob bytes", value: worstInlineBytes });
|
||||
|
||||
// Worst-file penalty, tempered by how WIDESPREAD oversized files are: one big section
|
||||
// in an otherwise-small tree is forgivable; many oversized files is systemic bloat.
|
||||
const oversized = files.filter((f) => f.text.length >= K.FILE_BYTES_GOOD * 2).length;
|
||||
const pFileMax = penalize(worstFileBytes, K.FILE_BYTES_GOOD, K.FILE_BYTES_BAD);
|
||||
const pFileSpread = penalize(oversized, 0, Math.max(3, files.length * 0.25));
|
||||
const pFile = 0.6 * pFileMax + 0.4 * pFileSpread;
|
||||
const pLine = penalize(worstLine, K.LINE_CHARS_GOOD, K.LINE_CHARS_BAD);
|
||||
const pInline = penalize(totalInlineBytes, K.INLINE_BYTES_GOOD, K.INLINE_BYTES_BAD);
|
||||
const payloadSub = 0.4 * pFile + 0.35 * pLine + 0.25 * pInline;
|
||||
|
||||
// =========================================================================
|
||||
// 2. COMPONENT DECOMPOSITION — is the page a monolith?
|
||||
// =========================================================================
|
||||
const dominance = maxFileTags / totalTags; // 1.0 = one giant file holds the page
|
||||
const bigSections = componentModules.filter((f) => f.lines >= K.BIG_SECTION_LINES && !isEntryFile(f.rel)).length;
|
||||
for (const f of componentModules) {
|
||||
if (f.lines >= K.BIG_SECTION_LINES && !isEntryFile(f.rel)) offenders.push({ file: f.rel, metric: "section LOC", value: f.lines });
|
||||
}
|
||||
const dMono = penalize(dominance, K.MONOLITH_DOMINANCE_GOOD, K.MONOLITH_DOMINANCE_BAD);
|
||||
const dCount = ramp(nonEntry.length, 0, K.COMPONENTS_GOOD);
|
||||
const dBig = penalize(bigSections, 0, 3);
|
||||
const decompositionSub = 0.45 * dMono + 0.3 * dCount + 0.25 * dBig;
|
||||
|
||||
// =========================================================================
|
||||
// 3. DUPLICATION — repeated helpers, near-identical components, repeated SVG paths.
|
||||
// =========================================================================
|
||||
let helperDefs = 0, helperDups = 0;
|
||||
for (const f of runtimeFiles) { const d = duplicateHelpers(f.text); helperDefs += d.defs; helperDups += d.dups; }
|
||||
const svgDup = svgPathDuplication(markupText);
|
||||
// Near-dup signatures over NON-trivial CONTENT components only: vectorized assets
|
||||
// (icons, logos, illustration frames) legitimately share a tag shape, so counting
|
||||
// them as "duplicates" would punish healthy asset sets rather than real copy-paste.
|
||||
const isAssetFile = (rel: string): boolean => /(^|\/)svgs?\//i.test(rel) || /(icon|illustration|logo)\d*\.[jt]sx$/i.test(basename(rel));
|
||||
const compSigs = nonEntry.filter((f) => !isAssetFile(f.rel)).map((f) => {
|
||||
const h = tagHistogram(f.text);
|
||||
const tags = Object.values(h).reduce((a, b) => a + b, 0);
|
||||
if (tags < K.NEAR_DUP_MIN_TAGS) return "";
|
||||
return Object.keys(h).sort().map((k) => `${k}:${h[k]}`).join(",");
|
||||
}).filter((s) => s.length > 0);
|
||||
const nearDup = nearDuplicateComponents(compSigs);
|
||||
|
||||
const helperDupRatio = helperDefs ? helperDups / helperDefs : 0;
|
||||
const svgDupRatio = svgDup.total ? svgDup.repeats / svgDup.total : 0;
|
||||
if (helperDups > 0) offenders.push({ file: "(helpers)", metric: "duplicate helper defs", value: helperDups });
|
||||
if (svgDup.repeats > 0) offenders.push({ file: "(inline svg)", metric: "repeated <path> strings", value: svgDup.repeats });
|
||||
if (nearDup > 0) offenders.push({ file: "(components)", metric: "near-identical component pairs", value: nearDup });
|
||||
|
||||
const uHelper = penalize(helperDupRatio, 0, K.DUP_HELPER_RATIO_BAD);
|
||||
const uSvg = penalize(svgDupRatio, 0, K.DUP_SVG_PATH_RATIO_BAD);
|
||||
const uNear = penalize(nearDup, 0, K.NEAR_DUP_COMPONENT_BAD);
|
||||
// Helper duplication is the strongest tell; near-dup components second; inline-SVG
|
||||
// path repeats are the weakest (shared icon sets repeat legitimately).
|
||||
const duplicationSub = 0.5 * uHelper + 0.35 * uNear + 0.15 * uSvg;
|
||||
|
||||
// =========================================================================
|
||||
// 4. SEMANTICS / A11Y — real tags over div soup, one h1, alt coverage, no aria traps.
|
||||
// =========================================================================
|
||||
const hist = tagHistogram(markupText);
|
||||
const totalEls = Object.values(hist).reduce((a, b) => a + b, 0) || 1;
|
||||
const divLike = (hist["div"] ?? 0) + (hist["span"] ?? 0);
|
||||
const semanticTags = (hist["section"] ?? 0) + (hist["nav"] ?? 0) + (hist["header"] ?? 0) + (hist["footer"] ?? 0)
|
||||
+ (hist["main"] ?? 0) + (hist["article"] ?? 0) + (hist["aside"] ?? 0) + (hist["button"] ?? 0)
|
||||
+ (hist["h1"] ?? 0) + (hist["h2"] ?? 0) + (hist["h3"] ?? 0) + (hist["ul"] ?? 0) + (hist["nav"] ?? 0);
|
||||
const h1Count = hist["h1"] ?? 0;
|
||||
const imgCount = hist["img"] ?? 0;
|
||||
const altCount = (markupText.match(/\balt\s*=/g) ?? []).length;
|
||||
const ariaHidden = ariaHiddenFocusables(markupText);
|
||||
const divRatio = divLike / totalEls;
|
||||
const altCoverage = imgCount ? clamp01(altCount / imgCount) : 1;
|
||||
|
||||
if (h1Count === 0) offenders.push({ file: "(page)", metric: "h1 count", value: 0 });
|
||||
if (ariaHidden > 0) offenders.push({ file: "(markup)", metric: "aria-hidden on focusables", value: ariaHidden });
|
||||
if (imgCount && altCoverage < K.ALT_COVERAGE_GOOD) offenders.push({ file: "(markup)", metric: "imgs missing alt", value: imgCount - altCount });
|
||||
|
||||
const aDiv = penalize(divRatio, K.DIV_RATIO_GOOD, K.DIV_RATIO_BAD);
|
||||
const aSem = clamp01(semanticTags / (totalEls * 0.12)); // ~12% semantic tags → full credit
|
||||
const aH1 = h1Count === K.H1_REQUIRED ? 1 : h1Count > K.H1_REQUIRED ? 0.8 : 0.1; // missing h1 is a heavy penalty; multiple h1 a minor ding
|
||||
const aAlt = altCoverage;
|
||||
const aAria = penalize(ariaHidden, 0, 6);
|
||||
const semanticsSub = 0.3 * aDiv + 0.2 * aSem + 0.25 * aH1 + 0.15 * aAlt + 0.1 * aAria;
|
||||
|
||||
// =========================================================================
|
||||
// 5. HYGIENE — whitespace literals, frozen sub-pixels, opaque tokens, probe leaks.
|
||||
// =========================================================================
|
||||
const wsLiterals = whitespaceLiterals(markupText);
|
||||
const subpixel = subpixelArbitraries(allText);
|
||||
let propTotal = 0, propOpaque = 0;
|
||||
for (const f of css) { const t = customPropTokens(f.text); propTotal += t.total; propOpaque += t.opaque; }
|
||||
const probes = probeArtifacts(allText);
|
||||
const opaqueRatio = propTotal ? propOpaque / propTotal : 0;
|
||||
const wsPerK = wsLiterals / Math.max(1, kTags);
|
||||
const subpixelPerK = subpixel / Math.max(1, kTags);
|
||||
|
||||
if (wsLiterals > 0) offenders.push({ file: "(markup)", metric: "{\" \"} whitespace literals", value: wsLiterals });
|
||||
if (subpixel > 0) offenders.push({ file: "(styles)", metric: "frozen sub-pixel arbitraries", value: subpixel });
|
||||
if (propOpaque > 0) offenders.push({ file: "(css)", metric: "opaque --token defs", value: propOpaque });
|
||||
if (probes > 0) offenders.push({ file: "(markup)", metric: "capture-probe artifacts", value: probes });
|
||||
|
||||
const hWs = penalize(wsPerK, K.WS_LITERAL_PER_KTAG_GOOD, K.WS_LITERAL_PER_KTAG_BAD);
|
||||
const hSub = penalize(subpixelPerK, K.SUBPIXEL_PER_KTAG_GOOD, K.SUBPIXEL_PER_KTAG_BAD);
|
||||
const hTok = penalize(opaqueRatio, 0, K.OPAQUE_TOKEN_RATIO_BAD);
|
||||
const hProbe = penalize(probes, 0, K.PROBE_ARTIFACTS_BAD);
|
||||
const hygieneSub = 0.28 * hWs + 0.28 * hSub + 0.28 * hTok + 0.16 * hProbe;
|
||||
|
||||
// =========================================================================
|
||||
// 6. RUNTIME DISCIPLINE — no leaked listeners, no undeclared imports.
|
||||
// =========================================================================
|
||||
let leaks = 0;
|
||||
for (const f of runtimeFiles) leaks += uncleanedListeners(f.text);
|
||||
const undeclaredImports = countUndeclaredImports(runtimeFiles);
|
||||
if (leaks > 0) offenders.push({ file: "(runtime)", metric: "uncleaned addEventListener", value: leaks });
|
||||
if (undeclaredImports > 0) offenders.push({ file: "(runtime)", metric: "undeclared imports", value: undeclaredImports });
|
||||
|
||||
const rLeak = penalize(leaks, K.LEAK_LISTENERS_GOOD, K.LEAK_LISTENERS_BAD);
|
||||
const rImp = penalize(undeclaredImports, 0, 6);
|
||||
const runtimeSub = 0.7 * rLeak + 0.3 * rImp;
|
||||
|
||||
// =========================================================================
|
||||
// BLEND + HARD CAPS
|
||||
// =========================================================================
|
||||
const dims = {
|
||||
payload: payloadSub,
|
||||
decomposition: decompositionSub,
|
||||
duplication: duplicationSub,
|
||||
semantics: semanticsSub,
|
||||
hygiene: hygieneSub,
|
||||
runtime: runtimeSub,
|
||||
};
|
||||
const sectionComponents = nonEntry.filter(isSectionFile).length;
|
||||
const svgFiles = componentModules.filter((f) => /(^|\/)svgs?\//i.test(f.rel) || /icon/i.test(basename(f.rel)) || (countTags(f.text) > 0 && /^[^<]*<svg/m.test(f.text.replace(/import[^\n]*\n/g, "")))).length;
|
||||
let total = 0;
|
||||
for (const [k, sub] of Object.entries(dims)) total += WEIGHTS[k as keyof typeof WEIGHTS] * sub;
|
||||
|
||||
// ---- naming ----
|
||||
const allComponentNames = new Set<string>();
|
||||
for (const f of componentModules) for (const n of componentNames(f.text)) allComponentNames.add(n);
|
||||
// exclude the page/Page entry symbol from the semantic judgement
|
||||
const judgedNames = [...allComponentNames].filter((n) => n !== "Page" && n !== "default" && n !== "RootLayout" && n !== "Layout");
|
||||
const genericNames = judgedNames.filter((n) => GENERIC_COMP_RE.test(n)).length;
|
||||
const compNameSemanticRatio = judgedNames.length ? 1 - genericNames / judgedNames.length : 0;
|
||||
|
||||
const allClassToks: string[] = [];
|
||||
for (const f of tsx) allClassToks.push(...classTokens(f.text));
|
||||
const classTotal = allClassToks.length || 1;
|
||||
const opaqueClasses = allClassToks.filter(isOpaqueClass).length;
|
||||
const classSemanticRatio = 1 - opaqueClasses / classTotal;
|
||||
const distinctClasses = new Set(allClassToks).size || 1;
|
||||
const classReuse = 1 - distinctClasses / classTotal; // 0 = every class used once (per-node), →1 = heavy reuse
|
||||
|
||||
// content field naming + editability
|
||||
const contentFiles = files.filter((f) => /(content|data)\.[jt]s$/i.test(basename(f.rel)) || /(^|\/)(content|lib)\//i.test(f.rel));
|
||||
const fieldNames: string[] = [];
|
||||
for (const f of contentFiles) fieldNames.push(...contentFieldNames(f.text));
|
||||
const plumbingFields = fieldNames.filter((n) => PLUMBING_FIELD_RE.test(n)).length;
|
||||
const fieldSemanticRatio = fieldNames.length ? 1 - plumbingFields / fieldNames.length : 0.5;
|
||||
|
||||
// ---- styling system ----
|
||||
const cssBytes = css.reduce((s, f) => s + f.text.length, 0);
|
||||
const cssBytesPerNode = cssBytes / totalTags;
|
||||
let tokenRefs = 0, tokenDefs = 0;
|
||||
for (const f of css) {
|
||||
tokenRefs += (f.text.match(/var\(--/g) ?? []).length;
|
||||
tokenDefs += (f.text.match(/^\s*--[\w-]+\s*:/gm) ?? []).length;
|
||||
// Catastrophe caps: any ONE unopenable payload drags the whole grade into D-range.
|
||||
if (worstFileBytes >= K.CATASTROPHE_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1e6).toFixed(1)}MB (>1MB)`);
|
||||
if (worstLine >= K.CATASTROPHE_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>100KB)`);
|
||||
if (totalInlineBytes >= K.CATASTROPHE_INLINE_BYTES) caps.push(`${(totalInlineBytes / 1e6).toFixed(1)}MB of embedded base64/HTML (>5MB)`);
|
||||
if (caps.length) total = Math.min(total, K.CAP_GRADE_D);
|
||||
else {
|
||||
// Softer warning band.
|
||||
if (worstFileBytes >= K.WARN_FILE_BYTES) caps.push(`file ${worstFileRel} is ${(worstFileBytes / 1000).toFixed(0)}KB (>${K.WARN_FILE_BYTES / 1000}KB)`);
|
||||
if (worstLine >= K.WARN_LINE_CHARS) caps.push(`line in ${worstLineRel} is ${(worstLine / 1000).toFixed(0)}KB (>${K.WARN_LINE_CHARS / 1000}KB)`);
|
||||
if (caps.length) total = Math.min(total, K.CAP_GRADE_C);
|
||||
}
|
||||
// Tailwind-style theme tokens count as token usage too (utility classes referencing roles)
|
||||
const tailwindTokenClasses = allClassToks.filter((t) => /(^|[-:])(bg|text|border|fill|stroke|ring|from|to|via)-/.test(t)).length;
|
||||
// per-node-unique-rule penalty: count `.cNNN{` selectors in CSS
|
||||
let perNodeRules = 0;
|
||||
for (const f of css) perNodeRules += (f.text.match(/\.c[a-z]?\d+\s*[\{,]/g) ?? []).length;
|
||||
const perNodeRuleRatio = perNodeRules / totalTags; // 1.0 = a unique rule per node
|
||||
total = r1(Math.max(0, Math.min(100, total)));
|
||||
|
||||
// magic literals in component markup (raw hex/rgb/px in className/style strings)
|
||||
let magicLiterals = 0;
|
||||
for (const f of componentModules) {
|
||||
magicLiterals += (f.text.match(/#[0-9a-fA-F]{3,8}\b/g) ?? []).length;
|
||||
magicLiterals += (f.text.match(/\brgba?\([^)]*\)/g) ?? []).length;
|
||||
}
|
||||
const magicPerNode = magicLiterals / totalTags;
|
||||
|
||||
// ---- idiomatic styling (the axes the old rubric was blind to) ----
|
||||
// A px ARBITRARY value (`w-[713.938px]`, `py-[64px]`) is a measurement, not a design choice —
|
||||
// the single biggest "machine-generated" tell. Count them across markup + hoisted class
|
||||
// consts, vs the standard scale / rem a human writes.
|
||||
let arbPx = 0, arbBands = 0, stdBreakpoints = 0, dataCidCount = 0;
|
||||
for (const f of [...componentModules, ...css]) {
|
||||
arbPx += (f.text.match(/\[[0-9]+(?:\.[0-9]+)?px\]/g) ?? []).length;
|
||||
arbBands += (f.text.match(/(?:min|max)-\[[0-9]+px\]:/g) ?? []).length; // arbitrary midpoint media variants
|
||||
stdBreakpoints += (f.text.match(/(?:^|[\s"'`])(?:max-)?(?:sm|md|lg|xl|2xl):/g) ?? []).length;
|
||||
dataCidCount += (f.text.match(/data-cid/g) ?? []).length;
|
||||
}
|
||||
const arbPxPerNode = arbPx / totalTags; // low is hand-authored; high is machine replay
|
||||
const bandShare = arbBands + stdBreakpoints > 0 ? arbBands / (arbBands + stdBreakpoints) : 0; // 1 = all arbitrary
|
||||
const dataCidPerNode = dataCidCount / totalTags;
|
||||
// Robotic, never-hand-written comment phrases.
|
||||
let roboticComments = 0;
|
||||
for (const f of [...componentModules, ...css, ...files.filter((x) => x.ext === ".ts")]) {
|
||||
roboticComments += (f.text.match(/Generated by clone-static|render-identical to the source|Do not edit by hand/g) ?? []).length;
|
||||
}
|
||||
|
||||
// ---- editability ----
|
||||
const hasContentModule = contentFiles.some((f) => /export\s+(const|type)/.test(f.text)) ? 1 : 0;
|
||||
// Destructured props with a default value, in any function/arrow param block — counts
|
||||
// string, array, and identifier defaults alike (a default is a default).
|
||||
let propsWithDefaults = 0;
|
||||
for (const f of componentModules) {
|
||||
for (const blk of f.text.match(/\(\s*\{[^{}]*\}/g) ?? []) {
|
||||
propsWithDefaults += (blk.match(/\b\w+\s*=\s*(?![=>])/g) ?? []).length;
|
||||
}
|
||||
}
|
||||
const defaultsSignal = clamp01(propsWithDefaults / Math.max(6, nonEntry.length));
|
||||
|
||||
// ---- file org ----
|
||||
const dirSet = new Set(componentModules.map((f) => f.rel.split("/").slice(0, -1).join("/")));
|
||||
const hasSectionsDir = [...dirSet].some((d) => /sections?$/i.test(d)) ? 1 : 0;
|
||||
const hasComponentsDir = [...dirSet].some((d) => /(components|ui)$/i.test(d)) ? 1 : 0;
|
||||
const hasLayoutDir = [...dirSet].some((d) => /(layout|svgs?)$/i.test(d)) ? 1 : 0;
|
||||
const orgDirs = hasSectionsDir + hasComponentsDir + hasLayoutDir;
|
||||
|
||||
// ---- metadata ----
|
||||
let docHeaders = 0, annotations = 0;
|
||||
for (const f of componentModules) {
|
||||
docHeaders += (f.text.match(/\/\*\*[\s\S]*?\*\//g) ?? []).length;
|
||||
// component-type annotations in attribute, object, or doc-tag form.
|
||||
annotations += (f.text.match(/@component|data-component/g) ?? []).length;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Category scoring
|
||||
// =========================================================================
|
||||
|
||||
// 1. Componentization (25): many components, low single-file dominance, sections present
|
||||
const cCount = ramp(nonEntry.length, 0, 12); // 0 → 12+ components
|
||||
const cDom = 1 - ramp(dominance, 0.3, 0.95); // penalize one giant file
|
||||
const cSections = ramp(sectionComponents, 0, 6); // semantic sections
|
||||
const componentization = 25 * (0.4 * cCount + 0.35 * cDom + 0.25 * cSections);
|
||||
|
||||
// 2. Semantic naming (25): component names, class names, content fields
|
||||
const naming = 25 * (0.4 * compNameSemanticRatio + 0.4 * classSemanticRatio + 0.2 * fieldSemanticRatio);
|
||||
|
||||
// 3. Styling system (20): class reuse, tokens, low per-node verbosity, few magic literals AND
|
||||
// — the axes the old rubric missed — idiomatic values (standard scale/rem, not a wall of
|
||||
// px arbitraries) and standard breakpoints (not arbitrary midpoint bands).
|
||||
const sReuse = clamp01(classReuse / 0.6); // 0.6+ reuse → full marks
|
||||
const sTokens = ramp(tokenRefs + tailwindTokenClasses, 0, Math.max(40, totalTags * 0.5));
|
||||
const sVerbosity = 1 - clamp01(perNodeRuleRatio); // per-node rules are bad
|
||||
const sMagic = 1 - clamp01(magicPerNode / 0.25);
|
||||
const sArb = 1 - clamp01(arbPxPerNode / 2); // ~0 arb-px/node → 1; 2+/node → 0
|
||||
const sBp = 1 - bandShare; // 0 arbitrary bands → 1
|
||||
const styling = 20 * (0.22 * sReuse + 0.13 * sTokens + 0.13 * sVerbosity + 0.07 * sMagic + 0.27 * sArb + 0.18 * sBp);
|
||||
|
||||
// 4. Editability (15): content module, typed fields, semantic fields, prop defaults
|
||||
const editability = 15 * (0.4 * hasContentModule + 0.3 * fieldSemanticRatio + 0.3 * defaultsSignal);
|
||||
|
||||
// 5. File org (10): folder structure + svgs extracted
|
||||
const fileOrg = 10 * (0.7 * (orgDirs / 3) + 0.3 * clamp01(svgFiles / 4));
|
||||
|
||||
// 6. Metadata (5): concise human doc headers + semantic component annotations — but a
|
||||
// "Generated by clone-static / render-identical" robotic header is a NEGATIVE signal (no
|
||||
// human writes it), and shipping a per-node data-cid on every element is markup noise.
|
||||
const cleanComments = roboticComments === 0 ? 1 : clamp01(1 - roboticComments / Math.max(4, nonEntry.length));
|
||||
const lowCidNoise = 1 - clamp01(dataCidPerNode); // ~0 data-cid/node → 1; 1/node → 0
|
||||
const metadata = 5 * (0.35 * clamp01(docHeaders / Math.max(4, nonEntry.length)) * cleanComments
|
||||
+ 0.35 * clamp01(annotations / Math.max(8, totalTags * 0.1))
|
||||
+ 0.3 * lowCidNoise);
|
||||
|
||||
const total = componentization + naming + styling + editability + fileOrg + metadata;
|
||||
// Order offenders by severity so the report's "top offenders" is meaningful.
|
||||
offenders.sort((a, b) => severity(b) - severity(a));
|
||||
|
||||
return {
|
||||
dir: root,
|
||||
total: r1(total),
|
||||
total,
|
||||
grade: toLetter(total),
|
||||
caps,
|
||||
categories: {
|
||||
componentization: { score: r1(componentization), max: 25, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), sectionComponents } },
|
||||
naming: { score: r1(naming), max: 25, metrics: { compNameSemanticPct: r1(compNameSemanticRatio * 100), classSemanticPct: r1(classSemanticRatio * 100), fieldSemanticPct: r1(fieldSemanticRatio * 100), genericNames } },
|
||||
styling: { score: r1(styling), max: 20, metrics: { classReusePct: r1(classReuse * 100), tokenRefs, perNodeRules, magicLiterals, arbPx, arbPxPerNode: r1(arbPxPerNode), arbBands, stdBreakpoints, bandSharePct: r1(bandShare * 100) } },
|
||||
editability: { score: r1(editability), max: 15, metrics: { hasContentModule, fieldSemanticPct: r1(fieldSemanticRatio * 100), propsWithDefaults } },
|
||||
fileOrg: { score: r1(fileOrg), max: 10, metrics: { orgDirs, svgFiles, hasSectionsDir, hasComponentsDir } },
|
||||
metadata: { score: r1(metadata), max: 5, metrics: { docHeaders, annotations, roboticComments, dataCidPerNode: r1(dataCidPerNode) } },
|
||||
payload: { score: r1(WEIGHTS.payload * payloadSub), max: WEIGHTS.payload, metrics: { maxFileKB: r1(worstFileBytes / 1000), maxLineKB: r1(worstLine / 1000), inlineBlobKB: r1(totalInlineBytes / 1000) } },
|
||||
decomposition: { score: r1(WEIGHTS.decomposition * decompositionSub), max: WEIGHTS.decomposition, metrics: { components: nonEntry.length, dominancePct: r1(dominance * 100), bigSections } },
|
||||
duplication: { score: r1(WEIGHTS.duplication * duplicationSub), max: WEIGHTS.duplication, metrics: { helperDups, helperDupPct: r1(helperDupRatio * 100), svgPathRepeats: svgDup.repeats, nearDupPairs: nearDup } },
|
||||
semantics: { score: r1(WEIGHTS.semantics * semanticsSub), max: WEIGHTS.semantics, metrics: { h1: h1Count, divRatioPct: r1(divRatio * 100), semanticTags, altCoveragePct: r1(altCoverage * 100), ariaHiddenFocusables: ariaHidden } },
|
||||
hygiene: { score: r1(WEIGHTS.hygiene * hygieneSub), max: WEIGHTS.hygiene, metrics: { wsLiterals, subpixelArbitraries: subpixel, opaqueTokenPct: r1(opaqueRatio * 100), probeArtifacts: probes } },
|
||||
runtime: { score: r1(WEIGHTS.runtime * runtimeSub), max: WEIGHTS.runtime, metrics: { uncleanedListeners: leaks, undeclaredImports } },
|
||||
},
|
||||
offenders: offenders.slice(0, 15),
|
||||
raw: {
|
||||
files: files.length, componentModules: componentModules.length, totalTags, maxFileTags,
|
||||
classTokens: classTotal, distinctClasses, opaqueClasses, cssBytes,
|
||||
worstFileBytes, worstLineChars: worstLine, inlineBytes: totalInlineBytes,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Undeclared imports: a bare-specifier import not present in any package.json/tsconfig
|
||||
* alias and not a relative path — a runtime crash waiting to happen. We approximate by
|
||||
* flagging imports of local-looking modules that resolve nowhere is expensive, so instead
|
||||
* we flag the cheap, robust tell: an import statement whose source is an empty string or a
|
||||
* malformed specifier. (Kept conservative to avoid false positives across frameworks.) */
|
||||
function countUndeclaredImports(files: SrcFile[]): number {
|
||||
let n = 0;
|
||||
for (const f of files) {
|
||||
for (const m of f.text.matchAll(/\bimport\s+[^;]*?\bfrom\s*["'`]([^"'`]*)["'`]/g)) {
|
||||
const spec = m[1]!;
|
||||
if (spec.trim() === "" || /\s/.test(spec) || spec === "undefined" || spec === "null") n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Relative weight of an offender for ordering the top-offenders list. */
|
||||
function severity(o: Offender): number {
|
||||
switch (o.metric) {
|
||||
case "file bytes": return o.value / 1000;
|
||||
case "max line chars": return o.value / 1000;
|
||||
case "inline blob bytes": return o.value / 2000;
|
||||
case "section LOC": return o.value / 20;
|
||||
case "opaque --token defs": return o.value * 0.5;
|
||||
case "frozen sub-pixel arbitraries": return o.value * 0.3;
|
||||
case "{\" \"} whitespace literals": return o.value * 0.1;
|
||||
case "h1 count": return 300; // a missing h1 is always a headline offender
|
||||
case "uncleaned addEventListener": return o.value * 3;
|
||||
default: return o.value;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function fmt(rep: QualityReport): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`\n ${rep.dir}`);
|
||||
lines.push(` TOTAL: ${rep.total}/100`);
|
||||
lines.push(` GRADE: ${rep.grade} (${rep.total}/100)`);
|
||||
if (rep.caps.length) lines.push(` CAPS: ${rep.caps.join("; ")}`);
|
||||
for (const [k, c] of Object.entries(rep.categories)) {
|
||||
const metrics = Object.entries(c.metrics).map(([mk, mv]) => `${mk}=${mv}`).join(" ");
|
||||
lines.push(` ${k.padEnd(16)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`);
|
||||
lines.push(` ${k.padEnd(15)} ${String(c.score).padStart(5)}/${c.max} ${metrics}`);
|
||||
}
|
||||
if (rep.offenders.length) {
|
||||
lines.push(` top offenders:`);
|
||||
for (const o of rep.offenders.slice(0, 8)) lines.push(` ${o.metric.padEnd(34)} ${String(o.value).padStart(10)} ${o.file}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
@@ -353,11 +614,11 @@ async function main(): Promise<void> {
|
||||
for (const rep of reports) console.log(fmt(rep));
|
||||
if (reports.length > 1) {
|
||||
console.log("\n ── comparison ──");
|
||||
console.log(" " + "category".padEnd(16) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join(""));
|
||||
console.log(" " + "dimension".padEnd(15) + reports.map((_, i) => `app${i + 1}`.padStart(10)).join(""));
|
||||
for (const cat of Object.keys(reports[0]!.categories)) {
|
||||
console.log(" " + cat.padEnd(16) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join(""));
|
||||
console.log(" " + cat.padEnd(15) + reports.map((r) => String(r.categories[cat]!.score).padStart(10)).join(""));
|
||||
}
|
||||
console.log(" " + "TOTAL".padEnd(16) + reports.map((r) => String(r.total).padStart(10)).join(""));
|
||||
console.log(" " + "GRADE".padEnd(15) + reports.map((r) => `${r.grade}(${r.total})`.padStart(10)).join(""));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { generateCss, RESET_CSS } from "../generate/css.js";
|
||||
import { generateInteractionCss } from "../generate/interactionCss.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 { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, injectLottieDep, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js";
|
||||
import { renderChildrenJsx, renderAttrs, buildComponentRegistry, componentPreamble, componentFiles, componentImports, componentDataDecls, summarizeComponents, fileBase, generateViteConfig, generateViteIndexHtml, viteGlobalsCss, cnImportLine, resolveHtmlBg, htmlBgRule, CN_UTILS_MODULE, PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW, TSCONFIG_JSON, TSCONFIG_JSON_VITE, NEXT_CONFIG, injectLottieDep, type AppFramework, type LinkRewrite, type ExtractedComponent, type RenderCtx } from "../generate/app.js";
|
||||
import { buildTailwind, tailwindGlobalsCss, createColorInterner, colorDefsCssOf, type TailwindOutput } from "../generate/tailwind.js";
|
||||
import type { InteractionCapture } from "../capture/interactions.js";
|
||||
import type { IRChild } from "../normalize/ir.js";
|
||||
@@ -29,7 +29,7 @@ import type { CaptureResult } from "../capture/capture.js";
|
||||
import { backfillLazyBackgrounds } from "../normalize/ir.js";
|
||||
import { toRoutePath, segmentsOf } from "../crawl/url.js";
|
||||
import { buildCanonicalChrome, chromeCssIr, middleChildren, middleIncludeFilter, CHROME_PREFIX, type ChromePlan } from "./sharedLayout.js";
|
||||
import { buildSeoInventory, emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, viewportExport, type SeoInventory, type SeoRouteSummary } from "../generate/seo.js";
|
||||
import { buildSeoInventory, emitSeoAssetFiles, emitSeoRoutes, jsonLdHeadMarkup, metadataExport, routeSummaryFromIr, seoStaticFiles, SITE_ORIGIN_LAYOUT_IMPORT, SITE_ORIGIN_MODULE, viewportExport, type SeoInventory, type SeoRouteSummary } from "../generate/seo.js";
|
||||
import { emitGeneratedDocs } from "../generate/docs.js";
|
||||
|
||||
export type RouteArtifact = {
|
||||
@@ -91,10 +91,10 @@ function unionFontCss(routes: RouteArtifact[]): string {
|
||||
|
||||
/** Shared page-base bits (entry html background + overflow-x clip) — same rationale as
|
||||
* single-page generation; used by both the plain-CSS and Tailwind globals. */
|
||||
function pageBaseOf(entry: RouteArtifact): { htmlBg: string; clip: string } {
|
||||
function pageBaseOf(entry: RouteArtifact): { htmlBg: string | null; clip: string } {
|
||||
const cw = entry.ir.doc.canonicalViewport;
|
||||
const pv = entry.ir.doc.perViewport[cw];
|
||||
const htmlBg = pv?.htmlBg && pv.htmlBg !== "rgba(0, 0, 0, 0)" ? pv.htmlBg : (pv?.bodyBg ?? "#ffffff");
|
||||
const htmlBg = resolveHtmlBg(pv);
|
||||
const noHScroll = Object.entries(entry.ir.doc.perViewport).every(([vp, d]) => d.scrollWidth <= Number(vp) * 1.03);
|
||||
return { htmlBg, clip: noHScroll ? "\nhtml, body { overflow-x: clip; }" : "" };
|
||||
}
|
||||
@@ -112,8 +112,7 @@ ${paletteCss}
|
||||
${tokensToCss(entry.tokens, true)}
|
||||
|
||||
/* page base */
|
||||
html { background: ${htmlBg}; }
|
||||
body { font-family: ${SYSTEM_FALLBACK}; }${clip}
|
||||
${htmlBgRule(htmlBg)}body { font-family: ${SYSTEM_FALLBACK}; }${clip}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -132,9 +131,10 @@ function layoutTsx(entry: RouteArtifact, bodyClass: string | undefined, chrome?:
|
||||
const viewport = seo ? viewportExport(seo) : `export const viewport = { width: "device-width", initialScale: 1 };\n`;
|
||||
const jsonLd = seo ? jsonLdHeadMarkup(seo, 8) : "";
|
||||
const head = jsonLd ? ` <head>\n${jsonLd}\n </head>\n` : "";
|
||||
const siteImport = seo ? SITE_ORIGIN_LAYOUT_IMPORT + "\n" : "";
|
||||
return `import "./globals.css";
|
||||
${chromeImport}import type { ReactNode } from "react";
|
||||
|
||||
${siteImport}
|
||||
${metadata}${viewport}
|
||||
|
||||
${pre}export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
@@ -271,6 +271,9 @@ export function generateSiteApp(opts: {
|
||||
// package.json is written after the route loop (below) so the lottie-web dependency can be
|
||||
// added when any route actually replays a Lottie animation.
|
||||
writeText(join(appDir, "tsconfig.json"), isVite ? TSCONFIG_JSON_VITE : TSCONFIG_JSON);
|
||||
writeText(join(appDir, "src", "lib", "utils.ts"), CN_UTILS_MODULE);
|
||||
// SITE_ORIGIN constant for SEO/metadata routes (Next only — Vite ships static SEO files).
|
||||
if (!isVite) writeText(join(appDir, "src", "lib", "site.ts"), SITE_ORIGIN_MODULE);
|
||||
writeText(join(appDir, ".gitignore"), "node_modules\n.next\nout\ndist\n");
|
||||
if (isVite) {
|
||||
rmSync(join(appDir, "next.config.mjs"), { force: true });
|
||||
@@ -384,11 +387,17 @@ export function generateSiteApp(opts: {
|
||||
// Stage 6: split each route component into its own route-local `components/Name.tsx`
|
||||
// (page imports them); per-route data stays inline. Route-local dirs keep names from
|
||||
// colliding across routes, which each name their components independently.
|
||||
for (const { name, module } of componentFiles(routeReg)) writeText(join(routeDir, "components", fileBase(name) + ".tsx"), module);
|
||||
// Depth of a route-local `components/Name.tsx` below `src`: src/app/<dir>/components
|
||||
// (Next) or src/routes/<key>/components (Vite). +1 for the components dir itself.
|
||||
const compDepth = (isVite ? 2 : (dir ? dir.split("/").length + 1 : 1)) + 1;
|
||||
for (const { name, module } of componentFiles(routeReg, undefined, compDepth)) writeText(join(routeDir, "components", fileBase(name) + ".tsx"), module);
|
||||
const compImport = componentImports(routeReg, 0); // route-local → ./components/Name
|
||||
const dataDecls = componentDataDecls(routeReg);
|
||||
const preBlock = dataDecls ? dataDecls + "\n\n" : "";
|
||||
const importLines = [wireImport.trimEnd(), lottieImport.trimEnd(), compImport].filter(Boolean).join("\n");
|
||||
// page.tsx sits at routeDir (src/app/<dir> or src/routes/<key>); depth below src.
|
||||
const pageDepth = isVite ? 2 : (dir ? dir.split("/").length + 1 : 1);
|
||||
const cnImport = /\bcn\(/.test(bodyJsx) ? cnImportLine(pageDepth) + "\n" : "";
|
||||
const importLines = [wireImport.trimEnd(), lottieImport.trimEnd(), cnImport.trimEnd(), compImport].filter(Boolean).join("\n");
|
||||
const pageTsx = `${isVite ? "" : 'import "./ditto.css";\n'}${importLines ? importLines + "\n" : ""}// Generated by clone-site. Do not edit by hand.\n${preBlock}export default function Page() {\n return (\n <>\n${bodyJsx}${wireBody}${lottieBody}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { IR, IRNode } from "../normalize/ir.js";
|
||||
import { isTextChild } from "../normalize/ir.js";
|
||||
import { isTextChild, irContentExtent } from "../normalize/ir.js";
|
||||
import type { PageSnapshot } from "../capture/walker.js";
|
||||
import type { GenNode } from "./render.js";
|
||||
import { indexByCid } from "./render.js";
|
||||
@@ -86,6 +86,32 @@ function hasVisibleElementChild(node: IRNode, vp: number): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** A source text node that the capture painted VISIBLE but the clone rendered HIDDEN at the same
|
||||
* viewport is a high-signal regression: the words are in the markup yet fall in an invisible gen
|
||||
* subtree (off-viewport-shifted, banded-hidden, or width-frozen off-screen). Cheap to detect —
|
||||
* the gen node exists (matched by cid) but reports `visible === false`. Counts DISTINCT source
|
||||
* cids over the run (a node hidden at several viewports counts once) so the number reads as
|
||||
* "how many text nodes went dark", not "hidden-node×viewport". Non-blocking: reported as a metric
|
||||
* only. Pure + input-driven → deterministic. */
|
||||
export function countVisibleInCaptureHiddenInClone(
|
||||
ir: IR,
|
||||
genSnaps: Record<number, PageSnapshot>,
|
||||
viewports: number[],
|
||||
): number {
|
||||
const hiddenCids = new Set<string>();
|
||||
for (const vp of viewports) {
|
||||
if (!genSnaps[vp]) continue;
|
||||
const gen = indexByCid(genSnaps[vp]!);
|
||||
for (const s of collectSrcNodes(ir, vp)) {
|
||||
if (!s.visible) continue;
|
||||
if (normText(s.directText).length === 0) continue;
|
||||
const g = gen.get(s.node.id);
|
||||
if (g && !g.visible) hiddenCids.add(s.node.id);
|
||||
}
|
||||
}
|
||||
return hiddenCids.size;
|
||||
}
|
||||
|
||||
// ---------- Pollution gate (stage 2): is the captured page degenerate? ----------
|
||||
// A clone can pass every structural gate while faithfully reproducing the WRONG
|
||||
// page: an egress/bot wall, a near-empty shell, or a cookie/consent modal that was
|
||||
@@ -119,11 +145,30 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
|
||||
let blocking = capture.dismissal?.blocking ?? false;
|
||||
for (const pv of capture.perViewport) { overlaysRemaining = Math.max(overlaysRemaining, pv.overlaysRemaining ?? 0); blocking = blocking || !!pv.blocking; }
|
||||
let minHeightRatio = Infinity;
|
||||
let maxHeightRatio = 0;
|
||||
for (const pv of capture.perViewport) {
|
||||
if (pv.height > 0) minHeightRatio = Math.min(minHeightRatio, pv.scrollHeight / pv.height);
|
||||
if (pv.height > 0) {
|
||||
const ratio = pv.scrollHeight / pv.height;
|
||||
minHeightRatio = Math.min(minHeightRatio, ratio);
|
||||
maxHeightRatio = Math.max(maxHeightRatio, ratio);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(minHeightRatio)) minHeightRatio = 1;
|
||||
|
||||
// Scroll-locked-capture contradiction: an email-capture/promo popup that sets
|
||||
// body{overflow:hidden;height:100vh} collapses `document.scrollHeight` to EXACTLY the viewport
|
||||
// height at EVERY width (ratio ~1.0 across the board) — yet the real page's IN-FLOW content
|
||||
// (the IR's sections) still lays out several viewports tall. A genuine one-screen landing page
|
||||
// has content extent ~= its scrollHeight, so this only fires when the two disagree: captured
|
||||
// scrollHeight pinned to one viewport WHILE the IR content spans multiple. That is a
|
||||
// scroll-locked, polluted capture — the overlay detector should have caught it, so fail loudly.
|
||||
let maxContentRatio = 0;
|
||||
for (const pv of capture.perViewport) {
|
||||
if (pv.height > 0) maxContentRatio = Math.max(maxContentRatio, irContentExtent(ir.root, pv.viewport) / pv.height);
|
||||
}
|
||||
// scrollHeight never exceeds ~1 viewport at any width, but the IR content is 2+ viewports tall.
|
||||
const scrollLockedContradiction = maxHeightRatio > 0 && maxHeightRatio < 1.15 && maxContentRatio >= 2;
|
||||
|
||||
// Degenerate signals. Calibrated against real captures: an egress/bot wall is
|
||||
// ~3 nodes / ~24 chars; the most minimal legitimate page in the suite
|
||||
// (michaelcole.me) is 26 nodes / 640 chars. Thresholds sit safely between.
|
||||
@@ -131,6 +176,7 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
|
||||
if (wall && nodeCount < 220) issues.push("bot/egress wall text on a small page");
|
||||
if (textChars < 60 && nodeCount < 50) issues.push(`near-empty page: ${textChars} visible text chars, ${nodeCount} nodes`);
|
||||
if (blocking) issues.push("a full-viewport modal still scroll-locks the page after dismissal");
|
||||
if (scrollLockedContradiction) issues.push(`scroll-locked capture: scrollHeight pinned to ~1 viewport at every width while IR content spans ${round2(maxContentRatio)} viewports`);
|
||||
|
||||
return {
|
||||
gate: "pollution",
|
||||
@@ -138,6 +184,7 @@ export function gatePollution(ir: IR, capture: CaptureResult, viewports: number[
|
||||
metrics: {
|
||||
nodeCount, visibleTextChars: textChars, wallTextDetected: wall,
|
||||
overlaysRemaining, blocking, minScrollHeightRatio: round2(minHeightRatio),
|
||||
maxScrollHeightRatio: round2(maxHeightRatio), maxContentExtentRatio: round2(maxContentRatio),
|
||||
dismissedCount: capture.dismissal?.dismissed.length ?? 0,
|
||||
overlaysRemoved: capture.dismissal?.removed ?? 0,
|
||||
videoStills: capture.dismissal?.videoStills ?? 0,
|
||||
@@ -175,7 +222,7 @@ export function gate2Assets(assetGraph: AssetGraph, fontGraph: FontGraph, gen: {
|
||||
if (zeroByte > 0) issues.push(`${zeroByte} zero-byte downloaded assets`);
|
||||
if (skippedNoReason > 0) issues.push(`${skippedNoReason} skipped assets without reason`);
|
||||
if (gen.remoteRefs.length > 0) issues.push(`${gen.remoteRefs.length} generated refs point to remote origin`);
|
||||
if (gen.failed404.length > 0) issues.push(`${gen.failed404.length} generated asset refs 404`);
|
||||
if (gen.failed404.length > 0) issues.push(`${gen.failed404.length} generated asset refs missing (HTTP >= 400 or file absent from export)`);
|
||||
const fontsResolvedOrFallback = fontGraph.entries.every((f) => f.status === "resolved" || (f.status === "fallback" && f.reason));
|
||||
if (!fontsResolvedOrFallback) issues.push("font declarations not resolved/fallback-recorded");
|
||||
return {
|
||||
@@ -257,6 +304,12 @@ export function gate3Dom(ir: IR, genSnaps: Record<number, PageSnapshot>, viewpor
|
||||
}
|
||||
}
|
||||
|
||||
// Non-blocking, high-signal diagnostic: source text the capture painted VISIBLE that the clone
|
||||
// renders HIDDEN at the same viewport (off-screen-shifted / banded / width-frozen subtree). Does
|
||||
// NOT gate pass — text presence already covers markup fidelity — but surfaces the "went dark"
|
||||
// count so a banner/feed row vanishing is legible in the report instead of hiding inside a 99.x.
|
||||
const textHiddenInClone = countVisibleInCaptureHiddenInClone(ir, genSnaps, viewports);
|
||||
|
||||
const matchPct = totalVisible ? matched / totalVisible : 1;
|
||||
const textPct = textTotal ? textPresent / textTotal : 1;
|
||||
const linkPct = linksTotal ? linksOk / linksTotal : 1;
|
||||
@@ -275,6 +328,7 @@ export function gate3Dom(ir: IR, genSnaps: Record<number, PageSnapshot>, viewpor
|
||||
nodeMatchPct: round4(matchPct), textPresentPct: round4(textPct),
|
||||
linkPct: round4(linkPct), mediaPct: round4(mediaPct),
|
||||
totalVisible, matched, textTotal, textPresent, linksTotal, mediaTotal, inventedText,
|
||||
textHiddenInClone,
|
||||
},
|
||||
issues,
|
||||
};
|
||||
@@ -292,8 +346,12 @@ function isValidRetag(srcTag: string, genTag: string): boolean {
|
||||
return genTag === "div" && /^(ul|ol|menu|dl|p|h[1-6])$/.test(srcTag); // content-model → div
|
||||
}
|
||||
|
||||
function normHref(href: string, origin: string): string {
|
||||
export function normHref(href: string, origin: string): string {
|
||||
if (!href) return "";
|
||||
// A `javascript:*` href is a script trigger, not a navigable URL. Generation emits an inert `#`
|
||||
// for it (React blocks the literal), so treat every javascript: href — on either side — as `#`
|
||||
// and let the two sides match instead of failing on the un-reproducible script string.
|
||||
if (/^\s*javascript:/i.test(href)) return "#";
|
||||
if (href.startsWith("#")) return href;
|
||||
try {
|
||||
const u = new URL(href, origin);
|
||||
@@ -346,7 +404,10 @@ export function gate4Style(ir: IR, genSnaps: Record<number, PageSnapshot>, viewp
|
||||
// equal, only numerically far apart. Treat any two such large radii as equivalent so the
|
||||
// idiomatic `rounded-full` doesn't trip the exact-px compare; real radii stay ±2px.
|
||||
if (p === "borderTopLeftRadius" && pxNum(s.computed[p] ?? "") >= PILL_PX && pxNum(g.computed[p] ?? "") >= PILL_PX) continue;
|
||||
if (!cmpNum(s.computed[p], g.computed[p], 2, 0)) { nodeOk = false; fails.push(p); }
|
||||
const eq = p === "letterSpacing"
|
||||
? letterSpacingEquivalent(s.computed[p], g.computed[p])
|
||||
: cmpNum(s.computed[p], g.computed[p], 2, 0);
|
||||
if (!eq) { nodeOk = false; fails.push(p); }
|
||||
}
|
||||
for (const p of PX4_PROPS) {
|
||||
// `gap: normal` is the initial value and resolves to 0 for flex/grid, so it
|
||||
@@ -405,6 +466,19 @@ function cmpNum(a: string | undefined, b: string | undefined, abs: number, pct:
|
||||
return withinAbs(na, nb, abs) || (pct > 0 && withinPct(na, nb, pct));
|
||||
}
|
||||
|
||||
/** Compare two computed `letter-spacing` values within the ±2px style tolerance, treating the keyword
|
||||
* `normal` as `0px`. `letter-spacing: normal` is the initial value and adds no extra spacing (it
|
||||
* computes to 0), so `normal` ≡ `0px`; crucially, Chromium serializes a computed `letter-spacing: 0`
|
||||
* BACK as the keyword `normal`, so a genuinely-zero (or sub-0.1px, snapped-to-zero) tracking shows up
|
||||
* as `normal` on one side and `0px` on the other — a spelling difference that `cmpNum` alone reads as
|
||||
* a NaN → exact-string mismatch. Normalizing the keyword before the numeric compare removes that false
|
||||
* failure while a real tracking delta (> 2px) still fails. Mirrors the `gap: normal → 0px` handling. */
|
||||
export function letterSpacingEquivalent(a: string | undefined, b: string | undefined): boolean {
|
||||
if (a === undefined) return true;
|
||||
const norm = (v: string | undefined): string => (v ?? "").replace(/\bnormal\b/g, "0px");
|
||||
return cmpNum(norm(a), norm(b), 2, 0);
|
||||
}
|
||||
|
||||
// ---------- Gate 5: layout / section equivalence ----------
|
||||
export function gate5Layout(ir: IR, genSnaps: Record<number, PageSnapshot>, sections: Section[], viewports: number[], reflow = false): GateResult {
|
||||
const issues: string[] = [];
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { InteractionCapture } from "../capture/interactions.js";
|
||||
import { buildRuntimeSpecs, specKey } from "../generate/interactive.js";
|
||||
import { buildMenuSpecs } from "../generate/menu.js";
|
||||
import type { GateResult } from "./gates.js";
|
||||
import { gotoAndSettle } from "./render.js";
|
||||
|
||||
/**
|
||||
* Stage 4 interaction gate. Drives the SAME interactions in the built clone that
|
||||
@@ -61,7 +62,10 @@ export async function driveInteractionGate(opts: {
|
||||
const ctx = await browser.newContext({ viewport: { width: vp, height: vh }, deviceScaleFactor: 1 });
|
||||
const page = await ctx.newPage();
|
||||
await page.addInitScript("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 45000 });
|
||||
// `load` + bounded network-quiet wait (not strict `networkidle`) so an autoplaying
|
||||
// long hero video — which keeps issuing bounded range fetches — can't make navigation
|
||||
// time out and fail the interaction gate before any interaction is even driven.
|
||||
await gotoAndSettle(page, url);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const styleOf = (cid: string, props: string[]): Promise<Record<string, string> | null> =>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { join, extname, normalize } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chromium } from "playwright";
|
||||
import { collectPage, type PageSnapshot } from "../capture/walker.js";
|
||||
import { captureFullPageViaCDP, normalizeVideoTime } from "../capture/capture.js";
|
||||
import { ensureDir, writeJSONCompact } from "../util/fsx.js";
|
||||
|
||||
const ESBUILD_SHIM =
|
||||
@@ -145,6 +146,50 @@ async function readLimited(p: string): Promise<Buffer> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of interpreting a `Range` header against a known resource size.
|
||||
* - `kind: "full"` → no (usable) Range header; answer with a normal 200 full body.
|
||||
* - `kind: "range"` → a single satisfiable `bytes=start-end`; answer 206 with [start,end].
|
||||
* - `kind: "unsatisfiable"` → a syntactically valid range wholly past EOF; answer 416. */
|
||||
export type RangeResolution =
|
||||
| { kind: "full" }
|
||||
| { kind: "range"; start: number; end: number }
|
||||
| { kind: "unsatisfiable" };
|
||||
|
||||
/** Parse a single-range HTTP `Range` header ("bytes=start-end", "bytes=start-",
|
||||
* "bytes=-suffix") against a resource of `size` bytes. Multi-range ("a-b,c-d") is
|
||||
* intentionally collapsed to a full 200 (Chromium's media element only ever asks for a
|
||||
* single range, and RFC 7233 lets a server ignore Range and reply 200). Anything the
|
||||
* spec calls malformed (missing "bytes=", non-numeric, inverted, both bounds empty) is
|
||||
* also treated as "full" — the safe fallback. A well-formed range that starts at or past
|
||||
* EOF is "unsatisfiable" → 416. `end` is inclusive and clamped to size-1. */
|
||||
export function parseRangeHeader(header: string | undefined, size: number): RangeResolution {
|
||||
if (!header) return { kind: "full" };
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
|
||||
if (!m) return { kind: "full" }; // absent/malformed/multi-range → serve full body
|
||||
const startStr = m[1]!;
|
||||
const endStr = m[2]!;
|
||||
if (startStr === "" && endStr === "") return { kind: "full" }; // "bytes=-" is malformed
|
||||
if (size <= 0) return { kind: "unsatisfiable" };
|
||||
let start: number;
|
||||
let end: number;
|
||||
if (startStr === "") {
|
||||
// Suffix range: "bytes=-N" → last N bytes.
|
||||
const suffix = Number(endStr);
|
||||
if (!Number.isFinite(suffix) || suffix <= 0) return { kind: "full" };
|
||||
start = Math.max(0, size - suffix);
|
||||
end = size - 1;
|
||||
} else {
|
||||
start = Number(startStr);
|
||||
if (!Number.isFinite(start)) return { kind: "full" };
|
||||
if (start >= size) return { kind: "unsatisfiable" }; // start past EOF
|
||||
end = endStr === "" ? size - 1 : Number(endStr);
|
||||
if (!Number.isFinite(end)) return { kind: "full" };
|
||||
if (end < start) return { kind: "full" }; // inverted → ignore Range
|
||||
end = Math.min(end, size - 1);
|
||||
}
|
||||
return { kind: "range", start, end };
|
||||
}
|
||||
|
||||
export function serveStatic(rootDir: string): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server: Server = createServer(async (req, res) => {
|
||||
try {
|
||||
@@ -167,8 +212,32 @@ export function serveStatic(rootDir: string): Promise<{ url: string; close: () =
|
||||
if (existsSync(fallback)) { res.writeHead(404, { "content-type": "text/html" }); res.end(await readLimited(fallback)); return; }
|
||||
res.writeHead(404); res.end("not found"); return;
|
||||
}
|
||||
const contentType = CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
||||
const size = statSync(filePath).size;
|
||||
// HTTP Range support (RFC 7233). Chromium requests <video>/<audio> with `Range: bytes=0-`
|
||||
// and, absent a 206, holds the single progressive stream open throttled to playback — a
|
||||
// long autoplay hero (76s) then means `waitUntil:"networkidle"` can mathematically never
|
||||
// fire. Answering 206 with bounded chunks lets Chromium fetch in pieces so the network
|
||||
// goes idle between reads. A HEAD-style range far past EOF gets a 416.
|
||||
const range = parseRangeHeader(req.headers.range as string | undefined, size);
|
||||
if (range.kind === "unsatisfiable") {
|
||||
res.writeHead(416, { "content-type": contentType, "content-range": `bytes */${size}`, "accept-ranges": "bytes" });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const data = await readLimited(filePath);
|
||||
res.writeHead(200, { "content-type": CONTENT_TYPES[extname(filePath).toLowerCase()] ?? "application/octet-stream" });
|
||||
if (range.kind === "range") {
|
||||
const chunk = data.subarray(range.start, range.end + 1);
|
||||
res.writeHead(206, {
|
||||
"content-type": contentType,
|
||||
"content-range": `bytes ${range.start}-${range.end}/${size}`,
|
||||
"accept-ranges": "bytes",
|
||||
"content-length": String(chunk.length),
|
||||
});
|
||||
res.end(chunk);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { "content-type": contentType, "accept-ranges": "bytes", "content-length": String(data.length) });
|
||||
res.end(data);
|
||||
} catch {
|
||||
res.writeHead(500); res.end("error");
|
||||
@@ -191,8 +260,151 @@ export type RenderResult = {
|
||||
runtimeErrors: string[];
|
||||
httpStatus: number;
|
||||
failedResources: string[];
|
||||
/** Non-fatal diagnostics: families the app declared via @font-face that never reached
|
||||
* `status:"loaded"` within the wait bound (their text was measured with a fallback face). */
|
||||
fontWarnings: string[];
|
||||
};
|
||||
|
||||
/** A single entry of `document.fonts` as reported in-page, reduced to the fields the
|
||||
* load decision needs. Serializable so the decision logic can be unit-tested outside a browser. */
|
||||
export type FontFaceStatus = { family: string; weight: string; style: string; status: string };
|
||||
|
||||
/** Pure decision for the font-load wait: given the current `document.fonts` entries, return the
|
||||
* families that are DECLARED (an @font-face exists for them) and still actively `"loading"`. Only
|
||||
* `"loading"` is pending: browsers lazy-load faces, so after `document.fonts.ready` a face the
|
||||
* rendered text actually needs is already fetching (`"loading"`), while a face left `"unloaded"`
|
||||
* is by definition unreferenced by any rendered text and will never load on its own — waiting on
|
||||
* it just burns the cap. `"loaded"`/`"error"` are terminal. Empty return ⇒ no face is still
|
||||
* fetching and the DOM may be measured. The check is state-based (not time-based), so the caller's
|
||||
* poll is deterministic: it ends the instant this returns empty, regardless of wall-clock timing. */
|
||||
export function pendingFontFamilies(faces: FontFaceStatus[]): string[] {
|
||||
const pending = new Set<string>();
|
||||
for (const f of faces) {
|
||||
const fam = f.family.replace(/^["']|["']$/g, "");
|
||||
// Only a face still actively fetching keeps its family pending; "unloaded" (unreferenced),
|
||||
// "loaded", and "error" are all non-blocking.
|
||||
if (f.status === "loading") pending.add(fam);
|
||||
}
|
||||
return [...pending].sort();
|
||||
}
|
||||
|
||||
/** Declared @font-face faces left `"unloaded"` after the wait bound: not fetched because no
|
||||
* rendered text resolves to them (unreferenced weight/style/unicode-subset siblings). Reported
|
||||
* per-face (family+weight+style) for an informational-only log — these are benign, never a
|
||||
* fidelity problem, since the text that IS measured renders in the face that actually loaded. */
|
||||
export function unreferencedFontFaces(faces: FontFaceStatus[]): FontFaceStatus[] {
|
||||
return faces
|
||||
.filter((f) => f.status === "unloaded")
|
||||
.map((f) => ({ family: f.family.replace(/^["']|["']$/g, ""), weight: f.weight, style: f.style, status: f.status }))
|
||||
.sort((a, b) => `${a.family} ${a.weight} ${a.style}`.localeCompare(`${b.family} ${b.weight} ${b.style}`));
|
||||
}
|
||||
|
||||
/** In-page: await `document.fonts.ready`, then poll (bounded) until no declared @font-face is still
|
||||
* `"loading"`. Browsers lazy-load faces, so once ready has fired the faces the rendered text needs
|
||||
* are already fetching; the poll only waits on those. Returns `{ pending, unreferenced }`: `pending`
|
||||
* = families whose face was STILL `"loading"` when the bound expired (a genuinely stuck fetch, kept
|
||||
* as a warning); `unreferenced` = faces left `"unloaded"` (declared but no rendered text resolves to
|
||||
* them — benign, surfaced per-face for an informational log only). Runs entirely inside the browser
|
||||
* context so it reads the live FontFaceSet. Bounded + state-based ⇒ deterministic (ends on state,
|
||||
* never on a fixed sleep). `capMs` is a hard ceiling so a hung font request can't stall the render. */
|
||||
async function awaitFontsLoaded(
|
||||
page: import("playwright").Page,
|
||||
opts?: { capMs?: number; pollMs?: number },
|
||||
): Promise<{ pending: string[]; unreferenced: FontFaceStatus[] }> {
|
||||
const capMs = opts?.capMs ?? 3000;
|
||||
const pollMs = opts?.pollMs ?? 50;
|
||||
try {
|
||||
return await page.evaluate(
|
||||
async ({ capMs, pollMs }) => {
|
||||
const doc = document as Document;
|
||||
const set = doc.fonts as unknown as { ready?: Promise<unknown>; forEach?: (cb: (f: FontFaceStatus) => void) => void } | undefined;
|
||||
if (!set) return { pending: [], unreferenced: [] };
|
||||
const snapshot = (): { family: string; weight: string; style: string; status: string }[] => {
|
||||
const out: { family: string; weight: string; style: string; status: string }[] = [];
|
||||
set.forEach?.((f) => out.push({ family: f.family, weight: f.weight, style: f.style, status: f.status }));
|
||||
return out;
|
||||
};
|
||||
// Only a face still actively fetching is pending; "unloaded" is unreferenced (never fetched).
|
||||
const pending = (faces: { family: string; weight: string; style: string; status: string }[]): string[] => {
|
||||
const p = new Set<string>();
|
||||
for (const f of faces) {
|
||||
if (f.status === "loading") p.add(f.family.replace(/^["']|["']$/g, ""));
|
||||
}
|
||||
return [...p].sort();
|
||||
};
|
||||
// First give the browser's own aggregate signal a chance (bounded — a hung request must not
|
||||
// hold ready forever). Then poll the per-face states until none are loading or the cap expires.
|
||||
await Promise.race([set.ready ?? Promise.resolve(), new Promise((r) => setTimeout(r, capMs))]);
|
||||
const deadline = Date.now() + capMs;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const faces = snapshot();
|
||||
const left = pending(faces);
|
||||
const done = left.length === 0 || Date.now() >= deadline;
|
||||
if (done) {
|
||||
const unreferenced = faces
|
||||
.filter((f) => f.status === "unloaded")
|
||||
.map((f) => ({ family: f.family.replace(/^["']|["']$/g, ""), weight: f.weight, style: f.style, status: f.status }));
|
||||
return { pending: left, unreferenced };
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
}
|
||||
},
|
||||
{ capMs, pollMs },
|
||||
) as { pending: string[]; unreferenced: FontFaceStatus[] };
|
||||
} catch {
|
||||
return { pending: [], unreferenced: [] }; // no FontFaceSet (or an evaluate fault) never blocks the walk
|
||||
}
|
||||
}
|
||||
|
||||
/** Navigate with `waitUntil:"load"` then wait for a bounded window of network quiet, so
|
||||
* validation NEVER hard-fails on media that trickles requests. `waitUntil:"networkidle"`
|
||||
* is a hard timeout: an autoplaying long video (even chunked via 206) keeps issuing bounded
|
||||
* range fetches, so a strict networkidle can miss its 500ms-idle window and throw at 45s,
|
||||
* leaving validation reportless. Instead we (1) `goto load` (fires on DOM+subresources, not
|
||||
* on ongoing media), then (2) poll for `maxQuietMs` of 500ms network silence up to a
|
||||
* `settleCapMs` ceiling, then proceed regardless. Normal pages reach quiet in well under a
|
||||
* second, so total time stays comparable; only trickle-media pages spend the extra budget and
|
||||
* then continue (the video is frame-0-normalized before the screenshot, so determinism holds).
|
||||
* Returns the goto response (for httpStatus). */
|
||||
export async function gotoAndSettle(
|
||||
page: import("playwright").Page,
|
||||
url: string,
|
||||
opts?: { gotoTimeout?: number; settleCapMs?: number; quietMs?: number; pollMs?: number },
|
||||
): Promise<import("playwright").Response | null> {
|
||||
const gotoTimeout = opts?.gotoTimeout ?? 45000;
|
||||
const settleCapMs = opts?.settleCapMs ?? 10000;
|
||||
const quietMs = opts?.quietMs ?? 500;
|
||||
const pollMs = opts?.pollMs ?? 500;
|
||||
const resp = await page.goto(url, { waitUntil: "load", timeout: gotoTimeout });
|
||||
// Count in-flight requests so we can detect network quiet without relying on the
|
||||
// strict networkidle heuristic (which throws on trickle-media).
|
||||
let inflight = 0;
|
||||
const onReq = (): void => { inflight++; };
|
||||
const onDone = (): void => { inflight = Math.max(0, inflight - 1); };
|
||||
page.on("request", onReq);
|
||||
page.on("requestfinished", onDone);
|
||||
page.on("requestfailed", onDone);
|
||||
try {
|
||||
const deadline = Date.now() + settleCapMs;
|
||||
let quietSince = inflight === 0 ? Date.now() : 0;
|
||||
while (Date.now() < deadline) {
|
||||
if (inflight === 0) {
|
||||
if (quietSince === 0) quietSince = Date.now();
|
||||
if (Date.now() - quietSince >= quietMs) break; // sustained quiet reached
|
||||
} else {
|
||||
quietSince = 0;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollMs));
|
||||
}
|
||||
} finally {
|
||||
page.off("request", onReq);
|
||||
page.off("requestfinished", onDone);
|
||||
page.off("requestfailed", onDone);
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T, i: number) => Promise<R>): Promise<R[]> {
|
||||
const out: R[] = new Array(items.length);
|
||||
let next = 0;
|
||||
@@ -218,6 +430,7 @@ export async function renderApp(opts: {
|
||||
const vh = viewportHeight(vw);
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: vw, height: vh }, deviceScaleFactor: 1 });
|
||||
const runtimeErrors: string[] = [];
|
||||
const fontWarnings: string[] = [];
|
||||
const failedResources = new Set<string>();
|
||||
let httpStatus = 0;
|
||||
try {
|
||||
@@ -226,9 +439,26 @@ export async function renderApp(opts: {
|
||||
page.on("pageerror", (e) => runtimeErrors.push(String(e)));
|
||||
page.on("response", (r) => { if (r.status() >= 400) failedResources.add(`${r.status()} ${r.url()}`); });
|
||||
page.on("requestfailed", (r) => failedResources.add(`failed ${r.url()}`));
|
||||
const resp = await page.goto(opts.url, { waitUntil: "networkidle", timeout: 45000 });
|
||||
const resp = await gotoAndSettle(page, opts.url);
|
||||
if (resp) httpStatus = resp.status();
|
||||
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ }
|
||||
// Webfonts must be APPLIED before the DOM walk and the screenshot: a rendered snapshot taken
|
||||
// while the app's @font-face faces are still "unloaded" measures every text box in the
|
||||
// fallback face (systematically narrower/wider glyphs → a bogus size delta attributed to the
|
||||
// clone). Await document.fonts.ready AND poll (bounded, state-based) until every declared face
|
||||
// is terminal, so this holds for both the walk below and the screenshot further down.
|
||||
const { pending: pendingFonts, unreferenced: unrefFaces } = await awaitFontsLoaded(page);
|
||||
if (pendingFonts.length) {
|
||||
const msg = `font-wait: ${pendingFonts.length} @font-face families still loading after wait bound at ${vw}px: ${pendingFonts.join(", ")}`;
|
||||
fontWarnings.push(msg);
|
||||
console.warn(`[render] ${msg}`);
|
||||
}
|
||||
// Declared-but-unreferenced faces (no rendered text resolves to them) are benign — the
|
||||
// browser never fetched them by design. Report them per-face (family+weight+style) as an
|
||||
// informational log only; they are NOT a fidelity warning and never enter fontWarnings.
|
||||
if (unrefFaces.length) {
|
||||
const detail = unrefFaces.map((f) => `${f.family} ${f.weight} ${f.style}`).join(", ");
|
||||
console.log(`[render] font-info: ${unrefFaces.length} declared-but-unreferenced faces at ${vw}px: ${detail}`);
|
||||
}
|
||||
// Scroll to settle any lazy effects, then back to top.
|
||||
await page.evaluate(async () => {
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
@@ -255,21 +485,36 @@ export async function renderApp(opts: {
|
||||
});
|
||||
const snapshot = await page.evaluate(collectPage);
|
||||
writeJSONCompact(join(opts.renderedDir, "dom", `dom-${vw}.json`), snapshot);
|
||||
// Screenshot via CDP (captureBeyondViewport, no scroll-stitch) to match the SOURCE
|
||||
// channel exactly — both sides must measure the same at-rest state. Fall back to
|
||||
// Playwright's fullPage stitch if CDP fails. The clone is static so scroll-stitch
|
||||
// matters less here, but symmetric capture is the requirement.
|
||||
try {
|
||||
await page.screenshot({ path: join(opts.renderedDir, "screenshots", `${vw}.png`), fullPage: true, timeout: 90_000, animations: "disabled" });
|
||||
const shotPath = join(opts.renderedDir, "screenshots", `${vw}.png`);
|
||||
// Pin every clone-side <video> to frame 0 (paused) before the shot — the SOURCE channel is
|
||||
// now normalized to frame 0 at every viewport too, so both sides show the same frame and a
|
||||
// video's playback time can't manufacture a perceptual diff. (The static clone rarely plays,
|
||||
// but a replayed/autoplaying video would otherwise drift; symmetric normalization is the rule.)
|
||||
await normalizeVideoTime(page);
|
||||
try {
|
||||
await captureFullPageViaCDP(page, shotPath);
|
||||
} catch {
|
||||
await page.screenshot({ path: shotPath, fullPage: true, timeout: 90_000, animations: "disabled" });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return { viewport: vw, snapshot, runtimeErrors, failedResources: [...failedResources], httpStatus };
|
||||
return { viewport: vw, snapshot, runtimeErrors, fontWarnings, failedResources: [...failedResources], httpStatus };
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
});
|
||||
const runtimeErrors = results.flatMap((r) => r.runtimeErrors);
|
||||
const fontWarnings = results.flatMap((r) => r.fontWarnings);
|
||||
const failedResources = new Set(results.flatMap((r) => r.failedResources));
|
||||
for (const r of results) snapshots[r.viewport] = r.snapshot;
|
||||
const non200 = results.find((r) => r.httpStatus && r.httpStatus !== 200);
|
||||
const httpStatus = non200?.httpStatus ?? results.find((r) => r.httpStatus)?.httpStatus ?? 0;
|
||||
ensureDir(join(opts.renderedDir, "computed"));
|
||||
return { snapshots, runtimeErrors, httpStatus, failedResources: [...failedResources] };
|
||||
return { snapshots, runtimeErrors, httpStatus, failedResources: [...failedResources], fontWarnings };
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -293,8 +538,10 @@ export async function measureProbeWidths(opts: { url: string; widths: number[] }
|
||||
const ctx = await browser.newContext({ ignoreHTTPSErrors: true, viewport: { width: w, height: viewportHeight(w) }, deviceScaleFactor: 1 });
|
||||
const page = await ctx.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.goto(opts.url, { waitUntil: "networkidle", timeout: 45000 });
|
||||
try { await page.evaluate(() => (document as Document).fonts?.ready); } catch { /* ignore */ }
|
||||
await gotoAndSettle(page, opts.url);
|
||||
// This probe walks the DOM and measures text boxes, so — exactly as renderApp does — webfonts
|
||||
// must be applied first, else the probe reads fallback-font widths at the off-band widths.
|
||||
await awaitFontsLoaded(page);
|
||||
await page.evaluate(() => {
|
||||
const win = window as unknown as { __dittoMotionStopped?: boolean; __dittoMotionStop?: () => void };
|
||||
try { win.__dittoMotionStopped = true; } catch { /* ignore */ }
|
||||
|
||||
@@ -28,6 +28,21 @@ export function probeWidthsFor(viewports: number[]): number[] {
|
||||
const widest = sorted[sorted.length - 1] ?? 1920;
|
||||
return [...mids, Math.round(widest * 4 / 3)];
|
||||
}
|
||||
/** True if the URL of an aborted `/assets/` fetch resolves to a real file under the served export.
|
||||
* Mirrors `serveStatic`'s path mapping (path segment joined onto the served root, `..` stripped) so
|
||||
* an aborted srcset candidate whose file is present on disk is not mistaken for a 404. A URL we
|
||||
* cannot parse, or one whose file is absent, is treated as a genuine miss (returns false). */
|
||||
export function servedAssetExists(servedRoot: string, url: string): boolean {
|
||||
let pathname: string;
|
||||
try {
|
||||
pathname = new URL(url).pathname;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const rel = decodeURIComponent(pathname).replace(/^(\.\.[/\\])+/, "").replace(/^\/+/, "");
|
||||
return existsSync(join(servedRoot, rel));
|
||||
}
|
||||
|
||||
import { buildReport, reportToMarkdown, type Report } from "./report.js";
|
||||
import { readJSON, writeJSON, writeText, ensureDir, fileExists } from "../util/fsx.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
@@ -108,6 +123,9 @@ export async function validateRun(runDir: string, opts?: { harnessDir?: string;
|
||||
try {
|
||||
const r = await renderApp({ url: server.url + "/", viewports, renderedDir });
|
||||
snapshots = r.snapshots; runtimeErrors = r.runtimeErrors; httpStatus = r.httpStatus; failedResources = r.failedResources;
|
||||
// Non-fatal: webfonts that never loaded before the render walk (text was measured in a fallback
|
||||
// face). Surfaced as a warning event, NOT a runtime error, so it never fails gate 0.
|
||||
if (r.fontWarnings.length) log({ event: "font_wait_warning", families: r.fontWarnings });
|
||||
probeSnaps = await measureProbeWidths({ url: server.url + "/", widths: probeWidthsFor(viewports) });
|
||||
if (capture.interaction) {
|
||||
interactionGate = await driveInteractionGate({ url: server.url + "/", viewports, ir, interaction: capture.interaction });
|
||||
@@ -122,16 +140,32 @@ export async function validateRun(runDir: string, opts?: { harnessDir?: string;
|
||||
motionGate = await driveMotionGate({ url: server.url + "/", viewports, ir, motion: capture.motion });
|
||||
log({ event: "motion_gate", pass: motionGate.pass, metrics: motionGate.metrics });
|
||||
}
|
||||
} catch (e) {
|
||||
// A render crash (e.g. a page.goto timeout on trickling media, or a Playwright fault)
|
||||
// must NOT escape reportless — otherwise validation/ stays empty and the run looks
|
||||
// un-validated. Record the error as a runtime error so httpStatus stays 0, gate 0 fails
|
||||
// with a visible issue, and the normal downstream path writes report.json as usual.
|
||||
runtimeErrors.push(`render error: ${e instanceof Error ? e.message : String(e)}`);
|
||||
log({ event: "render_error", error: e instanceof Error ? e.message : String(e) });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
// Video/audio elements stream and their requests are aborted ("failed") when the
|
||||
// render snapshot is taken before the stream finishes — the file is materialized,
|
||||
// so this is not an asset failure. Only count non-streaming assets.
|
||||
const assetFailed = failedResources.filter(
|
||||
(f) => f.includes("/assets/") && !/\.(mp4|webm|mov|m4v|ogv|ogg|mp3|wav|m3u8)(\?|$)/i.test(f),
|
||||
);
|
||||
// A generated asset ref is only a real failure if the file it points to is genuinely
|
||||
// missing from the served export. Two aborts are NOT failures even though Chromium logs
|
||||
// them as "failed": (a) video/audio streams aborted when the render snapshot is taken
|
||||
// before the stream finishes; (b) responsive-image <source srcset> candidates whose
|
||||
// in-flight low-priority fetch is aborted when the page closes or the candidate is
|
||||
// re-chosen — the image analogue of (a). So count an /assets/ entry only when it is a
|
||||
// real HTTP >= 400 response, OR a "failed " (aborted) entry whose target file is actually
|
||||
// absent from the served out/ dir. Genuine 404s (file missing) still fail.
|
||||
const assetFailed = failedResources.filter((f) => {
|
||||
if (!f.includes("/assets/")) return false;
|
||||
if (/\.(mp4|webm|mov|m4v|ogv|ogg|mp3|wav|m3u8)(\?|$)/i.test(f)) return false; // streaming media (a)
|
||||
if (!f.startsWith("failed ")) return true; // "<status> <url>" — a real >= 400 response, always count
|
||||
// Aborted fetch (b): excuse it if the file exists under the served export, else it is a real miss.
|
||||
return build.outDir ? !servedAssetExists(build.outDir, f.slice("failed ".length)) : true;
|
||||
});
|
||||
const artifactsPresent = ["manifest.json", "sections.json", "tokens.json", "assets.json", "fonts.json"].every((f) => fileExists(join(generatedDir, f)));
|
||||
const gate0Issues: string[] = [];
|
||||
if (!build.ok) gate0Issues.push("build failed: " + build.stderr.split("\n").filter(Boolean).slice(-3).join(" | "));
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
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 { buildTailwind } from "../src/generate/tailwind.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 };
|
||||
}
|
||||
|
||||
/** Per-viewport node: distinct computed style per width so a band delta is produced. */
|
||||
function pvNode(id: string, tag: string, byVp: Record<number, StyleMap>, children: IRChild[] = [], srcClass?: string): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = computed(byVp[vp]);
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children };
|
||||
if (srcClass) n.srcClass = srcClass;
|
||||
return n;
|
||||
}
|
||||
|
||||
function irWith(root: IRNode): IR {
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/banding",
|
||||
title: "Banding 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: 3,
|
||||
keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
// C3 — a base RAW value (gradient) with a band that RESETS the same prop to a NON-raw value
|
||||
// (`background-image: none` → utility `bg-[none]`) must keep the base gradient in ditto.css, not
|
||||
// inline. An inline style out-specifies the @media reset, painting the gradient where the source
|
||||
// turned it off (the mobile shimmer-blob defect). The banded-props set must count ALL band-touched
|
||||
// props, not just the raw ones.
|
||||
describe("buildTailwind banded non-raw override keeps base gradient in ditto.css (C3)", () => {
|
||||
it("does not inline a base gradient when a band resets background-image to none", () => {
|
||||
const grad = "linear-gradient(90deg, rgb(1, 2, 3), rgb(4, 5, 6))";
|
||||
// 1280 (canonical/base): gradient painted; 375 (mobile band): background-image none.
|
||||
const el = pvNode("n1", "span", {
|
||||
375: { backgroundImage: "none" },
|
||||
1280: { backgroundImage: grad },
|
||||
}, [{ text: "Grepping" } as IRChild]);
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const inline = tw.styleOf.get("n1");
|
||||
const inlineHasGrad = !!inline && [...inline.values()].some((v) => v.includes("gradient("));
|
||||
assert.ok(!inlineHasGrad, `banded gradient must NOT be inlined, got inline: ${inline ? JSON.stringify([...inline]) : "none"}`);
|
||||
// It must instead live in ditto.css (extraCss folded into pseudoCss) as a [data-cid] rule.
|
||||
assert.ok(/\[data-cid="n1"\][\s\S]*gradient\(/.test(tw.pseudoCss), `base gradient must be a ditto.css rule, got:\n${tw.pseudoCss}`);
|
||||
});
|
||||
|
||||
it("still inlines a base gradient with NO band touching background-image (unchanged path)", () => {
|
||||
const grad = "linear-gradient(90deg, rgb(1, 2, 3), rgb(4, 5, 6))";
|
||||
const el = pvNode("n1", "span", {
|
||||
375: { backgroundImage: grad },
|
||||
1280: { backgroundImage: grad },
|
||||
}, [{ text: "static gradient" } as IRChild]);
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const inline = tw.styleOf.get("n1");
|
||||
const inlineHasGrad = !!inline && [...inline.values()].some((v) => v.includes("gradient("));
|
||||
assert.ok(inlineHasGrad, `a truly static (un-banded) gradient is still inlined, got inline: ${inline ? JSON.stringify([...inline]) : "none"}`);
|
||||
});
|
||||
});
|
||||
|
||||
// C1 (tailwind intent side) — a variant-only `max-lg:grid-rows-subgrid` covers only a subset of
|
||||
// viewports (the axis resolves to explicit tracks at ≥lg). The partial-coverage bail must let subgrid
|
||||
// through as a banded variant instead of discarding it.
|
||||
describe("buildTailwind partial-coverage subgrid intent (C1)", () => {
|
||||
it("emits grid-rows-subgrid from a variant-only source class", () => {
|
||||
// Grid at both widths; subgrid computed at the mobile band (375) and explicit tracks at 1280.
|
||||
// Source authored `max-lg:grid-rows-subgrid`.
|
||||
const el = pvNode("n1", "div", {
|
||||
375: { display: "grid", gridTemplateRows: "subgrid" },
|
||||
1280: { display: "grid", gridTemplateRows: "340px 340px" },
|
||||
}, [{ text: "card" } as IRChild], "max-lg:grid-rows-subgrid");
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const cls = tw.classOf.get("n1") || "";
|
||||
assert.ok(/grid-rows-subgrid/.test(cls), `subgrid must survive the partial-intent path, got class: "${cls}"`);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 1 (source-intent side) — an authored banded FIXED height (`h-[4rem] md:h-[6.25rem]`) must be
|
||||
// recoverable through source intent even though sourceFluidLengthSuffix rejects fixed rem/px lengths.
|
||||
// The recovered value is re-emitted as the CAPTURED computed px (root-font-size independent), and the
|
||||
// generated `h-full`/`h-auto` on that axis is dropped in its favour.
|
||||
describe("buildTailwind recovers authored banded fixed height as computed px (FIX 1)", () => {
|
||||
// A per-vp node with distinct computed height + matching bbox on each axis, so geometry corroborates.
|
||||
function fixedHNode(id: string, tag: string, hByVp: Record<number, number>, srcClass: string): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = computed({ display: "flex", height: `${hByVp[vp]}px` });
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: vp, height: hByVp[vp]! };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
const n: IRNode = { id, tag, attrs: {}, visibleByVp, bboxByVp, computedByVp, children: [] };
|
||||
n.srcClass = srcClass;
|
||||
// Probe reads the fill↔content cycle (a grid/flex item whose child fills it): hAuto:false but
|
||||
// hFill:true, so the generator would otherwise emit h-full. Source intent must still recover.
|
||||
n.sizingByVp = Object.fromEntries(VPS.map((vp) => [vp, { wAuto: false, wFill: true, hAuto: false, hFill: true }]));
|
||||
return n;
|
||||
}
|
||||
|
||||
it("emits banded computed px (h-[60px]/h-[100px]) and no h-full", () => {
|
||||
// Source authors `h-[4rem]` (base) and `md:h-[6.25rem]`; the source root is 15px so 4rem→60px @375
|
||||
// and 6.25rem→100px @1280. Emitted as px so the clone's root font-size can't mis-size it.
|
||||
const el = fixedHNode("n1", "div", { 375: 60, 1280: 100 }, "flex h-[4rem] w-full md:h-[6.25rem]");
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const cls = tw.classOf.get("n1") || "";
|
||||
assert.ok(!/\bh-full\b/.test(cls), `authored fixed height must not stay h-full, got class: "${cls}"`);
|
||||
// The captured px must appear (as an arbitrary px value or a clean rem token equivalent). Accept the
|
||||
// px form or its 16px-root rem equivalent (100px→6.25rem, 60px→3.75rem) since the pretty-printer may
|
||||
// fold clean px back to rem — both resolve to the captured px at the clone's 16px root.
|
||||
assert.ok(/h-\[100px\]|h-\[6\.25rem\]/.test(cls), `desktop height (100px/6.25rem@16root) must appear, got class: "${cls}"`);
|
||||
assert.ok(/h-\[60px\]|h-\[3\.75rem\]/.test(cls), `mobile height (60px/3.75rem@16root) must appear, got class: "${cls}"`);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 2 (source-intent named-token validation) — a source built on an OLDER Tailwind authored
|
||||
// `max-w-md`, whose scale resolved `md` to 640px; the clone's Tailwind v4 resolves `max-w-md` to
|
||||
// 448px. Re-emitting the name verbatim silently re-sizes the box (640→448). The source-intent pass
|
||||
// must validate the modern named-token px against the captured computed px and, on mismatch, emit
|
||||
// the captured px as an arbitrary value instead of the mis-resolving name.
|
||||
describe("buildTailwind validates named length tokens against captured px (FIX 2)", () => {
|
||||
// A max-width node whose computed maxWidth is `capPx` at every viewport, carrying `srcClass`.
|
||||
function maxWNode(capPx: number, srcClass: string): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = computed({ maxWidth: `${capPx}px` });
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: Math.min(vp, capPx), height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
const n: IRNode = { id: "n1", tag: "div", attrs: {}, visibleByVp, bboxByVp, computedByVp, children: [{ text: "col" } as IRChild] };
|
||||
n.srcClass = srcClass;
|
||||
return n;
|
||||
}
|
||||
|
||||
it("re-emits max-w-md as the captured px when the modern token value disagrees (640 ≠ 448)", () => {
|
||||
// Modern max-w-md = 28rem = 448px, but the source computed a 640px cap → arbitrary px, not the name.
|
||||
const el = maxWNode(640, "max-w-md");
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const cls = tw.classOf.get("n1") || "";
|
||||
assert.ok(!/\bmax-w-md\b/.test(cls), `mis-resolving max-w-md name must not survive, got class: "${cls}"`);
|
||||
assert.ok(/max-w-\[640px\]|max-w-\[40rem\]/.test(cls), `captured 640px cap must be emitted arbitrarily, got class: "${cls}"`);
|
||||
});
|
||||
|
||||
it("keeps max-w-lg verbatim when the modern token value matches the captured px (512 == 512)", () => {
|
||||
// Modern max-w-lg = 32rem = 512px, and the source computed a 512px cap → the authored name is faithful.
|
||||
const el = maxWNode(512, "max-w-lg");
|
||||
const root = pvNode("n0", "body", { 375: {}, 1280: {} }, [el]);
|
||||
const tw = buildTailwind(irWith(root), new Map());
|
||||
const cls = tw.classOf.get("n1") || "";
|
||||
assert.ok(/\bmax-w-lg\b/.test(cls), `matching max-w-lg name must survive, got class: "${cls}"`);
|
||||
assert.ok(!/max-w-\[/.test(cls), `no arbitrary max-w should be emitted when the name matches, got class: "${cls}"`);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 3 (source-intent breakpoint specificity) — an authored gallery grid
|
||||
// `grid-cols-1 md:grid-cols-2 lg:grid-cols-3` (both `md:` and `lg:` are min-width variants, so BOTH
|
||||
// are active at 1280). Tailwind emits its rules sorted by breakpoint, so `lg:` wins there — the base
|
||||
// (canonical=1280) is grid-cols-3. Choosing the LAST active token in class-attribute order instead
|
||||
// (`… lg:grid-cols-3 md:grid-cols-2`) wrongly takes md's grid-cols-2 as base and drops the desktop
|
||||
// grid-cols-3 band entirely. The pass must pick the highest-min-width active variant per viewport.
|
||||
describe("buildTailwind source-intent picks the highest active breakpoint per viewport (FIX 3)", () => {
|
||||
const GVPS = [375, 768, 1280, 1920];
|
||||
function gridIr(srcClass: string): IR {
|
||||
const gtc: Record<number, string> = {
|
||||
375: "343px", 768: "348px 348px",
|
||||
1280: "346.66px 346.66px 346.66px", 1920: "346.66px 346.66px 346.66px",
|
||||
};
|
||||
const gridW: Record<number, number> = { 375: 343, 768: 720, 1280: 1064, 1920: 1064 };
|
||||
const mk = (id: string, byVp: Record<number, StyleMap>, w: Record<number, number>, kids: IRChild[] = [], sc?: string): IRNode => {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of GVPS) {
|
||||
computedByVp[vp] = computed(byVp[vp]);
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: w[vp]!, height: 100 };
|
||||
visibleByVp[vp] = true;
|
||||
}
|
||||
const n: IRNode = { id, tag: "div", attrs: {}, visibleByVp, bboxByVp, computedByVp, children: kids };
|
||||
if (sc) n.srcClass = sc;
|
||||
return n;
|
||||
};
|
||||
const items = [0, 1, 2].map((i) => mk(`c${i}`, Object.fromEntries(GVPS.map((vp) => [vp, { display: "block" }])), Object.fromEntries(GVPS.map((vp) => [vp, 340])), [{ text: "x" } as IRChild]));
|
||||
const grid = mk("n123", Object.fromEntries(GVPS.map((vp) => [vp, { display: "grid", gridTemplateColumns: gtc[vp], columnGap: "24px", rowGap: "24px", gap: "24px" }])), gridW, items, srcClass);
|
||||
const root = mk("n0", Object.fromEntries(GVPS.map((vp) => [vp, {}])), Object.fromEntries(GVPS.map((vp) => [vp, vp])), [grid]);
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/grid", title: "Grid", lang: "en", charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1", viewports: GVPS, sampleViewports: GVPS, canonicalViewport: 1280,
|
||||
perViewport: Object.fromEntries(GVPS.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: 5, keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
it("emits base grid-cols-3 (lg wins at 1280) even when md: follows lg: in the class string", () => {
|
||||
const tw = buildTailwind(gridIr("grid grid-cols-1 gap-6 lg:grid-cols-3 md:grid-cols-2"), new Map());
|
||||
const cls = tw.classOf.get("n123") || "";
|
||||
const toks = cls.split(/\s+/);
|
||||
assert.ok(toks.includes("grid-cols-3"), `desktop base must be grid-cols-3 (lg wins at 1280), got class: "${cls}"`);
|
||||
assert.ok(!toks.some((t) => /(?:^|:)grid-cols-2$/.test(t) && !t.includes("md:max-lg:")), `md's grid-cols-2 must not become the base, got class: "${cls}"`);
|
||||
assert.ok(toks.includes("md:max-lg:grid-cols-2"), `tablet band must be grid-cols-2, got class: "${cls}"`);
|
||||
assert.ok(toks.includes("max-md:grid-cols-1"), `mobile band must be grid-cols-1, got class: "${cls}"`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { captureCanvasStillsInPage } from "../src/capture/capture.js";
|
||||
import type { IRNode, IRChild, StyleMap, BBox } from "../src/normalize/ir.js";
|
||||
import { propsList, resolveTag, renderChildrenJsx } from "../src/generate/app.js";
|
||||
|
||||
// tsx/esbuild wraps functions with a __name() helper for stack traces; the serialized
|
||||
// page functions carry those calls, so shim it (same as capture.ts's init script).
|
||||
const ESBUILD_SHIM =
|
||||
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
|
||||
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
|
||||
|
||||
describe("capture: canvas raster fallback (in-page)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(`
|
||||
<canvas id="big" width="200" height="100"></canvas>
|
||||
<canvas id="tiny" width="20" height="20"></canvas>
|
||||
<canvas id="ghost" width="300" height="150" style="visibility:hidden"></canvas>
|
||||
<script>
|
||||
const ctx = document.getElementById("big").getContext("2d");
|
||||
ctx.fillStyle = "#3b82f6";
|
||||
ctx.fillRect(10, 10, 180, 80);
|
||||
</script>
|
||||
`);
|
||||
// setContent replaces the document without a navigation, so the init script may
|
||||
// not have applied — evaluate the shim directly (same as capture.ts's frame path).
|
||||
await page.evaluate(ESBUILD_SHIM);
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
it("rasterizes a meaningful 2D canvas to a PNG data URL under a synthetic clone-canvas URL", async () => {
|
||||
const plan = await page.evaluate(captureCanvasStillsInPage);
|
||||
assert.equal(plan.stills.length, 1, "exactly one canvas qualifies");
|
||||
assert.equal(plan.shots.length, 0, "readable 2D canvas needs no element-screenshot fallback");
|
||||
const still = plan.stills[0]!;
|
||||
assert.ok(still.dataUrl.startsWith("data:image/png"), `PNG data URL (got ${still.dataUrl.slice(0, 30)})`);
|
||||
assert.match(still.url, /^https:\/\/clone-canvas\.local\/0-[0-9a-z]+\.png$/);
|
||||
assert.equal(still.sel, 'canvas[data-clone-canvas="0"]');
|
||||
const bytes = Buffer.from(still.dataUrl.slice(still.dataUrl.indexOf(",") + 1), "base64");
|
||||
assert.ok(bytes.length > 0, "still decodes to non-empty bytes");
|
||||
|
||||
const stamped = await page.evaluate(() => ({
|
||||
big: document.getElementById("big")?.getAttribute("data-clone-canvas") ?? null,
|
||||
tiny: document.getElementById("tiny")?.getAttribute("data-clone-canvas") ?? null,
|
||||
ghost: document.getElementById("ghost")?.getAttribute("data-clone-canvas") ?? null,
|
||||
}));
|
||||
assert.equal(stamped.big, "0", "qualifying canvas gets the stable marker");
|
||||
assert.equal(stamped.tiny, null, "a 20x20 canvas is below the meaningful-size gate");
|
||||
assert.equal(stamped.ghost, null, "a hidden canvas is skipped");
|
||||
});
|
||||
});
|
||||
|
||||
const VPS = [375, 1280];
|
||||
const SOURCE = "https://example.test/page";
|
||||
const GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
||||
|
||||
function el(id: string, tag: string, attrs: Record<string, string> = {}, children: IRChild[] = [], visible = true): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { display: "block", position: "static", visibility: "visible" };
|
||||
bboxByVp[vp] = { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 };
|
||||
visibleByVp[vp] = visible;
|
||||
}
|
||||
return { id, tag, attrs, visibleByVp, bboxByVp, computedByVp, children };
|
||||
}
|
||||
|
||||
describe("generate: canvas-still emission", () => {
|
||||
const STILL_URL = "https://clone-canvas.local/0-abc.png";
|
||||
const LOCAL = "/assets/cloned/images/ab.png";
|
||||
const assetMap = new Map([[STILL_URL, LOCAL]]);
|
||||
const canvas = () => el("7", "canvas", { src: STILL_URL, width: "200", height: "100" });
|
||||
|
||||
it("retags a canvas carrying a captured still to <img>; a bare canvas stays canvas", () => {
|
||||
assert.equal(resolveTag(canvas(), false), "img");
|
||||
assert.equal(resolveTag(el("8", "canvas", { width: "200", height: "100" }), false), "canvas");
|
||||
});
|
||||
|
||||
it("resolves the synthetic still URL through the asset map and emits data-cid + alt", () => {
|
||||
const p = new Map(propsList(canvas(), assetMap, SOURCE));
|
||||
assert.equal(p.get("src"), JSON.stringify(LOCAL));
|
||||
assert.equal(p.get('"data-cid"'), JSON.stringify("7"));
|
||||
assert.equal(p.get("alt"), JSON.stringify(""), "decorative alt is injected for the raster still");
|
||||
assert.equal(p.get("width"), JSON.stringify("200"));
|
||||
assert.equal(p.get("height"), JSON.stringify("100"));
|
||||
});
|
||||
|
||||
it("renders a self-closing <img> in place of the canvas", () => {
|
||||
const jsx = renderChildrenJsx([canvas()], assetMap, SOURCE, 0);
|
||||
assert.match(jsx, /<img[^>]*data-cid="7"[^>]*\/>/);
|
||||
assert.match(jsx, new RegExp(`src="${LOCAL.replace(/[/]/g, "\\/")}"`));
|
||||
assert.ok(!jsx.includes("<canvas"), "no canvas element remains");
|
||||
});
|
||||
|
||||
it("falls back to the transparent GIF when the still missed the asset map (never a remote URL)", () => {
|
||||
const p = new Map(propsList(canvas(), new Map(), SOURCE));
|
||||
assert.equal(p.get("src"), JSON.stringify(GIF));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { PNG } from "pngjs";
|
||||
import pixelmatch from "pixelmatch";
|
||||
import { captureFullPageViaCDP } from "../src/capture/capture.js";
|
||||
|
||||
const ESBUILD_SHIM = "globalThis.__name = globalThis.__name || ((fn) => fn);";
|
||||
|
||||
// A STATIC page (no scroll-linked animation) several viewports tall: a few full-width
|
||||
// colored bands. Both capture paths should render the identical at-rest picture, so any
|
||||
// difference is purely a capture-mechanism artifact (scrollbar gutter, off-by-one), which
|
||||
// is exactly what this test measures.
|
||||
const BANDS_HTML =
|
||||
"<!doctype html><html><head><style>" +
|
||||
"*{margin:0;padding:0;box-sizing:border-box}" +
|
||||
"html,body{width:100%}" +
|
||||
".band{width:100%;height:600px;display:flex;align-items:center;justify-content:center;" +
|
||||
"font:bold 48px sans-serif;color:#fff}" +
|
||||
".b0{background:#c0392b}.b1{background:#27ae60}.b2{background:#2980b9}" +
|
||||
".b3{background:#8e44ad}.b4{background:#d35400}" +
|
||||
"</style></head><body>" +
|
||||
"<div class='band b0'>1</div><div class='band b1'>2</div>" +
|
||||
"<div class='band b2'>3</div><div class='band b3'>4</div>" +
|
||||
"<div class='band b4'>5</div>" +
|
||||
"</body></html>";
|
||||
|
||||
describe("CDP full-page capture vs Playwright fullPage stitch (static fixture)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let tmp: string;
|
||||
|
||||
before(async () => {
|
||||
browser = await chromium.launch({ args: ["--disable-dev-shm-usage"] });
|
||||
// A viewport SHORTER than the content so fullPage must span multiple bands: this is
|
||||
// where Playwright would scroll-stitch and CDP renders in one shot.
|
||||
const context = await browser.newContext({ viewport: { width: 800, height: 700 }, deviceScaleFactor: 1 });
|
||||
page = await context.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(BANDS_HTML, { waitUntil: "load" });
|
||||
tmp = mkdtempSync(join(tmpdir(), "cdp-shot-"));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
if (tmp) rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("produces a valid PNG of the full content height (not just the viewport)", async () => {
|
||||
const cdpPath = join(tmp, "cdp.png");
|
||||
await captureFullPageViaCDP(page, cdpPath);
|
||||
const cdp = PNG.sync.read(readFileSync(cdpPath));
|
||||
// 5 bands * 600px = 3000px tall, well beyond the 700px viewport.
|
||||
assert.ok(cdp.height >= 2900, `CDP shot spans full content (got ${cdp.height})`);
|
||||
assert.ok(cdp.width >= 780 && cdp.width <= 800, `CDP width ~= content width (got ${cdp.width})`);
|
||||
assert.ok(readFileSync(cdpPath).length > 1000, "CDP PNG is non-trivial in size");
|
||||
});
|
||||
|
||||
it("is pixel-equivalent to Playwright's fullPage screenshot on a static page", async () => {
|
||||
const cdpPath = join(tmp, "cdp2.png");
|
||||
const pwPath = join(tmp, "pw.png");
|
||||
await captureFullPageViaCDP(page, cdpPath);
|
||||
await page.screenshot({ path: pwPath, fullPage: true, animations: "disabled" });
|
||||
|
||||
const cdp = PNG.sync.read(readFileSync(cdpPath));
|
||||
const pw = PNG.sync.read(readFileSync(pwPath));
|
||||
|
||||
// Dimensions must match (a scrollbar-gutter difference would show up here first).
|
||||
assert.equal(cdp.width, pw.width, `width match (cdp ${cdp.width} vs pw ${pw.width})`);
|
||||
assert.equal(cdp.height, pw.height, `height match (cdp ${cdp.height} vs pw ${pw.height})`);
|
||||
|
||||
const { width, height } = cdp;
|
||||
const diff = new PNG({ width, height });
|
||||
const mismatched = pixelmatch(cdp.data, pw.data, diff.data, width, height, { threshold: 0.1 });
|
||||
const pct = (mismatched / (width * height)) * 100;
|
||||
// On a static page the two mechanisms should be near-identical. Allow a tiny tolerance
|
||||
// for antialiasing at band seams; assert well under 0.5% differing pixels.
|
||||
assert.ok(pct < 0.5, `pixel delta ${pct.toFixed(4)}% (${mismatched} px) must be < 0.5%`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
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 {
|
||||
CN_UTILS_MODULE,
|
||||
cnImportLine,
|
||||
componentFiles,
|
||||
generatePageTsx,
|
||||
injectLottieDep,
|
||||
PACKAGE_JSON,
|
||||
PACKAGE_JSON_TW,
|
||||
PACKAGE_JSON_VITE,
|
||||
PACKAGE_JSON_VITE_TW,
|
||||
propsList,
|
||||
type ComponentRegistry,
|
||||
} from "../src/generate/app.js";
|
||||
import { DITTO_WIRE_TSX, ACCORDION_TSX } from "../src/generate/interactive.js";
|
||||
import { DROPDOWN_MENU_TSX } from "../src/generate/menu.js";
|
||||
import { declToUtil, snapLen } from "../src/generate/tailwind.js";
|
||||
|
||||
const VPS = [1280];
|
||||
const CANONICAL = 1280;
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...over };
|
||||
}
|
||||
|
||||
function node(id: string, tag: string, cs: StyleMap, children: IRChild[] = []): 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/",
|
||||
title: "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,
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Fix 1: emitted runtime components clean up their listeners ----
|
||||
describe("runtime components abort their listeners on unmount (fix 1)", () => {
|
||||
const templates: Array<[string, string]> = [
|
||||
["DittoWire", DITTO_WIRE_TSX],
|
||||
["Accordion", ACCORDION_TSX],
|
||||
["DropdownMenu", DROPDOWN_MENU_TSX],
|
||||
];
|
||||
for (const [name, tsx] of templates) {
|
||||
it(`${name}: one AbortController, every addEventListener signalled, cleanup returns abort`, () => {
|
||||
assert.ok(tsx.includes("new AbortController()"), "creates an AbortController");
|
||||
assert.ok(tsx.includes("ac.abort()"), "effect cleanup aborts");
|
||||
// Every addEventListener must carry a listener-options object with the signal (either
|
||||
// `, { signal })` or `, { passive: true, signal })`). Count-match guarantees none is missed.
|
||||
const adds = (tsx.match(/addEventListener\(/g) ?? []).length;
|
||||
const signalled = (tsx.match(/, \{ signal \}\)/g) ?? []).length
|
||||
+ (tsx.match(/, \{ passive: true, signal \}\)/g) ?? []).length;
|
||||
assert.ok(adds > 0, "has listeners");
|
||||
assert.equal(signalled, adds, "every addEventListener passes the abort signal");
|
||||
// The stale re-wire guard is gone; effects are idempotent + cleaned up instead.
|
||||
assert.ok(!tsx.includes("wired.current"), "no wired.current guard");
|
||||
});
|
||||
}
|
||||
|
||||
it("DropdownMenu removes any still-open panels on unmount", () => {
|
||||
assert.ok(DROPDOWN_MENU_TSX.includes("openPanels"), "tracks open panels");
|
||||
assert.ok(DROPDOWN_MENU_TSX.includes("openPanels.splice(0)) p.remove()"), "removes panels on cleanup");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fix 2: cn() is a single shared module, imported (not copied per file) ----
|
||||
describe("cn() is deduplicated into src/lib/utils (fix 2)", () => {
|
||||
it("exports a single cn from the shared utils module", () => {
|
||||
assert.ok(CN_UTILS_MODULE.includes("export function cn("), "utils module exports cn");
|
||||
});
|
||||
|
||||
it("cnImportLine resolves to the shared module at the right depth", () => {
|
||||
assert.equal(cnImportLine(1), 'import { cn } from "../lib/utils";');
|
||||
assert.equal(cnImportLine(2), 'import { cn } from "../../lib/utils";');
|
||||
});
|
||||
|
||||
it("a component module that uses cn() imports it rather than redefining it", () => {
|
||||
// componentFiles reads only funcDefs / byName / fieldTypes / dataDecls / styleDecls.
|
||||
const reg = {
|
||||
byName: new Map([["Card", { runs: 1, instances: 2, cids: ["n1"] }]]),
|
||||
funcDefs: new Map([["Card", 'function Card({ styles }: { styles: string }) {\n return <div className={cn("p-4", styles)} />;\n}']]),
|
||||
fieldTypes: new Map(),
|
||||
dataDecls: [],
|
||||
cidDecls: [],
|
||||
styleDecls: [],
|
||||
} as unknown as ComponentRegistry;
|
||||
const files = componentFiles(reg);
|
||||
const card = files.find((f) => f.name === "Card")!.module;
|
||||
assert.ok(card.includes('import { cn } from "../../lib/utils";'), "imports shared cn");
|
||||
assert.ok(!card.includes("function cn("), "no inline cn definition");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fix 3: JSX whitespace is collapsed under white-space: normal ----
|
||||
describe("JSX whitespace collapses under white-space:normal (fix 3)", () => {
|
||||
it("collapses captured \\n\\t indentation and multi-space runs to single spaces", () => {
|
||||
const p = node("n1", "p", computed(), [{ text: "\n\t\tSkip to content\n\t\t" }]);
|
||||
const root = node("n0", "body", computed(), [p]);
|
||||
const tsx = generatePageTsx(irWith(root), new Map(), "https://example.test/");
|
||||
assert.ok(!/\{"[^"]*\\n[^"]*"\}/.test(tsx), "no literal \\n frozen in text");
|
||||
assert.ok(!/\{"\s{2,}"\}/.test(tsx), "no multi-space whitespace literal");
|
||||
assert.ok(tsx.includes("Skip to content"), "content preserved");
|
||||
});
|
||||
|
||||
it("preserves whitespace verbatim inside a <pre> (white-space: pre)", () => {
|
||||
const pre = node("n1", "pre", computed({ whiteSpace: "pre" }), [{ text: "line1\n\tline2" }]);
|
||||
const root = node("n0", "body", computed(), [pre]);
|
||||
const tsx = generatePageTsx(irWith(root), new Map(), "https://example.test/");
|
||||
assert.ok(tsx.includes("line1\\n\\tline2"), "pre keeps raw newlines/tabs");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fix 5: sub-pixel arbitrary values snap ----
|
||||
describe("sub-pixel lengths snap (fix 5)", () => {
|
||||
it("snapLen keeps genuine fractions at 1 decimal, snaps near-integer jitter to an integer", () => {
|
||||
assert.equal(snapLen("204.797px"), "204.8px");
|
||||
assert.equal(snapLen("627.188px"), "627.2px");
|
||||
assert.equal(snapLen("100.3px"), "100.3px");
|
||||
assert.equal(snapLen("204.98px"), "205px"); // within 0.1px of an integer → snap
|
||||
assert.equal(snapLen("2.996px"), "3px"); // rem/px jitter snaps
|
||||
assert.equal(snapLen("627px"), "627px"); // already clean
|
||||
});
|
||||
|
||||
it("snapLen leaves non-simple values (calc/percent/multi-token) untouched", () => {
|
||||
assert.equal(snapLen("calc(100% - 3.333px)"), "calc(100% - 3.333px)");
|
||||
assert.equal(snapLen("50.5%"), "50.5%");
|
||||
assert.equal(snapLen("0px 2.5px"), "0px 2.5px");
|
||||
});
|
||||
|
||||
it("declToUtil snaps a width arbitrary value but keeps border-width exact", () => {
|
||||
assert.equal(declToUtil("width", "204.797px"), "w-[204.8px]");
|
||||
// Border width sub-pixel precision is load-bearing — left untouched.
|
||||
assert.equal(declToUtil("border-width", "0.667px"), "border-[0.667px]");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Fix 7: every emitted runtime import is a declared dependency ----
|
||||
describe("lottie-web is declared when its import is emitted (fix 7)", () => {
|
||||
it("injectLottieDep pins lottie-web into every package.json template", () => {
|
||||
for (const pkg of [PACKAGE_JSON, PACKAGE_JSON_TW, PACKAGE_JSON_VITE, PACKAGE_JSON_VITE_TW]) {
|
||||
const injected = injectLottieDep(pkg);
|
||||
const deps = JSON.parse(injected).dependencies as Record<string, string>;
|
||||
assert.equal(deps["lottie-web"], "5.12.2", "lottie-web pinned to the harness version");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ---- FIX 5: javascript: hrefs are sanitized to an inert '#' ----
|
||||
// React refuses to render a `javascript:*` href verbatim — it rewrites it to a long
|
||||
// `javascript:throw new Error('React has blocked a javascript: URL…')` string that no longer matches
|
||||
// the source href in the link gate. Emit an inert `#` instead (the script behaviour isn't reproduced).
|
||||
describe("javascript: hrefs are emitted as an inert '#' (FIX 5)", () => {
|
||||
const hrefOf = (n: IRNode): string | undefined => {
|
||||
const p = propsList(n, new Map(), "https://example.test/").find(([k]) => k === "href");
|
||||
return p ? JSON.parse(p[1]) : undefined;
|
||||
};
|
||||
it("rewrites a javascript: href to #", () => {
|
||||
const a = node("n1", "a", computed());
|
||||
a.attrs = { href: "Javascript:{}" };
|
||||
assert.equal(hrefOf(a), "#", "a javascript: href is sanitized to #");
|
||||
});
|
||||
it("rewrites javascript:void(0) too (case-insensitive, with args)", () => {
|
||||
const a = node("n1", "a", computed());
|
||||
a.attrs = { href: "javascript:void(0)" };
|
||||
assert.equal(hrefOf(a), "#");
|
||||
});
|
||||
it("leaves a normal in-page anchor href untouched", () => {
|
||||
const a = node("n1", "a", computed());
|
||||
a.attrs = { href: "#section" };
|
||||
assert.equal(hrefOf(a), "#section", "a real fragment link is preserved");
|
||||
});
|
||||
it("leaves an ordinary external href resolved (not collapsed to #)", () => {
|
||||
const a = node("n1", "a", computed());
|
||||
a.attrs = { href: "https://example.test/products" };
|
||||
assert.equal(hrefOf(a), "https://example.test/products");
|
||||
});
|
||||
});
|
||||
+1156
-1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { deflateRawSync } from "node:zlib";
|
||||
import { isZipArchive, extractDotLottieJson, readZipEntry } from "../src/capture/dotlottie.js";
|
||||
import { extFromUrl } from "../src/capture/capture.js";
|
||||
|
||||
/**
|
||||
* Build a minimal, spec-correct ZIP archive in-memory from named entries. Supports both
|
||||
* stored (method 0) and deflated (method 8) compression so the extractor is exercised on both.
|
||||
* No CRC validation is needed for our reader, so CRC fields are left 0.
|
||||
*/
|
||||
function buildZip(entries: Array<{ name: string; data: Buffer; deflate?: boolean }>): Buffer {
|
||||
const locals: Buffer[] = [];
|
||||
const centrals: Buffer[] = [];
|
||||
let offset = 0;
|
||||
|
||||
for (const e of entries) {
|
||||
const nameBuf = Buffer.from(e.name, "utf8");
|
||||
const stored = e.deflate ? deflateRawSync(e.data) : e.data;
|
||||
const method = e.deflate ? 8 : 0;
|
||||
|
||||
const local = Buffer.alloc(30 + nameBuf.length);
|
||||
local.writeUInt32LE(0x04034b50, 0); // local file header sig
|
||||
local.writeUInt16LE(20, 4); // version needed
|
||||
local.writeUInt16LE(0, 6); // flags
|
||||
local.writeUInt16LE(method, 8);
|
||||
local.writeUInt16LE(0, 10); // time
|
||||
local.writeUInt16LE(0, 12); // date
|
||||
local.writeUInt32LE(0, 14); // crc
|
||||
local.writeUInt32LE(stored.length, 18); // comp size
|
||||
local.writeUInt32LE(e.data.length, 22); // uncomp size
|
||||
local.writeUInt16LE(nameBuf.length, 26);
|
||||
local.writeUInt16LE(0, 28); // extra len
|
||||
nameBuf.copy(local, 30);
|
||||
const localFull = Buffer.concat([local, stored]);
|
||||
locals.push(localFull);
|
||||
|
||||
const central = Buffer.alloc(46 + nameBuf.length);
|
||||
central.writeUInt32LE(0x02014b50, 0); // central dir sig
|
||||
central.writeUInt16LE(20, 4); // version made by
|
||||
central.writeUInt16LE(20, 6); // version needed
|
||||
central.writeUInt16LE(0, 8); // flags
|
||||
central.writeUInt16LE(method, 10);
|
||||
central.writeUInt16LE(0, 12); // time
|
||||
central.writeUInt16LE(0, 14); // date
|
||||
central.writeUInt32LE(0, 16); // crc
|
||||
central.writeUInt32LE(stored.length, 20); // comp size
|
||||
central.writeUInt32LE(e.data.length, 24); // uncomp size
|
||||
central.writeUInt16LE(nameBuf.length, 28);
|
||||
central.writeUInt16LE(0, 30); // extra
|
||||
central.writeUInt16LE(0, 32); // comment
|
||||
central.writeUInt16LE(0, 34); // disk
|
||||
central.writeUInt16LE(0, 36); // internal attrs
|
||||
central.writeUInt32LE(0, 38); // external attrs
|
||||
central.writeUInt32LE(offset, 42); // local header offset
|
||||
nameBuf.copy(central, 46);
|
||||
centrals.push(central);
|
||||
|
||||
offset += localFull.length;
|
||||
}
|
||||
|
||||
const centralStart = offset;
|
||||
const centralDir = Buffer.concat(centrals);
|
||||
const eocd = Buffer.alloc(22);
|
||||
eocd.writeUInt32LE(0x06054b50, 0);
|
||||
eocd.writeUInt16LE(0, 4); // disk
|
||||
eocd.writeUInt16LE(0, 6); // cd start disk
|
||||
eocd.writeUInt16LE(entries.length, 8);
|
||||
eocd.writeUInt16LE(entries.length, 10);
|
||||
eocd.writeUInt32LE(centralDir.length, 12);
|
||||
eocd.writeUInt32LE(centralStart, 16);
|
||||
eocd.writeUInt16LE(0, 20); // comment len
|
||||
|
||||
return Buffer.concat([...locals, centralDir, eocd]);
|
||||
}
|
||||
|
||||
const ANIM_1 = { v: "5.7.4", nm: "one", layers: [{ ind: 1 }] };
|
||||
const ANIM_2 = { v: "5.7.4", nm: "two", layers: [{ ind: 2 }] };
|
||||
const MANIFEST = { version: "1.0", animations: [{ id: "animation_default" }, { id: "animation_2" }] };
|
||||
|
||||
describe("dotLottie ZIP extraction", () => {
|
||||
it("detects ZIP archives by the PK local-header magic", () => {
|
||||
assert.equal(isZipArchive(Buffer.from([0x50, 0x4b, 0x03, 0x04, 0, 0])), true);
|
||||
assert.equal(isZipArchive(Buffer.from('{"v":"5.7"}', "utf8")), false);
|
||||
assert.equal(isZipArchive(Buffer.alloc(2)), false);
|
||||
});
|
||||
|
||||
it("extracts the manifest's default animation JSON from a stored dotLottie ZIP", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8") },
|
||||
{ name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") },
|
||||
{ name: "animations/animation_2.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out, "expected an extracted animation buffer");
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1);
|
||||
});
|
||||
|
||||
it("extracts a DEFLATE-compressed animation entry (the common real-world case)", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true },
|
||||
{ name: "animations/animation_default.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8"), deflate: true },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out);
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1);
|
||||
});
|
||||
|
||||
it("falls back to the name-sorted first animation when the manifest is absent", () => {
|
||||
const zip = buildZip([
|
||||
{ name: "animations/b.json", data: Buffer.from(JSON.stringify(ANIM_2), "utf8") },
|
||||
{ name: "animations/a.json", data: Buffer.from(JSON.stringify(ANIM_1), "utf8") },
|
||||
]);
|
||||
const out = extractDotLottieJson(zip);
|
||||
assert.ok(out);
|
||||
assert.deepEqual(JSON.parse(out!.toString("utf8")), ANIM_1); // "a.json" sorts first
|
||||
});
|
||||
|
||||
it("returns null for non-ZIP bytes and for a ZIP with no animations", () => {
|
||||
assert.equal(extractDotLottieJson(Buffer.from('{"v":"5.7"}', "utf8")), null);
|
||||
const noAnims = buildZip([{ name: "manifest.json", data: Buffer.from("{}", "utf8") }]);
|
||||
assert.equal(extractDotLottieJson(noAnims), null);
|
||||
});
|
||||
|
||||
it("reads a named entry directly", () => {
|
||||
const zip = buildZip([{ name: "manifest.json", data: Buffer.from(JSON.stringify(MANIFEST), "utf8"), deflate: true }]);
|
||||
const m = readZipEntry(zip, "manifest.json");
|
||||
assert.ok(m);
|
||||
assert.deepEqual(JSON.parse(m!.toString("utf8")), MANIFEST);
|
||||
assert.equal(readZipEntry(zip, "missing.json"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("asset extension preservation (extFromUrl)", () => {
|
||||
it("preserves real long extensions instead of truncating to 5 chars", () => {
|
||||
assert.equal(extFromUrl("https://x/anim.lottie"), "lottie");
|
||||
assert.equal(extFromUrl("https://x/site.webmanifest"), "webmanifest");
|
||||
assert.equal(extFromUrl("https://x/pic.png?v=2"), "png");
|
||||
assert.equal(extFromUrl("https://x/font.woff2"), "woff2");
|
||||
});
|
||||
|
||||
it("rejects absurdly long or non-alphanumeric trailing segments", () => {
|
||||
assert.equal(extFromUrl("https://x/name.thisisnotanextension"), "");
|
||||
assert.equal(extFromUrl("https://x/dir.with.dots/file"), ""); // dot is in a path segment, not the file
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { looksLikeFontFile } from "../src/capture/capture.js";
|
||||
import { buildFontGraph } from "../src/infer/fonts.js";
|
||||
import type { FontFace } from "../src/capture/walker.js";
|
||||
import type { AssetGraph, AssetEntry } from "../src/infer/assets.js";
|
||||
|
||||
// --- Magic-byte validator table -------------------------------------------------------------
|
||||
|
||||
function head(tag: string, len = 64): Buffer {
|
||||
return Buffer.concat([Buffer.from(tag, "latin1"), Buffer.alloc(Math.max(0, len - tag.length))]);
|
||||
}
|
||||
|
||||
describe("looksLikeFontFile (font container magic)", () => {
|
||||
it("accepts every real font-container signature", () => {
|
||||
assert.equal(looksLikeFontFile(head("wOF2")), true, "woff2");
|
||||
assert.equal(looksLikeFontFile(head("wOFF")), true, "woff");
|
||||
assert.equal(looksLikeFontFile(head("OTTO")), true, "CFF OpenType (otf)");
|
||||
assert.equal(looksLikeFontFile(head("true")), true, "TrueType 'true' sfnt");
|
||||
assert.equal(looksLikeFontFile(head("ttcf")), true, "TrueType collection");
|
||||
assert.equal(
|
||||
looksLikeFontFile(Buffer.concat([Buffer.from([0x00, 0x01, 0x00, 0x00]), Buffer.alloc(60)])),
|
||||
true,
|
||||
"TrueType \\x00\\x01\\x00\\x00 sfnt (ttf)",
|
||||
);
|
||||
// EOT: 0x504C ("LP") marker at byte offset 34.
|
||||
const eot = Buffer.alloc(80);
|
||||
eot[34] = 0x4c;
|
||||
eot[35] = 0x50;
|
||||
assert.equal(looksLikeFontFile(eot), true, "eot");
|
||||
});
|
||||
|
||||
it("rejects HTML/text impostor bodies (the SPA-router 200 shell)", () => {
|
||||
assert.equal(looksLikeFontFile(Buffer.from("<!DOCTYPE html><html><head></head></html>")), false);
|
||||
assert.equal(looksLikeFontFile(Buffer.from("<!doctype html>")), false);
|
||||
assert.equal(looksLikeFontFile(Buffer.from("<html><body>not a font</body></html>")), false);
|
||||
assert.equal(looksLikeFontFile(Buffer.from("<?xml version=\"1.0\"?>")), false);
|
||||
assert.equal(looksLikeFontFile(Buffer.from("just some plain text body")), false);
|
||||
});
|
||||
|
||||
it("rejects truncated/empty buffers", () => {
|
||||
assert.equal(looksLikeFontFile(Buffer.alloc(0)), false);
|
||||
assert.equal(looksLikeFontFile(Buffer.from([0x77, 0x4f])), false); // 2 bytes of "wO"
|
||||
});
|
||||
});
|
||||
|
||||
// --- Validity-aware first-insert preference in buildFontGraph -------------------------------
|
||||
|
||||
function fontEntry(sourceUrl: string, localPath: string | null): AssetEntry {
|
||||
return {
|
||||
sourceUrl,
|
||||
type: "font",
|
||||
classification: localPath ? "downloaded" : "skipped",
|
||||
localPath,
|
||||
storedFile: localPath ? localPath.split("/").pop()! : null,
|
||||
bytes: localPath ? 45_000 : 0,
|
||||
reason: localPath ? null : "font_file_unavailable",
|
||||
impact: null,
|
||||
via: [],
|
||||
};
|
||||
}
|
||||
|
||||
function graphOf(entries: AssetEntry[]): AssetGraph {
|
||||
const byUrl = new Map<string, AssetEntry>();
|
||||
for (const e of entries) byUrl.set(e.sourceUrl, e);
|
||||
return { entries, byUrl };
|
||||
}
|
||||
|
||||
const DOC = "https://host.example/page";
|
||||
// The genuine subset lives under the css file's sibling media dir; only THIS url is downloaded.
|
||||
const GOOD_URL = "https://host.example/marketing-static/_next/static/media/good.woff2";
|
||||
// The wrongly-based (document-relative) url that the SPA router answered with HTML and that was
|
||||
// therefore rejected at store time — it never becomes a downloaded asset.
|
||||
const BAD_URL = "https://host.example/media/impostor.woff2";
|
||||
|
||||
describe("buildFontGraph validity-aware preference (first-insert race)", () => {
|
||||
it("prefers the face whose src resolves to a downloaded file even when it was inserted SECOND", () => {
|
||||
const graph = graphOf([fontEntry(GOOD_URL, "/assets/cloned/fonts/good.woff2")]);
|
||||
// Insertion order mirrors the real race: the mis-based CSSOM face lands first, the correctly
|
||||
// based css-text face second. Only the good url is a downloaded asset.
|
||||
const faces: FontFace[] = [
|
||||
{ family: "Gothic", weight: "400", style: "normal", src: `url("${BAD_URL}") format("woff2")` },
|
||||
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
|
||||
];
|
||||
const { entries, css } = buildFontGraph(faces, graph, DOC);
|
||||
const gothic = entries.filter((e) => e.family === "Gothic");
|
||||
assert.equal(gothic.length, 1, "deduped to a single face");
|
||||
assert.equal(gothic[0]!.status, "resolved");
|
||||
assert.deepEqual(gothic[0]!.localPaths, ["/assets/cloned/fonts/good.woff2"]);
|
||||
assert.match(css, /good\.woff2/);
|
||||
assert.doesNotMatch(css, /impostor/);
|
||||
});
|
||||
|
||||
it("keeps the FIRST face when neither resolves (fallback, ties hold order)", () => {
|
||||
const graph = graphOf([]); // nothing downloaded
|
||||
const faces: FontFace[] = [
|
||||
{ family: "Gothic", weight: "400", style: "normal", src: `url("${BAD_URL}") format("woff2")` },
|
||||
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
|
||||
];
|
||||
const { entries } = buildFontGraph(faces, graph, DOC);
|
||||
const gothic = entries.filter((e) => e.family === "Gothic");
|
||||
assert.equal(gothic.length, 1);
|
||||
assert.equal(gothic[0]!.status, "fallback");
|
||||
});
|
||||
|
||||
it("keeps the FIRST resolving face when BOTH resolve (ties hold insertion order)", () => {
|
||||
const alt = "https://host.example/marketing-static/_next/static/media/alt.woff2";
|
||||
const graph = graphOf([
|
||||
fontEntry(GOOD_URL, "/assets/cloned/fonts/good.woff2"),
|
||||
fontEntry(alt, "/assets/cloned/fonts/alt.woff2"),
|
||||
]);
|
||||
const faces: FontFace[] = [
|
||||
{ family: "Gothic", weight: "400", style: "normal", src: `url("${GOOD_URL}") format("woff2")` },
|
||||
{ family: "Gothic", weight: "400", style: "normal", src: `url("${alt}") format("woff2")` },
|
||||
];
|
||||
const { entries } = buildFontGraph(faces, graph, DOC);
|
||||
const gothic = entries.filter((e) => e.family === "Gothic");
|
||||
assert.equal(gothic.length, 1);
|
||||
assert.deepEqual(gothic[0]!.localPaths, ["/assets/cloned/fonts/good.woff2"], "first resolving wins");
|
||||
});
|
||||
});
|
||||
|
||||
// --- baseHref-driven relative resolution in buildFontGraph ----------------------------------
|
||||
|
||||
describe("buildFontGraph resolves a face's relative src against its owning sheet (baseHref)", () => {
|
||||
it("uses baseHref, not the document url, so ../media climbs from the css file", () => {
|
||||
// The sheet lives at /marketing-static/_next/static/css/sheet.css; `../media/x` from THERE is
|
||||
// /marketing-static/_next/static/media/x.woff2, which is what actually downloaded. Resolved
|
||||
// against the document (/page) it would clamp to /marketing-static/_next/static/media only if
|
||||
// the css path were the base — the point of baseHref.
|
||||
const sheetUrl = "https://host.example/marketing-static/_next/static/css/sheet.css";
|
||||
const resolved = "https://host.example/marketing-static/_next/static/media/x.woff2";
|
||||
const graph = graphOf([fontEntry(resolved, "/assets/cloned/fonts/x.woff2")]);
|
||||
const faces: FontFace[] = [
|
||||
{ family: "Gothic", weight: "400", style: "normal", src: 'url("../media/x.woff2") format("woff2")', baseHref: sheetUrl },
|
||||
];
|
||||
const { entries } = buildFontGraph(faces, graph, DOC);
|
||||
assert.equal(entries[0]!.status, "resolved");
|
||||
assert.deepEqual(entries[0]!.localPaths, ["/assets/cloned/fonts/x.woff2"]);
|
||||
});
|
||||
|
||||
it("falls back to the document url when baseHref is absent (inline <style> / out-of-band parse)", () => {
|
||||
// An out-of-band parse bakes the absolute url into src, so document fallback is harmless.
|
||||
const abs = "https://host.example/fonts/y.woff2";
|
||||
const graph = graphOf([fontEntry(abs, "/assets/cloned/fonts/y.woff2")]);
|
||||
const faces: FontFace[] = [
|
||||
{ family: "Mono", weight: "400", style: "normal", src: `url("${abs}") format("woff2")` },
|
||||
];
|
||||
const { entries } = buildFontGraph(faces, graph, DOC);
|
||||
assert.equal(entries[0]!.status, "resolved");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer, type Server } from "node:http";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { collectPage, type PageSnapshot } from "../src/capture/walker.js";
|
||||
|
||||
// A relative url() inside a stylesheet must resolve against THAT sheet's url, not the document.
|
||||
// These tests serve real CSS files at a NESTED path so `../media/x` climbs differently depending
|
||||
// on the base: against the css file it lands under the nested dir, against the page it clamps to
|
||||
// the site root. We assert the harvested url (cssUrls) and the face's baseHref carry the sheet
|
||||
// base — the exact fix for the "HTML saved as woff2" font-materialization cluster.
|
||||
|
||||
// The nested layout mirrors a real bundler output:
|
||||
// /page (document)
|
||||
// /marketing-static/_next/static/css/x.css (external sheet)
|
||||
// /marketing-static/_next/static/media/f.woff2 ← where ../media/f.woff2 SHOULD resolve
|
||||
const CSS_PATH = "/marketing-static/_next/static/css/x.css";
|
||||
const EXPECTED_MEDIA = "/marketing-static/_next/static/media/f.woff2";
|
||||
// The WRONG (document-relative) resolution the old code produced:
|
||||
const WRONG_MEDIA = "/media/f.woff2";
|
||||
|
||||
function serveText(body: string, type: string, res: import("node:http").ServerResponse): void {
|
||||
res.writeHead(200, { "content-type": type });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
describe("font url() resolution against the owning stylesheet", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
let server: Server;
|
||||
let origin = "";
|
||||
|
||||
before(async () => {
|
||||
server = createServer((req, res) => {
|
||||
const url = (req.url || "/").split("?")[0];
|
||||
if (url === "/page") {
|
||||
return serveText(
|
||||
`<!doctype html><html><head><link rel="stylesheet" href="${CSS_PATH}"></head><body><p>hi</p></body></html>`,
|
||||
"text/html",
|
||||
res,
|
||||
);
|
||||
}
|
||||
if (url === "/import-host") {
|
||||
// A same-origin sheet that @imports the nested sheet; the imported sheet's own url must be
|
||||
// the base for its faces, not the host sheet or the page.
|
||||
return serveText(
|
||||
`<!doctype html><html><head><link rel="stylesheet" href="/top.css"></head><body><p>hi</p></body></html>`,
|
||||
"text/html",
|
||||
res,
|
||||
);
|
||||
}
|
||||
if (url === "/top.css") {
|
||||
return serveText(`@import url("${CSS_PATH}");`, "text/css", res);
|
||||
}
|
||||
if (url === CSS_PATH) {
|
||||
return serveText(
|
||||
`@font-face{font-family:"Gothic";font-style:normal;font-weight:400;` +
|
||||
`src:url("../media/f.woff2") format("woff2");}` +
|
||||
`.hero{background:url("../media/bg.png")}`,
|
||||
"text/css",
|
||||
res,
|
||||
);
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
||||
const addr = server.address();
|
||||
if (addr && typeof addr === "object") origin = `http://127.0.0.1:${addr.port}`;
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
await new Promise<void>((r) => server.close(() => r()));
|
||||
});
|
||||
|
||||
const snap = async (path: string): Promise<PageSnapshot> => {
|
||||
await page.goto(`${origin}${path}`, { waitUntil: "networkidle" });
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage) as Promise<PageSnapshot>;
|
||||
};
|
||||
|
||||
it("resolves a font-face src against the external sheet's url, not the document", async () => {
|
||||
const s = await snap("/page");
|
||||
assert.ok(
|
||||
s.cssUrls.includes(`${origin}${EXPECTED_MEDIA}`),
|
||||
`expected sheet-relative ${EXPECTED_MEDIA} in cssUrls, got ${JSON.stringify(s.cssUrls)}`,
|
||||
);
|
||||
assert.ok(
|
||||
!s.cssUrls.includes(`${origin}${WRONG_MEDIA}`),
|
||||
"must NOT produce the document-relative (wrong) resolution",
|
||||
);
|
||||
const face = s.fontFaces.find((f) => f.family === "Gothic");
|
||||
assert.ok(face, "font face captured");
|
||||
assert.equal(face!.baseHref, `${origin}${CSS_PATH}`, "face carries its owning sheet's base");
|
||||
});
|
||||
|
||||
it("resolves ordinary style-rule url() against the sheet too", async () => {
|
||||
const s = await snap("/page");
|
||||
assert.ok(
|
||||
s.cssUrls.includes(`${origin}/marketing-static/_next/static/media/bg.png`),
|
||||
`expected sheet-relative bg.png, got ${JSON.stringify(s.cssUrls)}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves an @import-nested sheet's face against the IMPORTED sheet's url", async () => {
|
||||
const s = await snap("/import-host");
|
||||
// top.css @imports x.css; the face lives in x.css, so ../media resolves from x.css's dir.
|
||||
assert.ok(
|
||||
s.cssUrls.includes(`${origin}${EXPECTED_MEDIA}`),
|
||||
`@import base must be the imported sheet, got ${JSON.stringify(s.cssUrls)}`,
|
||||
);
|
||||
const face = s.fontFaces.find((f) => f.family === "Gothic");
|
||||
assert.equal(face?.baseHref, `${origin}${CSS_PATH}`);
|
||||
});
|
||||
|
||||
it("falls back to the document base for an inline <style> (no sheet href)", async () => {
|
||||
await page.goto(`${origin}/page`, { waitUntil: "networkidle" });
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
await page.evaluate(() => {
|
||||
const st = document.createElement("style");
|
||||
st.textContent =
|
||||
'@font-face{font-family:"Inline";src:url("./inline/g.woff2") format("woff2")}';
|
||||
document.head.appendChild(st);
|
||||
});
|
||||
const s = (await page.evaluate(collectPage)) as PageSnapshot;
|
||||
// document base is /page → ./inline/g.woff2 resolves to /inline/g.woff2 (page-relative).
|
||||
assert.ok(
|
||||
s.cssUrls.includes(`${origin}/inline/g.woff2`),
|
||||
`inline <style> resolves against the document, got ${JSON.stringify(s.cssUrls)}`,
|
||||
);
|
||||
const face = s.fontFaces.find((f) => f.family === "Inline");
|
||||
assert.equal(face?.baseHref, undefined, "inline face has no sheet href");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { letterSpacingEquivalent, normHref, countVisibleInCaptureHiddenInClone, gate2Assets } from "../src/validate/gates.js";
|
||||
import { servedAssetExists } from "../src/validate/validate.js";
|
||||
import type { AssetGraph } from "../src/infer/assets.js";
|
||||
import type { FontGraph } from "../src/infer/fonts.js";
|
||||
import type { IR, IRNode } from "../src/normalize/ir.js";
|
||||
import type { PageSnapshot } from "../src/capture/walker.js";
|
||||
|
||||
// FIX 5 — the link gate must not fail a javascript: source href against the clone's sanitized value.
|
||||
// Generation emits an inert `#` for a `javascript:*` href (React blocks the literal), so normHref
|
||||
// collapses every javascript: href — on either side — to `#`, letting the two sides match.
|
||||
describe("normHref collapses javascript: hrefs to # (FIX 5)", () => {
|
||||
const origin = "https://example.test";
|
||||
it("normalizes a javascript: source href to #", () => {
|
||||
assert.equal(normHref("Javascript:{}", origin), "#");
|
||||
assert.equal(normHref("javascript:void(0)", origin), "#");
|
||||
});
|
||||
it("makes a javascript: source match the emitted # value", () => {
|
||||
assert.equal(normHref("javascript:{}", origin), normHref("#", origin), "source and clone agree");
|
||||
});
|
||||
it("still distinguishes a real fragment from a full URL", () => {
|
||||
assert.equal(normHref("#top", origin), "#top");
|
||||
assert.equal(normHref("https://example.test/x/", origin), "https://example.test/x");
|
||||
});
|
||||
});
|
||||
|
||||
// Chromium serializes a computed `letter-spacing: 0` back as the keyword `normal`. The emitter, after
|
||||
// snapping a sub-0.1px authored tracking to 0, ships `letter-spacing: 0px` — which the CLONE then
|
||||
// reports as `normal` too, but a source that authored an explicit near-zero px can land on either
|
||||
// spelling. Gate 4 must treat `normal` and `0px` as equal, or every such node fails on spelling alone.
|
||||
describe("gate4 letterSpacing normal ↔ 0px normalization", () => {
|
||||
it("treats `normal` and `0px` as equivalent", () => {
|
||||
assert.ok(letterSpacingEquivalent("normal", "0px"));
|
||||
assert.ok(letterSpacingEquivalent("0px", "normal"));
|
||||
});
|
||||
|
||||
it("treats `normal` and `normal` as equivalent", () => {
|
||||
assert.ok(letterSpacingEquivalent("normal", "normal"));
|
||||
});
|
||||
|
||||
it("treats a sub-2px authored tracking vs `normal` as equivalent (within ±2px)", () => {
|
||||
// source computed `-0.08px`, clone serialized `normal` — a 0.08px delta, well within tolerance.
|
||||
assert.ok(letterSpacingEquivalent("-0.08px", "normal"));
|
||||
assert.ok(letterSpacingEquivalent("normal", "-0.0375px"));
|
||||
});
|
||||
|
||||
it("still equates two close real px values (−0.24px vs −0.2px)", () => {
|
||||
assert.ok(letterSpacingEquivalent("-0.24px", "-0.2px"));
|
||||
});
|
||||
|
||||
it("still FAILS a genuine tracking difference beyond ±2px", () => {
|
||||
assert.ok(!letterSpacingEquivalent("4px", "normal"));
|
||||
assert.ok(!letterSpacingEquivalent("normal", "-3px"));
|
||||
assert.ok(!letterSpacingEquivalent("6px", "2px"));
|
||||
});
|
||||
|
||||
it("passes when the source did not constrain the property (undefined)", () => {
|
||||
assert.ok(letterSpacingEquivalent(undefined, "0px"));
|
||||
});
|
||||
});
|
||||
|
||||
// A capture-visible text node that the clone renders hidden (off-screen-shifted / banded /
|
||||
// width-frozen subtree) is the emil-banner / maxbo-feed regression class. The diagnostic counts
|
||||
// DISTINCT source cids whose gen counterpart exists but reports visible:false. Non-blocking; it
|
||||
// only has to be a faithful, deterministic count.
|
||||
describe("countVisibleInCaptureHiddenInClone diagnostic", () => {
|
||||
// Minimal IR text leaf: `text` at every listed vp, visible per `vis`.
|
||||
const leaf = (id: string, text: string, vis: Record<number, boolean>): IRNode => {
|
||||
const vps = Object.keys(vis).map(Number);
|
||||
const rec = <T,>(v: T): Record<number, T> => Object.fromEntries(vps.map((vp) => [vp, v]));
|
||||
return {
|
||||
id, tag: "a", attrs: {},
|
||||
visibleByVp: vis,
|
||||
bboxByVp: rec({ x: 0, y: 0, width: 100, height: 16 }),
|
||||
computedByVp: rec({} as Record<string, string>),
|
||||
children: [{ text }],
|
||||
} as unknown as IRNode;
|
||||
};
|
||||
const makeIR = (leaves: IRNode[]): IR => ({
|
||||
doc: {} as IR["doc"],
|
||||
root: { id: "n0", tag: "body", attrs: {}, visibleByVp: {}, bboxByVp: {}, computedByVp: {}, children: leaves } as unknown as IRNode,
|
||||
});
|
||||
// Minimal clone snapshot: one node per (cid, visible) with matching direct text.
|
||||
const snap = (nodes: Array<{ cid: string; text: string; visible: boolean }>): PageSnapshot => ({
|
||||
root: {
|
||||
tag: "body", attrs: { "data-cid": "n0" }, computed: {}, bbox: { x: 0, y: 0, width: 100, height: 100 }, visible: true,
|
||||
children: nodes.map((n) => ({
|
||||
tag: "a", attrs: { "data-cid": n.cid }, computed: {}, bbox: { x: 0, y: 0, width: 100, height: 16 },
|
||||
visible: n.visible, children: [{ text: n.text }],
|
||||
})),
|
||||
},
|
||||
} as unknown as PageSnapshot);
|
||||
|
||||
it("counts a source-visible text node the clone renders hidden", () => {
|
||||
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
|
||||
const gen = { 375: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]) };
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 1);
|
||||
});
|
||||
|
||||
it("does NOT count a node visible in both source and clone", () => {
|
||||
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
|
||||
const gen = { 375: snap([{ cid: "n4", text: "Enrollment open!", visible: true }]) };
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
|
||||
});
|
||||
|
||||
it("does NOT count a node the SOURCE itself hid (faithful hide)", () => {
|
||||
// maxbo n192 @375: source already off-screen (visible:false) — the clone hiding it is correct.
|
||||
const ir = makeIR([leaf("n192", "Avatar: Fire and Ash", { 375: false, 768: true })]);
|
||||
const gen = {
|
||||
375: snap([{ cid: "n192", text: "Avatar: Fire and Ash", visible: false }]),
|
||||
768: snap([{ cid: "n192", text: "Avatar: Fire and Ash", visible: true }]),
|
||||
};
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375, 768]), 0);
|
||||
});
|
||||
|
||||
it("counts a node once even when hidden at several viewports (distinct cids)", () => {
|
||||
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true, 768: true })]);
|
||||
const gen = {
|
||||
375: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]),
|
||||
768: snap([{ cid: "n4", text: "Enrollment open!", visible: false }]),
|
||||
};
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375, 768]), 1);
|
||||
});
|
||||
|
||||
it("ignores whitespace-only text nodes", () => {
|
||||
const ir = makeIR([leaf("n5", " ", { 375: true })]);
|
||||
const gen = { 375: snap([{ cid: "n5", text: " ", visible: false }]) };
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
|
||||
});
|
||||
|
||||
it("does not count when the clone has no node for the cid (a different miss)", () => {
|
||||
const ir = makeIR([leaf("n4", "Enrollment open!", { 375: true })]);
|
||||
const gen = { 375: snap([]) };
|
||||
assert.equal(countVisibleInCaptureHiddenInClone(ir, gen, [375]), 0);
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 3 — an aborted srcset-candidate image fetch (requestfailed, file present on disk) must not be
|
||||
// counted as a "404" asset failure. servedAssetExists maps a failed URL to a file under the served
|
||||
// export exactly as serveStatic does; the assetFailed filter in validate.ts uses it to excuse
|
||||
// aborted fetches whose file exists, while genuine misses (>= 400, or file absent) still fail.
|
||||
describe("servedAssetExists (FIX 3 aborted-image excuse)", () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "served-out-"));
|
||||
mkdirSync(join(root, "assets", "cloned", "images"), { recursive: true });
|
||||
writeFileSync(join(root, "assets", "cloned", "images", "present.webp"), "RIFFxxxxWEBP");
|
||||
|
||||
it("resolves a URL to a present file under the served root", () => {
|
||||
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/present.webp"), true);
|
||||
});
|
||||
|
||||
it("returns false when the file is absent from the served root (a genuine miss)", () => {
|
||||
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/absent.webp"), false);
|
||||
});
|
||||
|
||||
it("ignores query strings when resolving to disk", () => {
|
||||
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/assets/cloned/images/present.webp?206w"), true);
|
||||
});
|
||||
|
||||
it("does not escape the served root via .. traversal", () => {
|
||||
assert.equal(servedAssetExists(root, "http://127.0.0.1:5000/../../../etc/passwd"), false);
|
||||
});
|
||||
|
||||
it("an unparseable URL is treated as a genuine miss", () => {
|
||||
assert.equal(servedAssetExists(root, "not-a-url"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gate2Assets failed-asset message (FIX 3)", () => {
|
||||
const emptyAssets: AssetGraph = { entries: [], byUrl: new Map() };
|
||||
const emptyFonts: FontGraph = { entries: [], css: "" };
|
||||
|
||||
it("passes when no generated asset refs are missing", () => {
|
||||
const r = gate2Assets(emptyAssets, emptyFonts, { remoteRefs: [], failed404: [] });
|
||||
assert.equal(r.pass, true);
|
||||
assert.equal(r.metrics.failed404, 0);
|
||||
});
|
||||
|
||||
it("fails and reports missing refs (not '404') when a genuine miss is passed", () => {
|
||||
const r = gate2Assets(emptyAssets, emptyFonts, { remoteRefs: [], failed404: ["failed http://127.0.0.1:5000/assets/cloned/images/absent.webp"] });
|
||||
assert.equal(r.pass, false);
|
||||
assert.equal(r.metrics.failed404, 1);
|
||||
assert.ok(r.issues.some((i) => i.includes("generated asset refs missing")), `expected 'missing' wording, got: ${r.issues.join(" | ")}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveHtmlBg, htmlBgRule } from "../src/generate/app.js";
|
||||
|
||||
// Regression: when the source layers a full-bleed backdrop at z-index<0 behind a
|
||||
// body-propagated canvas background, emitting an opaque `html { background }`
|
||||
// flips CSS 2.1 §14.2 background propagation and buries that backdrop under the
|
||||
// body box. `html` must only paint when the SOURCE html actually painted.
|
||||
describe("resolveHtmlBg — source-faithful canvas propagation", () => {
|
||||
it("transparent html + colored body → no html background (body propagates to canvas)", () => {
|
||||
const htmlBg = resolveHtmlBg({ htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgb(246, 244, 238)" });
|
||||
assert.equal(htmlBg, null);
|
||||
assert.equal(htmlBgRule(htmlBg), "");
|
||||
});
|
||||
|
||||
it("colored html → html background kept", () => {
|
||||
const htmlBg = resolveHtmlBg({ htmlBg: "rgb(10, 20, 30)", bodyBg: "rgb(246, 244, 238)" });
|
||||
assert.equal(htmlBg, "rgb(10, 20, 30)");
|
||||
assert.equal(htmlBgRule(htmlBg), "html { background: rgb(10, 20, 30); }\n");
|
||||
});
|
||||
|
||||
it("both transparent → #ffffff fallback (never a UA-default canvas)", () => {
|
||||
const htmlBg = resolveHtmlBg({ htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgba(0, 0, 0, 0)" });
|
||||
assert.equal(htmlBg, "#ffffff");
|
||||
assert.equal(htmlBgRule(htmlBg), "html { background: #ffffff; }\n");
|
||||
});
|
||||
|
||||
it("missing perViewport entry → #ffffff fallback", () => {
|
||||
const htmlBg = resolveHtmlBg(undefined);
|
||||
assert.equal(htmlBg, "#ffffff");
|
||||
});
|
||||
|
||||
it("undefined html + colored body (only body captured) → no html rule", () => {
|
||||
// Body-only styling is the common case the old fallback existed for; it must
|
||||
// still leave html transparent so the body color reaches the canvas.
|
||||
const htmlBg = resolveHtmlBg({ bodyBg: "rgb(0, 0, 0)" });
|
||||
assert.equal(htmlBg, null);
|
||||
});
|
||||
|
||||
it("deterministic: identical input yields identical output", () => {
|
||||
const pv = { htmlBg: "rgba(0, 0, 0, 0)", bodyBg: "rgb(1, 2, 3)" };
|
||||
assert.equal(resolveHtmlBg(pv), resolveHtmlBg({ ...pv }));
|
||||
});
|
||||
});
|
||||
@@ -72,6 +72,23 @@ describe("planForFrameUrl", () => {
|
||||
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft");
|
||||
assert.equal(planForFrameUrl("http://127.0.0.1:4001/iframe-embed.html"), "graft");
|
||||
});
|
||||
|
||||
it("SKIPS full-viewport promo POPUP CREATIVE hosts (Attentive/Recart/Wunderkind overlays)", () => {
|
||||
// These vendor hosts serve a full-viewport interstitial creative — grafting pours the
|
||||
// popup's copy into the DOM/text channel and paints the modal over the real page.
|
||||
assert.equal(planForFrameUrl("https://creatives.attn.tv/creative/12345"), "skip");
|
||||
assert.equal(planForFrameUrl("https://creative.attn.tv/loader/x"), "skip");
|
||||
assert.equal(planForFrameUrl("https://app.recart.com/popup/abc"), "skip");
|
||||
assert.equal(planForFrameUrl("https://tag.wunderkind.co/creative"), "skip");
|
||||
assert.equal(planForFrameUrl("https://api.bounceexchange.com/creative"), "skip");
|
||||
});
|
||||
|
||||
it("STILL grafts INLINE embed hosts even for the same vendors (inline forms are a feature)", () => {
|
||||
// Caution guard: a deliberately-grafted inline signup form must never be caught by the
|
||||
// popup-creative skip list. Inline-form hosts differ from overlay-creative hosts.
|
||||
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/forms/abc"), "graft");
|
||||
assert.equal(planForFrameUrl("https://manage.kmail-lists.com/subscriptions/subscribe"), "graft");
|
||||
});
|
||||
});
|
||||
|
||||
describe("graftFrameIntoSnapshot", () => {
|
||||
@@ -146,6 +163,54 @@ describe("graftFrameIntoSnapshot", () => {
|
||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
||||
assert.ok(host.keyframes.includes("@keyframes spin { to { transform: rotate(360deg); } }"));
|
||||
});
|
||||
|
||||
// A frame's `position: fixed` chrome pins to the FRAME viewport, not the page's. Grafted
|
||||
// as plain DOM, `fixed` would resolve against the main page viewport and escape the
|
||||
// iframe's clip box — the observed regression: a gallery embed's
|
||||
// `fixed inset-0 z-[-1]` dark backdrop painted the whole clone dark. It must be demoted
|
||||
// to `absolute` so the host box contains and clips it.
|
||||
it("demotes frame `position: fixed` to `absolute` so it stays inside the iframe box", () => {
|
||||
const backdrop = raw("div", { class: "backdrop" }, [], {
|
||||
computed: { display: "block", position: "fixed" },
|
||||
bbox: { x: 0, y: 0, width: 320, height: 160 },
|
||||
});
|
||||
const frame = pageSnap(raw("body", {}, [backdrop]), "https://frame.test/embed");
|
||||
const host = mkHost();
|
||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
||||
const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode;
|
||||
const grafted = wrapper.children[0] as RawNode;
|
||||
assert.equal(grafted.computed.position, "absolute");
|
||||
});
|
||||
|
||||
it("demotes frame `position: sticky` to `relative` (no sticking to the page scroll)", () => {
|
||||
const bar = raw("div", { class: "sticky-bar" }, [], {
|
||||
computed: { display: "block", position: "sticky" },
|
||||
bbox: { x: 0, y: 0, width: 320, height: 40 },
|
||||
});
|
||||
const frame = pageSnap(raw("body", {}, [bar]), "https://frame.test/embed");
|
||||
const host = mkHost();
|
||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, frame);
|
||||
const wrapper = findFrameNode(host.root, 0)!.children[0] as RawNode;
|
||||
const grafted = wrapper.children[0] as RawNode;
|
||||
assert.equal(grafted.computed.position, "relative");
|
||||
});
|
||||
|
||||
it("promotes a `static` host to `relative` so demoted absolutes anchor to the frame box", () => {
|
||||
const host = mkHost(); // iframe host computed.position defaults to "static"
|
||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||
assert.equal(findFrameNode(host.root, 0)!.computed.position, "relative");
|
||||
});
|
||||
|
||||
it("leaves an already-positioned host's position untouched", () => {
|
||||
const host = pageSnap(raw("body", {}, [
|
||||
raw("iframe", { "data-ditto-frame": "0", src: "https://frame.test/embed" }, [], {
|
||||
computed: { display: "block", position: "absolute" },
|
||||
bbox: { x: 40, y: 30, width: 320, height: 160 },
|
||||
}),
|
||||
]));
|
||||
graftFrameIntoSnapshot(host, { idx: 0, contentX: 40, contentY: 30 }, mkFrame());
|
||||
assert.equal(findFrameNode(host.root, 0)!.computed.position, "absolute");
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Cross-origin integration: capture → snapshot graft → IR → generated tag ----
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { captureInteractions, tagElements, diffStyle } from "../src/capture/interactions.js";
|
||||
|
||||
describe("diffStyle", () => {
|
||||
it("returns only the changed keys, with the b-side value", () => {
|
||||
const a = { color: "rgb(0, 0, 0)", opacity: "1", transform: "none" };
|
||||
const b = { color: "rgb(255, 0, 0)", opacity: "1", transform: "scale(1.1)" };
|
||||
assert.deepEqual(diffStyle(a, b), { color: "rgb(255, 0, 0)", transform: "scale(1.1)" });
|
||||
});
|
||||
it("is empty when nothing changed", () => {
|
||||
const a = { color: "rgb(0, 0, 0)", opacity: "1" };
|
||||
assert.deepEqual(diffStyle(a, { ...a }), {});
|
||||
});
|
||||
it("only reports keys present in b (b drives the comparison)", () => {
|
||||
// a resting style that carries an extra key does not fabricate a delta.
|
||||
assert.deepEqual(diffStyle({ color: "red", extra: "x" }, { color: "red" }), {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureInteractions hover capture (occlusion-immune)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const setup = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
await tagElements(page);
|
||||
};
|
||||
|
||||
it("captures an authored :hover state even when a transparent full-viewport overlay covers the target", async () => {
|
||||
// Exact regression for the empty-hover-capture bug: a modern builder stack parks a
|
||||
// transparent fixed layer over the whole page. page.hover moves the real cursor to the
|
||||
// link's centre, the point lands on the overlay, and `:hover` never reaches the link — so
|
||||
// pointer-based probing captured ZERO hover states. Forcing the pseudo-class fixes it.
|
||||
await setup(`
|
||||
<style>
|
||||
a.cta { color: rgb(0, 0, 0); background-color: rgb(255, 255, 255); }
|
||||
a.cta:hover { color: rgb(255, 0, 0); background-color: rgb(0, 0, 255); }
|
||||
.overlay { position: fixed; inset: 0; z-index: 9999; background: transparent; }
|
||||
</style>
|
||||
<a class="cta" href="#">Shop now</a>
|
||||
<div class="overlay"></div>`);
|
||||
const cap = await captureInteractions(page, { maxCandidates: 50 });
|
||||
const hoverCaps = Object.keys(cap.hover);
|
||||
assert.ok(hoverCaps.length >= 1, "at least one hover state is captured through the overlay");
|
||||
const delta = cap.hover[hoverCaps[0]!]!;
|
||||
assert.equal(delta.color, "rgb(255, 0, 0)", "hover color change is captured");
|
||||
assert.equal(delta.backgroundColor, "rgb(0, 0, 255)", "hover background change is captured");
|
||||
});
|
||||
|
||||
it("captures :hover on a cursor:pointer element that is not a native interactive", async () => {
|
||||
await setup(`
|
||||
<style>
|
||||
.card { cursor: pointer; border: 2px solid rgb(1, 1, 1); }
|
||||
.card:hover { border-color: rgb(9, 9, 9); }
|
||||
</style>
|
||||
<div class="card" style="width:200px;height:120px;">Card</div>`);
|
||||
const cap = await captureInteractions(page, { maxCandidates: 50 });
|
||||
const found = Object.values(cap.hover).some((d) => d.borderTopColor === "rgb(9, 9, 9)");
|
||||
assert.ok(found, "a cursor:pointer card's authored :hover border change is captured");
|
||||
});
|
||||
|
||||
it("records no hover delta for an element with no authored :hover (self-limiting)", async () => {
|
||||
await setup(`
|
||||
<a class="plain" href="#" style="color:rgb(0,0,0);">No hover</a>`);
|
||||
const cap = await captureInteractions(page, { maxCandidates: 50 });
|
||||
assert.equal(Object.keys(cap.hover).length, 0, "no authored hover -> empty hover map");
|
||||
});
|
||||
|
||||
it("restores the resting state after probing (forced pseudo-state cleared)", async () => {
|
||||
await setup(`
|
||||
<style>
|
||||
a.cta { color: rgb(0, 0, 0); }
|
||||
a.cta:hover { color: rgb(255, 0, 0); }
|
||||
</style>
|
||||
<a class="cta" href="#">Link</a>`);
|
||||
await captureInteractions(page, { maxCandidates: 50 });
|
||||
const resting = await page.evaluate(() => getComputedStyle(document.querySelector("a.cta")!).color);
|
||||
assert.equal(resting, "rgb(0, 0, 0)", "the page is left in its resting (non-hover) state");
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { RawNode, RawChild } from "../src/capture/walker.js";
|
||||
import { buildIR, isTextChild, type IRNode } from "../src/normalize/ir.js";
|
||||
import { buildIR, isTextChild, neutralizeAnimatedTransforms, type IRNode } from "../src/normalize/ir.js";
|
||||
|
||||
const VPS = [375, 1280];
|
||||
|
||||
@@ -98,3 +98,217 @@ describe("IR prune keeps <source> media candidates", () => {
|
||||
assert.equal(findByTag(root, "picture"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IR drops font-metric probe nodes (fix 4)", () => {
|
||||
it("excludes a walker-tagged probe node from the IR, keeping its siblings", () => {
|
||||
const probe: RawNode = {
|
||||
...raw("div", { class: "font-probe" }, [{ text: "Mgy" }]),
|
||||
probe: true,
|
||||
};
|
||||
const real = raw("h1", { class: "title" }, [{ text: "Heading" }]);
|
||||
const body = raw("body", {}, [real, probe]);
|
||||
|
||||
const root = buildFixtureIR(body);
|
||||
|
||||
const kept = root.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
|
||||
assert.deepEqual(kept, ["h1"], "probe div is dropped, the real heading survives");
|
||||
// Its text must not leak into the tree either.
|
||||
assert.equal(findByTag(root, "div"), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IR drops popup-vendor OVERLAY containers, keeps inline embedded forms", () => {
|
||||
it("drops an email-capture popup overlay container (vendor overlay id) but keeps a real section", () => {
|
||||
const overlay = raw("div", { id: "attentive_overlay" }, [
|
||||
raw("iframe", { id: "attentive_creative", src: "https://creatives.attn.tv/x" }),
|
||||
]);
|
||||
const real = raw("section", { class: "hero" }, [raw("h1", {}, [{ text: "Real content" }])]);
|
||||
const body = raw("body", {}, [real, overlay]);
|
||||
|
||||
const root = buildFixtureIR(body);
|
||||
const kept = root.children.filter((c) => !isTextChild(c)).map((c) => (c as IRNode).tag);
|
||||
assert.deepEqual(kept, ["section"], "the attentive overlay subtree is dropped");
|
||||
assert.equal(findByTag(root, "iframe"), null, "the popup creative iframe never reaches the IR");
|
||||
});
|
||||
|
||||
it("does NOT drop an INLINE embedded signup form that merely carries a vendor name (feature, not popup)", () => {
|
||||
// A deliberately-grafted inline Klaviyo form: a real, sized form embedded in page content. Its
|
||||
// class names the vendor but is NOT an overlay-container marker, so it must survive.
|
||||
const inlineForm = raw("div", { class: "klaviyo-form klaviyo-form-inline" }, [
|
||||
raw("form", { id: "email-signup" }, [raw("input", { type: "email" })]),
|
||||
]);
|
||||
const body = raw("body", {}, [inlineForm]);
|
||||
|
||||
const root = buildFixtureIR(body);
|
||||
assert.ok(findByTag(root, "form"), "the inline signup form survives the prune");
|
||||
assert.ok(findByTag(root, "input"), "its input survives too");
|
||||
});
|
||||
});
|
||||
|
||||
// Defect C (normalize side) — an infinite CSS animation gated to a breakpoint (a Webflow `max-lg`
|
||||
// marquee) is `animation:none` at the widths it does not run, but the browser still reports the last
|
||||
// FROZEN translateX there. `neutralizeAnimatedTransforms` zeroes the transform at EVERY viewport
|
||||
// once a genuine infinite animation is present at some width, so no frozen mid-scroll offset (the
|
||||
// base residue or the animated phases) is banded and clips the strip offscreen at rest.
|
||||
describe("neutralizeAnimatedTransforms (Defect C)", () => {
|
||||
it("zeroes the transform at every viewport when an infinite animation runs at some width", () => {
|
||||
const byVp = {
|
||||
375: { animationName: "track", animationIterationCount: "infinite", transform: "matrix(1, 0, 0, 1, -284.025, 0)" } as any,
|
||||
768: { animationName: "track", animationIterationCount: "infinite", transform: "matrix(1, 0, 0, 1, -280.982, 0)" } as any,
|
||||
1280: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -9.94731, 0)" } as any,
|
||||
};
|
||||
neutralizeAnimatedTransforms(byVp as any);
|
||||
assert.equal(byVp[375].transform, "none");
|
||||
assert.equal(byVp[768].transform, "none");
|
||||
assert.equal(byVp[1280].transform, "none", "the non-animated base residue is neutralized too");
|
||||
});
|
||||
|
||||
it("leaves a static transform untouched when NO viewport carries an infinite animation", () => {
|
||||
const byVp = {
|
||||
375: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -40, 0)" } as any,
|
||||
1280: { animationName: "none", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -40, 0)" } as any,
|
||||
};
|
||||
neutralizeAnimatedTransforms(byVp as any);
|
||||
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -40, 0)", "a deliberate static offset is preserved");
|
||||
assert.equal(byVp[1280].transform, "matrix(1, 0, 0, 1, -40, 0)");
|
||||
});
|
||||
|
||||
it("does not fire for a FINITE animation (a one-shot entrance, not a perpetual marquee)", () => {
|
||||
const byVp = {
|
||||
375: { animationName: "slideIn", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -100, 0)" } as any,
|
||||
1280: { animationName: "slideIn", animationIterationCount: "1", transform: "matrix(1, 0, 0, 1, -100, 0)" } as any,
|
||||
};
|
||||
neutralizeAnimatedTransforms(byVp as any);
|
||||
assert.equal(byVp[375].transform, "matrix(1, 0, 0, 1, -100, 0)", "finite animations own a settled end state, not a perpetual phase");
|
||||
});
|
||||
});
|
||||
|
||||
/** A RawNode with an explicit computed style and bbox (same at every viewport). Unlike `raw()` this
|
||||
* lets a test author invisible-but-laid-out boxes, transforms, margins, and positions directly. */
|
||||
function rawS(
|
||||
tag: string,
|
||||
computed: Record<string, string>,
|
||||
bbox: { x: number; y: number; width: number; height: number },
|
||||
children: RawChild[] = [],
|
||||
visible = true,
|
||||
attrs: Record<string, string> = {},
|
||||
): RawNode {
|
||||
return { tag, attrs, computed, bbox, visible, children };
|
||||
}
|
||||
|
||||
// FIX 2 — an in-flow `visibility:hidden` box with a nonzero border box is load-bearing geometry: it
|
||||
// reserves the row height its absolutely-positioned siblings paint into. It is invisible (so the plain
|
||||
// visibility prune would drop it), but dropping it collapses the column. It must survive as a sized
|
||||
// placeholder; a genuinely empty display:none-everywhere box must still prune.
|
||||
describe("IR prune keeps sized invisible (visibility:hidden) spacers (FIX 2)", () => {
|
||||
it("keeps an in-flow visibility:hidden ghost column with a nonzero bbox", () => {
|
||||
const ghost = rawS(
|
||||
"div",
|
||||
{ display: "flex", position: "static", visibility: "hidden" },
|
||||
{ x: 0, y: 0, width: 650, height: 723 },
|
||||
// its children are themselves invisible (they set no visibility:visible) — the box is a spacer
|
||||
[rawS("img", { display: "block", position: "static", visibility: "hidden" }, { x: 0, y: 0, width: 650, height: 240 }, [], false)],
|
||||
false,
|
||||
);
|
||||
// the real content is an absolutely-positioned sibling layered over the ghost's reserved space
|
||||
const painted = rawS("img", { display: "block", position: "absolute", visibility: "visible" }, { x: 0, y: 0, width: 650, height: 723 }, [], true);
|
||||
const column = rawS("div", { display: "block", position: "relative", visibility: "visible" }, { x: 0, y: 0, width: 650, height: 723 }, [ghost, painted], true);
|
||||
const body = raw("body", {}, [column]);
|
||||
|
||||
const root = buildFixtureIR(body);
|
||||
const kept = findByTag(root, "div");
|
||||
assert.ok(kept, "the column survives");
|
||||
// The ghost div (a second nested div) must still be present as a sized placeholder.
|
||||
const divs: IRNode[] = [];
|
||||
const collect = (n: IRNode): void => { if (n.tag === "div") divs.push(n); for (const c of n.children) if (!isTextChild(c)) collect(c as IRNode); };
|
||||
collect(root);
|
||||
// column + ghost = 2 divs (body is not a div)
|
||||
assert.equal(divs.length, 2, "the visibility:hidden ghost column is retained, not pruned");
|
||||
const ghostIR = divs.find((d) => d.computedByVp[1280]?.visibility === "hidden");
|
||||
assert.ok(ghostIR, "the retained ghost carries visibility:hidden");
|
||||
assert.ok(ghostIR!.bboxByVp[1280]!.height > 0, "the ghost keeps its load-bearing height");
|
||||
});
|
||||
|
||||
it("still prunes a display:none-everywhere empty box (no resurrection)", () => {
|
||||
const hidden = rawS("div", { display: "none", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 0, height: 0 }, [], false);
|
||||
const body = raw("body", {}, [rawS("section", { display: "block", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 640, height: 100 }, [hidden], true)]);
|
||||
const root = buildFixtureIR(body);
|
||||
assert.equal(findByTag(root, "div"), null, "a display:none-everywhere box stays pruned");
|
||||
});
|
||||
|
||||
it("does not keep an out-of-flow (absolute) visibility:hidden box as a spacer", () => {
|
||||
// Absolute boxes take no space in flow, so an invisible absolute box is not load-bearing geometry.
|
||||
const absHidden = rawS("div", { display: "block", position: "absolute", visibility: "hidden" }, { x: 0, y: 0, width: 300, height: 300 }, [], false);
|
||||
const body = raw("body", {}, [rawS("section", { display: "block", position: "static", visibility: "visible" }, { x: 0, y: 0, width: 640, height: 100 }, [absHidden], true)]);
|
||||
const root = buildFixtureIR(body);
|
||||
assert.equal(findByTag(root, "div"), null, "an out-of-flow invisible box is not kept as a spacer");
|
||||
});
|
||||
});
|
||||
|
||||
// FIX 3 — a settled loop carousel parks its track with a baked translateX and prepends invisible clone
|
||||
// slides that occupy exactly [translateX, 0]; the first REAL slide paints at x=0. Pruning drops the
|
||||
// off-screen clones, but the baked translateX would survive verbatim and push every real slide
|
||||
// offscreen-left. Re-anchor the track's translateX by the aggregate outer width of the dropped leading
|
||||
// clones so the first kept slide lands at its captured position.
|
||||
describe("IR prune re-anchors a translated track after dropping leading clones (FIX 3)", () => {
|
||||
it("re-anchors translateX toward 0 by the dropped leading clone width", () => {
|
||||
// Three off-screen leading loop clones, each 200px wide (translateX = -600 = -(3×200)); two reals.
|
||||
// The clones are OFF-SCREEN (visible:false via the off-screen test), NOT visibility:hidden — so
|
||||
// they prune and the FIX-2 sized-invisible-spacer carve-out (which requires visibility:hidden) does
|
||||
// not resurrect them. This mirrors a real settled Splide loop's leading clones.
|
||||
const clone = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: -600 + i * 200, y: 0, width: 200, height: 100 }, [], false);
|
||||
const real = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
|
||||
const track = rawS(
|
||||
"ul",
|
||||
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -600, 0)" },
|
||||
{ x: 0, y: 0, width: 1000, height: 100 },
|
||||
[clone(0), clone(1), clone(2), real(0), real(1)],
|
||||
true,
|
||||
);
|
||||
const body = raw("body", {}, [track]);
|
||||
const root = buildFixtureIR(body);
|
||||
const ul = findByTag(root, "ul")!;
|
||||
// The clones are pruned (invisible everywhere), leaving the two real slides.
|
||||
const slides = ul.children.filter((c) => !isTextChild(c));
|
||||
assert.equal(slides.length, 2, "only the real slides survive");
|
||||
// The baked -600 translate is re-anchored to 0 (−600 + 3×200).
|
||||
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 0)", "track translateX re-anchored to origin");
|
||||
assert.equal(ul.computedByVp[375]!.transform, "matrix(1, 0, 0, 1, 0, 0)", "re-anchored at every viewport");
|
||||
});
|
||||
|
||||
it("leaves a track untouched when no leading children are dropped", () => {
|
||||
const real = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
|
||||
const track = rawS(
|
||||
"ul",
|
||||
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -120, 0)" },
|
||||
{ x: 0, y: 0, width: 400, height: 100 },
|
||||
[real(0), real(1)],
|
||||
true,
|
||||
);
|
||||
const body = raw("body", {}, [track]);
|
||||
const root = buildFixtureIR(body);
|
||||
const ul = findByTag(root, "ul")!;
|
||||
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, -120, 0)", "a track with no dropped leading children keeps its offset");
|
||||
});
|
||||
|
||||
it("does not re-anchor when the dropped leading siblings are out of flow", () => {
|
||||
const absClone = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "absolute", visibility: "hidden" }, { x: -400 + i * 200, y: 0, width: 200, height: 100 }, [], false);
|
||||
const real = (i: number): RawNode =>
|
||||
rawS("li", { display: "block", position: "static", visibility: "visible" }, { x: i * 200, y: 0, width: 200, height: 100 }, [{ text: `real${i}` }], true);
|
||||
const track = rawS(
|
||||
"ul",
|
||||
{ display: "flex", position: "relative", visibility: "visible", transform: "matrix(1, 0, 0, 1, -80, 0)" },
|
||||
{ x: 0, y: 0, width: 400, height: 100 },
|
||||
[absClone(0), absClone(1), real(0), real(1)],
|
||||
true,
|
||||
);
|
||||
const body = raw("body", {}, [track]);
|
||||
const root = buildFixtureIR(body);
|
||||
const ul = findByTag(root, "ul")!;
|
||||
assert.equal(ul.computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, -80, 0)", "out-of-flow clones don't advance the track, so no re-anchor");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { DITTO_LOTTIE_TSX } from "../src/generate/lottie.js";
|
||||
import { isIdentityTransform, canonicalizeTransforms } from "../src/normalize/ir.js";
|
||||
import type { StyleMap } from "../src/normalize/ir.js";
|
||||
|
||||
/**
|
||||
* The DittoLottie runtime must NOT erase the captured placeholder frame before the animation
|
||||
* has successfully loaded — a failed load would otherwise blank the container (erasing hero /
|
||||
* logo / footer media). These are string assertions on the emitted 'use client' template.
|
||||
*/
|
||||
describe("DittoLottie placeholder retention", () => {
|
||||
it("does not clear the container's innerHTML before mounting", () => {
|
||||
assert.ok(
|
||||
!/el\.innerHTML\s*=\s*""/.test(DITTO_LOTTIE_TSX),
|
||||
"template must not eagerly clear the placeholder via el.innerHTML = \"\"",
|
||||
);
|
||||
});
|
||||
|
||||
it("mounts the live animation into a separate overlay child, not the container itself", () => {
|
||||
assert.match(DITTO_LOTTIE_TSX, /createElement\(["']div["']\)/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /container:\s*mount/);
|
||||
});
|
||||
|
||||
it("only removes the placeholder after a successful load event (DOMLoaded)", () => {
|
||||
// The reveal/swap must be gated behind lottie's ready event, and the placeholder removal
|
||||
// must live inside that gated path (removing original children once mount is ready).
|
||||
assert.match(DITTO_LOTTIE_TSX, /addEventListener\(\s*["']DOMLoaded["']/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /removeChild/);
|
||||
// The removal must reference the ready swap, not run unconditionally at mount time.
|
||||
const revealIdx = DITTO_LOTTIE_TSX.indexOf("DOMLoaded");
|
||||
const clearIdx = DITTO_LOTTIE_TSX.indexOf("removeChild");
|
||||
assert.ok(revealIdx >= 0 && clearIdx >= 0, "both the ready gate and the placeholder removal must be present");
|
||||
});
|
||||
|
||||
it("handles a failed load without leaving a broken mount stacked over the placeholder", () => {
|
||||
assert.match(DITTO_LOTTIE_TSX, /data_failed/);
|
||||
});
|
||||
|
||||
// Sizing: the host box carries the captured per-viewport height; only an absolutely-filled overlay
|
||||
// inherits that definite box. If the reveal reverted the overlay to static flow, the player's svg
|
||||
// (style height:100%) would collapse to auto and inflate to the source aspect (a portrait viewBox
|
||||
// then oversizes far past the captured, letterboxed box). So the overlay must STAY absolute/inset
|
||||
// for its whole life, and the reveal must NOT clear those styles.
|
||||
it("mounts the overlay as an absolute, inset-0 layer", () => {
|
||||
assert.match(DITTO_LOTTIE_TSX, /mount\.style\.position\s*=\s*["']absolute["']/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /mount\.style\.inset\s*=\s*["']0["']/);
|
||||
});
|
||||
|
||||
it("keeps the overlay absolute after the swap (never reverts it to static flow)", () => {
|
||||
// The reveal must not strip the overlay's positioning — a `mount.style.position = ""` (or inset)
|
||||
// un-pins it from the host's captured height and lets the runtime svg inflate by aspect.
|
||||
assert.ok(
|
||||
!/mount\.style\.position\s*=\s*["']["']/.test(DITTO_LOTTIE_TSX),
|
||||
"reveal must not clear mount.style.position (would un-pin the overlay → aspect inflation)",
|
||||
);
|
||||
assert.ok(
|
||||
!/mount\.style\.inset\s*=\s*["']["']/.test(DITTO_LOTTIE_TSX),
|
||||
"reveal must not clear mount.style.inset (would un-pin the overlay → aspect inflation)",
|
||||
);
|
||||
});
|
||||
|
||||
it("marks the host with data-ditto-lottie so emitted CSS can force the runtime svg to fit", () => {
|
||||
assert.match(DITTO_LOTTIE_TSX, /setAttribute\(\s*["']data-ditto-lottie["']/);
|
||||
});
|
||||
|
||||
// The player creates its <svg>/<canvas> at runtime WITHOUT a data-cid, so the DOM/media gate can't
|
||||
// map it back to the captured node. The reveal must forward the discarded placeholder's data-cid
|
||||
// onto the runtime-rendered element (validation-agnostic; no gate special-casing).
|
||||
it("forwards the placeholder's data-cid onto the runtime-rendered svg/canvas before the swap", () => {
|
||||
// reads the placeholder cid, then reassigns it to the rendered svg/canvas inside the mount
|
||||
assert.match(DITTO_LOTTIE_TSX, /getAttribute\(\s*["']data-cid["']\s*\)/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /mount\.querySelector\(\s*["']svg,\s*canvas["']\s*\)/);
|
||||
assert.match(DITTO_LOTTIE_TSX, /setAttribute\(\s*["']data-cid["']\s*,/);
|
||||
// the cid capture must precede its removal so the placeholder is still in the DOM when read
|
||||
const readIdx = DITTO_LOTTIE_TSX.indexOf('getAttribute("data-cid")');
|
||||
const removeIdx = DITTO_LOTTIE_TSX.indexOf("removeChild");
|
||||
assert.ok(readIdx >= 0 && removeIdx >= 0 && readIdx < removeIdx, "must read the placeholder cid before removing it");
|
||||
});
|
||||
});
|
||||
|
||||
describe("identity-transform canonicalization (isIdentityTransform)", () => {
|
||||
it("treats none and identity matrices as identity", () => {
|
||||
assert.equal(isIdentityTransform("none"), true);
|
||||
assert.equal(isIdentityTransform(undefined), true);
|
||||
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 0)"), true);
|
||||
assert.equal(isIdentityTransform("matrix(1,0,0,1,0,0)"), true);
|
||||
assert.equal(
|
||||
isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)"),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT treat a real (non-identity) transform as identity", () => {
|
||||
assert.equal(isIdentityTransform("matrix(1, 0, 0, 1, 0, 67.75)"), false);
|
||||
assert.equal(isIdentityTransform("translateY(67.75px)"), false);
|
||||
assert.equal(isIdentityTransform("matrix(0.5, 0, 0, 0.5, 0, 0)"), false);
|
||||
assert.equal(isIdentityTransform("matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 10, 0, 1)"), false);
|
||||
});
|
||||
|
||||
it("rewrites identity values to `none` while leaving a real transform observable at other widths", () => {
|
||||
// The contamination scenario: the base viewport (1280) baked a scroll-linked translateY,
|
||||
// while the other widths report identity (none / identity matrix). After canonicalization
|
||||
// the identity widths read literal `none`, so the generator's per-band delta emits the
|
||||
// explicit reset and the base transform can't cascade across bands.
|
||||
const computedByVp: Record<number, StyleMap> = {
|
||||
375: { transform: "none" } as StyleMap,
|
||||
768: { transform: "matrix(1, 0, 0, 1, 0, 0)" } as StyleMap,
|
||||
1280: { transform: "matrix(1, 0, 0, 1, 0, 67.75)" } as StyleMap, // contaminated base
|
||||
1920: { transform: "none" } as StyleMap,
|
||||
};
|
||||
canonicalizeTransforms(computedByVp);
|
||||
assert.equal(computedByVp[375]!.transform, "none");
|
||||
assert.equal(computedByVp[768]!.transform, "none"); // identity matrix normalized to none
|
||||
assert.equal(computedByVp[1280]!.transform, "matrix(1, 0, 0, 1, 0, 67.75)"); // real transform preserved
|
||||
assert.equal(computedByVp[1920]!.transform, "none");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
medianVelocityPxPerSec,
|
||||
classifyVelocitySamples,
|
||||
hasRepeatedChildren,
|
||||
} from "../src/capture/motion.js";
|
||||
|
||||
// These cover the pure discriminators extracted from detectMarquees' in-browser sampling.
|
||||
// The regression they defend: on a scroll-LINKED-easing page, a static logo row is still
|
||||
// lerping toward its scroll-target right after scrollIntoView, reading as a constant
|
||||
// velocity over ONE short window → four phantom marquees at identical -34px/s. The layered
|
||||
// tests below make that classification fail.
|
||||
|
||||
describe("medianVelocityPxPerSec", () => {
|
||||
it("converts a steady per-120ms delta to px/s and ignores the wrap-reset outlier", () => {
|
||||
// steady -4px per 120ms ≈ -33px/s; one big +200 wrap jump is an outlier the median drops.
|
||||
const deltas = [-4, -4, 200, -4, -4];
|
||||
assert.equal(medianVelocityPxPerSec(deltas, 120), Math.round((-4 / 120) * 1000));
|
||||
});
|
||||
it("returns 0 for empty deltas or a non-positive cadence", () => {
|
||||
assert.equal(medianVelocityPxPerSec([], 120), 0);
|
||||
assert.equal(medianVelocityPxPerSec([-4, -4], 0), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyVelocitySamples — sustained constant velocity (discriminator 2)", () => {
|
||||
const MS = 120;
|
||||
it("PASSES a real marquee: velocity holds across both windows", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [-4, -4, -4, -4, -4];
|
||||
const r = classifyVelocitySamples(w1, w2, MS);
|
||||
assert.equal(r.isMarquee, true);
|
||||
assert.equal(r.pxPerSec, medianVelocityPxPerSec(w1, MS));
|
||||
});
|
||||
|
||||
it("REJECTS a scroll-settle lerp: velocity decays sharply in window 2 (the false positive)", () => {
|
||||
// window1 still lerping fast toward scroll-target; window2 nearly settled.
|
||||
const w1 = [-4, -4, -4, -4, -4]; // ≈ -33px/s (the phantom "-34px/s")
|
||||
const w2 = [-0.4, -0.4, -0.4, -0.4, -0.4]; // decayed to ~-3px/s
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS when window 2 has fully settled (velocity ~0)", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [0, 0, 0, 0, 0];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS when the direction reverses between windows", () => {
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [4, 4, 4, 4, 4];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("REJECTS a static row: no motion in either window", () => {
|
||||
assert.equal(classifyVelocitySamples([0, 0, 0], [0, 0, 0], MS).isMarquee, false);
|
||||
});
|
||||
|
||||
it("PASSES a slightly slower-but-still-moving second window (within tolerance)", () => {
|
||||
// small natural jitter (10% slower) must not disqualify a genuine ticker.
|
||||
const w1 = [-4, -4, -4, -4, -4];
|
||||
const w2 = [-3.6, -3.6, -3.6, -3.6, -3.6];
|
||||
assert.equal(classifyVelocitySamples(w1, w2, MS).isMarquee, true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRepeatedChildren — genuine duplication (discriminator 4)", () => {
|
||||
it("PASSES two consecutive children with an identical outerHTML hash (a cloned copy)", () => {
|
||||
const hashes = [111, 111, 222]; // first two are a literal duplicate
|
||||
const widths = [80, 80, 120];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), true);
|
||||
});
|
||||
|
||||
it("PASSES a duplicated content block via the repeated width sequence [A B A B]", () => {
|
||||
// hashes differ (cloned then attribute-tweaked) but geometry repeats: [100,60,100,60].
|
||||
const hashes = [1, 2, 3, 4];
|
||||
const widths = [100, 60, 100, 60];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), true);
|
||||
});
|
||||
|
||||
it("REJECTS a row of distinct logos: no consecutive repetition (the false-positive shape)", () => {
|
||||
// four DIFFERENT logos — the exact static-logo-row case that produced phantom marquees.
|
||||
const hashes = [10, 20, 30, 40];
|
||||
const widths = [90, 110, 75, 130];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), false);
|
||||
});
|
||||
|
||||
it("REJECTS fewer than two children", () => {
|
||||
assert.equal(hasRepeatedChildren([5], [50]), false);
|
||||
assert.equal(hasRepeatedChildren([], []), false);
|
||||
});
|
||||
|
||||
it("does NOT treat a run of zero-width nodes as repetition (hash 0 / width 0 guards)", () => {
|
||||
assert.equal(hasRepeatedChildren([0, 0], [0, 0]), false);
|
||||
assert.equal(hasRepeatedChildren([1, 2, 3, 4], [0, 0, 0, 0]), false);
|
||||
});
|
||||
|
||||
it("REJECTS an odd-length width sequence that can't split into halves", () => {
|
||||
const hashes = [1, 2, 3];
|
||||
const widths = [100, 60, 100];
|
||||
assert.equal(hasRepeatedChildren(hashes, widths), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { finalizeOverlaysInPage, clickDismissInPage } from "../src/capture/capture.js";
|
||||
import { irContentExtent, type IRNode, type BBox, type StyleMap } from "../src/normalize/ir.js";
|
||||
import { gatePollution } from "../src/validate/gates.js";
|
||||
import type { CaptureResult } from "../src/capture/capture.js";
|
||||
import type { IR } from "../src/normalize/ir.js";
|
||||
|
||||
// tsx/esbuild wraps serialized page functions with __name()/__defProp; shim so page.evaluate works.
|
||||
const ESBUILD_SHIM =
|
||||
"globalThis.__name = globalThis.__name || ((fn) => fn);" +
|
||||
"globalThis.__defProp = globalThis.__defProp || Object.defineProperty;";
|
||||
|
||||
const VW = 1280, VH = 800;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// finalizeOverlaysInPage — effective-z resolution, overlay-unit grouping,
|
||||
// lock-relaxed z gate, shadow-root traversal, loud-failure blocking.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("capture: finalizeOverlaysInPage overlay detection", () => {
|
||||
let browser: Browser;
|
||||
before(async () => { browser = await chromium.launch(); });
|
||||
after(async () => { await browser.close(); });
|
||||
|
||||
const newPage = async (html: string): Promise<Page> => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewportSize({ width: VW, height: VH });
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(html);
|
||||
await page.evaluate(ESBUILD_SHIM);
|
||||
return page;
|
||||
};
|
||||
|
||||
it("groups a 0-height max-z wrapper + its full-viewport z:auto iframe as ONE overlay and removes both (scroll-locked)", async () => {
|
||||
// Mirrors the Attentive structure: body scroll-locked; a fixed z=INT_MAX wrapper of height 0
|
||||
// whose child is a fixed full-viewport iframe with z-index:auto. Neither passes the naive
|
||||
// per-element area+z gate alone (wrapper: area 0; iframe: z parses to 0).
|
||||
const page = await newPage(`
|
||||
<style>
|
||||
html,body{margin:0}
|
||||
body{position:absolute;top:0;left:0;right:0;height:${VH}px;overflow:hidden}
|
||||
#wrap{position:fixed;top:${VH}px;left:0;width:${VW}px;height:0;z-index:2147483647}
|
||||
#creative{position:fixed;top:0;left:0;width:${VW}px;height:${VH}px;z-index:auto;background:#000}
|
||||
</style>
|
||||
<main>real page content</main>
|
||||
<div id="wrap"><iframe id="creative"></iframe></div>
|
||||
`);
|
||||
const res = await page.evaluate(finalizeOverlaysInPage);
|
||||
assert.equal(res.removed, 1, "the wrapper+iframe unit is removed as one node");
|
||||
assert.ok(res.removedLabels.some((l) => l.includes("wrap")), `removed the max-z wrapper (got ${JSON.stringify(res.removedLabels)})`);
|
||||
const gone = await page.evaluate(() => !document.getElementById("wrap") && !document.getElementById("creative"));
|
||||
assert.ok(gone, "both the wrapper and its inner iframe are gone");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("detects a z=50 full-viewport fixed dialog ONLY because the page is scroll-locked (lock relaxes the z>=100 gate)", async () => {
|
||||
const page = await newPage(`
|
||||
<style>
|
||||
html,body{margin:0}
|
||||
body{overflow:hidden}
|
||||
#ccpa{position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:50;background:rgba(0,0,0,.7)}
|
||||
</style>
|
||||
<main>content</main>
|
||||
<div id="ccpa">California Residents Privacy Rights</div>
|
||||
`);
|
||||
const res = await page.evaluate(finalizeOverlaysInPage);
|
||||
assert.equal(res.removed, 1, "the z=50 dialog is removed on a locked page");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("does NOT remove a z=50 full-viewport fixed layer when the page is NOT scroll-locked (z floor preserved)", async () => {
|
||||
const page = await newPage(`
|
||||
<style>
|
||||
html,body{margin:0}
|
||||
#hero{position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:50;background:#eee}
|
||||
</style>
|
||||
<div id="hero">a legit fixed hero, page scrolls normally</div>
|
||||
`);
|
||||
const res = await page.evaluate(finalizeOverlaysInPage);
|
||||
assert.equal(res.removed, 0, "no removal off a locked page — the z>=100 floor guards legit fixed content");
|
||||
assert.equal(res.blocking, false);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("removes a promo popup mounted inside an OPEN shadow root and reports the host label", async () => {
|
||||
const page = await newPage(`
|
||||
<style>html,body{margin:0}body{overflow:hidden}</style>
|
||||
<main>content</main>
|
||||
<div id="recart-popup-root"></div>
|
||||
<script>
|
||||
const host = document.getElementById("recart-popup-root");
|
||||
const root = host.attachShadow({ mode: "open" });
|
||||
const layer = document.createElement("div");
|
||||
layer.style.cssText = "position:fixed;inset:0;width:${VW}px;height:${VH}px;z-index:999;background:rgba(0,0,0,.6)";
|
||||
layer.textContent = "Enjoy 15% off";
|
||||
root.appendChild(layer);
|
||||
</script>
|
||||
`);
|
||||
const res = await page.evaluate(finalizeOverlaysInPage);
|
||||
assert.equal(res.removed, 1, "the shadow-hosting popup element is removed as one unit");
|
||||
const gone = await page.evaluate(() => !document.getElementById("recart-popup-root"));
|
||||
assert.ok(gone, "the shadow host is gone from the light DOM");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("reports blocking=true when the scroll-lock persists even with NO overlay detected (loud failure)", async () => {
|
||||
// Body is scroll-locked but there is no fixed full-viewport layer to find (e.g. a closed
|
||||
// shadow root we cannot pierce). A silently-locked capture is polluted → surface it.
|
||||
const page = await newPage(`
|
||||
<style>html,body{margin:0}body{overflow:hidden;position:absolute;height:${VH}px}</style>
|
||||
<main>content</main>
|
||||
`);
|
||||
const res = await page.evaluate(finalizeOverlaysInPage);
|
||||
assert.equal(res.removed, 0);
|
||||
assert.equal(res.blocking, true, "a persistent lock with nothing removable still reports blocking");
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("reports blocking=false and removes nothing on an ordinary unlocked page", async () => {
|
||||
const page = await newPage(`
|
||||
<style>html,body{margin:0}</style>
|
||||
<header>nav</header><main>lots of content</main><footer>footer</footer>
|
||||
`);
|
||||
const res = await page.evaluate(finalizeOverlaysInPage);
|
||||
assert.equal(res.removed, 0);
|
||||
assert.equal(res.blocking, false);
|
||||
assert.equal(res.overlaysRemaining, 0);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
it("protects real sticky page chrome (header/nav) from removal even when detected", async () => {
|
||||
// A scroll-locked page with a sticky header that happens to be tall — the header is PROTECTED,
|
||||
// and it isn't full-viewport, so it never even qualifies. Assert nothing is stripped.
|
||||
const page = await newPage(`
|
||||
<style>
|
||||
html,body{margin:0}body{overflow:hidden}
|
||||
#masthead{position:sticky;top:0;width:${VW}px;height:80px;z-index:1000;background:#fff}
|
||||
</style>
|
||||
<header id="masthead">site chrome</header>
|
||||
<main>content</main>
|
||||
`);
|
||||
const res = await page.evaluate(finalizeOverlaysInPage);
|
||||
const present = await page.evaluate(() => !!document.getElementById("masthead"));
|
||||
assert.ok(present, "the header survives");
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// clickDismissInPage — decline/close matchers, shadow-root traversal.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("capture: clickDismissInPage matchers", () => {
|
||||
let browser: Browser;
|
||||
before(async () => { browser = await chromium.launch(); });
|
||||
after(async () => { await browser.close(); });
|
||||
|
||||
const run = async (html: string): Promise<string[]> => {
|
||||
const page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(html);
|
||||
await page.evaluate(ESBUILD_SHIM);
|
||||
const res = await page.evaluate(clickDismissInPage);
|
||||
await page.close();
|
||||
return res;
|
||||
};
|
||||
|
||||
it("clicks a Decline button inside an overlay container (email-capture opt-out)", async () => {
|
||||
const dismissed = await run(`
|
||||
<div class="signup-overlay" style="position:fixed;inset:0;width:400px;height:400px">
|
||||
<button onclick="window.__declined=true">Decline</button>
|
||||
</div>
|
||||
`);
|
||||
assert.ok(dismissed.includes("text:decline"), `clicked Decline (got ${JSON.stringify(dismissed)})`);
|
||||
});
|
||||
|
||||
it("clicks an aria-label close (×) inside an overlay container when no accept/decline text matches", async () => {
|
||||
const dismissed = await run(`
|
||||
<div id="promo-popup" style="position:fixed;inset:0;width:400px;height:400px">
|
||||
<button aria-label="Close dialog">×</button>
|
||||
</div>
|
||||
`);
|
||||
assert.ok(dismissed.some((d) => d.startsWith("close:")), `clicked the aria close (got ${JSON.stringify(dismissed)})`);
|
||||
});
|
||||
|
||||
it("matches an overlay/ccpa/privacy container id/class (extended container selector)", async () => {
|
||||
const dismissed = await run(`
|
||||
<div id="ccpaPop" style="position:fixed;inset:0;width:400px;height:400px">
|
||||
<button>Accept</button>
|
||||
</div>
|
||||
`);
|
||||
assert.ok(dismissed.includes("text:accept"), `matched the ccpa container (got ${JSON.stringify(dismissed)})`);
|
||||
});
|
||||
|
||||
it("traverses an open shadow root to find the close control", async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.addInitScript(ESBUILD_SHIM);
|
||||
await page.setContent(`<div id="host"></div>`);
|
||||
await page.evaluate(ESBUILD_SHIM);
|
||||
await page.evaluate(() => {
|
||||
const host = document.getElementById("host")!;
|
||||
const root = host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `<div class="popup-modal" style="position:fixed;inset:0;width:400px;height:400px"><button aria-label="close">×</button></div>`;
|
||||
});
|
||||
const dismissed = await page.evaluate(clickDismissInPage);
|
||||
await page.close();
|
||||
assert.ok(dismissed.some((d) => d.startsWith("close:")), `found the shadow-root close (got ${JSON.stringify(dismissed)})`);
|
||||
});
|
||||
|
||||
it("never clicks an ordinary page button outside any overlay container", async () => {
|
||||
const dismissed = await run(`<main><button>Accept</button><a>Continue</a></main>`);
|
||||
assert.deepEqual(dismissed, [], "no overlay container ⇒ no clicks");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// irContentExtent — pure geometry (unclamp / pollution trigger).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("normalize: irContentExtent", () => {
|
||||
const bbox = (x: number, y: number, w: number, h: number): BBox => ({ x, y, width: w, height: h });
|
||||
const style = (over: Partial<StyleMap> = {}): StyleMap => ({ position: "static", ...over } as StyleMap);
|
||||
const node = (over: Partial<IRNode>): IRNode => ({
|
||||
id: "n", tag: "div", attrs: {},
|
||||
visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 0, 100, 100) },
|
||||
computedByVp: { 1280: style() }, children: [], ...over,
|
||||
});
|
||||
|
||||
it("returns the max in-flow descendant border-box bottom", () => {
|
||||
const root = node({
|
||||
tag: "body",
|
||||
children: [
|
||||
node({ bboxByVp: { 1280: bbox(0, 0, 1280, 800) } }),
|
||||
node({ bboxByVp: { 1280: bbox(0, 800, 1280, 4398) } }), // footer bottom = 5198
|
||||
],
|
||||
});
|
||||
assert.equal(irContentExtent(root, 1280), 5198);
|
||||
});
|
||||
|
||||
it("excludes out-of-flow (fixed/absolute) and floated boxes", () => {
|
||||
const root = node({
|
||||
tag: "body",
|
||||
children: [
|
||||
node({ bboxByVp: { 1280: bbox(0, 0, 1280, 900) } }),
|
||||
node({ computedByVp: { 1280: style({ position: "fixed" }) }, bboxByVp: { 1280: bbox(0, 0, 1280, 9999) } }),
|
||||
node({ computedByVp: { 1280: style({ float: "left" } as Partial<StyleMap>) }, bboxByVp: { 1280: bbox(0, 0, 1280, 8888) } }),
|
||||
],
|
||||
});
|
||||
assert.equal(irContentExtent(root, 1280), 900, "fixed + float extents ignored");
|
||||
});
|
||||
|
||||
it("descends into nested in-flow subtrees", () => {
|
||||
const root = node({
|
||||
tag: "body",
|
||||
children: [node({ children: [node({ bboxByVp: { 1280: bbox(0, 0, 500, 3000) } })] })],
|
||||
});
|
||||
assert.equal(irContentExtent(root, 1280), 3000);
|
||||
});
|
||||
|
||||
it("ignores nodes invisible at the queried viewport", () => {
|
||||
const root = node({
|
||||
tag: "body",
|
||||
children: [node({ visibleByVp: { 1280: false }, bboxByVp: { 1280: bbox(0, 0, 100, 5000) } })],
|
||||
});
|
||||
assert.equal(irContentExtent(root, 1280), 0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// gatePollution — scroll-locked-capture contradiction.
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("validate: pollution gate scroll-locked contradiction", () => {
|
||||
const bbox = (x: number, y: number, w: number, h: number): BBox => ({ x, y, width: w, height: h });
|
||||
const st = (over: Partial<StyleMap> = {}): StyleMap => ({ position: "static", ...over } as StyleMap);
|
||||
// A body with plenty of text and a footer at y=5198 (content extent ~6.4 viewports @800).
|
||||
const mkIr = (): IR => ({
|
||||
doc: {
|
||||
sourceUrl: "https://x.test", title: "t", lang: "en", charset: "UTF-8", metaViewport: "",
|
||||
viewports: [1280], sampleViewports: [1280], canonicalViewport: 1280,
|
||||
perViewport: { 1280: { scrollHeight: 800, scrollWidth: 1280, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
|
||||
nodeCount: 200, keyframes: [],
|
||||
},
|
||||
root: {
|
||||
id: "n0", tag: "body", attrs: {}, visibleByVp: { 1280: true },
|
||||
bboxByVp: { 1280: bbox(0, 0, 1280, 800) }, computedByVp: { 1280: st() },
|
||||
children: [
|
||||
{ id: "n1", tag: "section", attrs: {}, visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 0, 1280, 800) }, computedByVp: { 1280: st() },
|
||||
children: [{ text: "hero copy ".repeat(20) }] },
|
||||
{ id: "n2", tag: "footer", attrs: {}, visibleByVp: { 1280: true }, bboxByVp: { 1280: bbox(0, 4074, 1280, 1124) }, computedByVp: { 1280: st() },
|
||||
children: [{ text: "footer copy ".repeat(20) }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
const mkCapture = (scrollHeight: number): CaptureResult => ({
|
||||
perViewport: [
|
||||
{ viewport: 375, height: 812, scrollHeight: 812, nodeCount: 200, truncated: false },
|
||||
{ viewport: 1280, height: 800, scrollHeight, nodeCount: 200, truncated: false },
|
||||
],
|
||||
dismissal: { dismissed: [], overlaysRemaining: 0, removed: 0, videoStills: 0, blocking: false },
|
||||
} as unknown as CaptureResult);
|
||||
|
||||
it("FAILS when scrollHeight is pinned to ~1 viewport at every width but IR content spans multiple viewports", () => {
|
||||
const res = gatePollution(mkIr(), mkCapture(800), [1280]);
|
||||
assert.equal(res.pass, false, "the scroll-locked contradiction is caught");
|
||||
assert.ok(res.issues.some((i) => i.includes("scroll-locked capture")), `issue reported (got ${JSON.stringify(res.issues)})`);
|
||||
});
|
||||
|
||||
it("PASSES when scrollHeight matches the tall content (a genuinely scrollable page)", () => {
|
||||
const res = gatePollution(mkIr(), mkCapture(5198), [1280]);
|
||||
assert.ok(!res.issues.some((i) => i.includes("scroll-locked capture")), "no false positive when the page really scrolled");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
inlineBlobBytes,
|
||||
filePayload,
|
||||
whitespaceLiterals,
|
||||
subpixelArbitraries,
|
||||
customPropTokens,
|
||||
probeArtifacts,
|
||||
uncleanedListeners,
|
||||
tagHistogram,
|
||||
ariaHiddenFocusables,
|
||||
duplicateHelpers,
|
||||
svgPathDuplication,
|
||||
nearDuplicateComponents,
|
||||
componentNames,
|
||||
toLetter,
|
||||
scoreApp,
|
||||
type SrcFile,
|
||||
} from "../src/runner/qualityScore.js";
|
||||
|
||||
const srcFile = (rel: string, text: string): SrcFile => ({
|
||||
path: rel, rel, ext: rel.slice(rel.lastIndexOf(".")), text, lines: text.split("\n").length,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metric extractors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("inlineBlobBytes", () => {
|
||||
it("counts base64 data-URI payload bytes", () => {
|
||||
const b64 = "A".repeat(500);
|
||||
const text = `const img = "data:image/png;base64,${b64}";`;
|
||||
assert.equal(inlineBlobBytes(text), 500);
|
||||
});
|
||||
|
||||
it("counts a long markup string handed in as a prop", () => {
|
||||
const html = "<div>" + "<span>x</span>".repeat(250) + "</div>";
|
||||
const text = `<Frame html={${JSON.stringify(html)}} />`;
|
||||
assert.ok(inlineBlobBytes(text) > 1000, "flags HTML-as-string blob");
|
||||
});
|
||||
|
||||
it("ignores ordinary short strings", () => {
|
||||
assert.equal(inlineBlobBytes(`const s = "hello world";`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filePayload", () => {
|
||||
it("reports a giant single line", () => {
|
||||
const giant = "x".repeat(200_000);
|
||||
const p = filePayload(srcFile("a.tsx", `const a = 1;\nconst b = "${giant}";\n`));
|
||||
assert.ok(p.maxLine >= 200_000, "detects the giant line");
|
||||
assert.ok(p.bytes >= 200_000, "counts the bytes");
|
||||
});
|
||||
|
||||
it("a normal formatted file has a small max line", () => {
|
||||
const p = filePayload(srcFile("a.tsx", "const a = 1;\nconst b = 2;\nexport default a;\n"));
|
||||
assert.ok(p.maxLine < 40, "small max line");
|
||||
});
|
||||
});
|
||||
|
||||
describe("whitespaceLiterals", () => {
|
||||
it("counts {\" \"} capture-whitespace literals", () => {
|
||||
const text = `<p>Hi{" "}there{" "}world{' '}!</p>`;
|
||||
assert.equal(whitespaceLiterals(text), 3);
|
||||
});
|
||||
|
||||
it("does not flag meaningful expression literals", () => {
|
||||
assert.equal(whitespaceLiterals(`<p>{name}{count}</p>`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subpixelArbitraries", () => {
|
||||
it("flags non-integer px/rem arbitraries but not whole ones", () => {
|
||||
// 713.938px (frozen) + 12.5rem (=200px, whole) → only the first counts.
|
||||
const text = `<div className="w-[713.938px] h-[12.5rem] p-[16px]" />`;
|
||||
assert.equal(subpixelArbitraries(text), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("customPropTokens", () => {
|
||||
it("splits opaque --clr-N / hash tokens from named ones", () => {
|
||||
const css = `:root{ --clr-7:#fff; --c12:#000; --a1b2c3:#111; --brand-primary:#f00; --space-4:1rem; }`;
|
||||
const t = customPropTokens(css);
|
||||
assert.equal(t.total, 5);
|
||||
assert.equal(t.opaque, 3, "clr-7, c12, hash a1b2c3 are opaque; brand-primary/space-4 are named");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeArtifacts", () => {
|
||||
it("flags off-screen capture-probe scaffolding", () => {
|
||||
const text = `<span data-probe="1">m</span><i style={{clip: 'rect(0 0 0 0)'}} />`;
|
||||
assert.ok(probeArtifacts(text) >= 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("uncleanedListeners", () => {
|
||||
it("counts addEventListener with no cleanup", () => {
|
||||
const text = `el.addEventListener("scroll", fn);\nwin.addEventListener("resize", fn);`;
|
||||
assert.equal(uncleanedListeners(text), 2);
|
||||
});
|
||||
|
||||
it("does not flag when a matching removeEventListener / teardown exists", () => {
|
||||
const text = `useEffect(() => { el.addEventListener("scroll", fn); return () => el.removeEventListener("scroll", fn); });`;
|
||||
assert.equal(uncleanedListeners(text), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagHistogram", () => {
|
||||
it("counts opening element tags by name", () => {
|
||||
const h = tagHistogram(`<div><div/><span>x</span><button>b</button></div>`);
|
||||
assert.equal(h["div"], 2);
|
||||
assert.equal(h["span"], 1);
|
||||
assert.equal(h["button"], 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ariaHiddenFocusables", () => {
|
||||
it("flags aria-hidden on a focusable element", () => {
|
||||
const text = `<button aria-hidden="true">x</button><a aria-hidden={true} href="#">y</a>`;
|
||||
assert.equal(ariaHiddenFocusables(text), 2);
|
||||
});
|
||||
|
||||
it("ignores aria-hidden on a decorative div", () => {
|
||||
assert.equal(ariaHiddenFocusables(`<div aria-hidden="true" />`), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("duplicateHelpers", () => {
|
||||
it("counts copy-paste helper bodies (e.g. a repeated cn())", () => {
|
||||
const body = `{ return classes.filter(Boolean).join(" ").replace(/\\s+/g, " ").trim(); }`;
|
||||
const text = `function cn(...classes) ${body}\nfunction cx(...classes) ${body}\nfunction merge(...classes) ${body}`;
|
||||
const d = duplicateHelpers(text);
|
||||
assert.equal(d.defs, 3);
|
||||
assert.equal(d.dups, 2, "two of the three are identical duplicates");
|
||||
});
|
||||
|
||||
it("does not flag distinct helper bodies", () => {
|
||||
const text = `function a(x) { return x + 1111111111; }\nfunction b(x) { return x - 2222222222; }`;
|
||||
assert.equal(duplicateHelpers(text).dups, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("svgPathDuplication", () => {
|
||||
it("counts repeated inline <path d=...> strings", () => {
|
||||
const d = "M10 10 L20 20 L30 30 Z aaaaaaaaaaaaaaaa";
|
||||
const text = `<path d="${d}"/><path d="${d}"/><path d="M1 1 L2 2 different pathhhhhhhh"/>`;
|
||||
const r = svgPathDuplication(text);
|
||||
assert.equal(r.total, 3);
|
||||
assert.equal(r.repeats, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("nearDuplicateComponents", () => {
|
||||
it("counts pairs sharing a tag signature", () => {
|
||||
assert.equal(nearDuplicateComponents(["div:2,span:1", "div:2,span:1", "nav:1"]), 1);
|
||||
assert.equal(nearDuplicateComponents(["a", "a", "a"]), 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("componentNames", () => {
|
||||
it("extracts exported + declared component symbols", () => {
|
||||
const text = `export default function HeroSection(){}\nexport function Footer(){}\nconst Navbar = () => {};`;
|
||||
const names = componentNames(text);
|
||||
assert.ok(names.includes("HeroSection"));
|
||||
assert.ok(names.includes("Footer"));
|
||||
assert.ok(names.includes("Navbar"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("toLetter", () => {
|
||||
it("maps scores to the expected grade bands", () => {
|
||||
assert.equal(toLetter(95), "A");
|
||||
assert.equal(toLetter(82), "B-");
|
||||
assert.equal(toLetter(75), "C");
|
||||
assert.equal(toLetter(67), "D+");
|
||||
assert.equal(toLetter(59), "F");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// scoreApp — end-to-end on synthetic app trees (generic fixture content)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTree(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "qs-"));
|
||||
for (const [rel, text] of Object.entries(files)) {
|
||||
const p = join(root, rel);
|
||||
mkdirSync(p.slice(0, p.lastIndexOf("/")), { recursive: true });
|
||||
writeFileSync(p, text);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
describe("scoreApp — hard cap on catastrophic payload", () => {
|
||||
it("caps a tree containing a multi-megabyte source file into D-range", () => {
|
||||
const giant = "x".repeat(1_200_000); // >1MB single file
|
||||
const root = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>Hi</h1><section><p>ok</p></section></main>; }`,
|
||||
"src/app/svgs/blob.tsx": `export const blob = "${giant}";`,
|
||||
});
|
||||
try {
|
||||
const rep = scoreApp(root);
|
||||
assert.ok(rep.caps.length > 0, "a catastrophe cap is recorded");
|
||||
assert.ok(rep.total <= 68, `grade is capped into D-range, got ${rep.total}`);
|
||||
assert.ok(["D+", "D", "D-", "F"].includes(rep.grade), `grade ${rep.grade} is D-range or below`);
|
||||
assert.ok(rep.categories.payload!.score < rep.categories.payload!.max * 0.5, "payload dimension collapses");
|
||||
} finally { rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreApp — semantics", () => {
|
||||
it("penalizes a page with no h1 vs one with a single h1", () => {
|
||||
const withH1 = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>Title</h1><section><p>a</p></section><nav><a href="#">x</a></nav></main>; }`,
|
||||
});
|
||||
const noH1 = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <div><div><div><div><span>a</span></div></div></div></div>; }`,
|
||||
});
|
||||
try {
|
||||
const a = scoreApp(withH1);
|
||||
const b = scoreApp(noH1);
|
||||
assert.ok(a.categories.semantics!.score > b.categories.semantics!.score, "h1 + semantic tags score higher");
|
||||
assert.equal(b.categories.semantics!.metrics.h1, 0);
|
||||
} finally {
|
||||
rmSync(withH1, { recursive: true, force: true });
|
||||
rmSync(noH1, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("scoreApp — decomposition", () => {
|
||||
it("rates a decomposed tree above a single-file monolith of the same markup", () => {
|
||||
const bodyTags = "<div><p>x</p><a href='#'>l</a></div>".repeat(40);
|
||||
const monolith = makeTree({
|
||||
"src/app/page.tsx": `export default function Page(){ return <main><h1>H</h1>${bodyTags}</main>; }`,
|
||||
});
|
||||
const decomposed = makeTree({
|
||||
"src/app/page.tsx": `import Hero from "./sections/hero";\nimport Feature from "./sections/feature";\nexport default function Page(){ return <main><h1>H</h1><Hero/><Feature/></main>; }`,
|
||||
"src/app/sections/hero.tsx": `export function Hero(){ return <section>${"<div><p>x</p></div>".repeat(20)}</section>; }`,
|
||||
"src/app/sections/feature.tsx": `export function Feature(){ return <section>${"<div><a href='#'>l</a></div>".repeat(20)}</section>; }`,
|
||||
});
|
||||
try {
|
||||
const m = scoreApp(monolith);
|
||||
const d = scoreApp(decomposed);
|
||||
assert.ok(d.categories.decomposition!.score > m.categories.decomposition!.score, "decomposed scores higher on decomposition");
|
||||
assert.ok(d.total > m.total, "and grades higher overall");
|
||||
} finally {
|
||||
rmSync(monolith, { recursive: true, force: true });
|
||||
rmSync(decomposed, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
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 { RecipeReport, RecipeCandidate, RecipeResponsiveRegime } from "../src/infer/recipes.js";
|
||||
import { recipeResponsiveClassCleaner } from "../src/generate/app.js";
|
||||
|
||||
// The recipe pass infers a container's column count by grouping item bounding boxes into rows and
|
||||
// taking the widest row. When an item spans multiple tracks that under-reports the real track count,
|
||||
// so the synthesized column plan must NOT override the authored/computed grid geometry that the
|
||||
// Tailwind emitter already baked into the className. These fixtures build a 3-track grid whose main
|
||||
// card spans 2 columns (heuristic reports 2 columns) and assert the emitted classes keep 3 tracks
|
||||
// and the span.
|
||||
|
||||
const VPS = [768, 1280];
|
||||
|
||||
function computed(over: StyleMap = {}): StyleMap {
|
||||
return { display: "block", position: "static", visibility: "visible", whiteSpace: "normal", ...over };
|
||||
}
|
||||
|
||||
function node(id: string, tag: string, byVp: Record<number, StyleMap>, children: IRChild[] = []): IRNode {
|
||||
const computedByVp: Record<number, StyleMap> = {};
|
||||
const bboxByVp: Record<number, BBox> = {};
|
||||
const visibleByVp: Record<number, boolean> = {};
|
||||
for (const vp of VPS) {
|
||||
computedByVp[vp] = { ...computed(), ...(byVp[vp] ?? {}) };
|
||||
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/",
|
||||
title: "Fixture",
|
||||
lang: "en",
|
||||
charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: VPS,
|
||||
sampleViewports: VPS,
|
||||
canonicalViewport: 1280,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function regime(viewport: number, columns: number, visibleItems: number): RecipeResponsiveRegime {
|
||||
return { viewport, layout: "grid", rootBox: { x: 0, y: 0, width: viewport, height: 400 }, visibleItems, columns, rows: 1 };
|
||||
}
|
||||
|
||||
function candidate(over: Partial<RecipeCandidate>): RecipeCandidate {
|
||||
return {
|
||||
id: "r0",
|
||||
kind: "product-grid",
|
||||
confidence: 0.89,
|
||||
risk: "low",
|
||||
rootCid: "n0",
|
||||
rootTag: "section",
|
||||
itemParentCid: "n1",
|
||||
componentName: "ProductGridSection",
|
||||
itemCount: 2,
|
||||
repeatedItems: [],
|
||||
responsiveRegimes: [regime(768, 2, 2), regime(1280, 2, 2)],
|
||||
sourceHints: [],
|
||||
signals: [],
|
||||
emissionStatus: "report-only",
|
||||
fallbackReason: "",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function report(candidates: RecipeCandidate[]): RecipeReport {
|
||||
return {
|
||||
version: 1,
|
||||
sourceUrl: "https://example.test/",
|
||||
canonicalViewport: 1280,
|
||||
viewports: VPS,
|
||||
sampledViewports: VPS,
|
||||
summary: { totalCandidates: candidates.length, highConfidence: candidates.length, byKind: {}, templateReadyKinds: [] },
|
||||
candidates,
|
||||
};
|
||||
}
|
||||
|
||||
// A 3-track grid: the main card spans 2 tracks (`grid-column: 1 / 3`) and a photo occupies track 3.
|
||||
// The item-count heuristic groups the two items into one row → columns = 2, which disagrees with the
|
||||
// computed 3 tracks.
|
||||
function spanningGridIr(): IR {
|
||||
const threeTracks = "284px 284px 284px";
|
||||
const card = node("n2", "article", {
|
||||
768: { gridColumnStart: "1", gridColumnEnd: "3" },
|
||||
1280: { gridColumnStart: "1", gridColumnEnd: "3" },
|
||||
});
|
||||
const photo = node("n3", "img", {
|
||||
768: { gridColumnStart: "3", gridColumnEnd: "4" },
|
||||
1280: { gridColumnStart: "3", gridColumnEnd: "4" },
|
||||
});
|
||||
const parent = node("n1", "div", {
|
||||
768: { display: "grid", gridTemplateColumns: threeTracks },
|
||||
1280: { display: "grid", gridTemplateColumns: threeTracks },
|
||||
}, [card, photo]);
|
||||
return irWith(node("n0", "section", {}, [parent]));
|
||||
}
|
||||
|
||||
describe("recipe grid geometry: computed tracks/spans are ground truth", () => {
|
||||
it("does not override a 3-track grid with a heuristic 2-column plan (span-2 item present)", () => {
|
||||
const ir = spanningGridIr();
|
||||
const c = candidate({ itemParentCid: "n1", repeatedItems: [
|
||||
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 568, height: 300 } },
|
||||
{ cid: "n3", tag: "img", textSample: "", mediaCount: 1, headingCount: 0, bbox: { x: 584, y: 0, width: 284, height: 300 } },
|
||||
] });
|
||||
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
|
||||
|
||||
// The Tailwind emitter has already put the authored 3-track grid on the container className.
|
||||
const containerIn = "grid grid-cols-3 gap-4";
|
||||
const containerOut = clean("n1", containerIn)!.split(/\s+/);
|
||||
assert.ok(containerOut.includes("grid-cols-3"), "authored 3-track grid-cols-3 survives");
|
||||
assert.ok(!containerOut.some((t) => /(?:^|:)grid-cols-2$/.test(t)), "no synthesized grid-cols-2 override");
|
||||
// No responsive column-plan tokens are appended (the plan was rejected as untrustworthy).
|
||||
assert.ok(!containerOut.some((t) => /^(?:md|lg|2xl):grid-cols-/.test(t)), "no responsive grid-cols plan appended");
|
||||
|
||||
// The span-2 item keeps its authored column span (emitter tokens pass through untouched).
|
||||
const itemOut = clean("n2", "col-start-1 col-end-3 flex flex-col")!;
|
||||
assert.equal(itemOut, "col-start-1 col-end-3 flex flex-col", "span-2 item className is unchanged");
|
||||
});
|
||||
|
||||
it("does not override an ASYMMETRIC 2-track sidebar grid with a grid-cols-2 plan", () => {
|
||||
// A sidebar layout: `grid-template-columns: 260px 1020px` (authored `260px 1fr`). The item-count
|
||||
// heuristic sees 2 items in one row → 2 columns, which agrees with the 2 computed tracks by COUNT.
|
||||
// But `grid-cols-2` = two EQUAL 640px tracks, which destroys the 260/1020 geometry. The plan must be
|
||||
// rejected and the authored template kept.
|
||||
const sidebar = node("n2", "aside", {
|
||||
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
});
|
||||
const main = node("n3", "div", {
|
||||
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
});
|
||||
const parent = node("n1", "div", {
|
||||
768: { display: "grid", gridTemplateColumns: "220px 500px" },
|
||||
1280: { display: "grid", gridTemplateColumns: "260px 1020px" },
|
||||
}, [sidebar, main]);
|
||||
const ir = irWith(node("n0", "section", {}, [parent]));
|
||||
const c = candidate({
|
||||
itemParentCid: "n1",
|
||||
itemCount: 2,
|
||||
responsiveRegimes: [regime(768, 2, 2), regime(1280, 2, 2)],
|
||||
repeatedItems: [
|
||||
{ cid: "n2", tag: "aside", textSample: "", mediaCount: 0, headingCount: 0, bbox: { x: 0, y: 0, width: 260, height: 300 } },
|
||||
{ cid: "n3", tag: "div", textSample: "", mediaCount: 0, headingCount: 1, bbox: { x: 276, y: 0, width: 1020, height: 300 } },
|
||||
],
|
||||
});
|
||||
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
|
||||
// The emitter baked the authored asymmetric template as an arbitrary grid-template-columns utility.
|
||||
const containerIn = "grid grid-cols-[260px_1020px] gap-6";
|
||||
const out = clean("n1", containerIn)!.split(/\s+/);
|
||||
assert.ok(out.includes("grid-cols-[260px_1020px]"), `authored asymmetric template must survive, got: ${out.join(" ")}`);
|
||||
assert.ok(!out.some((t) => /(?:^|:)grid-cols-2$/.test(t)), `no equal-halves grid-cols-2 override, got: ${out.join(" ")}`);
|
||||
assert.ok(!out.some((t) => /^(?:md|lg|2xl):grid-cols-/.test(t)), `no responsive grid-cols plan appended, got: ${out.join(" ")}`);
|
||||
});
|
||||
|
||||
it("still re-flows a genuinely uniform grid whose computed tracks match the heuristic", () => {
|
||||
// 2-track grid at 768, 3-track at 1280, no spanning items → heuristic agrees with computed.
|
||||
const item = (id: string): IRNode => node(id, "article", {
|
||||
768: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
1280: { gridColumnStart: "auto", gridColumnEnd: "auto" },
|
||||
});
|
||||
const parent = node("n1", "div", {
|
||||
768: { display: "grid", gridTemplateColumns: "300px 300px" },
|
||||
1280: { display: "grid", gridTemplateColumns: "284px 284px 284px" },
|
||||
}, [item("n2"), item("n3"), item("n4")]);
|
||||
const ir = irWith(node("n0", "section", {}, [parent]));
|
||||
const c = candidate({
|
||||
itemParentCid: "n1",
|
||||
itemCount: 3,
|
||||
responsiveRegimes: [regime(768, 2, 3), regime(1280, 3, 3)],
|
||||
repeatedItems: [
|
||||
{ cid: "n2", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 0, width: 300, height: 300 } },
|
||||
{ cid: "n3", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 316, y: 0, width: 300, height: 300 } },
|
||||
{ cid: "n4", tag: "article", textSample: "", mediaCount: 1, headingCount: 1, bbox: { x: 0, y: 316, width: 300, height: 300 } },
|
||||
],
|
||||
});
|
||||
const clean = recipeResponsiveClassCleaner(ir, report([c]), { tailwind: true });
|
||||
const out = clean("n1", "grid grid-cols-2 gap-4")!.split(/\s+/);
|
||||
// Geometry agreed → the responsive plan is applied: base 2 columns, lg:3 at the wider viewport.
|
||||
assert.ok(out.includes("grid-cols-2"), "base column count applied");
|
||||
assert.ok(out.includes("lg:grid-cols-3"), "responsive column bump applied");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import type { IR, IRNode } from "../src/normalize/ir.js";
|
||||
import type { MotionCapture } from "../src/capture/motion.js";
|
||||
import { captureMotion } from "../src/capture/motion.js";
|
||||
import { buildMotionSpec, DITTO_MOTION_TSX } from "../src/generate/motion.js";
|
||||
|
||||
// A rotator cycles the text of a single LEAF word/phrase. A container that bears element
|
||||
// children must never be treated as a rotator: the runtime replaces its content on each swap,
|
||||
// so classifying a structural panel as a rotator would flatten its whole subtree to one text
|
||||
// node. This regression guards all three defense layers against that misclassification.
|
||||
|
||||
// ---- Minimal IR factory (only the fields buildMotionSpec reads: id, attrs, children) ----
|
||||
function node(id: string, cap: string | null, children: IRNode[] = [], text?: string): IRNode {
|
||||
const kids = text !== undefined ? ([{ text }] as unknown as IRNode[]) : children;
|
||||
return {
|
||||
id,
|
||||
tag: "div",
|
||||
attrs: cap !== null ? { "data-cid-cap": cap } : {},
|
||||
visibleByVp: {},
|
||||
bboxByVp: {},
|
||||
computedByVp: {},
|
||||
children: kids,
|
||||
} as unknown as IRNode;
|
||||
}
|
||||
|
||||
function ir(root: IRNode): IR {
|
||||
return { doc: { canonicalViewport: 1280 } as unknown as IR["doc"], root };
|
||||
}
|
||||
|
||||
function motionWith(rotators: MotionCapture["rotators"]): MotionCapture {
|
||||
return { waapi: [], rotators, reveals: [], marquees: [] };
|
||||
}
|
||||
|
||||
describe("rotator misclassification guard — layer 1 (emission)", () => {
|
||||
it("emits a rotator whose target is a genuine text leaf (no element children)", () => {
|
||||
const tree = ir(node("n0", null, [node("n1", "5", [], "Design")]));
|
||||
const spec = buildMotionSpec(tree, motionWith([{ cap: "5", texts: ["Design", "Build"], intervalMs: 900 }]));
|
||||
assert.equal(spec.rotators.length, 1, "leaf rotator survives emission");
|
||||
assert.equal(spec.rotators[0]!.cid, "n1");
|
||||
});
|
||||
|
||||
it("DROPS a rotator whose target IR node has element children (structural container)", () => {
|
||||
// n1 is capped and was classified as a rotator, but it holds real element rows (n2, n3).
|
||||
const container = node("n1", "5", [
|
||||
node("n2", null, [], "row one"),
|
||||
node("n3", null, [], "row two"),
|
||||
]);
|
||||
const tree = ir(node("n0", null, [container]));
|
||||
const spec = buildMotionSpec(tree, motionWith([{ cap: "5", texts: ["a", "b"], intervalMs: 240 }]));
|
||||
assert.equal(spec.rotators.length, 0, "element-bearing container is not emitted as a rotator");
|
||||
});
|
||||
|
||||
it("keeps genuine leaf rotators while dropping a sibling container in the same spec", () => {
|
||||
const tree = ir(node("n0", null, [
|
||||
node("n1", "5", [], "Design"), // genuine leaf
|
||||
node("n2", "6", [node("n3", null, [], "row")]), // container → dropped
|
||||
]));
|
||||
const spec = buildMotionSpec(tree, motionWith([
|
||||
{ cap: "5", texts: ["Design", "Build"], intervalMs: 900 },
|
||||
{ cap: "6", texts: ["x", "y"], intervalMs: 240 },
|
||||
]));
|
||||
assert.equal(spec.rotators.length, 1);
|
||||
assert.equal(spec.rotators[0]!.cid, "n1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotator misclassification guard — layer 3 (non-destructive runtime)", () => {
|
||||
it("saves & restores the target's child NODES, never a flattened textContent string", () => {
|
||||
// Save path: the original child nodes are cloned (structure preserved), not read as a string.
|
||||
assert.match(DITTO_MOTION_TSX, /Array\.from\(el\.childNodes\)\.map\(\(n\) => n\.cloneNode\(true\)\)/);
|
||||
// Restore path: rebuild via replaceChildren with cloned nodes — no `textContent = r.original`.
|
||||
assert.match(DITTO_MOTION_TSX, /r\.el\.replaceChildren\(\.\.\.r\.original\.map\(\(n\) => n\.cloneNode\(true\)\)\)/);
|
||||
assert.doesNotMatch(DITTO_MOTION_TSX, /r\.el\.textContent = r\.original/, "no lossy textContent restore remains");
|
||||
});
|
||||
|
||||
it("refuses to install a rotator on an element that has element children at runtime", () => {
|
||||
assert.match(DITTO_MOTION_TSX, /if \(el\.childElementCount > 0\) continue;/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotator misclassification guard — layer 2 (capture leaf guard)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
it("does not record an element-bearing container (runtime-injected uncapped rows) as a rotator", async () => {
|
||||
// #panel is capped BEFORE its rows are injected (mirrors cid-cap tagging preceding the site's
|
||||
// late row appends). Its rows are uncapped. #word is a genuine capped leaf cycling text.
|
||||
await page.setContent(`<!doctype html><html><body>
|
||||
<div id="panel" data-cid-cap="10"></div>
|
||||
<span id="word" data-cid-cap="11">Design</span>
|
||||
<script>
|
||||
var glyphs = ["\\u2b22", "\\u2b21"], gi = 0;
|
||||
var panel = document.getElementById("panel"), rows = 0;
|
||||
// Append real (uncapped) element rows over time, and toggle a glyph inside them.
|
||||
setInterval(function () {
|
||||
if (rows < 4) {
|
||||
var r = document.createElement("div");
|
||||
r.className = "row";
|
||||
r.innerHTML = '<span class="g">' + glyphs[gi % 2] + '</span> line ' + rows;
|
||||
panel.appendChild(r); rows++;
|
||||
} else {
|
||||
gi++;
|
||||
var gs = panel.querySelectorAll(".g");
|
||||
for (var k = 0; k < gs.length; k++) gs[k].textContent = glyphs[gi % 2];
|
||||
}
|
||||
}, 120);
|
||||
// #word: a genuine leaf whose text content cycles.
|
||||
var words = ["Design", "Build", "Ship"], wi = 0, w = document.getElementById("word");
|
||||
setInterval(function () { wi = (wi + 1) % words.length; w.textContent = words[wi]; }, 200);
|
||||
</script>
|
||||
</body></html>`);
|
||||
// tsx/esbuild names the evaluated function; provide the helper it references in-page.
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
const motion = await captureMotion(page, { observeMs: 1600 });
|
||||
const caps = new Set(motion.rotators.map((r) => r.cap));
|
||||
assert.ok(!caps.has("10"), "element-bearing panel is NOT recorded as a rotator");
|
||||
assert.ok(caps.has("11"), "genuine leaf word IS recorded as a rotator");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { detectSections, detectSectionNodes } from "../src/infer/sections.js";
|
||||
import { planSections } from "../src/generate/sectionSplit.js";
|
||||
import type { IR, IRNode, IRChild } from "../src/normalize/ir.js";
|
||||
|
||||
const CW = 1280;
|
||||
|
||||
type Box = { x?: number; y: number; width?: number; height: number };
|
||||
|
||||
/** An element node at the canonical viewport (ids assigned later in pre-order). */
|
||||
function el(tag: string, box: Box, children: IRChild[] = [], display = "block"): IRNode {
|
||||
return {
|
||||
id: "",
|
||||
tag,
|
||||
attrs: {},
|
||||
visibleByVp: { [CW]: true },
|
||||
bboxByVp: { [CW]: { x: box.x ?? 0, y: box.y, width: box.width ?? CW, height: box.height } },
|
||||
computedByVp: { [CW]: { display } },
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function text(t: string): IRChild {
|
||||
return { text: t };
|
||||
}
|
||||
|
||||
/** Wrap children in a <body> root and assign stable pre-order ids (n0, n1, …). */
|
||||
function page(children: IRNode[], pageH: number): IR {
|
||||
const root = el("body", { y: 0, height: pageH }, 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: pageH, scrollWidth: CW, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
|
||||
nodeCount: i, keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
/** A content band with a heading (so section naming has honest evidence). Bands get
|
||||
* `variant` extra paragraphs so adjacent one-off sections differ structurally — the
|
||||
* repeated-run filter (≥3 identical signatures → component cluster) must not fire. */
|
||||
function band(y: number, height: number, heading: string, variant = 0): IRNode {
|
||||
const extras: IRNode[] = [];
|
||||
for (let k = 0; k <= variant % 7; k++) {
|
||||
extras.push(el("p", { y: y + 80 + k * 30, height: 24, x: 120, width: 600 }, [text(`Body copy ${k}`)]));
|
||||
}
|
||||
return el("section", { y, height }, [
|
||||
el("h2", { y: y + 24, height: 40, x: 120, width: 600 }, [text(heading)]),
|
||||
...extras,
|
||||
]);
|
||||
}
|
||||
|
||||
/** navbar(62) + hero(800) + 3 bands + footer(400), flat under body. */
|
||||
function sixBandPage(): IR {
|
||||
const nav = el("nav", { y: 0, height: 62 });
|
||||
const hero = el("section", { y: 62, height: 800 }, [
|
||||
el("h1", { y: 120, height: 60, x: 120, width: 900 }, [text("Ship faster with widgets")]),
|
||||
]);
|
||||
const b1 = band(862, 600, "Trusted by teams", 0);
|
||||
const b2 = band(1462, 700, "Everything you need", 1);
|
||||
const b3 = band(2162, 500, "What customers say", 2);
|
||||
const footer = el("footer", { y: 2662, height: 400 }, [
|
||||
el("a", { y: 2700, height: 20, x: 120, width: 200, }, [text("Privacy")], "inline"),
|
||||
]);
|
||||
return page([nav, hero, b1, b2, b3, footer], 3062);
|
||||
}
|
||||
|
||||
/** Same bands, but body > div#root > (nav + main(13 bands) + footer) — the real-site shape. */
|
||||
function wrappedPage(bandCount = 13): IR {
|
||||
const nav = el("nav", { y: 0, height: 62 });
|
||||
const bandH = 900;
|
||||
const bands: IRNode[] = [];
|
||||
for (let k = 0; k < bandCount; k++) {
|
||||
bands.push(k === 0
|
||||
? el("section", { y: 62, height: bandH }, [el("h1", { y: 100, height: 60, x: 120, width: 900 }, [text("Design your future")])])
|
||||
: band(62 + k * bandH, bandH, `Feature area ${k}`, k));
|
||||
}
|
||||
const mainH = bandCount * bandH;
|
||||
const main = el("main", { y: 62, height: mainH }, bands);
|
||||
const footer = el("footer", { y: 62 + mainH, height: 400 });
|
||||
const pageH = 62 + mainH + 400;
|
||||
const wrapper = el("div", { y: 0, height: pageH }, [nav, main, footer]);
|
||||
return page([wrapper], pageH);
|
||||
}
|
||||
|
||||
describe("section decomposition (recursive descent)", () => {
|
||||
it("splits navbar + hero + 3 bands + footer into 6 sections", () => {
|
||||
const ir = sixBandPage();
|
||||
const sections = detectSections(ir);
|
||||
assert.equal(sections.length, 6);
|
||||
assert.equal(sections[0]!.role, "navbar");
|
||||
assert.equal(sections[1]!.role, "hero");
|
||||
assert.equal(sections[5]!.role, "footer");
|
||||
// the 62px navbar (below the old 64px bar) is still the navbar
|
||||
assert.equal(sections[0]!.bboxByVp[CW]!.height, 62);
|
||||
});
|
||||
|
||||
it("descends body > div > main wrappers to the real bands", () => {
|
||||
const ir = wrappedPage(13);
|
||||
const sections = detectSections(ir);
|
||||
assert.equal(sections.length, 15); // nav + 13 bands + footer
|
||||
assert.equal(sections[0]!.role, "navbar");
|
||||
assert.equal(sections[1]!.role, "hero");
|
||||
assert.equal(sections[14]!.role, "footer");
|
||||
// no wrapper survives as a section
|
||||
const tags = new Set(detectSectionNodes(ir).map((n) => n.tag));
|
||||
assert.ok(!tags.has("main") && !tags.has("body") && !tags.has("div"));
|
||||
});
|
||||
|
||||
it("sections tile the page top-to-bottom without gaps or overlaps", () => {
|
||||
const ir = wrappedPage(13);
|
||||
const boxes = detectSections(ir).map((s) => s.bboxByVp[CW]!).sort((a, b) => a.y - b.y);
|
||||
const pageH = ir.doc.perViewport[CW]!.scrollHeight;
|
||||
let cursor = 0;
|
||||
for (const b of boxes) {
|
||||
assert.ok(Math.abs(b.y - cursor) <= 8, `gap/overlap at y=${b.y} (expected ~${cursor})`);
|
||||
cursor = b.y + b.height;
|
||||
}
|
||||
assert.ok(pageH - cursor <= 8, `uncovered tail: ${pageH - cursor}px`);
|
||||
});
|
||||
|
||||
it("keeps a single-band page as one section (degenerate stays legal)", () => {
|
||||
const inner = el("div", { y: 100, height: 300, x: 320, width: 640 });
|
||||
const only = el("div", { y: 0, height: 800 }, [inner]);
|
||||
const ir = page([only], 800);
|
||||
const sections = detectSections(ir);
|
||||
assert.equal(sections.length, 1);
|
||||
});
|
||||
|
||||
it("does not split side-by-side columns or overlaid layers", () => {
|
||||
// two full-width overlapping layers inside a page-covering wrapper
|
||||
const a = el("div", { y: 100, height: 1900 });
|
||||
const b = el("div", { y: 100, height: 1900 });
|
||||
const wrapper = el("div", { y: 100, height: 1900 }, [a, b]);
|
||||
const nav = el("nav", { y: 0, height: 100 });
|
||||
const ir = page([nav, wrapper], 2000);
|
||||
const sections = detectSections(ir);
|
||||
assert.equal(sections.length, 2); // nav + the wrapper, unsplit
|
||||
});
|
||||
|
||||
it("does not descend when children leave large coverage gaps", () => {
|
||||
// one small band inside a page-covering container: splitting would drop content
|
||||
const lone = band(0, 300, "Only child");
|
||||
const container = el("div", { y: 0, height: 2000 }, [lone]);
|
||||
const ir = page([container], 2000);
|
||||
assert.equal(detectSections(ir).length, 1);
|
||||
});
|
||||
|
||||
it("keeps a 62px fixed div-wrapped navbar as its own band (nav evidence beats the 64px bar)", () => {
|
||||
// real-site shape: hero at y=0, a thin fixed bar (styled div, real <nav> nested
|
||||
// narrow inside) floating over it
|
||||
const navInner = el("nav", { y: 12, height: 62, x: 700, width: 454 });
|
||||
const bar = el("div", { y: 12, height: 62 }, [el("div", { y: 12, height: 62 }, [navInner])]);
|
||||
const hero = el("section", { y: 0, height: 800 }, [
|
||||
el("h1", { y: 200, height: 60, x: 120, width: 900 }, [text("We recruit designers")]),
|
||||
]);
|
||||
const b1 = band(800, 700, "Our mission", 1);
|
||||
const b2 = band(1500, 900, "Services", 2);
|
||||
const footer = el("footer", { y: 2400, height: 300 });
|
||||
const ir = page([hero, bar, b1, b2, footer], 2700);
|
||||
const sections = detectSections(ir);
|
||||
assert.equal(sections.length, 5);
|
||||
assert.equal(sections[0]!.role, "hero"); // y=0 sorts before the bar at y=12
|
||||
assert.equal(sections[1]!.role, "navbar");
|
||||
const names = [...planSections(ir).roots.values()];
|
||||
assert.ok(names.includes("Navbar"), `expected Navbar in ${names.join(", ")}`);
|
||||
});
|
||||
|
||||
it("is deterministic: same capture, byte-identical sections", () => {
|
||||
const a = JSON.stringify(detectSections(wrappedPage(13)));
|
||||
const b = JSON.stringify(detectSections(wrappedPage(13)));
|
||||
assert.equal(a, b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("section component planning (emission roots + names)", () => {
|
||||
it("names one component per band: Navbar, HeroSection, content sections, Footer", () => {
|
||||
const ir = sixBandPage();
|
||||
const plan = planSections(ir);
|
||||
const names = [...plan.roots.values()];
|
||||
assert.equal(names.length, 6);
|
||||
assert.equal(names[0], "Navbar");
|
||||
assert.equal(names[1], "HeroSection");
|
||||
assert.equal(names[5], "Footer");
|
||||
assert.ok(names.includes("TrustedByTeamsSection"));
|
||||
assert.ok(names.includes("WhatCustomersSaySection"));
|
||||
assert.equal(new Set(names).size, 6, "names are unique");
|
||||
});
|
||||
|
||||
it("plans a component per band through body > div > main wrappers", () => {
|
||||
const plan = planSections(wrappedPage(13));
|
||||
assert.equal(plan.roots.size, 15);
|
||||
const names = [...plan.roots.values()];
|
||||
assert.equal(names[0], "Navbar");
|
||||
assert.equal(names[1], "HeroSection");
|
||||
assert.equal(names[14], "Footer");
|
||||
});
|
||||
|
||||
it("falls back to evidence names for bands without headings", () => {
|
||||
const nav = el("nav", { y: 0, height: 62 });
|
||||
const hero = el("section", { y: 62, height: 800 }, [
|
||||
el("h1", { y: 120, height: 60, x: 120, width: 900 }, [text("Hello")]),
|
||||
]);
|
||||
const formBand = el("section", { y: 862, height: 500 }, [
|
||||
el("form", { y: 900, height: 200, x: 320, width: 640 }),
|
||||
]);
|
||||
const mediaBand = el("section", { y: 1362, height: 500 }, [
|
||||
el("video", { y: 1400, height: 400, x: 160, width: 960 }),
|
||||
]);
|
||||
const footer = el("footer", { y: 1862, height: 300 });
|
||||
const ir = page([nav, hero, formBand, mediaBand, footer], 2162);
|
||||
const names = [...planSections(ir).roots.values()];
|
||||
assert.ok(names.includes("ContactSection"), `expected ContactSection in ${names.join(", ")}`);
|
||||
assert.ok(names.includes("MediaSection"), `expected MediaSection in ${names.join(", ")}`);
|
||||
});
|
||||
|
||||
it("leaves a page with too few bands unsplit (no monolithic 'hero' misnomer)", () => {
|
||||
const only = el("div", { y: 0, height: 800 }, [el("div", { y: 100, height: 300, x: 320, width: 640 })]);
|
||||
const plan = planSections(page([only], 800));
|
||||
assert.equal(plan.roots.size, 0);
|
||||
});
|
||||
|
||||
it("is deterministic across runs", () => {
|
||||
const a = JSON.stringify([...planSections(wrappedPage(13)).roots.entries()]);
|
||||
const b = JSON.stringify([...planSections(wrappedPage(13)).roots.entries()]);
|
||||
assert.equal(a, b);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildColorPalette, colorClusterKey } from "../src/infer/semanticTokens.js";
|
||||
import { planSections, nameFromSourceToken } from "../src/generate/sectionSplit.js";
|
||||
import type { IR, IRNode, IRChild, StyleMap } from "../src/normalize/ir.js";
|
||||
|
||||
const CW = 1280;
|
||||
|
||||
type Box = { x?: number; y: number; width?: number; height: number };
|
||||
function el(tag: string, box: Box, computed: StyleMap, children: IRChild[] = [], attrs: Record<string, string> = {}, srcClass?: string): IRNode {
|
||||
const n: IRNode = {
|
||||
id: "", tag, attrs,
|
||||
visibleByVp: { [CW]: true },
|
||||
bboxByVp: { [CW]: { x: box.x ?? 0, y: box.y, width: box.width ?? CW, height: box.height } },
|
||||
computedByVp: { [CW]: { display: "block", ...computed } },
|
||||
children,
|
||||
};
|
||||
if (srcClass) n.srcClass = srcClass;
|
||||
return n;
|
||||
}
|
||||
function text(t: string): IRChild { return { text: t }; }
|
||||
|
||||
function page(children: IRNode[], pageH: number, body?: { bodyBg?: string; bodyColor?: string }): IR {
|
||||
// The body carries its own bg/fg computed style (as a real capture does), so the palette's
|
||||
// usage histogram actually sees the page background/foreground colours it will name.
|
||||
const root = el("body", { y: 0, height: pageH }, {
|
||||
...(body?.bodyBg ? { backgroundColor: body.bodyBg } : {}),
|
||||
...(body?.bodyColor ? { color: body.bodyColor } : {}),
|
||||
}, 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: pageH, scrollWidth: CW, htmlBg: "", bodyBg: body?.bodyBg ?? "", bodyColor: body?.bodyColor ?? "", bodyFont: "" } },
|
||||
nodeCount: i, keyframes: [],
|
||||
},
|
||||
root,
|
||||
} as IR;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 1) Semantic role assignment — a fixture IR with known roles must produce the
|
||||
// expected named tokens (deterministically), leftovers only for role-less colours.
|
||||
// ----------------------------------------------------------------------------
|
||||
describe("semantic color palette: role assignment", () => {
|
||||
// page bg = cream; body text = near-black; a saturated red brand used on buttons/links;
|
||||
// a light-gray surface used as several card backgrounds; a border gray; one odd accent.
|
||||
function siteIr(): IR {
|
||||
const kids: IRNode[] = [];
|
||||
// Body text (near-black) x8
|
||||
for (let k = 0; k < 8; k++) kids.push(el("p", { y: 100 + k * 30, height: 20 }, { color: "rgb(20, 20, 19)" }, [text("copy")]));
|
||||
// Brand red on buttons/links x6 (interactive → --primary)
|
||||
for (let k = 0; k < 6; k++) kids.push(el("a", { y: 400 + k * 30, height: 40 }, { backgroundColor: "rgb(188, 0, 0)", color: "rgb(255,255,255)" }, [text("Buy")]));
|
||||
// Light-gray card surfaces x5 (bg, light, low-sat → --surface)
|
||||
for (let k = 0; k < 5; k++) kids.push(el("div", { y: 800 + k * 60, height: 50 }, { backgroundColor: "rgb(240, 238, 230)" }));
|
||||
// Border gray x4 (border only → --border)
|
||||
for (let k = 0; k < 4; k++) kids.push(el("div", { y: 1200 + k * 30, height: 20 }, { borderTopColor: "rgb(200, 200, 200)", borderTopWidth: "1px" }));
|
||||
return page(kids, 1600, { bodyBg: "rgb(252, 251, 246)", bodyColor: "rgb(20, 20, 19)" });
|
||||
}
|
||||
|
||||
it("names background / foreground / primary / surface / border from usage evidence", () => {
|
||||
const p = buildColorPalette(siteIr());
|
||||
const byName = new Map(p.tokens.map((t) => [t.name, t.value]));
|
||||
assert.equal(byName.get("--background"), "rgb(252, 251, 246)");
|
||||
assert.equal(byName.get("--foreground"), "rgb(20, 20, 19)");
|
||||
assert.equal(byName.get("--primary"), "rgb(188, 0, 0)");
|
||||
assert.equal(byName.get("--surface"), "rgb(240, 238, 230)");
|
||||
assert.equal(byName.get("--border"), "rgb(200, 200, 200)");
|
||||
// Every named token resolves back to itself.
|
||||
assert.equal(p.varForColor("rgb(188, 0, 0)"), "var(--primary)");
|
||||
});
|
||||
|
||||
it("is deterministic: same IR → byte-identical token list", () => {
|
||||
const a = buildColorPalette(siteIr()).css;
|
||||
const b = buildColorPalette(siteIr()).css;
|
||||
assert.equal(a, b);
|
||||
});
|
||||
|
||||
it("resolves oklab/oklch forms of a named colour to the SAME semantic token (±2 sRGB)", () => {
|
||||
// oklch(0.987… 97°) ≈ rgb(252,251,246) — the page background. Reached only via a
|
||||
// gradient/decoration property, it must still map to --background, not a fresh --clr-N.
|
||||
const p = buildColorPalette(page([
|
||||
...Array.from({ length: 4 }, (_, k) => el("p", { y: 100 + k * 30, height: 20 }, { color: "rgb(20, 20, 19)" }, [text("x")])),
|
||||
], 400, { bodyBg: "oklch(0.987472 0.00667657 97.3497)", bodyColor: "rgb(20, 20, 19)" }));
|
||||
const bg = p.tokens.find((t) => t.name === "--background");
|
||||
assert.ok(bg, "background named");
|
||||
// The rgb() equivalent within tolerance resolves to --background via the ±2 fallback.
|
||||
assert.equal(p.varForColor("rgb(252, 251, 246)"), "var(--background)");
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 2) colorClusterKey — visually identical literals share a key; distinct colours don't.
|
||||
// ----------------------------------------------------------------------------
|
||||
describe("colorClusterKey (interner visual dedup)", () => {
|
||||
it("collapses oklab forms that round to the same sRGB", () => {
|
||||
// Both oklab(0.988…) whites → rgb(251,251,251).
|
||||
const a = colorClusterKey("oklab(0.988242 -0.0000812355 0.00000757745)");
|
||||
const b = colorClusterKey("oklab(0.988371 -0.0000803481 0.00000749468)");
|
||||
assert.ok(a);
|
||||
assert.equal(a, b);
|
||||
});
|
||||
it("keeps genuinely different colours on different keys", () => {
|
||||
assert.notEqual(colorClusterKey("rgb(0,0,0)"), colorClusterKey("rgb(255,255,255)"));
|
||||
assert.notEqual(colorClusterKey("rgb(188,0,0)"), colorClusterKey("rgb(0,0,188)"));
|
||||
});
|
||||
it("separates alpha variants", () => {
|
||||
assert.notEqual(colorClusterKey("rgba(0,0,0,0.5)"), colorClusterKey("rgba(0,0,0,0.75)"));
|
||||
});
|
||||
it("returns null for unparseable values (keeps raw-literal keying)", () => {
|
||||
assert.equal(colorClusterKey("var(--x)"), null);
|
||||
assert.equal(colorClusterKey("currentColor"), null);
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 3) Name sanitization — hashy-suffix stripping + generic-word filtering.
|
||||
// ----------------------------------------------------------------------------
|
||||
describe("nameFromSourceToken (source-id sanitization)", () => {
|
||||
it("strips Shopify template prefix + trailing hash → semantic slug", () => {
|
||||
assert.equal(nameFromSourceToken("shopify-section-template--19797275672650__split_callout_JtTWTt"), "SplitCallout");
|
||||
// `grid` is a generic structural word (dropped); the hash `RbEALJ` is stripped.
|
||||
assert.equal(nameFromSourceToken("shopify-section-template--19797275672650__media_card_grid_RbEALJ"), "MediaCard");
|
||||
});
|
||||
it("strips mixed-case build hashes (JtTWTt / dDMm2q / RbEALJ)", () => {
|
||||
assert.equal(nameFromSourceToken("split_callout_JtTWTt"), "SplitCallout");
|
||||
assert.equal(nameFromSourceToken("hero_hD9krx"), "Hero");
|
||||
});
|
||||
it("drops generic structural words entirely", () => {
|
||||
assert.equal(nameFromSourceToken("g_section_wrap"), "");
|
||||
assert.equal(nameFromSourceToken("section-inner-content"), "");
|
||||
assert.equal(nameFromSourceToken("shopify-section-group-header"), "Header");
|
||||
});
|
||||
it("handles js-* hooks and long numeric ids", () => {
|
||||
assert.equal(nameFromSourceToken("js-media-banner-section"), "MediaBanner");
|
||||
assert.equal(nameFromSourceToken("template--19797275672650"), "");
|
||||
});
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 4) planSections — a Shopify-id section beats a heading slug; noisy classes don't.
|
||||
// ----------------------------------------------------------------------------
|
||||
describe("planSections: source-id naming precedence", () => {
|
||||
it("names a section from its CMS section id (hash stripped) over generic evidence", () => {
|
||||
const nav = el("nav", { y: 0, height: 62 }, {});
|
||||
const hero = el("section", { y: 62, height: 800 }, {}, [el("h1", { y: 120, height: 60, x: 120, width: 900 }, {}, [text("Welcome")])]);
|
||||
// A one-off band with NO heading but a clean Shopify id → SplitCalloutSection.
|
||||
const callout = el("div", { y: 862, height: 600 }, {}, [
|
||||
el("p", { y: 900, height: 40, x: 120, width: 600 }, {}, [text("Some marketing copy here")]),
|
||||
], { id: "shopify-section-template--19797275672650__split_callout_JtTWTt" }, "shopify-section js-split-callout-section");
|
||||
const b2 = el("section", { y: 1462, height: 500 }, {}, [el("h2", { y: 1500, height: 40, x: 120, width: 600 }, {}, [text("Everything you need today")]), el("p", { y: 1560, height: 24, x: 120, width: 600 }, {}, [text("extra")])]);
|
||||
const b3 = el("section", { y: 1962, height: 500 }, {}, [el("h2", { y: 2000, height: 40, x: 120, width: 600 }, {}, [text("What customers say now")]), el("p", { y: 2060, height: 24, x: 120, width: 600 }, {}, [text("a")]), el("p", { y: 2090, height: 24, x: 120, width: 600 }, {}, [text("b")])]);
|
||||
const footer = el("footer", { y: 2462, height: 400 }, {}, [el("a", { y: 2500, height: 20, x: 120, width: 200 }, {}, [text("Privacy")], {}, undefined)]);
|
||||
const ir = page([nav, hero, callout, b2, b3, footer], 2862);
|
||||
const names = [...planSections(ir).roots.values()];
|
||||
assert.ok(names.includes("SplitCalloutSection"), `expected SplitCalloutSection in ${names.join(", ")}`);
|
||||
});
|
||||
|
||||
it("does NOT mine arbitrary utility classes (falls back to heading slug)", () => {
|
||||
const nav = el("nav", { y: 0, height: 62 }, {});
|
||||
const hero = el("section", { y: 62, height: 800 }, {}, [el("h1", { y: 120, height: 60, x: 120, width: 900 }, {}, [text("Welcome home")])]);
|
||||
// Heading present, but the only source class is a noisy Webflow utility → use the heading.
|
||||
const band = el("section", { y: 862, height: 600 }, {}, [
|
||||
el("h2", { y: 900, height: 40, x: 120, width: 600 }, {}, [text("Latest releases from us")]),
|
||||
], {}, "g_section_space duraldar-cta_section w-variant-60a7ad7d");
|
||||
const b2 = el("section", { y: 1462, height: 500 }, {}, [el("h2", { y: 1500, height: 40, x: 120, width: 600 }, {}, [text("Everything you need")]), el("p", { y: 1560, height: 24, x: 120, width: 600 }, {}, [text("x")])]);
|
||||
const footer = el("footer", { y: 1962, height: 400 }, {}, [el("a", { y: 2000, height: 20, x: 120, width: 200 }, {}, [text("Privacy")])]);
|
||||
const ir = page([nav, hero, band, b2, footer], 2362);
|
||||
const names = [...planSections(ir).roots.values()];
|
||||
assert.ok(names.some((n) => /^LatestReleases/.test(n)), `expected heading slug, got ${names.join(", ")}`);
|
||||
assert.ok(!names.some((n) => /Duraldar|Space/.test(n)), `must not mine utility classes: ${names.join(", ")}`);
|
||||
});
|
||||
});
|
||||
@@ -195,3 +195,54 @@ describe("generated Next config", () => {
|
||||
assert.ok(NEXT_CONFIG.includes("devIndicators: false"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("SEO origin references the clone, not the source domain (fix 6)", () => {
|
||||
it("sitemap.ts / robots.ts resolve against SITE_ORIGIN, not the source origin", () => {
|
||||
const ir = fixtureIr();
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const files = seoRouteFiles(report, [routeSummaryFromIr(ir, "/", "/", ir.doc.sourceUrl)]);
|
||||
const robots = files.find(([p]) => p === "robots.ts")![1];
|
||||
const sitemap = files.find(([p]) => p === "sitemap.ts")![1];
|
||||
for (const body of [robots, sitemap]) {
|
||||
assert.ok(body.includes('import { SITE_ORIGIN } from "../lib/site";'), "imports SITE_ORIGIN");
|
||||
assert.ok(body.includes("SITE_ORIGIN +"), "builds URLs from SITE_ORIGIN");
|
||||
assert.ok(!body.includes("example.test"), "never bakes the source domain");
|
||||
}
|
||||
// sitemap path is relativized off the source origin.
|
||||
assert.ok(sitemap.includes('SITE_ORIGIN + "/seo"') || sitemap.includes('SITE_ORIGIN + "/"'));
|
||||
});
|
||||
|
||||
it("metadata sets metadataBase from SITE_ORIGIN and relativizes the canonical", () => {
|
||||
const ir = fixtureIr();
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const metadata = metadataExport(report);
|
||||
assert.ok(metadata.includes('new URL(SITE_ORIGIN || "http://localhost:3000")'), "metadataBase from SITE_ORIGIN");
|
||||
assert.ok(metadata.includes('"canonical": "/seo"'), "canonical relativized to a path");
|
||||
// The source domain must not survive in canonical (og:image assets are localized elsewhere).
|
||||
assert.ok(!metadata.includes('"canonical": "https://example.test'), "canonical is not absolute to source");
|
||||
});
|
||||
|
||||
it("relativizes og:url off the source origin", () => {
|
||||
const ir = fixtureIr();
|
||||
ir.doc.head!.meta!.push({ property: "og:url", content: "https://example.test/seo" });
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const metadata = metadataExport(report);
|
||||
assert.ok(metadata.includes('"url": "/seo"'), "og:url relativized to a path");
|
||||
assert.ok(!metadata.includes('"url": "https://example.test/seo"'), "og:url not absolute to source");
|
||||
});
|
||||
|
||||
it("rewrites on-origin JSON-LD @id/url off the source domain via SITE_ORIGIN", () => {
|
||||
const ir = fixtureIr();
|
||||
// JSON-LD carrying the source origin in @id/url (escaped-slash form, like WordPress emits).
|
||||
ir.doc.head!.jsonLd = [{
|
||||
id: "graph",
|
||||
text: '{"@context":"https:\\/\\/schema.org","@id":"https:\\/\\/example.test\\/#website","url":"https:\\/\\/example.test\\/"}',
|
||||
}];
|
||||
const report = buildSeoInventory(ir, fixtureAssets(), fixtureCapture());
|
||||
const markup = jsonLdHeadMarkup(report);
|
||||
assert.ok(markup.includes(".join(SITE_ORIGIN)"), "rejoins segments with SITE_ORIGIN at runtime");
|
||||
// The source origin must not survive as a literal (schema.org context is off-origin, kept).
|
||||
assert.ok(!markup.includes("example.test"), "source origin removed from JSON-LD");
|
||||
assert.ok(markup.includes("schema.org"), "off-origin @context left untouched");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, it, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { serveStatic, parseRangeHeader, pendingFontFamilies, unreferencedFontFaces, type FontFaceStatus } from "../src/validate/render.js";
|
||||
|
||||
// ---- Pure helper: parseRangeHeader ----
|
||||
describe("parseRangeHeader", () => {
|
||||
const SIZE = 1000;
|
||||
|
||||
it("no header → full 200", () => {
|
||||
assert.deepEqual(parseRangeHeader(undefined, SIZE), { kind: "full" });
|
||||
assert.deepEqual(parseRangeHeader("", SIZE), { kind: "full" });
|
||||
});
|
||||
|
||||
it("bytes=0- (Chromium's open-ended media probe) → whole file as a range", () => {
|
||||
assert.deepEqual(parseRangeHeader("bytes=0-", SIZE), { kind: "range", start: 0, end: 999 });
|
||||
});
|
||||
|
||||
it("bounded range bytes=100-199 → [100,199] inclusive", () => {
|
||||
assert.deepEqual(parseRangeHeader("bytes=100-199", SIZE), { kind: "range", start: 100, end: 199 });
|
||||
});
|
||||
|
||||
it("end past EOF is clamped to size-1", () => {
|
||||
assert.deepEqual(parseRangeHeader("bytes=900-5000", SIZE), { kind: "range", start: 900, end: 999 });
|
||||
});
|
||||
|
||||
it("suffix range bytes=-100 → last 100 bytes", () => {
|
||||
assert.deepEqual(parseRangeHeader("bytes=-100", SIZE), { kind: "range", start: 900, end: 999 });
|
||||
});
|
||||
|
||||
it("suffix larger than file → whole file", () => {
|
||||
assert.deepEqual(parseRangeHeader("bytes=-5000", SIZE), { kind: "range", start: 0, end: 999 });
|
||||
});
|
||||
|
||||
it("start at or past EOF → unsatisfiable (416)", () => {
|
||||
assert.deepEqual(parseRangeHeader("bytes=1000-", SIZE), { kind: "unsatisfiable" });
|
||||
assert.deepEqual(parseRangeHeader("bytes=2000-3000", SIZE), { kind: "unsatisfiable" });
|
||||
});
|
||||
|
||||
it("zero-length resource → unsatisfiable for any range", () => {
|
||||
assert.deepEqual(parseRangeHeader("bytes=0-", 0), { kind: "unsatisfiable" });
|
||||
});
|
||||
|
||||
it("malformed / multi-range / inverted → full (safe fallback, never throws)", () => {
|
||||
assert.deepEqual(parseRangeHeader("bytes=abc-def", SIZE), { kind: "full" });
|
||||
assert.deepEqual(parseRangeHeader("items=0-10", SIZE), { kind: "full" });
|
||||
assert.deepEqual(parseRangeHeader("bytes=0-10,20-30", SIZE), { kind: "full" }); // multi-range collapsed
|
||||
assert.deepEqual(parseRangeHeader("bytes=-", SIZE), { kind: "full" });
|
||||
assert.deepEqual(parseRangeHeader("bytes=500-100", SIZE), { kind: "full" }); // inverted
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Pure helper: pendingFontFamilies (the font-load wait decision) ----
|
||||
// The render walk must run after webfonts are APPLIED, else text boxes measure the fallback face
|
||||
// (a systematic width delta wrongly attributed to the clone). This helper is the state-based (not
|
||||
// time-based) predicate the in-page poll ends on: it returns the DECLARED families still actively
|
||||
// fetching. Browsers lazy-load faces, so after document.fonts.ready a face the rendered text needs
|
||||
// is already "loading"; a face left "unloaded" is unreferenced and will never load on its own —
|
||||
// waiting on it only burns the cap, so ONLY "loading" is pending.
|
||||
describe("pendingFontFamilies", () => {
|
||||
const face = (family: string, status: string, weight = "400", style = "normal"): FontFaceStatus => ({ family, weight, style, status });
|
||||
|
||||
it("no faces declared → nothing pending", () => {
|
||||
assert.deepEqual(pendingFontFamilies([]), []);
|
||||
});
|
||||
|
||||
it("all faces loaded → nothing pending (walk may proceed)", () => {
|
||||
assert.deepEqual(pendingFontFamilies([face("Avenir", "loaded"), face("Inter", "loaded")]), []);
|
||||
});
|
||||
|
||||
it("an unloaded face is unreferenced (lazy-load) → NOT pending", () => {
|
||||
assert.deepEqual(pendingFontFamilies([face("Avenir", "unloaded")]), []);
|
||||
});
|
||||
|
||||
it("a loading face makes its family pending", () => {
|
||||
assert.deepEqual(pendingFontFamilies([face("Avenir", "loading")]), ["Avenir"]);
|
||||
});
|
||||
|
||||
it("errored faces are terminal — never reported pending (waiting longer is pointless)", () => {
|
||||
assert.deepEqual(pendingFontFamilies([face("Avenir", "error")]), []);
|
||||
});
|
||||
|
||||
it("a family is pending if ANY weight is still loading", () => {
|
||||
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "loading", "700")];
|
||||
assert.deepEqual(pendingFontFamilies(faces), ["Avenir"]);
|
||||
});
|
||||
|
||||
it("a partially-used family (used face loaded, sibling faces unloaded) is NOT pending", () => {
|
||||
// The real-world false positive this fix removes: a matched face loaded, unreferenced
|
||||
// weight/style siblings stay "unloaded" — the walk may proceed without waiting on them.
|
||||
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "unloaded", "700"), face("Avenir", "unloaded", "400", "italic")];
|
||||
assert.deepEqual(pendingFontFamilies(faces), []);
|
||||
});
|
||||
|
||||
it("a family whose faces are all terminal (loaded or errored) is not pending", () => {
|
||||
const faces = [face("Avenir", "loaded", "400"), face("Avenir", "error", "700")];
|
||||
assert.deepEqual(pendingFontFamilies(faces), []);
|
||||
});
|
||||
|
||||
it("strips surrounding quotes from the reported family name and dedupes+sorts (loading only)", () => {
|
||||
const faces = [face('"Source Serif"', "loading"), face("Avenir", "loading"), face("Avenir", "unloaded")];
|
||||
assert.deepEqual(pendingFontFamilies(faces), ["Avenir", "Source Serif"]);
|
||||
});
|
||||
|
||||
it("only the still-loading families are returned when some are loaded/unloaded", () => {
|
||||
const faces = [face("Inter", "loaded"), face("Avenir", "loading"), face("JetBrains", "unloaded")];
|
||||
assert.deepEqual(pendingFontFamilies(faces), ["Avenir"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Pure helper: unreferencedFontFaces (informational-only per-face report) ----
|
||||
// Faces left "unloaded" after the wait bound: declared but no rendered text resolves to them.
|
||||
// Reported per-face (family+weight+style) for an informational log; benign, never a fidelity issue.
|
||||
describe("unreferencedFontFaces", () => {
|
||||
const face = (family: string, status: string, weight = "400", style = "normal"): FontFaceStatus => ({ family, weight, style, status });
|
||||
|
||||
it("returns only the unloaded faces (not loaded/loading/error)", () => {
|
||||
const faces = [face("Avenir", "loaded"), face("Merriweather", "unloaded", "700"), face("Inter", "loading"), face("Gotham", "error")];
|
||||
assert.deepEqual(unreferencedFontFaces(faces), [{ family: "Merriweather", weight: "700", style: "normal", status: "unloaded" }]);
|
||||
});
|
||||
|
||||
it("reports per-face (family+weight+style), not collapsed per-family, and strips quotes + sorts", () => {
|
||||
const faces = [
|
||||
face('"Merriweather"', "unloaded", "700", "normal"),
|
||||
face("Merriweather", "unloaded", "300", "italic"),
|
||||
face("Avenir", "unloaded", "400", "normal"),
|
||||
];
|
||||
assert.deepEqual(unreferencedFontFaces(faces), [
|
||||
{ family: "Avenir", weight: "400", style: "normal", status: "unloaded" },
|
||||
{ family: "Merriweather", weight: "300", style: "italic", status: "unloaded" },
|
||||
{ family: "Merriweather", weight: "700", style: "normal", status: "unloaded" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("no unloaded faces → empty", () => {
|
||||
assert.deepEqual(unreferencedFontFaces([face("Avenir", "loaded"), face("Inter", "loading")]), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Integration: serveStatic answers real Range requests with 206/416 ----
|
||||
describe("serveStatic Range support (integration)", () => {
|
||||
let rootDir = "";
|
||||
let base = "";
|
||||
let close: (() => Promise<void>) | null = null;
|
||||
const BODY = Buffer.alloc(5000, 0x41); // 5000 'A' bytes stands in for a media file
|
||||
|
||||
before(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "ditto-serve-"));
|
||||
mkdirSync(join(rootDir, "assets"), { recursive: true });
|
||||
writeFileSync(join(rootDir, "assets", "hero.webm"), BODY);
|
||||
writeFileSync(join(rootDir, "index.html"), "<!doctype html><html><body>ok</body></html>");
|
||||
const s = await serveStatic(rootDir);
|
||||
base = s.url;
|
||||
close = s.close;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await close?.();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("open-ended Range bytes=0- → 206 with a bounded body + Content-Range/Accept-Ranges", async () => {
|
||||
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=0-" } });
|
||||
assert.equal(res.status, 206);
|
||||
assert.equal(res.headers.get("accept-ranges"), "bytes");
|
||||
assert.equal(res.headers.get("content-range"), `bytes 0-4999/5000`);
|
||||
assert.equal(res.headers.get("content-type"), "video/webm");
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
assert.equal(buf.length, 5000);
|
||||
});
|
||||
|
||||
it("bounded Range bytes=100-199 → 206 returning exactly those 100 bytes", async () => {
|
||||
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=100-199" } });
|
||||
assert.equal(res.status, 206);
|
||||
assert.equal(res.headers.get("content-range"), `bytes 100-199/5000`);
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
assert.equal(buf.length, 100);
|
||||
assert.ok(buf.equals(BODY.subarray(100, 200)));
|
||||
});
|
||||
|
||||
it("Range past EOF → 416 with Content-Range: bytes */size", async () => {
|
||||
const res = await fetch(base + "/assets/hero.webm", { headers: { Range: "bytes=9000-" } });
|
||||
assert.equal(res.status, 416);
|
||||
assert.equal(res.headers.get("content-range"), `bytes */5000`);
|
||||
// Drain the (empty) body so the socket is released.
|
||||
await res.arrayBuffer();
|
||||
});
|
||||
|
||||
it("no Range header → full 200 with Accept-Ranges advertised", async () => {
|
||||
const res = await fetch(base + "/assets/hero.webm");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.headers.get("accept-ranges"), "bytes");
|
||||
const buf = Buffer.from(await res.arrayBuffer());
|
||||
assert.equal(buf.length, 5000);
|
||||
});
|
||||
|
||||
it("HTML routes still serve a normal 200 (Range logic doesn't disturb pages)", async () => {
|
||||
const res = await fetch(base + "/");
|
||||
assert.equal(res.status, 200);
|
||||
const text = await res.text();
|
||||
assert.match(text, /<body>ok<\/body>/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveSvgRootFill, isRealPaint } from "../src/generate/app.js";
|
||||
|
||||
describe("isRealPaint", () => {
|
||||
it("treats none / empty / transparent as non-paint", () => {
|
||||
for (const v of [undefined, null, "", " ", "none", "NONE", "transparent", "rgba(0, 0, 0, 0)", "rgba(0,0,0,0)"]) {
|
||||
assert.equal(isRealPaint(v), false, `${String(v)} should not paint`);
|
||||
}
|
||||
});
|
||||
it("treats a color / currentColor as a real paint", () => {
|
||||
for (const v of ["rgb(255, 255, 255)", "#000", "currentColor", "red", "rgb(0, 0, 0)"]) {
|
||||
assert.equal(isRealPaint(v), true, `${v} should paint`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSvgRootFill", () => {
|
||||
it("keeps a raw fill that is itself a real paint", () => {
|
||||
assert.deepEqual(resolveSvgRootFill("#123456", { fill: "rgb(255,255,255)", color: "rgb(0,0,0)" }), { mode: "keep" });
|
||||
assert.deepEqual(resolveSvgRootFill("red", null), { mode: "keep" });
|
||||
});
|
||||
|
||||
it("falls back when no raw fill is declared (existing currentColor default)", () => {
|
||||
assert.deepEqual(resolveSvgRootFill(undefined, { fill: "rgb(255,255,255)", color: "rgb(255,255,255)" }), { mode: "fallback" });
|
||||
assert.deepEqual(resolveSvgRootFill(null, null), { mode: "fallback" });
|
||||
});
|
||||
|
||||
it("recovers currentColor when fill=none but computed fill tracks the element color (wordmark case)", () => {
|
||||
// The a16z logo case: fill="none" attribute, but CSS `fill: currentColor` with white color.
|
||||
const r = resolveSvgRootFill("none", { fill: "rgb(255, 255, 255)", color: "rgb(255, 255, 255)" });
|
||||
assert.equal(r.mode, "emit");
|
||||
assert.equal(r.value, "currentColor");
|
||||
assert.equal(r.emitColor, "rgb(255, 255, 255)");
|
||||
});
|
||||
|
||||
it("emits the literal computed fill when it differs from the element color", () => {
|
||||
const r = resolveSvgRootFill("none", { fill: "rgb(255, 0, 0)", color: "rgb(0, 0, 0)" });
|
||||
assert.equal(r.mode, "emit");
|
||||
assert.equal(r.value, "rgb(255, 0, 0)");
|
||||
assert.equal(r.emitColor, undefined);
|
||||
});
|
||||
|
||||
it("leaves a genuinely unfilled svg as none (computed fill also none)", () => {
|
||||
assert.deepEqual(resolveSvgRootFill("none", { fill: "none", color: "rgb(0,0,0)" }), { mode: "keep" });
|
||||
assert.deepEqual(resolveSvgRootFill("none", { fill: "rgba(0, 0, 0, 0)", color: "rgb(0,0,0)" }), { mode: "keep" });
|
||||
// No computed paint captured at all → cannot prove it paints → stays none.
|
||||
assert.deepEqual(resolveSvgRootFill("none", null), { mode: "keep" });
|
||||
assert.deepEqual(resolveSvgRootFill("none", undefined), { mode: "keep" });
|
||||
});
|
||||
|
||||
it("does not emit color when the recovered fill is currentColor but color is not a real paint", () => {
|
||||
// fill == color but color is transparent → cannot be a meaningful currentColor recovery;
|
||||
// the equality branch is guarded on isRealPaint(color), so it emits the literal fill instead.
|
||||
const r = resolveSvgRootFill("none", { fill: "rgb(0, 128, 0)", color: "transparent" });
|
||||
assert.equal(r.mode, "emit");
|
||||
assert.equal(r.value, "rgb(0, 128, 0)");
|
||||
assert.equal(r.emitColor, undefined);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { declToUtil } from "../src/generate/tailwind.js";
|
||||
import { declToUtil, snapBase, prettifyBase, collapseBases } from "../src/generate/tailwind.js";
|
||||
|
||||
// Zero-value gating. A named `-0` step only exists for props on Tailwind's spacing (or numeric)
|
||||
// scales; for the rest the class compiles to NOTHING — a silent no-op that ships the wrong style
|
||||
@@ -41,3 +41,113 @@ describe("declToUtil zero values", () => {
|
||||
assert.equal(declToUtil("letter-spacing", "-0.5px"), "tracking-[-0.5px]");
|
||||
});
|
||||
});
|
||||
|
||||
// BUG B — the spacing-scale snap must only fire when a value lands ESSENTIALLY ON a step (≤0.25px).
|
||||
// The scale is 2px-granular (`p-0.5`=2px, `p-1.5`=6px), so 3.5px is BETWEEN steps — snapping it up to
|
||||
// p-1 (4px) adds +0.5px per side, which accumulates across a fixed-width flex row until items overflow
|
||||
// and wrap. On-step values (2px→p-0.5, 4px→p-1) still snap.
|
||||
describe("snapBase spacing-scale snapping", () => {
|
||||
it("keeps a between-steps value arbitrary (3.5px does NOT snap to p-1)", () => {
|
||||
assert.equal(snapBase("p-[3.5px]"), "p-[3.5px]");
|
||||
});
|
||||
|
||||
it("does not snap the same sub-step delta on other spacing props", () => {
|
||||
assert.equal(snapBase("pl-[3.5px]"), "pl-[3.5px]");
|
||||
assert.equal(snapBase("gap-[3.5px]"), "gap-[3.5px]");
|
||||
assert.equal(snapBase("mt-[13.5px]"), "mt-[13.5px]"); // between p-3 (12px) and p-3.5 (14px)
|
||||
});
|
||||
|
||||
it("still snaps a value that sits on a 0.5-step (2px → p-0.5, 6px → p-1.5)", () => {
|
||||
assert.equal(snapBase("p-[2px]"), "p-0.5");
|
||||
assert.equal(snapBase("p-[6px]"), "p-1.5");
|
||||
});
|
||||
|
||||
it("still snaps a near-exact on-step value within the tight budget (3.98px → p-1)", () => {
|
||||
assert.equal(snapBase("p-[3.98px]"), "p-1");
|
||||
});
|
||||
|
||||
it("leaves an already-clean scale utility unchanged and snaps 16px → mx-4", () => {
|
||||
assert.equal(snapBase("p-1"), "p-1");
|
||||
assert.equal(snapBase("mx-[16px]"), "mx-4");
|
||||
});
|
||||
});
|
||||
|
||||
// A percentage 0 on a MAIN-SIZE axis (flex-basis) or a %-of-indefinite-height axis (height/min-height)
|
||||
// is NOT the definite zero `-0`: `flex-basis:0%` content-sizes against an auto-sized flex container,
|
||||
// whereas `flex-basis:0` gives a zero base size (collapsing a `flex:1 1 0%` item in an auto-height
|
||||
// column). prettifyBase must keep those literal. Width/inset 0% resolve against the definite
|
||||
// containing-block width, so their `-0` rewrite stays.
|
||||
describe("prettifyBase 0% on indefinite-axis prefixes", () => {
|
||||
it("keeps basis-[0%] literal (0% ≠ definite 0 for flex-basis)", () => {
|
||||
assert.equal(prettifyBase("basis-[0%]"), "basis-[0%]");
|
||||
});
|
||||
it("keeps h-[0%] and min-h-[0%] literal (%-of-indefinite-height → auto)", () => {
|
||||
assert.equal(prettifyBase("h-[0%]"), "h-[0%]");
|
||||
assert.equal(prettifyBase("min-h-[0%]"), "min-h-[0%]");
|
||||
});
|
||||
it("still rewrites width/inset 0% to the definite -0 (definite containing-block width)", () => {
|
||||
assert.equal(prettifyBase("w-[0%]"), "w-0");
|
||||
assert.equal(prettifyBase("min-w-[0%]"), "min-w-0");
|
||||
assert.equal(prettifyBase("left-[0%]"), "left-0");
|
||||
assert.equal(prettifyBase("inset-x-[0%]"), "inset-x-0");
|
||||
});
|
||||
it("still rewrites non-zero fractions on every prefix (basis-[33.3333%] → basis-1/3)", () => {
|
||||
assert.equal(prettifyBase("basis-[33.3333%]"), "basis-1/3");
|
||||
assert.equal(prettifyBase("h-[50%]"), "h-1/2");
|
||||
assert.equal(prettifyBase("min-h-[100%]"), "min-h-full");
|
||||
});
|
||||
});
|
||||
|
||||
// flex:1 1 0% is Tailwind's `flex-1` — fold the grow-[1] + zero-basis pair so the emitted class both
|
||||
// reads idiomatically AND resolves to the exact flex longhands (avoiding the basis-[0%] hazard).
|
||||
describe("collapseBases flex-1 folding", () => {
|
||||
it("folds grow-[1] + basis-[0%] → flex-1 (shrink defaults to 1, elided)", () => {
|
||||
assert.deepEqual(collapseBases(["grow-[1]", "basis-[0%]"]), ["flex-1"]);
|
||||
});
|
||||
it("folds grow-[1] + basis-0 (already-shortened band delta) → flex-1", () => {
|
||||
assert.deepEqual(collapseBases(["grow-[1]", "basis-0"]), ["flex-1"]);
|
||||
});
|
||||
it("does NOT fold when shrink-0 is present (flex:1 0 0% ≠ flex-1)", () => {
|
||||
assert.deepEqual(collapseBases(["grow-[1]", "shrink-0", "basis-[0%]"]).sort(), ["basis-[0%]", "grow-[1]", "shrink-0"]);
|
||||
});
|
||||
it("does NOT fold a non-zero basis (grow-[1] + basis-[50%] left as-is)", () => {
|
||||
assert.deepEqual(collapseBases(["grow-[1]", "basis-[50%]"]).sort(), ["basis-[50%]", "grow-[1]"]);
|
||||
});
|
||||
});
|
||||
|
||||
// letter-spacing is authored at a finer scale than box lengths; a real -0.08px tracking is within
|
||||
// snapLen's 0.1px integer-snap window and would collapse to 0px → Chromium serializes it as `normal`
|
||||
// → a false style-gate mismatch. Tracking must skip the integer snap (snapBase keeps 2 decimals).
|
||||
describe("declToUtil letter-spacing sub-0.1px preservation", () => {
|
||||
it("keeps a real -0.08px tracking (does NOT snap to tracking-[0px])", () => {
|
||||
assert.equal(declToUtil("letter-spacing", "-0.08px"), "tracking-[-0.08px]");
|
||||
});
|
||||
it("keeps -0.0375px through declToUtil, then snapBase rounds to 2 decimals (not to zero)", () => {
|
||||
assert.equal(declToUtil("letter-spacing", "-0.0375px"), "tracking-[-0.0375px]");
|
||||
assert.equal(snapBase("tracking-[-0.0375px]"), "tracking-[-0.04px]");
|
||||
});
|
||||
it("still keeps a genuine zero as tracking-[0px]", () => {
|
||||
assert.equal(declToUtil("letter-spacing", "0px"), "tracking-[0px]");
|
||||
});
|
||||
it("still integer-snaps a BOX length near an integer (204.9994px → 205px)", () => {
|
||||
assert.equal(declToUtil("width", "204.9994px"), "w-[205px]");
|
||||
});
|
||||
});
|
||||
|
||||
// text-wrap: modern heading line-balancing. `balance`/`pretty` rebalance where a title wraps;
|
||||
// without emitting them a two-line heading breaks differently in the clone. Tailwind v4 has the
|
||||
// named utilities text-balance / text-pretty / text-nowrap / text-wrap; anything else (e.g.
|
||||
// `stable`) falls through to the arbitrary property escape.
|
||||
describe("declToUtil text-wrap", () => {
|
||||
it("maps balance and pretty to the named Tailwind v4 utilities", () => {
|
||||
assert.equal(declToUtil("text-wrap", "balance"), "text-balance");
|
||||
assert.equal(declToUtil("text-wrap", "pretty"), "text-pretty");
|
||||
});
|
||||
it("maps wrap and nowrap to their named utilities", () => {
|
||||
assert.equal(declToUtil("text-wrap", "wrap"), "text-wrap");
|
||||
assert.equal(declToUtil("text-wrap", "nowrap"), "text-nowrap");
|
||||
});
|
||||
it("falls back to the arbitrary property for an unmapped value", () => {
|
||||
assert.equal(declToUtil("text-wrap", "stable"), "[text-wrap:stable]");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { selectVideoSourceIndex, planVideoSeek, type VideoSourceCandidate } from "../src/capture/capture.js";
|
||||
|
||||
// Predicate builders for the injected matchMedia / canPlayType.
|
||||
const matchesAny = (matching: Set<string>) => (m: string) => matching.has(m);
|
||||
const canPlayAll = () => true;
|
||||
const canPlayNone = () => false;
|
||||
|
||||
describe("selectVideoSourceIndex (video <source> resource-selection)", () => {
|
||||
it("picks the first source whose media matches; missing media matches unconditionally", () => {
|
||||
// Aspect-gated hero: landscape sources first, portrait after, plain fall-through last.
|
||||
const sources: VideoSourceCandidate[] = [
|
||||
{ media: "(min-aspect-ratio: 21/9)", type: "video/webm" },
|
||||
{ media: "(min-aspect-ratio: 16/9)", type: "video/webm" },
|
||||
{ media: "(max-aspect-ratio: 4/5)", type: "video/webm" },
|
||||
{ media: null, type: "video/webm" }, // plain fall-through
|
||||
];
|
||||
// Portrait viewport: only the max-aspect-ratio query matches → index 2.
|
||||
assert.equal(
|
||||
selectVideoSourceIndex(sources, matchesAny(new Set(["(max-aspect-ratio: 4/5)"])), canPlayAll),
|
||||
2,
|
||||
);
|
||||
// Landscape/wide viewport: the min-aspect 16/9 query matches → index 1.
|
||||
assert.equal(
|
||||
selectVideoSourceIndex(sources, matchesAny(new Set(["(min-aspect-ratio: 16/9)"])), canPlayAll),
|
||||
1,
|
||||
);
|
||||
// Nothing matches → falls through to the plain no-media source (index 3).
|
||||
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayAll), 3);
|
||||
});
|
||||
|
||||
it("skips a source whose type the UA cannot play", () => {
|
||||
const sources: VideoSourceCandidate[] = [
|
||||
{ media: null, type: "video/webm" }, // unplayable here
|
||||
{ media: null, type: "video/mp4" },
|
||||
];
|
||||
const canPlayMp4 = (t: string) => t === "video/mp4";
|
||||
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayMp4), 1);
|
||||
});
|
||||
|
||||
it("treats a missing/empty type as never disqualifying", () => {
|
||||
const sources: VideoSourceCandidate[] = [
|
||||
{ media: null, type: "" },
|
||||
{ media: null },
|
||||
];
|
||||
// canPlay is never consulted when type is absent, even if it would reject everything.
|
||||
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayNone), 0);
|
||||
});
|
||||
|
||||
it("returns -1 when no source is eligible", () => {
|
||||
const sources: VideoSourceCandidate[] = [
|
||||
{ media: "(max-aspect-ratio: 4/5)", type: "video/webm" },
|
||||
];
|
||||
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set()), canPlayNone), -1);
|
||||
assert.equal(selectVideoSourceIndex([], matchesAny(new Set()), canPlayAll), -1);
|
||||
});
|
||||
|
||||
it("is deterministic and first-match-wins in document order", () => {
|
||||
const sources: VideoSourceCandidate[] = [
|
||||
{ media: "(min-width: 100px)", type: "video/mp4" },
|
||||
{ media: "(min-width: 100px)", type: "video/mp4" }, // also eligible, but later
|
||||
];
|
||||
assert.equal(selectVideoSourceIndex(sources, matchesAny(new Set(["(min-width: 100px)"])), canPlayAll), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planVideoSeek (post-reselection seek decision)", () => {
|
||||
it("forces an epsilon seek on a reloaded video even at t=0 (clears the show-poster flag)", () => {
|
||||
// The core regression: a just-reloaded video is at t=0 with the poster flag set. Seeking to 0
|
||||
// fires no `seeked` and leaves the poster showing; only a genuine seek to a nonzero epsilon
|
||||
// clears the flag and paints the new source's frame 0.
|
||||
const plan = planVideoSeek(true, 0);
|
||||
assert.ok(plan, "reloaded video must seek");
|
||||
assert.ok(plan!.target > 0, "reloaded seek target must be nonzero so `seeked` actually fires");
|
||||
assert.ok(plan!.target < 1e-3, "epsilon must be well inside frame 0");
|
||||
});
|
||||
|
||||
it("still forces the epsilon seek on a reloaded video that reports a small nonzero time", () => {
|
||||
const plan = planVideoSeek(true, 5e-4);
|
||||
assert.deepEqual(plan, { target: 1e-4 });
|
||||
});
|
||||
|
||||
it("fast-skips a non-reloaded video already at frame 0", () => {
|
||||
assert.equal(planVideoSeek(false, 0), null);
|
||||
assert.equal(planVideoSeek(false, 5e-4), null); // within the 1e-3 skip band
|
||||
});
|
||||
|
||||
it("seeks a non-reloaded playing video back to 0", () => {
|
||||
assert.deepEqual(planVideoSeek(false, 3.2), { target: 0 });
|
||||
assert.deepEqual(planVideoSeek(false, 1e-3), { target: 0 }); // exactly at the band edge → seek
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { RawNode, RawChild } from "../src/capture/walker.js";
|
||||
import { buildIR, isTextChild, type IR, type IRNode } from "../src/normalize/ir.js";
|
||||
import { collectNodeRules } from "../src/generate/css.js";
|
||||
|
||||
// Per-viewport DOM divergence handling:
|
||||
// • GRAFT — a child that exists ONLY at non-canonical viewports enters the IR as a sibling at
|
||||
// its source position, carrying per-viewport data only for the widths it appeared at, and is
|
||||
// emitted display:none at base + revealed in its band(s).
|
||||
// • DRIFT — a container whose children are a wholly DIFFERENT SET at some viewport (content
|
||||
// identity drift) is NOT grafted (no duplication); the canonical children stand in at that
|
||||
// width (the display:none banding is skipped) and the divergence is recorded in
|
||||
// doc.contentDrift for the manifest.
|
||||
|
||||
function raw(tag: string, attrs: Record<string, string> = {}, children: RawChild[] = [], visible = true, computedOver: Record<string, string> = {}): RawNode {
|
||||
return {
|
||||
tag, attrs,
|
||||
computed: { display: visible ? "block" : "none", position: "static", visibility: "visible", ...computedOver },
|
||||
bbox: { x: 0, y: 0, width: visible ? 640 : 0, height: visible ? 360 : 0 },
|
||||
visible,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot(vp: number, root: RawNode): object {
|
||||
return {
|
||||
doc: {
|
||||
url: "https://example.test/page", title: "Fixture",
|
||||
head: { description: "", canonical: "", ogTitle: "", ogDescription: "", ogImage: "", ogType: "", ogSiteName: "", twitterCard: "", themeColor: "" },
|
||||
lang: "en", charset: "UTF-8", viewportWidth: vp, viewportHeight: 800,
|
||||
scrollWidth: vp, scrollHeight: 800, htmlBg: "rgb(255, 255, 255)", bodyBg: "rgb(255, 255, 255)",
|
||||
bodyColor: "rgb(0, 0, 0)", bodyFont: "Arial", metaViewport: "width=device-width, initial-scale=1",
|
||||
nodeCount: 10, truncated: false,
|
||||
},
|
||||
root, cssVars: {}, fontFaces: [], cssUrls: [], domAssets: [], keyframes: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Build an IR from a DIFFERENT raw body tree per viewport. */
|
||||
function buildDivergentIR(rootByVp: Record<number, RawNode>): IR {
|
||||
const vps = Object.keys(rootByVp).map(Number).sort((a, b) => a - b);
|
||||
const sourceDir = mkdtempSync(join(tmpdir(), "ditto-ir-graft-"));
|
||||
mkdirSync(join(sourceDir, "capture"), { recursive: true });
|
||||
for (const vp of vps) {
|
||||
writeFileSync(join(sourceDir, "capture", `dom-${vp}.json`), JSON.stringify(snapshot(vp, rootByVp[vp]!)));
|
||||
}
|
||||
return buildIR(sourceDir, vps);
|
||||
}
|
||||
|
||||
function findByTag(node: IRNode, tag: string): IRNode | null {
|
||||
if (node.tag === tag) return node;
|
||||
for (const c of node.children) {
|
||||
if (isTextChild(c)) continue;
|
||||
const hit = findByTag(c, tag);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function elemChildren(node: IRNode): IRNode[] {
|
||||
return node.children.filter((c): c is IRNode => !isTextChild(c));
|
||||
}
|
||||
|
||||
// ---- fixtures ----
|
||||
|
||||
/** Carousel whose pagination (ul + 3 bullet li) exists ONLY below the canonical width. */
|
||||
function carouselFixture(): { small: RawNode; large: RawNode } {
|
||||
const track = (): RawNode => raw("div", { id: "track" });
|
||||
const pagination = (): RawNode =>
|
||||
raw("ul", { id: "pagination" }, [
|
||||
raw("li", {}, [raw("button", { type: "button" }, [{ text: "1" }])]),
|
||||
raw("li", {}, [raw("button", { type: "button" }, [{ text: "2" }])]),
|
||||
raw("li", {}, [raw("button", { type: "button" }, [{ text: "3" }])]),
|
||||
]);
|
||||
const small = raw("body", {}, [raw("section", { id: "carousel" }, [track(), pagination()])]);
|
||||
const large = raw("body", {}, [raw("section", { id: "carousel" }, [track()])]);
|
||||
return { small, large };
|
||||
}
|
||||
|
||||
/** Container serving a DIFFERENT item set per viewport (content identity drift). */
|
||||
function driftFixture(): { small: RawNode; large: RawNode } {
|
||||
const item = (id: string): RawNode => raw("li", { id }, [raw("span", {}, [{ text: `Item ${id}` }])]);
|
||||
const small = raw("body", {}, [raw("ul", { id: "rotator" }, [item("b1"), item("b2"), item("b3"), item("b4")])]);
|
||||
const large = raw("body", {}, [raw("ul", { id: "rotator" }, [item("a1"), item("a2"), item("a3"), item("a4")])]);
|
||||
return { small, large };
|
||||
}
|
||||
|
||||
describe("IR grafts non-canonical-only children", () => {
|
||||
it("grafts a subtree present only at 375 into the canonical tree at its source position", () => {
|
||||
const { small, large } = carouselFixture();
|
||||
const ir = buildDivergentIR({ 375: small, 1280: large });
|
||||
|
||||
const section = findByTag(ir.root, "section")!;
|
||||
const kids = elemChildren(section);
|
||||
assert.deepEqual(kids.map((k) => k.tag), ["div", "ul"], "pagination grafted after the matched track");
|
||||
|
||||
const ul = kids[1]!;
|
||||
assert.equal(ul.attrs.id, "pagination");
|
||||
// Per-viewport data ONLY at the source viewport — nothing invented at canonical.
|
||||
assert.deepEqual(Object.keys(ul.computedByVp), ["375"]);
|
||||
assert.equal(ul.visibleByVp[375], true);
|
||||
assert.equal(ul.computedByVp[1280], undefined);
|
||||
assert.equal(ul.bboxByVp[1280], undefined);
|
||||
// The grafted subtree's descendants carry the same viewport-scoped data.
|
||||
const bullets = elemChildren(ul);
|
||||
assert.equal(bullets.length, 3);
|
||||
for (const li of bullets) {
|
||||
assert.deepEqual(Object.keys(li.computedByVp), ["375"]);
|
||||
const btn = elemChildren(li)[0]!;
|
||||
assert.equal(btn.tag, "button");
|
||||
assert.deepEqual(Object.keys(btn.computedByVp), ["375"]);
|
||||
}
|
||||
// No drift recorded — this is an additive responsive difference, not a set swap.
|
||||
assert.equal(section.childDriftVps, undefined);
|
||||
assert.equal(ir.doc.contentDrift, undefined);
|
||||
});
|
||||
|
||||
it("merges the same non-canonical-only child across several viewports into ONE grafted node", () => {
|
||||
const { small, large } = carouselFixture();
|
||||
const ir = buildDivergentIR({ 375: small, 768: structuredClone(small), 1280: large });
|
||||
|
||||
const section = findByTag(ir.root, "section")!;
|
||||
const uls = elemChildren(section).filter((k) => k.tag === "ul");
|
||||
assert.equal(uls.length, 1, "one grafted node, not one per viewport");
|
||||
assert.deepEqual(Object.keys(uls[0]!.computedByVp).map(Number).sort((a, b) => a - b), [375, 768]);
|
||||
});
|
||||
|
||||
it("is deterministic: two builds from the same capture are byte-identical", () => {
|
||||
const { small, large } = carouselFixture();
|
||||
const a = buildDivergentIR({ 375: small, 1280: large });
|
||||
const b = buildDivergentIR({ 375: structuredClone(small), 1280: structuredClone(large) });
|
||||
assert.equal(JSON.stringify(a), JSON.stringify(b));
|
||||
});
|
||||
|
||||
it("does not graft a child that never paints at any band viewport", () => {
|
||||
const { small, large } = carouselFixture();
|
||||
// Make the whole pagination subtree invisible at 375.
|
||||
const section = small.children[0] as RawNode;
|
||||
const pag = (section.children as RawNode[]).find((c) => c.tag === "ul")!;
|
||||
const markInvisible = (n: RawNode): void => {
|
||||
n.visible = false; n.computed.display = "none"; n.bbox = { x: 0, y: 0, width: 0, height: 0 };
|
||||
for (const c of n.children) if ((c as RawNode).tag) markInvisible(c as RawNode);
|
||||
};
|
||||
markInvisible(pag);
|
||||
const ir = buildDivergentIR({ 375: small, 1280: large });
|
||||
assert.equal(findByTag(ir.root, "ul"), null, "invisible-everywhere subtree is not grafted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("IR whole-set content drift falls back to faithful-at-canonical", () => {
|
||||
it("records drift instead of grafting when both sides mutually mismatch", () => {
|
||||
const { small, large } = driftFixture();
|
||||
const ir = buildDivergentIR({ 375: small, 1280: large });
|
||||
|
||||
const rotator = findByTag(ir.root, "ul")!;
|
||||
const kids = elemChildren(rotator);
|
||||
// Canonical set only — the divergent 375 set is NOT grafted (no duplication).
|
||||
assert.deepEqual(kids.map((k) => k.attrs.id), ["a1", "a2", "a3", "a4"]);
|
||||
assert.deepEqual(rotator.childDriftVps, [375]);
|
||||
// Canonical children carry no invented data at the drift viewport.
|
||||
for (const k of kids) assert.equal(k.computedByVp[375], undefined);
|
||||
// Surfaced for the manifest, with the final (post-renumber) id.
|
||||
assert.deepEqual(ir.doc.contentDrift, [{ id: rotator.id, tag: "ul", viewports: [375] }]);
|
||||
});
|
||||
|
||||
it("does not misread an additive difference as drift", () => {
|
||||
// Canonical set fully matches at 375; 375 merely has extras → graft path, no drift.
|
||||
const item = (id: string): RawNode => raw("li", { id });
|
||||
const small = raw("body", {}, [raw("ul", { id: "list" }, [item("x1"), item("x2"), item("e1"), item("e2"), item("e3")])]);
|
||||
const large = raw("body", {}, [raw("ul", { id: "list" }, [item("x1"), item("x2")])]);
|
||||
const ir = buildDivergentIR({ 375: small, 1280: large });
|
||||
const list = findByTag(ir.root, "ul")!;
|
||||
assert.equal(list.childDriftVps, undefined);
|
||||
assert.deepEqual(elemChildren(list).map((k) => k.attrs.id), ["x1", "x2", "e1", "e2", "e3"]);
|
||||
assert.deepEqual(Object.keys(elemChildren(list)[2]!.computedByVp), ["375"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emission of grafted and drift nodes", () => {
|
||||
it("emits a grafted node as display:none at base with a full reveal band at its viewport", () => {
|
||||
const { small, large } = carouselFixture();
|
||||
const ir = buildDivergentIR({ 375: small, 1280: large });
|
||||
const ul = findByTag(ir.root, "ul")!;
|
||||
const rules = collectNodeRules(ir, new Map());
|
||||
const nr = rules.get(ul.id)!;
|
||||
assert.deepEqual([...nr.base.entries()], [["display", "none"]], "hidden at the canonical base");
|
||||
assert.equal(nr.bands.length, 1, "exactly one band: the reveal at its source viewport");
|
||||
const band = nr.bands[0]!;
|
||||
assert.match(band.media, /max-width/);
|
||||
assert.equal(band.decls.get("display"), "block", "the band reveals AND lays out the node");
|
||||
});
|
||||
|
||||
it("skips the display:none band for drift stand-ins (and their descendants), keeps it otherwise", () => {
|
||||
const { small, large } = driftFixture();
|
||||
// Control: a genuinely canonical-only sibling (absent at 375, NOT under a drift container)
|
||||
// must still be banded display:none at 375.
|
||||
(large.children as RawNode[]).push(raw("aside", { id: "desktop-only" }, [{ text: "Desktop" }]));
|
||||
const ir = buildDivergentIR({ 375: small, 1280: large });
|
||||
const rules = collectNodeRules(ir, new Map());
|
||||
|
||||
const rotator = findByTag(ir.root, "ul")!;
|
||||
for (const li of elemChildren(rotator)) {
|
||||
const nr = rules.get(li.id)!;
|
||||
assert.equal(nr.bands.some((b) => b.decls.get("display") === "none"), false, `stand-in ${li.attrs.id} not hidden at the drift viewport`);
|
||||
// Descendants of the stand-in subtree are not hidden either.
|
||||
const span = elemChildren(li)[0]!;
|
||||
const snr = rules.get(span.id)!;
|
||||
assert.equal(snr.bands.some((b) => b.decls.get("display") === "none"), false, "stand-in descendant not hidden");
|
||||
}
|
||||
|
||||
const aside = findByTag(ir.root, "aside")!;
|
||||
const anr = rules.get(aside.id)!;
|
||||
assert.equal(anr.bands.some((b) => b.decls.get("display") === "none"), true, "unrelated canonical-only node still hidden per band");
|
||||
});
|
||||
});
|
||||
@@ -158,3 +158,358 @@ describe("walker off-screen visibility", () => {
|
||||
assert.equal(fixed.visible, false, "fixed box below the viewport is invisible");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker font-metric probe tagging (fix 4)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage();
|
||||
await page.setViewportSize({ width: 375, height: 768 });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("tags a far-off-screen, non-painting measurement scratch node as a probe", async () => {
|
||||
// The classic font-metric probe pattern (WordPress/typography libs): absolutely positioned,
|
||||
// parked ~100000px off-screen, visibility:hidden, holding measurement text.
|
||||
const snap = await capture(`
|
||||
<p class="real">Real content</p>
|
||||
<div class="probe" style="position:absolute;top:-99999px;left:-99999px;
|
||||
visibility:hidden;white-space:nowrap;">Mgy</div>`);
|
||||
const probe = findByClass(snap.root, "probe")!;
|
||||
assert.equal(probe.probe, true, "far-off-screen hidden scratch node is a probe");
|
||||
const real = findByClass(snap.root, "real")!;
|
||||
assert.ok(!real.probe, "real content is not a probe");
|
||||
});
|
||||
|
||||
it("does NOT tag a near-off-screen hidden drawer (real content) as a probe", async () => {
|
||||
// A slide-in drawer parked just off the left edge (x:-375, visibility:hidden) is real
|
||||
// content that a controller can reveal — it must NOT be mistaken for a measurement probe.
|
||||
const snap = await capture(`
|
||||
<div class="drawer" style="position:fixed;left:0;top:0;width:375px;height:768px;
|
||||
transform:translateX(-100%);visibility:hidden;">
|
||||
<h2 class="dtitle">Menu</h2>
|
||||
</div>`);
|
||||
const drawer = findByClass(snap.root, "drawer")!;
|
||||
assert.ok(!drawer.probe, "a near-off-screen drawer is not a probe");
|
||||
});
|
||||
|
||||
it("does NOT tag an sr-only (visible, on-screen-adjacent) accessibility label as a probe", async () => {
|
||||
// Screen-reader-only text stays visibility:visible so AT can read it; even parked far off
|
||||
// via left:-9999px it must survive (and 9999px is under the 10000px probe threshold anyway).
|
||||
const snap = await capture(`
|
||||
<a href="/"><span class="sr" style="position:absolute;left:-9999px;">Skip to content</span>Home</a>`);
|
||||
const sr = findByClass(snap.root, "sr")!;
|
||||
assert.ok(!sr.probe, "sr-only accessible text is not a probe");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker sizing probe: circular authored-height guard", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("keeps an explicit 100vh height (circular hero/fill-child pair)", async () => {
|
||||
// A hero authored `height:100vh` with a `height:100%` fill child: setting the hero to
|
||||
// `height:auto` still reproduces its box because the child pins it back, so the raw probe
|
||||
// would read hAuto:true and the authored 100vh would be dropped — hero collapses to 0.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.hero { height: 100vh; display: flex; }
|
||||
.fill { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<section class="hero"><div class="fill"><p>content</p></div></section>`);
|
||||
const hero = findByClass(snap.root, "hero")!;
|
||||
assert.ok(hero.sizing, "hero was probed");
|
||||
assert.equal(hero.sizing!.hAuto, false, "explicit 100vh is not content-sized");
|
||||
assert.equal(hero.sizing!.hFill, false, "explicit 100vh is authored, not a parent fill");
|
||||
// Box actually equals the viewport height (720), proving the circular reproduction.
|
||||
assert.ok(Math.abs(hero.bbox.height - 720) <= 1, "hero rendered at 100vh");
|
||||
});
|
||||
|
||||
it("keeps an explicit px height whose fill child reproduces it", async () => {
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.section { height: 400px; display: flex; }
|
||||
.fill { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<div class="section"><div class="fill"><p>content</p></div></div>`);
|
||||
const section = findByClass(snap.root, "section")!;
|
||||
assert.ok(section.sizing, "section was probed");
|
||||
assert.equal(section.sizing!.hAuto, false, "explicit 400px is not content-sized");
|
||||
assert.equal(section.sizing!.hFill, false, "explicit 400px is authored, not a fill");
|
||||
assert.ok(Math.abs(section.bbox.height - 400) <= 1, "section rendered at 400px");
|
||||
});
|
||||
|
||||
it("keeps an explicit height authored via inline style", async () => {
|
||||
const snap = await capture(`
|
||||
<style>.fill { height: 100%; width: 100%; }</style>
|
||||
<div class="box" style="height: 300px; display: flex;">
|
||||
<div class="fill"><p>content</p></div>
|
||||
</div>`);
|
||||
const box = findByClass(snap.root, "box")!;
|
||||
assert.ok(box.sizing, "box was probed");
|
||||
assert.equal(box.sizing!.hAuto, false, "inline explicit height is kept");
|
||||
assert.equal(box.sizing!.hFill, false, "inline explicit height is not a fill");
|
||||
});
|
||||
|
||||
it("resolves the mutual parent/child pair without disturbing the fill child", async () => {
|
||||
// The child is a GENUINE fill (height:100%) and must keep hFill:true / hAuto:false so the
|
||||
// generator emits h-full for it; only the parent's circular verdict is corrected.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.outer { height: 500px; display: flex; }
|
||||
.inner { height: 100%; width: 100%; }
|
||||
</style>
|
||||
<div class="outer"><div class="inner"><p>content</p></div></div>`);
|
||||
const outer = findByClass(snap.root, "outer")!;
|
||||
const inner = findByClass(snap.root, "inner")!;
|
||||
assert.equal(outer.sizing!.hAuto, false, "parent explicit height kept");
|
||||
assert.equal(outer.sizing!.hFill, false, "parent is authored, not a fill");
|
||||
// The child authors only `height:100%` (a fill), so the explicit-height guard must NOT fire on
|
||||
// it — hFill stays true so the generator can still emit h-full for the genuine fill child.
|
||||
assert.equal(inner.sizing!.hFill, true, "child still fills the definite parent");
|
||||
});
|
||||
|
||||
it("still detects a genuinely content-sized (auto) height as hAuto", async () => {
|
||||
// No authored height anywhere: the box is content-sized and must stay droppable.
|
||||
const snap = await capture(`
|
||||
<style>.wrap { display: block; }</style>
|
||||
<div class="wrap"><p>just some flowing text content</p></div>`);
|
||||
const wrap = findByClass(snap.root, "wrap")!;
|
||||
assert.ok(wrap.sizing, "wrap was probed");
|
||||
assert.equal(wrap.sizing!.hAuto, true, "content-sized height is still auto");
|
||||
});
|
||||
|
||||
it("does not treat a percentage or zero authored height as explicit", async () => {
|
||||
// height:100% is the FILL case (handled by hFill), and height:0 is not definite; neither
|
||||
// should trip the explicit-height override.
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.pct-parent { height: 300px; }
|
||||
.pct { height: 100%; }
|
||||
</style>
|
||||
<div class="pct-parent"><div class="pct"><p>x</p></div></div>`);
|
||||
const pct = findByClass(snap.root, "pct")!;
|
||||
assert.ok(pct.sizing, "pct was probed");
|
||||
// A true fill child: hFill true, hAuto false — untouched by the explicit-height guard.
|
||||
assert.equal(pct.sizing!.hFill, true, "percentage height stays a fill");
|
||||
assert.equal(pct.sizing!.hAuto, false, "percentage fill is not content-sized");
|
||||
});
|
||||
});
|
||||
|
||||
// T4 — symmetric circular-WIDTH guard: an authored `width:24px` inside a SHRINK-TO-FIT parent makes
|
||||
// both width:auto and width:100% reproduce the box (the parent's width still holds), so the raw probe
|
||||
// reads wAuto/wFill and the width is dropped — collapsing the swatch in the clone. When the element
|
||||
// authors an explicit definite width (cascade or inline), the probe must trust that and clear both.
|
||||
describe("walker sizing probe: circular authored-width guard (T4)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("keeps an explicit px width inside a shrink-wrap parent (cascade rule)", async () => {
|
||||
// The wrapper is an inline-block that shrink-wraps to the swatch; width:auto on the swatch still
|
||||
// reads 24px because the parent width holds → the raw verdict would be wAuto:true (dropped).
|
||||
const snap = await capture(`
|
||||
<style>
|
||||
.wrapper { display: inline-block; border: 1px solid #000; }
|
||||
.swatch { width: 24px; height: 24px; display: block; background: red; }
|
||||
</style>
|
||||
<span class="wrapper"><a class="swatch"></a></span>`);
|
||||
const swatch = findByClass(snap.root, "swatch")!;
|
||||
assert.ok(swatch.sizing, "swatch was probed");
|
||||
assert.equal(swatch.sizing!.wAuto, false, "explicit 24px width is not content-sized (auto)");
|
||||
assert.equal(swatch.sizing!.wFill, false, "explicit 24px width is authored, not a parent fill");
|
||||
});
|
||||
|
||||
it("keeps an explicit width authored via inline style", async () => {
|
||||
const snap = await capture(`
|
||||
<span style="display:inline-block;border:1px solid #000;">
|
||||
<a class="swatch2" style="width:24px;height:24px;display:block;background:blue;"></a>
|
||||
</span>`);
|
||||
const swatch = findByClass(snap.root, "swatch2")!;
|
||||
assert.ok(swatch.sizing, "swatch was probed");
|
||||
assert.equal(swatch.sizing!.wAuto, false, "inline explicit width is kept (not auto)");
|
||||
assert.equal(swatch.sizing!.wFill, false, "inline explicit width is not a fill");
|
||||
});
|
||||
|
||||
it("still detects a genuinely content-sized (auto) width as wAuto", async () => {
|
||||
// No authored width: an inline-block sizing to its text must stay droppable (wAuto:true).
|
||||
const snap = await capture(`
|
||||
<div style="display:block;"><span class="cw" style="display:inline-block;">hello content</span></div>`);
|
||||
const cw = findByClass(snap.root, "cw")!;
|
||||
assert.ok(cw.sizing, "cw was probed");
|
||||
assert.equal(cw.sizing!.wAuto, true, "content-sized width is still auto");
|
||||
});
|
||||
|
||||
it("harvests an explicit-width rule from a custom element's SHADOW ROOT stylesheet", async () => {
|
||||
// The width:24px rule lives inside the custom element's shadow root (a <style> in the shadow tree),
|
||||
// never in document.styleSheets. Without walking shadow-root sheets the harvest misses it and the
|
||||
// circular-width guard can't fire for the swatch link. Verify the shadow swatch keeps its width.
|
||||
const snap = await capture(`
|
||||
<script>
|
||||
customElements.define('color-swatch', class extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const r = this.attachShadow({ mode: 'open' });
|
||||
r.innerHTML = '<style>.wrap{display:inline-block;border:1px solid #000}.pin{width:24px;height:24px;display:block;background:green}</style><span class="wrap"><a class="pin"></a></span>';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<color-swatch></color-swatch>`);
|
||||
const pin = findByClass(snap.root, "pin")!;
|
||||
assert.ok(pin.sizing, "shadow swatch was probed");
|
||||
assert.equal(pin.sizing!.wAuto, false, "shadow-root explicit 24px width is kept (not auto)");
|
||||
assert.equal(pin.sizing!.wFill, false, "shadow-root explicit width is not a fill");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker text-wrap capture", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("captures text-wrap:balance on a heading (modern line-balancing)", async () => {
|
||||
// Real case: a hero heading authored `text-wrap:balance` wraps its two lines evenly; without
|
||||
// capturing the prop the clone wraps it lopsidedly.
|
||||
const snap = await capture(`<h1 style="text-wrap:balance">BUILT RUGGED. WORN DAILY.</h1>`);
|
||||
const h1 = findByTag(snap.root, "h1")!;
|
||||
assert.equal(h1.computed.textWrap, "balance", "text-wrap:balance is captured");
|
||||
});
|
||||
|
||||
it("captures text-wrap:pretty", async () => {
|
||||
const snap = await capture(`<p style="text-wrap:pretty">Some flowing paragraph text here.</p>`);
|
||||
const p = findByTag(snap.root, "p")!;
|
||||
assert.equal(p.computed.textWrap, "pretty", "text-wrap:pretty is captured");
|
||||
});
|
||||
});
|
||||
|
||||
describe("walker shadow-DOM composed-tree serialization (FIX 1)", () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
before(async () => {
|
||||
browser = await chromium.launch();
|
||||
page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
});
|
||||
after(async () => {
|
||||
await browser.close();
|
||||
});
|
||||
|
||||
// Attach an OPEN shadow root to any host carrying data-shadow, injecting its data-shadow value as
|
||||
// the shadow tree HTML. Runs in-page before collectPage so getComputedStyle/bbox see the composed tree.
|
||||
const capture = async (html: string) => {
|
||||
await page.setContent(html);
|
||||
await page.evaluate(() => {
|
||||
for (const host of Array.from(document.querySelectorAll("[data-shadow]"))) {
|
||||
const markup = host.getAttribute("data-shadow") || "";
|
||||
const sr = (host as HTMLElement).attachShadow({ mode: "open" });
|
||||
sr.innerHTML = markup;
|
||||
}
|
||||
});
|
||||
await page.evaluate("globalThis.__name = globalThis.__name || ((fn) => fn);");
|
||||
return page.evaluate(collectPage);
|
||||
};
|
||||
|
||||
it("serializes an open custom element's shadow tree as the host's children", async () => {
|
||||
// A `<product-info>` web component renders its swatches/title/price INSIDE its shadow root; a
|
||||
// childNodes-only walk captured it empty. The composed walk must surface the shadow content.
|
||||
const snap = await capture(`
|
||||
<product-info data-shadow="
|
||||
<div class='pi-title'>Magnolia Shirt</div>
|
||||
<span class='pi-price'>$78.00</span>
|
||||
"></product-info>`);
|
||||
const host = findByTag(snap.root, "product-info")!;
|
||||
assert.ok(host, "the custom element host is present");
|
||||
assert.equal(host.shadowHost, true, "the host is tagged as a shadow host");
|
||||
assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Magnolia Shirt $78.00", "the shadow tree text is captured");
|
||||
const title = findByClass(host, "pi-title")!;
|
||||
assert.ok(title, "a shadow descendant node is serialized");
|
||||
assert.equal(title.inShadow, true, "shadow descendants are tagged inShadow");
|
||||
assert.ok(title.bbox.width > 0, "shadow nodes get real bboxes via getBoundingClientRect");
|
||||
});
|
||||
|
||||
it("renders the FLATTENED tree: a <slot> is replaced by its assigned light-DOM nodes", async () => {
|
||||
// The host's light child (the assigned node) renders at the slot position — once, and NOT tagged
|
||||
// inShadow (it is the author's real content). Shadow chrome around the slot still appears.
|
||||
const snap = await capture(`
|
||||
<my-card data-shadow="
|
||||
<div class='card-frame'><slot></slot></div>
|
||||
"><h2 class='slotted'>Vintage Sunset T-Shirt</h2></my-card>`);
|
||||
const host = findByTag(snap.root, "my-card")!;
|
||||
const frame = findByClass(host, "card-frame")!;
|
||||
assert.ok(frame, "the shadow frame around the slot is serialized");
|
||||
assert.equal(frame.inShadow, true, "the shadow frame is tagged inShadow");
|
||||
const slotted = findByClass(host, "slotted")!;
|
||||
assert.ok(slotted, "the slotted light child renders at the slot position");
|
||||
assert.equal(textRun(slotted).trim(), "Vintage Sunset T-Shirt");
|
||||
assert.ok(!slotted.inShadow, "the slotted light child is NOT tagged inShadow (author content)");
|
||||
// No double-serialization: exactly one node carries the slotted text.
|
||||
let count = 0;
|
||||
const countText = (n: RawNode): void => {
|
||||
if (n.children.some((c) => isText(c) && c.text.includes("Vintage Sunset T-Shirt"))) count++;
|
||||
for (const c of n.children) if (!isText(c)) countText(c as RawNode);
|
||||
};
|
||||
countText(host);
|
||||
assert.equal(count, 1, "the slotted child is serialized exactly once");
|
||||
});
|
||||
|
||||
it("uses <slot> fallback content when nothing is assigned", async () => {
|
||||
const snap = await capture(`
|
||||
<my-badge data-shadow="
|
||||
<span class='badge'><slot>Default Label</slot></span>
|
||||
"></my-badge>`);
|
||||
const host = findByTag(snap.root, "my-badge")!;
|
||||
assert.equal(textRun(host).replace(/\s+/g, " ").trim(), "Default Label", "unfilled slot falls back to its own content");
|
||||
});
|
||||
|
||||
it("does not tag ordinary light-DOM nodes as shadow", async () => {
|
||||
const snap = await capture(`<div class="plain"><p>light</p></div>`);
|
||||
const plain = findByClass(snap.root, "plain")!;
|
||||
assert.ok(!plain.shadowHost, "a plain div is not a shadow host");
|
||||
assert.ok(!plain.inShadow, "plain light DOM is not tagged inShadow");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user