Pattern catalog: pinned library fingerprints as hint-only recipe evidence
Ported from PR #15: a SHA256-pinned catalog of 49 generic library and platform fingerprints (carousel/consent/chat/commerce vendors) with an O(1) signature index. Catalog hits strengthen recipe recognition (small confidence deltas, marquee->logo-cloud, carousel false-grid skip, lightbox->gallery, commerce prior) and are recorded in the manifest as pattern evidence with node references - they never override captured geometry, which remains gated by the existing plan-agreement checks. The integrity pin is unconditional: a catalog/lock mismatch always throws (the fork's env-var soft-warning mode is removed). 515 tests pass (10 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7e8863fc7e
commit
f991f469cf
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"version": 1,
|
||||
"description": "Frozen pattern catalog: signature-indexed widget/platform/animation detectors. Matched against IR srcClass/tag/attrs (frozen capture evidence) by src/knowledge/patternIndex.ts. Deterministic: same catalog + same IR => same hints. Regenerate the lock after edits: node --import tsx src/knowledge/patternIndex.ts --write-lock",
|
||||
"patterns": [
|
||||
{ "id": "carousel_slick", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["slick-slider", "slick-track", "slick-slide", "slick-dots", "slick-list"] } },
|
||||
{ "id": "carousel_swiper", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["swiper", "swiper-wrapper", "swiper-slide", "swiper-container", "swiper-pagination"] } },
|
||||
{ "id": "carousel_splide", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["splide", "splide__track", "splide__slide", "splide__list"] } },
|
||||
{ "id": "carousel_owl", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["owl-carousel", "owl-stage", "owl-item"] } },
|
||||
{ "id": "carousel_flickity", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["flickity-enabled", "flickity-viewport", "flickity-slider"] } },
|
||||
{ "id": "carousel_glide", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["glide", "glide__track", "glide__slides", "glide__slide"] } },
|
||||
{ "id": "carousel_embla", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["embla", "embla__container", "embla__slide"] } },
|
||||
{ "id": "carousel_keen", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["keen-slider", "keen-slider__slide"] } },
|
||||
{ "id": "carousel_tiny", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["tns-outer", "tns-inner", "tns-slider", "tns-item"] } },
|
||||
{ "id": "carousel_bootstrap", "kind": "carousel", "flags": ["deferred_interactive", "gallery"], "match": { "classTokens": ["carousel-inner", "carousel-item", "carousel-indicators"] } },
|
||||
{ "id": "marquee_rfm", "kind": "marquee", "flags": ["deferred_interactive"], "match": { "classPrefixes": ["rfm-"] } },
|
||||
{ "id": "marquee_generic", "kind": "marquee", "flags": ["deferred_interactive"], "match": { "classTokens": ["marquee", "ticker", "scroller"], "tags": ["marquee"] } },
|
||||
{ "id": "counter_odometer", "kind": "counter", "flags": ["counter", "deferred_interactive"], "match": { "classTokens": ["odometer", "odometer-inside", "odometer-digit"] } },
|
||||
{ "id": "counter_countup", "kind": "counter", "flags": ["counter", "deferred_interactive"], "match": { "classTokens": ["countup", "counter-up", "purecounter"] } },
|
||||
{ "id": "typed_text", "kind": "text_effect", "flags": ["deferred_interactive"], "match": { "classTokens": ["typed-cursor", "typewrite", "typewriter"] } },
|
||||
{ "id": "anim_aos", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classTokens": ["aos-init", "aos-animate"], "attrNames": ["data-aos"] } },
|
||||
{ "id": "anim_wow", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classTokens": ["wow"], "attrNames": ["data-wow-delay", "data-wow-duration"] } },
|
||||
{ "id": "anim_animate_css", "kind": "css_animation", "flags": ["motion_lib"], "match": { "classPrefixes": ["animate__"], "classTokens": ["animated"] } },
|
||||
{ "id": "anim_locomotive", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "attrNames": ["data-scroll", "data-scroll-container", "data-scroll-section"] } },
|
||||
{ "id": "anim_rellax", "kind": "parallax", "flags": ["motion_lib"], "match": { "classTokens": ["rellax"], "attrNames": ["data-rellax-speed"] } },
|
||||
{ "id": "anim_gsap_scrolltrigger", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "classPrefixes": ["gsap-"], "attrNames": ["data-gsap"] } },
|
||||
{ "id": "anim_sal", "kind": "scroll_animation", "flags": ["motion_lib"], "match": { "attrNames": ["data-sal", "data-sal-delay"] } },
|
||||
{ "id": "lottie_widget", "kind": "lottie", "flags": ["deferred_interactive", "motion_lib"], "match": { "classTokens": ["lottie", "lottie-animation"], "tags": ["lottie-player", "dotlottie-player"], "attrNames": ["data-lottie", "data-animation-path"] } },
|
||||
{ "id": "particles_bg", "kind": "background_effect", "flags": ["deferred_interactive"], "match": { "classTokens": ["particles-js", "tsparticles"], "idPrefixes": ["particles-js"] } },
|
||||
{ "id": "video_player_lib", "kind": "video_player", "flags": ["deferred_interactive"], "match": { "classTokens": ["video-js", "plyr", "jwplayer", "vjs-tech"] } },
|
||||
{ "id": "lightbox_gallery", "kind": "lightbox", "flags": ["deferred_interactive", "gallery"], "match": { "classPrefixes": ["lg-", "pswp"], "classTokens": ["fancybox", "glightbox", "lightbox"] } },
|
||||
{ "id": "platform_elementor", "kind": "platform", "flags": ["platform_elementor"], "match": { "classPrefixes": ["elementor-"], "classTokens": ["elementor"] } },
|
||||
{ "id": "platform_wp_blocks", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["wp-block-", "wp-container-"] } },
|
||||
{ "id": "platform_wpbakery", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["vc_"], "classTokens": ["wpb_wrapper", "wpb_row"] } },
|
||||
{ "id": "platform_divi", "kind": "platform", "flags": ["platform_wordpress"], "match": { "classPrefixes": ["et_pb_"] } },
|
||||
{ "id": "platform_webflow", "kind": "platform", "flags": ["platform_webflow"], "match": { "classTokens": ["w-nav", "w-slider", "w-dropdown", "w-container", "w-richtext"], "attrNames": ["data-wf-page", "data-wf-site"] } },
|
||||
{ "id": "platform_shopify", "kind": "platform", "flags": ["platform_shopify"], "match": { "classTokens": ["shopify-section"], "idPrefixes": ["shopify-section"] } },
|
||||
{ "id": "platform_squarespace", "kind": "platform", "flags": ["platform_squarespace"], "match": { "classPrefixes": ["sqs-"], "attrNames": ["data-block-type"] } },
|
||||
{ "id": "platform_framer", "kind": "platform", "flags": ["platform_framer"], "match": { "classPrefixes": ["framer-"], "attrNames": ["data-framer-name"] } },
|
||||
{ "id": "platform_woocommerce", "kind": "platform", "flags": ["platform_wordpress", "ecommerce"], "match": { "classTokens": ["woocommerce", "woocommerce-page"] } },
|
||||
{ "id": "nav_hamburger", "kind": "nav_toggle", "flags": ["nav_toggle"], "match": { "classTokens": ["hamburger", "navbar-toggler", "menu-toggle", "nav-toggle", "burger", "menu-btn"] } },
|
||||
{ "id": "nav_mega_menu", "kind": "nav_menu", "flags": ["nav_toggle"], "match": { "classTokens": ["mega-menu", "megamenu", "dropdown-menu", "sub-menu"] } },
|
||||
{ "id": "consent_onetrust", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["ot-sdk-", "onetrust-"], "idPrefixes": ["onetrust-"] } },
|
||||
{ "id": "consent_cookiebot", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "idPrefixes": ["CybotCookiebot"] } },
|
||||
{ "id": "consent_osano", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["osano-"] } },
|
||||
{ "id": "consent_cookieyes", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["cky-"] } },
|
||||
{ "id": "consent_complianz", "kind": "consent", "flags": ["consent_overlay", "third_party_widget"], "match": { "classPrefixes": ["cmplz-"] } },
|
||||
{ "id": "chat_intercom", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["intercom-"], "idPrefixes": ["intercom-"] } },
|
||||
{ "id": "chat_crisp", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["crisp-"], "idPrefixes": ["crisp-"] } },
|
||||
{ "id": "chat_drift", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["drift-"], "idPrefixes": ["drift-"] } },
|
||||
{ "id": "chat_hubspot", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "classPrefixes": ["hs-chat-", "hubspot-"], "idPrefixes": ["hubspot-"] } },
|
||||
{ "id": "chat_tawk", "kind": "chat_widget", "flags": ["third_party_widget"], "match": { "idPrefixes": ["tawk-"] } },
|
||||
{ "id": "accordion_generic", "kind": "accordion", "flags": ["nav_toggle"], "match": { "classTokens": ["accordion", "accordion-item", "accordion-header", "collapsible"] } },
|
||||
{ "id": "tabs_generic", "kind": "tabs", "flags": ["nav_toggle"], "match": { "classTokens": ["tabs", "tab-content", "tab-pane", "tablist"] } }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
3f46aca40bb69d5c114e957c28b3a4924c58902cecb35332d34f3065114d0e75
|
||||
@@ -4,6 +4,7 @@ import type { Tokens } from "../infer/tokens.js";
|
||||
import type { AssetGraph } from "../infer/assets.js";
|
||||
import type { FontGraph } from "../infer/fonts.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
import type { PatternHints } from "../knowledge/patternIndex.js";
|
||||
|
||||
export const COMPILER_VERSION = "0.1.0";
|
||||
export const SCHEMA_VERSION = 1;
|
||||
@@ -16,8 +17,9 @@ export function buildManifest(args: {
|
||||
fontGraph: FontGraph;
|
||||
capture: CaptureResult;
|
||||
componentCount: number;
|
||||
patternHints?: PatternHints;
|
||||
}): Record<string, unknown> {
|
||||
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount } = args;
|
||||
const { ir, sections, tokens, assetGraph, fontGraph, capture, componentCount, patternHints } = args;
|
||||
|
||||
const byType: Record<string, number> = {};
|
||||
let downloaded = 0, skipped = 0;
|
||||
@@ -56,6 +58,23 @@ export function buildManifest(args: {
|
||||
fallback: fontGraph.entries.filter((f) => f.status === "fallback").length,
|
||||
},
|
||||
components: { count: componentCount },
|
||||
// Frozen-catalog pattern evidence (hint-only, additive): library/platform fingerprints
|
||||
// detected in the IR, with the node cids that carried each signature (bounded pre-order
|
||||
// sample). Deterministic — matches are id-sorted and cids are pre-order, so the same
|
||||
// catalog + IR yields byte-identical evidence. Generated docs read this to say e.g.
|
||||
// "Swiper carousel detected". Omitted when no hints were computed.
|
||||
...(patternHints
|
||||
? {
|
||||
patterns: {
|
||||
catalogVersion: patternHints.catalogVersion,
|
||||
catalogHash: patternHints.catalogHash,
|
||||
flags: patternHints.flags,
|
||||
platforms: patternHints.platforms,
|
||||
simpleStatic: patternHints.simpleStatic,
|
||||
matches: patternHints.matches.map((m) => ({ id: m.id, kind: m.kind, count: m.count, cids: m.cids })),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// Fidelity note: containers whose children were a DIFFERENT SET at some band viewport(s)
|
||||
// (content-identity drift — the source deterministically served other content there). The
|
||||
// clone shows the canonical-viewport children at those widths (faithful-at-canonical) instead
|
||||
|
||||
@@ -13,6 +13,7 @@ import { buildCodeQualityReport, codeQualityReportToMarkdown, type CodeQualityRe
|
||||
import { interactionRejectedSet } from "./interactive.js";
|
||||
import { buildManifest } from "./manifest.js";
|
||||
import { buildSeoInventory, seoInventoryToMarkdown, type SeoInventory } from "./seo.js";
|
||||
import { resolvePatternHints, type PatternHints } from "../knowledge/patternIndex.js";
|
||||
import { writeJSON, writeText, readJSON, fileExists } from "../util/fsx.js";
|
||||
import type { CaptureResult } from "../capture/capture.js";
|
||||
|
||||
@@ -24,6 +25,7 @@ export type GenerateAllResult = {
|
||||
fontGraph: FontGraph;
|
||||
recipeReport: RecipeReport;
|
||||
interactionRecipeReport: InteractionRecipeReport;
|
||||
patternHints: PatternHints;
|
||||
seoInventory: SeoInventory;
|
||||
codeQuality: CodeQualityReport;
|
||||
manifest: Record<string, unknown>;
|
||||
@@ -59,6 +61,10 @@ export function generateAll(opts: {
|
||||
|
||||
const sections = detectSections(ir);
|
||||
const tokens = extractTokens(ir);
|
||||
// Pattern hints: frozen-catalog signature scan over the IR (deterministic; the
|
||||
// pin is asserted here and throws on catalog/lock drift). Fed as ADDITIVE evidence
|
||||
// into recipe recognition — never overrides captured geometry.
|
||||
const patternHints = resolvePatternHints(ir);
|
||||
const assetGraph = buildAssetGraph(capture);
|
||||
const fontGraph = buildFontGraph(capture.fontFaces, assetGraph, url);
|
||||
const seoInventory = buildSeoInventory(ir, assetGraph, capture);
|
||||
@@ -69,7 +75,7 @@ export function generateAll(opts: {
|
||||
const tokensCss = (palette.css ? palette.css + "\n" : "") + tokensToCss(tokens, true);
|
||||
const tokenResolver = buildTokenResolver(tokens);
|
||||
const primitives = recognizePrimitives(ir);
|
||||
const recipeReport = buildRecipeReport(ir, sections, primitives);
|
||||
const recipeReport = buildRecipeReport(ir, sections, primitives, patternHints);
|
||||
const interactionRecipeReport = buildInteractionRecipeReport(ir, sections, capture.interaction);
|
||||
// Patterns the interaction gate previously rejected (don't reproduce) → left static.
|
||||
const rejPath = join(sourceDir, "interaction-rejected.json");
|
||||
@@ -95,6 +101,7 @@ export function generateAll(opts: {
|
||||
writeJSON(join(outDir, "fonts.json"), fontGraph.entries);
|
||||
const inventory = inventoryOf(ir, primitives);
|
||||
writeJSON(join(outDir, "components.json"), inventory);
|
||||
writeJSON(join(outDir, "patterns.json"), patternHints);
|
||||
writeJSON(join(outDir, "recipes.json"), recipeReport);
|
||||
writeText(join(outDir, "recipes.md"), recipeReportToMarkdown(recipeReport));
|
||||
writeJSON(join(outDir, "interaction-recipes.json"), interactionRecipeReport);
|
||||
@@ -104,8 +111,8 @@ export function generateAll(opts: {
|
||||
const codeQuality = buildCodeQualityReport(appDir, recipeReport);
|
||||
writeJSON(join(outDir, "code-quality.json"), codeQuality);
|
||||
writeText(join(outDir, "code-quality.md"), codeQualityReportToMarkdown(codeQuality));
|
||||
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count });
|
||||
const manifest = buildManifest({ ir, sections, tokens, assetGraph, fontGraph, capture, componentCount: inventory.count, patternHints });
|
||||
writeJSON(join(outDir, "manifest.json"), manifest);
|
||||
|
||||
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
||||
return { ir, sections, tokens, assetGraph, fontGraph, recipeReport, interactionRecipeReport, patternHints, seoInventory, codeQuality, manifest, assetsCopied: mat.copied, assetsMissing: mat.missing };
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ export type { GateResult } from "./validate/gates.js";
|
||||
export { buildIR } from "./normalize/ir.js";
|
||||
export type { IR } from "./normalize/ir.js";
|
||||
|
||||
// ---- Pattern knowledge (frozen catalog; deterministic hints; pin asserted on load) ----
|
||||
export { resolvePatternHints, loadPatternIndex, assertPinnedCatalog, matchCatalogNode } from "./knowledge/patternIndex.js";
|
||||
export type { PatternHints, PatternMatch, PatternDef, PatternCatalog } from "./knowledge/patternIndex.js";
|
||||
|
||||
// ---- Capture surface + version ----
|
||||
export { captureSite, REQUIRED_VIEWPORTS } from "./capture/capture.js";
|
||||
export type { CaptureResult } from "./capture/capture.js";
|
||||
|
||||
@@ -3,6 +3,7 @@ import { isTextChild } from "../normalize/ir.js";
|
||||
import type { PrimitiveType } from "./primitives.js";
|
||||
import type { Section } from "./sections.js";
|
||||
import { round } from "../util/canonical.js";
|
||||
import { matchCatalogNode, type PatternDef, type PatternHints } from "../knowledge/patternIndex.js";
|
||||
|
||||
export type RecipeKind = "logo-cloud" | "feature-grid" | "card-grid" | "product-grid" | "gallery-showcase" | "cta-band";
|
||||
|
||||
@@ -60,6 +61,8 @@ export type RecipeReport = {
|
||||
highConfidence: number;
|
||||
byKind: Record<string, number>;
|
||||
templateReadyKinds: RecipeKind[];
|
||||
/** page-level frozen-catalog pattern ids (from resolvePatternHints), when provided */
|
||||
catalogPatterns?: string[];
|
||||
};
|
||||
candidates: RecipeCandidate[];
|
||||
};
|
||||
@@ -78,6 +81,10 @@ type RecipeContext = {
|
||||
parentById: ParentMap;
|
||||
sectionByNodeId: SectionMap;
|
||||
primitives: Map<string, PrimitiveType>;
|
||||
/** per-node frozen-catalog matches, memoized (pattern catalog as a 4th evidence source) */
|
||||
catalogByCid: Map<string, PatternDef[]>;
|
||||
/** page-level catalog flags from resolvePatternHints (e.g. platform_shopify, ecommerce) */
|
||||
pageFlags: Set<string>;
|
||||
};
|
||||
|
||||
type ItemStats = {
|
||||
@@ -233,7 +240,7 @@ function nodeStats(ctx: RecipeContext, n: IRNode): ItemStats {
|
||||
};
|
||||
}
|
||||
|
||||
function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeContext {
|
||||
function buildContext(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>, patternHints?: PatternHints): RecipeContext {
|
||||
const nodes: IRNode[] = [];
|
||||
const byId: NodeMap = new Map();
|
||||
const parentById: ParentMap = new Map();
|
||||
@@ -254,9 +261,32 @@ function buildContext(ir: IR, sections: Section[], primitives: Map<string, Primi
|
||||
parentById,
|
||||
sectionByNodeId: new Map(sections.map((s) => [s.nodeId, s])),
|
||||
primitives,
|
||||
catalogByCid: new Map(),
|
||||
pageFlags: new Set(patternHints?.flags ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function catalogDefs(ctx: RecipeContext, node: IRNode): PatternDef[] {
|
||||
let defs = ctx.catalogByCid.get(node.id);
|
||||
if (!defs) {
|
||||
defs = matchCatalogNode(node);
|
||||
ctx.catalogByCid.set(node.id, defs);
|
||||
}
|
||||
return defs;
|
||||
}
|
||||
|
||||
/** First catalog pattern in `root`'s subtree matching `pred` (pre-order; memoized
|
||||
* per node). The frozen catalog is deterministic, so recipe evidence stays
|
||||
* byte-stable across regenerations. */
|
||||
function subtreeCatalogMatch(ctx: RecipeContext, root: IRNode, pred: (d: PatternDef) => boolean): PatternDef | null {
|
||||
for (const d of catalogDefs(ctx, root)) if (pred(d)) return d;
|
||||
for (const c of elementChildren(root)) {
|
||||
const hit = subtreeCatalogMatch(ctx, c, pred);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function nearestSection(ctx: RecipeContext, n: IRNode): Section | undefined {
|
||||
let cur: IRNode | undefined = n;
|
||||
while (cur) {
|
||||
@@ -545,18 +575,23 @@ function detectLogoClouds(ctx: RecipeContext): RecipeDraft[] {
|
||||
const rootText = textContent(root, 1200);
|
||||
const layout = isLayoutish(ctx, parent, logos.map((s) => s.node));
|
||||
const avgHeight = logos.reduce((sum, s) => sum + (s.bbox?.height ?? 0), 0) / logos.length;
|
||||
// Logo strips often ride marquee libraries (react-fast-marquee, ticker) —
|
||||
// a catalog marquee hit is strong logo-cloud evidence.
|
||||
const catalogMarquee = subtreeCatalogMatch(ctx, parent, (d) => d.kind === "marquee");
|
||||
const signals = [
|
||||
`${logos.length} repeated media-light children`,
|
||||
layout ? "item parent behaves like grid/flex/wrapped row" : "item parent has repeated logo geometry",
|
||||
TRUSTED_COPY.test(rootText) ? "nearby copy matches trusted-by/brand language" : "",
|
||||
avgHeight <= 96 ? "logo item boxes stay small" : "",
|
||||
catalogMarquee ? `pattern catalog identifies a ${catalogMarquee.id} strip` : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.58
|
||||
+ Math.min(0.18, logos.length * 0.025)
|
||||
+ (logos.length / kids.length) * 0.12
|
||||
+ (layout ? 0.08 : 0)
|
||||
+ (TRUSTED_COPY.test(rootText) ? 0.10 : 0)
|
||||
+ (avgHeight <= 96 ? 0.04 : 0);
|
||||
+ (avgHeight <= 96 ? 0.04 : 0)
|
||||
+ (catalogMarquee ? 0.04 : 0);
|
||||
out.push(baseDraft(ctx, "logo-cloud", parent, logos, confidence, signals));
|
||||
}
|
||||
return out;
|
||||
@@ -568,6 +603,10 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
for (const parent of ctx.nodes) {
|
||||
if (parent.id === ctx.ir.root.id) continue;
|
||||
if (subtreeSourceClassMatches(parent, DEFERRED_INTERACTIVE_COLLECTION)) continue;
|
||||
// Catalog-known carousel/marquee libraries the regex misses (embla, flickity,
|
||||
// keen-slider, glide, tns, …) are the same deferred-interactive class: their
|
||||
// children are slides, not a static grid.
|
||||
if (subtreeCatalogMatch(ctx, parent, (d) => d.kind === "carousel" || d.kind === "marquee")) continue;
|
||||
const kids = visibleElementChildren(parent, ctx.cw);
|
||||
if (kids.length < 2 || kids.length > 36) continue;
|
||||
const stats = kids.map((k) => nodeStats(ctx, k));
|
||||
@@ -581,7 +620,10 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
const rootTooBroad = rootInfo.root.id === ctx.ir.root.id || (!!pageH && !!rootBox && rootBox.height > pageH * 0.55);
|
||||
const localText = textContent(parent, 1200);
|
||||
const parentText = rootTooBroad ? localText : textContent(rootInfo.root, 1600);
|
||||
const galleryContext = subtreeAttrOrSourceMatches(parent, GALLERY_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, GALLERY_SOURCE));
|
||||
// Lightbox libraries (fancybox, photoswipe, lightGallery, …) mark a thumbnail
|
||||
// grid as a gallery even when no gallery-ish class names are present.
|
||||
const catalogLightbox = subtreeCatalogMatch(ctx, parent, (d) => d.kind === "lightbox");
|
||||
const galleryContext = subtreeAttrOrSourceMatches(parent, GALLERY_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, GALLERY_SOURCE)) || !!catalogLightbox;
|
||||
const testimonialContext = subtreeAttrOrSourceMatches(parent, TESTIMONIAL_SOURCE) || TESTIMONIAL_COPY.test(localText);
|
||||
const featureItems = stats.filter(isLikelyFeatureItem);
|
||||
const cardItems = stats.filter(isLikelyCardItem);
|
||||
@@ -598,6 +640,7 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
const signals = [
|
||||
`${cardLikeItems.length} repeated ${testimonialContext ? "testimonial/gallery" : "media/gallery"} items`,
|
||||
galleryContext ? "source attributes/classes identify a gallery or carousel track" : "",
|
||||
catalogLightbox ? `pattern catalog identifies a ${catalogLightbox.id} lightbox gallery` : "",
|
||||
testimonialContext ? "source text/classes identify a testimonial or horizontal story strip" : "",
|
||||
oneRowRegimes >= 2 ? "items remain in a horizontal gallery row across sampled widths" : "",
|
||||
mediaRatio >= 0.6 ? "most gallery items include media" : "",
|
||||
@@ -619,9 +662,13 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
const productItems = stats.filter(isLikelyProductItem);
|
||||
const forbiddenProductContext = subtreeAttrOrSourceMatches(parent, /\b(?:footer|directory|nav|menu)\b/i)
|
||||
|| (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, /\b(?:footer|directory|nav|menu)\b/i));
|
||||
// Page-level catalog prior: a Shopify/WooCommerce page makes repeated card
|
||||
// grids product grids even when per-node class names carry no commerce words.
|
||||
const catalogCommerce = ctx.pageFlags.has("ecommerce") || ctx.pageFlags.has("platform_shopify");
|
||||
const productContext = !forbiddenProductContext &&
|
||||
(PRODUCT_COPY.test(localText) || subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE)
|
||||
|| (!rootTooBroad && (PRODUCT_COPY.test(parentText) || subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))));
|
||||
|| (!rootTooBroad && (PRODUCT_COPY.test(parentText) || subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE)))
|
||||
|| catalogCommerce);
|
||||
const productStats = productItems.length >= 2 ? productItems : (productContext ? cardLikeItems : []);
|
||||
if (productStats.length >= 2 && productStats.length / kids.length >= 0.45 && productContext) {
|
||||
const key = itemSetKey("product-grid", productStats);
|
||||
@@ -635,6 +682,7 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
productCopyRatio >= 0.5 ? "most items contain commerce/product copy" : "",
|
||||
ctaRatio >= 0.5 ? "most products include link/button affordances" : "",
|
||||
(subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? "source context is product/promo/commerce-like" : "",
|
||||
catalogCommerce ? "pattern catalog identifies an e-commerce platform page" : "",
|
||||
].filter(Boolean);
|
||||
const confidence = 0.58
|
||||
+ Math.min(0.14, productStats.length * 0.025)
|
||||
@@ -642,7 +690,8 @@ function detectGrids(ctx: RecipeContext): RecipeDraft[] {
|
||||
+ (layout ? 0.10 : 0)
|
||||
+ (productCopyRatio >= 0.5 ? 0.07 : 0)
|
||||
+ (ctaRatio >= 0.5 ? 0.05 : 0)
|
||||
+ ((subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? 0.06 : 0);
|
||||
+ ((subtreeAttrOrSourceMatches(parent, PRODUCT_SOURCE) || (!rootTooBroad && subtreeAttrOrSourceMatches(rootInfo.root, PRODUCT_SOURCE))) ? 0.06 : 0)
|
||||
+ (catalogCommerce ? 0.05 : 0);
|
||||
out.push(baseDraft(ctx, "product-grid", parent, productStats, confidence, signals));
|
||||
}
|
||||
continue;
|
||||
@@ -832,8 +881,8 @@ function candidateSort(ctx: RecipeContext, a: RecipeDraft, b: RecipeDraft): numb
|
||||
|| b.confidence - a.confidence;
|
||||
}
|
||||
|
||||
export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>): RecipeReport {
|
||||
const ctx = buildContext(ir, sections, primitives);
|
||||
export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<string, PrimitiveType>, patternHints?: PatternHints): RecipeReport {
|
||||
const ctx = buildContext(ir, sections, primitives, patternHints);
|
||||
const drafts = suppressDuplicates(ctx, [
|
||||
...detectLogoClouds(ctx),
|
||||
...detectGrids(ctx),
|
||||
@@ -853,6 +902,7 @@ export function buildRecipeReport(ir: IR, sections: Section[], primitives: Map<s
|
||||
highConfidence: candidates.filter((c) => c.confidence >= 0.82).length,
|
||||
byKind,
|
||||
templateReadyKinds: TEMPLATE_READY,
|
||||
...(patternHints ? { catalogPatterns: patternHints.matches.map((m) => m.id) } : {}),
|
||||
},
|
||||
candidates,
|
||||
};
|
||||
@@ -880,6 +930,7 @@ export function recipeReportToMarkdown(report: RecipeReport): string {
|
||||
lines.push("");
|
||||
lines.push(`- Candidates: ${report.summary.totalCandidates}`);
|
||||
lines.push(`- High confidence: ${report.summary.highConfidence}`);
|
||||
if (report.summary.catalogPatterns?.length) lines.push(`- Catalog patterns on page: ${report.summary.catalogPatterns.join(", ")}`);
|
||||
lines.push(`- By kind: ${Object.entries(report.summary.byKind).map(([k, v]) => `${k} ${v}`).join(", ") || "none"}`);
|
||||
lines.push("");
|
||||
lines.push("## Candidates");
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Pattern index: O(1) signature lookup over the frozen pattern catalog
|
||||
* (data/pattern-catalog.json). Matches known widget/platform/animation
|
||||
* signatures against the IR's diagnostic evidence (srcClass, tags, attrs)
|
||||
* and produces deterministic PatternHints consumed as a generation artifact
|
||||
* (patterns.json) and as an ADDITIVE evidence source for recipe recognition.
|
||||
*
|
||||
* Determinism contract: the catalog is FROZEN data pinned by sha256
|
||||
* (data/pattern-catalog.lock). Same catalog + same IR => byte-identical hints.
|
||||
* There is no learning layer here — hints are catalog-only by construction.
|
||||
* The pin is UNCONDITIONAL: a catalog/lock mismatch (or a missing lock) always
|
||||
* throws, so pinned evidence can never drift silently.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { IR, IRNode, IRChild } from "../normalize/ir.js";
|
||||
|
||||
export type PatternMatchSpec = {
|
||||
/** exact class-token equality (lowercase) */
|
||||
classTokens?: string[];
|
||||
/** class-token prefix, e.g. "elementor-", "wp-block-" */
|
||||
classPrefixes?: string[];
|
||||
/** element tag names, e.g. custom elements like "lottie-player" */
|
||||
tags?: string[];
|
||||
/** attribute presence, e.g. "data-aos" */
|
||||
attrNames?: string[];
|
||||
/** id-attribute prefix, e.g. "shopify-section" */
|
||||
idPrefixes?: string[];
|
||||
};
|
||||
|
||||
export type PatternDef = {
|
||||
id: string;
|
||||
kind: string;
|
||||
flags: string[];
|
||||
match: PatternMatchSpec;
|
||||
};
|
||||
|
||||
export type PatternCatalog = {
|
||||
version: number;
|
||||
description?: string;
|
||||
patterns: PatternDef[];
|
||||
};
|
||||
|
||||
export type PatternMatch = {
|
||||
id: string;
|
||||
kind: string;
|
||||
flags: string[];
|
||||
/** how many IR nodes matched at least one signature of this pattern */
|
||||
count: number;
|
||||
/** first few matching node ids, pre-order (bounded sample for diagnostics) */
|
||||
cids: string[];
|
||||
};
|
||||
|
||||
export type PatternHints = {
|
||||
catalogVersion: number;
|
||||
catalogHash: string;
|
||||
/** matched patterns, sorted by id for determinism */
|
||||
matches: PatternMatch[];
|
||||
/** union of flags across matches, sorted */
|
||||
flags: string[];
|
||||
/** platform_* flags with the prefix stripped, sorted (e.g. ["elementor","wordpress"]) */
|
||||
platforms: string[];
|
||||
/** no deferred-interactive / motion-lib / counter / nav-toggle signatures and a small tree —
|
||||
* callers may skip optional capture/inference stages for such pages */
|
||||
simpleStatic: boolean;
|
||||
};
|
||||
|
||||
const CATALOG_PATH = fileURLToPath(new URL("../../data/pattern-catalog.json", import.meta.url));
|
||||
const LOCK_PATH = fileURLToPath(new URL("../../data/pattern-catalog.lock", import.meta.url));
|
||||
const SAMPLE_CIDS = 5;
|
||||
/** simpleStatic requires the whole tree under this node count (matches "small landing page"). */
|
||||
const SIMPLE_STATIC_MAX_NODES = 1500;
|
||||
const SIMPLE_STATIC_BLOCKERS = ["deferred_interactive", "motion_lib", "counter", "nav_toggle"];
|
||||
|
||||
type CompiledIndex = {
|
||||
catalog: PatternCatalog;
|
||||
hash: string;
|
||||
byClassToken: Map<string, PatternDef[]>;
|
||||
classPrefixes: Array<[string, PatternDef]>;
|
||||
byTag: Map<string, PatternDef[]>;
|
||||
byAttrName: Map<string, PatternDef[]>;
|
||||
idPrefixes: Array<[string, PatternDef]>;
|
||||
};
|
||||
|
||||
let cached: CompiledIndex | null = null;
|
||||
|
||||
function addTo<K>(map: Map<K, PatternDef[]>, key: K, def: PatternDef): void {
|
||||
const list = map.get(key);
|
||||
if (list) list.push(def);
|
||||
else map.set(key, [def]);
|
||||
}
|
||||
|
||||
export function loadPatternIndex(): CompiledIndex {
|
||||
if (cached) return cached;
|
||||
const raw = readFileSync(CATALOG_PATH, "utf8");
|
||||
const hash = createHash("sha256").update(raw).digest("hex");
|
||||
const catalog = JSON.parse(raw) as PatternCatalog;
|
||||
if (!Array.isArray(catalog.patterns)) throw new Error("pattern catalog: missing patterns[]");
|
||||
const idx: CompiledIndex = {
|
||||
catalog,
|
||||
hash,
|
||||
byClassToken: new Map(),
|
||||
classPrefixes: [],
|
||||
byTag: new Map(),
|
||||
byAttrName: new Map(),
|
||||
idPrefixes: [],
|
||||
};
|
||||
for (const p of catalog.patterns) {
|
||||
for (const t of p.match.classTokens ?? []) addTo(idx.byClassToken, t.toLowerCase(), p);
|
||||
for (const pre of p.match.classPrefixes ?? []) idx.classPrefixes.push([pre.toLowerCase(), p]);
|
||||
for (const t of p.match.tags ?? []) addTo(idx.byTag, t.toLowerCase(), p);
|
||||
for (const a of p.match.attrNames ?? []) addTo(idx.byAttrName, a.toLowerCase(), p);
|
||||
for (const pre of p.match.idPrefixes ?? []) idx.idPrefixes.push([pre, p]);
|
||||
}
|
||||
cached = idx;
|
||||
return idx;
|
||||
}
|
||||
|
||||
/** Enforce the frozen-catalog pin. UNCONDITIONAL: a missing lock or a hash
|
||||
* mismatch always throws — pinned evidence must never drift silently, so the
|
||||
* generator refuses to run against an unpinned/edited catalog. Regenerate the
|
||||
* lock deliberately after a catalog edit with `--write-lock`. */
|
||||
export function assertPinnedCatalog(): void {
|
||||
const idx = loadPatternIndex();
|
||||
if (!existsSync(LOCK_PATH)) {
|
||||
throw new Error("pattern catalog lock missing (data/pattern-catalog.lock)");
|
||||
}
|
||||
const pinned = readFileSync(LOCK_PATH, "utf8").trim();
|
||||
if (pinned !== idx.hash) {
|
||||
throw new Error(
|
||||
`pattern catalog hash mismatch: pinned ${pinned.slice(0, 12)}… actual ${idx.hash.slice(0, 12)}…`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isElement(c: IRChild): c is IRNode {
|
||||
return (c as IRNode).id !== undefined;
|
||||
}
|
||||
|
||||
/** Match ONE node's own evidence (srcClass tokens, tag, attr names, id prefix)
|
||||
* against the catalog. Shared by the page-level hint scan and per-candidate
|
||||
* recipe scoring. Returns matched defs, deduped, in catalog order. */
|
||||
export function matchCatalogNode(node: Pick<IRNode, "tag" | "attrs" | "srcClass">): PatternDef[] {
|
||||
const idx = loadPatternIndex();
|
||||
const hits = new Set<PatternDef>();
|
||||
if (node.srcClass) {
|
||||
for (const tok of node.srcClass.toLowerCase().split(/\s+/)) {
|
||||
if (!tok) continue;
|
||||
for (const def of idx.byClassToken.get(tok) ?? []) hits.add(def);
|
||||
for (const [pre, def] of idx.classPrefixes) if (tok.startsWith(pre)) hits.add(def);
|
||||
}
|
||||
}
|
||||
for (const def of idx.byTag.get(node.tag.toLowerCase()) ?? []) hits.add(def);
|
||||
for (const attr of Object.keys(node.attrs)) {
|
||||
for (const def of idx.byAttrName.get(attr.toLowerCase()) ?? []) hits.add(def);
|
||||
}
|
||||
const id = node.attrs.id;
|
||||
if (id) for (const [pre, def] of idx.idPrefixes) if (id.startsWith(pre)) hits.add(def);
|
||||
return idx.catalog.patterns.filter((p) => hits.has(p));
|
||||
}
|
||||
|
||||
/** Single deterministic pre-order walk of the IR: tokenize srcClass, check tag /
|
||||
* attr / id signatures, accumulate per-pattern counts + a bounded cid sample.
|
||||
* Asserts the frozen-catalog pin first (throws on mismatch). */
|
||||
export function resolvePatternHints(ir: IR): PatternHints {
|
||||
const idx = loadPatternIndex();
|
||||
assertPinnedCatalog();
|
||||
|
||||
const counts = new Map<string, { def: PatternDef; count: number; cids: string[] }>();
|
||||
const hit = (def: PatternDef, cid: string, seen: Set<string>) => {
|
||||
if (seen.has(def.id)) return; // one hit per node per pattern
|
||||
seen.add(def.id);
|
||||
let e = counts.get(def.id);
|
||||
if (!e) { e = { def, count: 0, cids: [] }; counts.set(def.id, e); }
|
||||
e.count++;
|
||||
if (e.cids.length < SAMPLE_CIDS) e.cids.push(cid);
|
||||
};
|
||||
|
||||
const visit = (node: IRNode) => {
|
||||
const seen = new Set<string>();
|
||||
for (const def of matchCatalogNode(node)) hit(def, node.id, seen);
|
||||
for (const c of node.children) if (isElement(c)) visit(c);
|
||||
};
|
||||
visit(ir.root);
|
||||
|
||||
const matches: PatternMatch[] = [...counts.values()]
|
||||
.map((e) => ({ id: e.def.id, kind: e.def.kind, flags: e.def.flags, count: e.count, cids: e.cids }))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
const flags = [...new Set(matches.flatMap((m) => m.flags))].sort();
|
||||
const platforms = flags
|
||||
.filter((f) => f.startsWith("platform_"))
|
||||
.map((f) => f.slice("platform_".length))
|
||||
.sort();
|
||||
const simpleStatic =
|
||||
ir.doc.nodeCount < SIMPLE_STATIC_MAX_NODES && !flags.some((f) => SIMPLE_STATIC_BLOCKERS.includes(f));
|
||||
|
||||
return { catalogVersion: idx.catalog.version, catalogHash: idx.hash, matches, flags, platforms, simpleStatic };
|
||||
}
|
||||
|
||||
// `--write-lock` maintenance entry: refresh the pin after a deliberate catalog edit.
|
||||
if (process.argv[1] && import.meta.url === new URL(`file://${process.argv[1]}`).href && process.argv.includes("--write-lock")) {
|
||||
const idx = loadPatternIndex();
|
||||
writeFileSync(LOCK_PATH, idx.hash + "\n");
|
||||
console.log("pinned pattern catalog:", idx.hash);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
loadPatternIndex,
|
||||
assertPinnedCatalog,
|
||||
resolvePatternHints,
|
||||
matchCatalogNode,
|
||||
} from "../src/knowledge/patternIndex.js";
|
||||
import { planForFrameUrl } from "../src/capture/graft.js";
|
||||
import type { IR, IRNode, IRChild } from "../src/normalize/ir.js";
|
||||
|
||||
const CW = 1280;
|
||||
const LOCK_PATH = fileURLToPath(new URL("../data/pattern-catalog.lock", import.meta.url));
|
||||
|
||||
/** Element node at the canonical viewport (ids assigned in pre-order by `page`). */
|
||||
function el(
|
||||
tag: string,
|
||||
opts: { srcClass?: string; attrs?: Record<string, string>; children?: IRChild[] } = {},
|
||||
): IRNode {
|
||||
return {
|
||||
id: "",
|
||||
tag,
|
||||
attrs: opts.attrs ?? {},
|
||||
srcClass: opts.srcClass,
|
||||
visibleByVp: { [CW]: true },
|
||||
bboxByVp: { [CW]: { x: 0, y: 0, width: CW, height: 100 } },
|
||||
computedByVp: { [CW]: { display: "block" } },
|
||||
children: opts.children ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Wrap children in a <body> root and assign stable pre-order ids (n0, n1, …). */
|
||||
function page(children: IRNode[]): IR {
|
||||
const root = el("body", { children });
|
||||
let i = 0;
|
||||
const assign = (n: IRNode): void => {
|
||||
n.id = `n${i++}`;
|
||||
for (const c of n.children) if ((c as IRNode).tag) assign(c as IRNode);
|
||||
};
|
||||
assign(root);
|
||||
return {
|
||||
doc: {
|
||||
sourceUrl: "https://example.test/", title: "Fixture", lang: "en", charset: "UTF-8",
|
||||
metaViewport: "width=device-width, initial-scale=1",
|
||||
viewports: [CW], sampleViewports: [CW], canonicalViewport: CW,
|
||||
perViewport: { [CW]: { scrollHeight: 100, scrollWidth: CW, htmlBg: "", bodyBg: "", bodyColor: "", bodyFont: "" } },
|
||||
nodeCount: i, keyframes: [],
|
||||
},
|
||||
root,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pattern catalog: load + pin", () => {
|
||||
it("loads the frozen catalog and pins by sha256 (lock verifies)", () => {
|
||||
const idx = loadPatternIndex();
|
||||
assert.equal(idx.catalog.version, 1);
|
||||
assert.ok(idx.catalog.patterns.length > 0, "catalog has patterns");
|
||||
// The on-disk lock must equal the catalog's live hash — no throw.
|
||||
assert.doesNotThrow(() => assertPinnedCatalog());
|
||||
const pinned = readFileSync(LOCK_PATH, "utf8").trim();
|
||||
assert.equal(pinned, idx.hash, "lock matches catalog hash");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pattern catalog: integrity check throws unconditionally", () => {
|
||||
const original = readFileSync(LOCK_PATH, "utf8");
|
||||
afterEach(() => {
|
||||
// Always restore the real lock so later tests / runs see the pinned hash.
|
||||
writeFileSync(LOCK_PATH, original);
|
||||
});
|
||||
|
||||
it("throws on a lock/catalog hash mismatch (no env-var escape hatch)", () => {
|
||||
writeFileSync(LOCK_PATH, "deadbeef".repeat(8) + "\n");
|
||||
assert.throws(() => assertPinnedCatalog(), /hash mismatch/);
|
||||
// Restoring must make it pass again.
|
||||
writeFileSync(LOCK_PATH, original);
|
||||
assert.doesNotThrow(() => assertPinnedCatalog());
|
||||
});
|
||||
});
|
||||
|
||||
describe("pattern catalog: fingerprint fixtures hit", () => {
|
||||
it("matchCatalogNode identifies a generic carousel fingerprint by class token", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: {}, srcClass: "swiper swiper-wrapper" });
|
||||
assert.deepEqual(hits.map((h) => h.id), ["carousel_swiper"]);
|
||||
assert.equal(hits[0].kind, "carousel");
|
||||
});
|
||||
|
||||
it("matchCatalogNode identifies a platform fingerprint by class prefix", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: {}, srcClass: "elementor-widget-container" });
|
||||
assert.ok(hits.some((h) => h.id === "platform_elementor"), "elementor prefix hit");
|
||||
});
|
||||
|
||||
it("matchCatalogNode identifies a scroll-animation fingerprint by attribute presence", () => {
|
||||
const hits = matchCatalogNode({ tag: "div", attrs: { "data-aos": "fade-up" }, srcClass: undefined });
|
||||
assert.ok(hits.some((h) => h.id === "anim_aos"), "data-aos attr hit");
|
||||
});
|
||||
|
||||
it("resolvePatternHints walks the IR deterministically and records id + cid evidence", () => {
|
||||
const ir = page([
|
||||
el("div", { srcClass: "swiper", children: [el("div", { srcClass: "swiper-slide" })] }),
|
||||
el("div", { srcClass: "swiper-slide" }),
|
||||
]);
|
||||
const hints = resolvePatternHints(ir);
|
||||
const swiper = hints.matches.find((m) => m.id === "carousel_swiper");
|
||||
assert.ok(swiper, "swiper detected");
|
||||
// Three nodes carried a swiper token → count 3, cids are pre-order node ids.
|
||||
assert.equal(swiper!.count, 3);
|
||||
assert.deepEqual(swiper!.cids, ["n1", "n2", "n3"]);
|
||||
// matches are sorted by id → deterministic evidence ordering.
|
||||
const ids = hints.matches.map((m) => m.id);
|
||||
assert.deepEqual(ids, [...ids].sort((a, b) => a.localeCompare(b)));
|
||||
// Same catalog + same IR ⇒ byte-identical hints.
|
||||
assert.deepEqual(resolvePatternHints(page([
|
||||
el("div", { srcClass: "swiper", children: [el("div", { srcClass: "swiper-slide" })] }),
|
||||
el("div", { srcClass: "swiper-slide" }),
|
||||
])), hints);
|
||||
});
|
||||
|
||||
it("resolvePatternHints exposes platform flags (stripped) and simpleStatic", () => {
|
||||
const ir = page([el("section", { srcClass: "shopify-section" })]);
|
||||
const hints = resolvePatternHints(ir);
|
||||
assert.ok(hints.platforms.includes("shopify"), "shopify platform surfaced");
|
||||
assert.ok(hints.flags.includes("platform_shopify"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("popup-vendor skip list: FRAME_SKIP semantics preserved (single source of truth)", () => {
|
||||
// The catalog's consent/chat entries are DOM class/id fingerprints; the capture
|
||||
// pipeline's popup-vendor skip list (graft.ts FRAME_SKIP_RE) matches iframe host
|
||||
// URLs. They are orthogonal axes — the catalog carries no URL hosts, so there is
|
||||
// nothing to consolidate; capture's list stays the single source of truth for
|
||||
// frame-graft decisions. Assert its semantics are unchanged.
|
||||
it("skips promo/consent creative iframe hosts", () => {
|
||||
assert.equal(planForFrameUrl("https://widget.privy.com/assets/popup.html"), "skip");
|
||||
assert.equal(planForFrameUrl("https://app.recart.com/creative"), "skip");
|
||||
assert.equal(planForFrameUrl("https://www.googletagmanager.com/ns.html"), "skip");
|
||||
});
|
||||
|
||||
it("still grafts inline form embeds and same-origin blank frames", () => {
|
||||
assert.equal(planForFrameUrl("https://static-forms.klaviyo.com/form"), "graft");
|
||||
assert.equal(planForFrameUrl("about:blank"), "graft");
|
||||
assert.equal(planForFrameUrl(""), "graft");
|
||||
});
|
||||
|
||||
it("still renders media players as stills", () => {
|
||||
assert.equal(planForFrameUrl("https://www.youtube.com/embed/abc"), "still");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user