Section decomposition: recursive descent with semantic band detection
detectSections gave up on real pages: <body> swallowed everything, a single oversized-wrapper expansion pass required >=3 candidates, and a 62px navbar failed the 64px height bar - so nearly every clone shipped as one section and one monolithic component. Rewritten around a shared recursive core (detectSectionNodes): containers covering >50% of the page are wrappers to descend into; a split is accepted only when >=2 stacked bands tile >=80% of the container (side-by-side columns and overlays never split; truly single-band pages legally stay 1). Semantic tags get a 24px bar with nav-evidence detection for thin bars. The validator's section list and the emitted section components now consume the same root set, so gate-5 bands and sections/*.tsx files always agree. Naming is evidence-based and deterministic: navbar/header/hero (only when actually near the top), recipe names (logo-cloud, product-grid...), heading slugs, contact/media, positional fallback. Frozen-capture probes: 1 -> 6..17 sections across the reference runs, with the old whole-page "hero-section" monoliths splitting into honest bands. 231 tests pass (13 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
d3ec154bae
commit
6a930d57b1
@@ -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;
|
||||
@@ -225,18 +220,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 +234,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 +253,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 +264,26 @@ 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 {
|
||||
const slug = slugWords(titleText(sec));
|
||||
name = slug ? `${slug}Section` : `Section${i + 1}`;
|
||||
name = 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)));
|
||||
});
|
||||
|
||||
+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"] {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user