Composed-tree shadow DOM capture, sized invisible spacers, track re-anchoring, block fill-width slides, javascript: href sanitization
- Walker serializes the composed (flattened) tree: open shadow roots walk as host children with <slot> replaced by its assigned light nodes (or fallback), so custom-element content (product cards, web components) is captured instead of arriving childless - In-flow visibility:hidden nodes with a nonzero border box survive the prune as sized invisible placeholders - ghost spacers carry load-bearing geometry - When pruning drops leading in-flow children of a horizontally-translated track, the baked translateX is re-anchored by their aggregate margin-box width (settled carousel tracks return to origin instead of pushing real content off-screen); animation-owned transforms unaffected - Circular-shrink slide guard counts block-level fill children as width-deriving regardless of wAuto (block auto-width IS fill), pinning library-sized slide widths uniformly - javascript: hrefs emit as "#" and the link gate normalizes both sides 382 tests pass (18 new), typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6e4921884c
commit
762855664c
@@ -48,6 +48,14 @@ export type RawNode = {
|
||||
// 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;
|
||||
@@ -316,7 +324,7 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
|| bbox.x >= OFFSCREEN_PROBE_PX + vpW || bbox.y >= OFFSCREEN_PROBE_PX + pageH;
|
||||
};
|
||||
|
||||
const serializeElement = (el: Element): RawNode | null => {
|
||||
const serializeElement = (el: Element, inShadow = false): RawNode | null => {
|
||||
if (nodeCount >= MAX_NODES) { truncated = true; return null; }
|
||||
const tag = el.tagName.toLowerCase();
|
||||
nodeCount++;
|
||||
@@ -445,6 +453,7 @@ export function collectPage(opts?: { maxNodes?: number } | void): PageSnapshot {
|
||||
bbox,
|
||||
visible: isVisible(el, cs, bbox),
|
||||
...(isProbe(cs, bbox) ? { probe: true } : {}),
|
||||
...(inShadow ? { inShadow: true } : {}),
|
||||
...(sizing ? { sizing } : {}),
|
||||
children: [],
|
||||
};
|
||||
@@ -488,8 +497,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) {
|
||||
@@ -509,8 +538,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -369,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);
|
||||
|
||||
@@ -1034,7 +1034,16 @@ function isCircularShrinkSlide(node: IRNode, parentNode: IRNode | undefined, vie
|
||||
const cpos = ccs.position || "static";
|
||||
if (cpos === "absolute" || cpos === "fixed") continue; // out of flow → no width contribution
|
||||
const csz = c.sizingByVp?.[vp];
|
||||
if (csz && csz.wFill === true && csz.wAuto === false) { fillChildren++; continue; } // fills the slide → derives width from it
|
||||
// A block-level in-flow box (block/flex/grid/table/list-item) takes its width FROM its
|
||||
// container by definition: its `width:auto` computed size IS the fill size, so the probe
|
||||
// reports wAuto AND wFill both true — indistinguishable from a genuine content-sized source.
|
||||
// Such a child still DERIVES its width from the slide (it never establishes one), so it must
|
||||
// count as a fill child, not veto the circular case. Only wFill matters for a block child;
|
||||
// wAuto is redundant with it. Inline / inline-block children (whose auto width is genuinely
|
||||
// content-derived and can size the slide) keep the strict wFill && !wAuto test.
|
||||
const cDisp = ccs.display || "";
|
||||
const blockLevel = /^(block|flex|grid|table|list-item|flow-root)$/.test(cDisp);
|
||||
if (csz && csz.wFill === true && (blockLevel || csz.wAuto === false)) { fillChildren++; continue; } // fills/derives width from the slide
|
||||
return false; // a genuine in-flow width source → not circular
|
||||
}
|
||||
if (fillChildren === 0) return false;
|
||||
|
||||
@@ -251,6 +251,54 @@ export function isIdentityTransform(value: string | undefined): boolean {
|
||||
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:
|
||||
@@ -506,6 +554,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) {
|
||||
@@ -522,16 +572,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) {
|
||||
|
||||
@@ -313,8 +313,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);
|
||||
|
||||
Reference in New Issue
Block a user