fix(inspector): grep timeline windows, continuation, candidates and timeline text from the Canary dogfood (#5595)

* fix(inspector): report nested hosts' authored window, one location per source line

The runtime strips data-duration/data-end from nested composition hosts after
boot and keeps them as data-hf-authored-*. Timeline inspection built its
resolver without authored attrs, so a host with data-duration="4" reported
its GSAP timeline length (3.72s) instead.

Timeline text and events[].sources listed the same file:line once per mapped
snippet (creation, caller, dependency), so a one-line script printed one
location up to nine times.

* refactor(inspector): carry the dedupe reason in the helper's name

* fix(inspector): end continuation on a page that fits no snippet; fill budget with candidates

* fix(inspector): timeline groups by component, names attr values, eases, film end

* fix(studio-server): stamp hf ids into a file without reformatting it

* fix(inspector): find an element's tag before its served hf id reaches the source

* fix(inspector): live element map shows authored classes and text, not the playhead's

* fix(inspector): collapse script-built text so the element map ignores the playhead

Class names stay in source order, a runtime-stripped id is shown as id="…"
rather than a selector, and a grouped row names which children it covers.

* fix(inspector): keep paging, stamping, and the live map still on the reported edges

A snippet larger than the page ended the walk, so later snippets could not
be reached. Raw text ended at a longer tag name, which could stamp an id
inside a script. An empty or mixed source element showed live text, and an
unknown duration read as a film that ends at 0s.

* fix(inspector): mint ids for an empty hf id and keep an unquoted class

An empty data-hf-id was treated as already assigned, so stamping left it
blank while the older stamper minted one. An unquoted class was read as no
class, so the live map dropped it.

* fix(inspector): narrow markup before reading a child tag

skipMarkup returns a boolean, so the build still sees Markup where a tag
is required. Narrow on the kind first so the inspector typechecks.

* fix: quiet check false positives and name the ancestor of an id-less target

Endpoint contact, disjoint fromTo pairs, and a negative z-index under a
stacking-context parent were warnings with bad advice. Layout and contrast
blamed a dot grid, a circular avatar's panel, and a visibility override.
A faint texture veil is no longer the click target.

* fix(lint): drop a history sentence and six gsap comments

The comments check treats "load-bearing" as history, and gsap.ts added six
comment lines. The names already carry what those lines said.

* fix(parsers): ignore an empty id written inside another attribute

The empty-id scan matched the first data-hf-id="" in the tag, including one
inside a quoted title. It now skips quoted text, so the real attribute is minted.

* fix(lint): keep real covers and a negative z-index under a card

A gradient scale-down treated every fill like a radial dot, so a panel
that starts transparent stopped counting as a cover. A stacking context
on any ancestor cleared a negative z-index, and a faint wrapper made
images inside it unpickable.

* fix: keep an emoji from shifting a stamped id

The quote mask replaced one emoji with one space, so the id landed before
the closing quote. A radial cover that turns opaque after a clear stop
stays fully painted.

* fix(lint): score a letter-only dot as a dot, not a color name

namesAColor saw #fff and rgba as words, so those dot grids counted as a
full cover. Hex and rgb colors are stripped before that word check.
This commit is contained in:
Xuanru Li
2026-10-14 09:16:20 +00:00
committed by GitHub
parent 38cfcdd56c
commit 45825faa5d
37 changed files with 1769 additions and 122 deletions
@@ -286,6 +286,16 @@ window.__contrastAuditPrepare = function () {
var fontWeight = Number(cs.fontWeight) || 400;
var large = fontSize >= 24 || (fontSize >= 19 && fontWeight >= 700);
// Glyphs sit on this element's own fill. The bounding box also contains
// border-radius corners and neighbouring pixels, and those read as the
// panel behind a circular avatar. An opaque background with no image is
// the fill; pixel sampling stays for transparent text.
var ownBg = null;
if ((cs.backgroundImage || "none") === "none") {
var parsedOwnBg = parseColor(cs.backgroundColor || "");
if (parsedOwnBg && parsedOwnBg[3] >= 0.99) ownBg = parsedOwnBg;
}
// Hide this element's OWN text paint so the caller's next screenshot
// reveals the true pixels behind the glyphs. Layout-neutral: color/fill
// never affect box geometry. `!important` beats any non-!important rule
@@ -341,6 +351,7 @@ window.__contrastAuditPrepare = function () {
fontSize: fontSize,
fontWeight: fontWeight,
large: large,
ownBg: ownBg,
bbox: { x: rect.x, y: rect.y, w: rect.width, h: rect.height },
});
}
@@ -465,15 +476,21 @@ window.__contrastAuditFinish = async function (imgBase64, time, candidates) {
}
if (rr.length === 0) continue;
// A transparent sampled backdrop is supplied by the downstream editor,
// not this composition. WCAG contrast cannot be inferred until that live
// video/image is known, so do not invent an opaque browser default and
// hard-fail an otherwise valid alpha overlay.
if (median(aa) < 255) continue;
var bgR = median(rr),
bgG = median(gg),
var bgR, bgG, bgB;
if (c.ownBg && c.ownBg.length >= 4 && c.ownBg[3] >= 0.99) {
bgR = c.ownBg[0];
bgG = c.ownBg[1];
bgB = c.ownBg[2];
} else {
// A transparent sampled backdrop is supplied by the downstream editor,
// not this composition. WCAG contrast cannot be inferred until that live
// video/image is known, so do not invent an opaque browser default and
// hard-fail an otherwise valid alpha overlay.
if (median(aa) < 255) continue;
bgR = median(rr);
bgG = median(gg);
bgB = median(bb);
}
// Composite foreground alpha over the measured background
var fg = c.fg;
+117 -12
View File
@@ -662,10 +662,14 @@
}
function hasAllowOverlapFlag(element) {
// Overlap intent belongs to the text block that participates in the
// layering. Inheriting this marker from a scene/root would turn one local
// waiver into a blanket exemption for every collision in that subtree.
return element.hasAttribute("data-layout-allow-overlap");
// The marker applies to the element that carries it and to text inside
// that container. It stops at the composition root: a waiver on the scene
// itself would exempt every collision in the subtree.
for (let current = element; current; current = current.parentElement) {
if (current.hasAttribute("data-composition-id")) return false;
if (current.hasAttribute("data-layout-allow-overlap")) return true;
}
return false;
}
function isTransparentColor(color) {
@@ -800,16 +804,48 @@
message: "Two text blocks overlap and may render unreadable.",
rect: a.rect,
fixHint:
"Give each block its own zone, or mark intentional layering with data-layout-allow-overlap.",
"Give each block its own zone, or put data-layout-allow-overlap on the text or on a container inside the scene. A marker on the scene root does not apply.",
};
}
function hiddenVisibilityAncestor(element) {
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
const visibility = getComputedStyle(parent).visibility;
if (visibility === "hidden" || visibility === "collapse") return parent;
}
return null;
}
function visibilityEscapeIssue(block, hiddenAncestor, time) {
return {
code: "visibility_escapes_segment",
severity: "warning",
time,
selector: uniqueSelectorFor(block.element),
containerSelector: uniqueSelectorFor(hiddenAncestor),
text: textContentFor(block.element),
message:
"This text stays on screen after its segment ends. visibility:visible here overrides an ancestor's visibility:hidden, so the runtime hide does not reach it.",
rect: block.rect,
fixHint: "Remove the explicit visibility:visible so this element hides with its ancestor.",
};
}
function contentOverlapIssues(root, time) {
const blocks = collectSolidTextBlocks(root);
const issues = [];
for (let i = 0; i < blocks.length; i++) {
for (let j = i + 1; j < blocks.length; j++) {
const issue = overlapIssue(blocks[i], blocks[j], time);
const participating = [];
for (const block of blocks) {
const hiddenAncestor = hiddenVisibilityAncestor(block.element);
if (hiddenAncestor) {
issues.push(visibilityEscapeIssue(block, hiddenAncestor, time));
continue;
}
participating.push(block);
}
for (let i = 0; i < participating.length; i++) {
for (let j = i + 1; j < participating.length; j++) {
const issue = overlapIssue(participating[i], participating[j], time);
if (issue) issues.push(issue);
}
}
@@ -820,8 +856,10 @@
let imageAlpha = 0;
if (style.backgroundImage && style.backgroundImage !== "none") {
if (style.backgroundImage.includes("url(")) return true;
// A gradient only occludes as much as its colours — a 4%-alpha grid/scrim must not count.
imageAlpha = gradientLayersAlpha(style.backgroundImage);
// A gradient only occludes as much as its colours — a 4%-alpha grid/scrim
// must not count, and neither must a solid-color dot a couple of pixels
// wide tiled across a much larger background-size.
imageAlpha = gradientLayersAlpha(style.backgroundImage, style.backgroundSize);
}
const colorValue = isTransparentColor(style.backgroundColor)
? 0
@@ -831,14 +869,68 @@
}
// background-image layers stack: two 0.5-alpha gradients paint at 1-(1-.5)^2 = .75.
function gradientLayersAlpha(backgroundImage) {
function gradientLayersAlpha(backgroundImage, backgroundSize) {
let combined = 0;
for (const layer of splitTopLevelCommas(backgroundImage)) {
combined = 1 - (1 - combined) * (1 - gradientMaxAlpha(layer));
const coverage = gradientStopCoverage(layer, backgroundSize);
combined = 1 - (1 - combined) * (1 - gradientMaxAlpha(layer) * coverage);
}
return combined;
}
// Share of a radial dot's tile. A linear fill, or an opaque stop after a
// transparent one, stays fully painted. A 1.6px dot on a 38px tile does not.
function gradientStopCoverage(layer, backgroundSize) {
if (!/radial-gradient/i.test(layer) || namesAColor(withoutScoredColors(layer))) return 1;
const stops = [
...layer.matchAll(/(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|transparent)\s+([\d.]+)(px|%)/gi),
];
const tile = pxTile(backgroundSize);
const reach = tile ? leadingOpaqueRadius(stops, tile) : null;
if (reach === null) return 1;
return Math.min(1, (Math.PI * reach * reach) / (tile * tile));
}
function pxTile(backgroundSize) {
const sizeMatch = String(backgroundSize || "").match(/([\d.]+)px/);
return sizeMatch ? Number(sizeMatch[1]) : 0;
}
function leadingOpaqueRadius(stops, tile) {
const walked = walkOpaquePrefix(stops, tile);
if (!walked || !walked.sawOpaque || !walked.sawTransparent) return null;
return walked.reach;
}
function walkOpaquePrefix(stops, tile) {
const state = { reach: 0, sawOpaque: false, sawTransparent: false };
for (const stop of stops) {
if (!takeRadiusStop(state, stop, tile)) return null;
}
return state;
}
function takeRadiusStop(state, stop, tile) {
if (stopAlpha(stop[1] || "") <= 0.6) {
state.sawTransparent = true;
return true;
}
if (state.sawTransparent) return false;
state.sawOpaque = true;
state.reach = Math.max(state.reach, stopLength(stop, tile));
return true;
}
function stopAlpha(color) {
if (color === "transparent") return 0;
if (color.startsWith("#")) return 1;
return colorAlpha(color);
}
function stopLength(stop, tile) {
return stop[3] === "%" ? (Number(stop[2]) / 100) * tile : Number(stop[2]);
}
function splitTopLevelCommas(value) {
const parts = [];
let depth = 0;
@@ -862,6 +954,7 @@
.replace(/(?:repeating-)?(?:linear|radial|conic)-gradient\(/gi, "(")
.replace(/rgba?\([^)]*\)/gi, "");
if (/[a-z][a-z-]+\(/i.test(known)) return 1;
if (namesAColor(known)) return 1;
const colors = backgroundImage.match(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b|\btransparent\b/g);
if (!colors) return 1;
let max = 0;
@@ -884,6 +977,18 @@
return max;
}
function withoutScoredColors(layer) {
return layer.replace(/rgba?\([^)]*\)|#[0-9a-fA-F]{3,8}\b/g, " ");
}
function namesAColor(known) {
const words = known.replace(
/\b(?:to|top|bottom|left|right|center|circle|ellipse|closest-side|farthest-side|closest-corner|farthest-corner|from|deg|rad|turn|grad|transparent|repeating|linear|radial|conic|gradient)\b/gi,
" ",
);
return /\b[a-z]{3,}\b/i.test(words);
}
const RASTER_TAGS = new Set(["IMG", "VIDEO", "CANVAS"]);
const FRAME_MEDIA_TAGS = new Set([...RASTER_TAGS, "SVG"]);
const imageAlphaCanvases = new WeakMap();
@@ -2273,6 +2273,7 @@ describe("layout-audit.browser content overlap", () => {
b: { textRect: rect({ left: 300, top: 120, width: 400, height: 100 }) },
}).find((issue) => issue.code === "content_overlap");
expect(overlap).toMatchObject({ selector: "#a", containerSelector: "#b" });
expect(overlap?.fixHint).toContain("container inside the scene");
});
it("ignores blocks that overlap by less than a fifth of the smaller box", () => {
@@ -2315,6 +2316,50 @@ describe("layout-audit.browser content overlap", () => {
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(true);
});
it("honors data-layout-allow-overlap on a container inside the scene", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="card" data-layout-allow-overlap><div id="a">Block A copy</div></div>
<div id="b">Block B copy</div>
</div>
`;
installOverlapStyles({ a: "rgb(0, 0, 0)", b: "rgb(0, 0, 0)" }, { a: "none", b: "none" });
installOverlapGeometry({
a: [rect({ left: 100, top: 100, width: 400, height: 100 })],
b: [rect({ left: 300, top: 120, width: 400, height: 100 })],
});
installAuditScript();
expect(runAudit().some((issue) => issue.code === "content_overlap")).toBe(false);
});
it("names a visibility:visible descendant of a hidden ancestor instead of an unrelated overlap", () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div id="host"><div id="a">Leftover line</div></div>
<div id="b">Next scene line</div>
</div>
`;
installOverlapStyles(
{ a: "rgb(0, 0, 0)", b: "rgb(0, 0, 0)" },
{ a: "none", b: "none" },
{},
{ host: "hidden", a: "visible", b: "visible" },
);
installOverlapGeometry({
a: [rect({ left: 100, top: 100, width: 400, height: 100 })],
b: [rect({ left: 300, top: 120, width: 400, height: 100 })],
});
installAuditScript();
const issues = runAudit();
expect(issues.some((issue) => issue.code === "content_overlap")).toBe(false);
expect(issues.find((issue) => issue.code === "visibility_escapes_segment")).toMatchObject({
selector: "#a",
containerSelector: "#host",
});
});
// A typewriter span clipped to nothing (clip-path: inset(0 100% 0 0)) keeps a
// normal box but paints zero pixels; overlapping it must not flag the visible
// block beneath. The clipped element is unreachable by elementFromPoint, which
@@ -2712,6 +2757,47 @@ describe("contrast-audit.browser background sampling", () => {
});
});
it("uses an element's own opaque background instead of the panel pixels around a circular avatar", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<span id="avatar">M</span>
</div>
`;
vi.spyOn(window, "getComputedStyle").mockImplementation(
() =>
({
display: "block",
visibility: "visible",
opacity: "1",
color: "rgb(20, 21, 26)",
backgroundColor: "rgb(255, 0, 91)",
backgroundImage: "none",
fontSize: "18px",
fontWeight: "700",
clipPath: "none",
}) as unknown as CSSStyleDeclaration,
);
vi.spyOn(document.getElementById("avatar")!, "getBoundingClientRect").mockReturnValue(
rect({ left: 50, top: 50, width: 46, height: 46 }),
);
(document as unknown as { elementFromPoint: () => Element | null }).elementFromPoint = () =>
null;
installContrastScript(
pixelsWithRegion({ left: 0, top: 0, width: 640, height: 360 }, [28, 30, 33], [28, 30, 33]),
);
const result = await runContrastAudit();
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
selector: "#avatar",
text: "M",
bg: "rgb(255,0,91)",
wcagAA: true,
});
});
it("skips text whose sampled backdrop remains transparent", async () => {
document.body.innerHTML = `
<div id="root" data-composition-id="overlay" data-width="640" data-height="360">
@@ -2810,12 +2896,13 @@ function installOverlapStyles(
colors: Record<string, string>,
clipPaths: Record<string, string>,
overflows: Record<string, string> = {},
visibilities: Record<string, string> = {},
): void {
vi.spyOn(window, "getComputedStyle").mockImplementation((element) => {
const id = (element as Element).id;
return {
display: "block",
visibility: "visible",
visibility: visibilities[id] ?? "visible",
opacity: "1",
color: colors[id] ?? "rgb(0, 0, 0)",
clipPath: clipPaths[id] ?? "none",
@@ -2910,6 +2997,63 @@ describe("layout-audit.browser occlusion", () => {
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
});
it("still treats a linear fill that starts transparent as text occlusion", () => {
const occluded = auditOcclusionScene({
overlayStyle: {
backgroundImage: "linear-gradient(transparent 10px, #000 10px)",
backgroundSize: "400px 400px",
},
topmostId: "overlay",
}).find((issue) => issue.code === "text_occluded");
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
});
it("still treats a named-color linear fill as text occlusion", () => {
const occluded = auditOcclusionScene({
overlayStyle: {
backgroundImage: "linear-gradient(transparent 50%, black 50%)",
backgroundSize: "100px 100px",
},
topmostId: "overlay",
}).find((issue) => issue.code === "text_occluded");
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
});
it("still treats a radial cover after a clear stop as text occlusion", () => {
const occluded = auditOcclusionScene({
overlayStyle: {
backgroundImage: "radial-gradient(circle, #fff 1px, transparent 1px, #000 2px)",
backgroundSize: "400px 400px",
},
topmostId: "overlay",
}).find((issue) => issue.code === "text_occluded");
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
});
it("does not treat a sparse dot grid as an opaque cover", () => {
for (const backgroundImage of [
"radial-gradient(#d4d5d8 1.6px, transparent 1.7px)",
"radial-gradient(#fff 1.6px, transparent 1.7px)",
"radial-gradient(rgba(255,255,255,.9) 1px, transparent 1px)",
]) {
const issues = auditOcclusionScene({
overlayStyle: { backgroundImage, backgroundSize: "38px 38px", opacity: "0.7" },
topmostId: "overlay",
});
expect(issues.some((issue) => issue.code === "text_occluded")).toBe(false);
}
});
it("still treats a solid gradient overlay as text occlusion", () => {
const occluded = auditOcclusionScene({
overlayStyle: {
backgroundImage: "linear-gradient(rgb(10, 10, 10), rgb(10, 10, 10))",
},
topmostId: "overlay",
}).find((issue) => issue.code === "text_occluded");
expect(occluded).toMatchObject({ selector: "#headline", containerSelector: "#overlay" });
});
it("ignores low-opacity overlays such as scrims and grain", () => {
const issues = auditOcclusionScene({
overlayStyle: { backgroundColor: "rgb(10, 10, 10)", opacity: "0.3" },
+1
View File
@@ -1439,6 +1439,7 @@ const LAYOUT_ISSUE_CODES: readonly LayoutIssueCode[] = [
"canvas_overflow",
"container_overflow",
"content_overlap",
"visibility_escapes_segment",
"text_occluded",
"text_not_painted",
"caption_zone_collision",
+2
View File
@@ -16,6 +16,7 @@ export type LayoutIssueCode =
| "canvas_overflow"
| "container_overflow"
| "content_overlap"
| "visibility_escapes_segment"
| "text_occluded"
| "text_not_painted"
| "caption_zone_collision"
@@ -203,6 +204,7 @@ const PERSISTENCE_TIERED_CODES: ReadonlySet<LayoutIssueCode> = new Set([
"canvas_overflow",
"container_overflow",
"content_overlap",
"visibility_escapes_segment",
"text_occluded",
"escaped_container",
"panel_out_of_canvas",
+7
View File
@@ -86,6 +86,12 @@ export function installInspection(win: InspectorWindow, generation: string, limi
}
return value;
};
const ancestorId = (el: Element) => {
for (let parent = el.parentElement; parent; parent = parent.parentElement) {
if (parent.id) return parent.id;
}
return "";
};
const describe = (el: Element): ElementRef => ({
ref: ref(el),
tag: el.tagName.toLowerCase(),
@@ -93,6 +99,7 @@ export function installInspection(win: InspectorWindow, generation: string, limi
hfId: el.getAttribute("data-hf-id"),
classes: [...el.classList],
composition: el.closest("[data-composition-id]")?.getAttribute("data-composition-id") ?? null,
withinId: ancestorId(el),
});
function record(value: unknown, stack: string) {
if (animation(value) && !records.has(value) && count < limit) {
@@ -19,6 +19,7 @@ export interface ElementRef {
hfId: string | null;
classes: string[];
composition: string | null;
withinId?: string;
}
export interface RuntimeAnimation {
stack: string | null;
+21
View File
@@ -292,6 +292,27 @@ describe("createPickerModule", () => {
}
});
it("still picks an image inside a faint texture wrapper", () => {
const picker = createPickerModule({ postMessage: createMockPostMessage() });
picker.installPickerApi();
document.body.innerHTML =
'<div id="scene"><div id="wash" style="opacity:0.07;background-image:url(grain.svg)"><img id="photo" alt=""></div></div>';
const el = (id: string) => document.getElementById(id)!;
expect(candidatesUnder(() => [el("photo"), el("wash"), el("scene")])).toContain("#photo");
});
it("does not pick a faint texture veil that covers the frame", () => {
const picker = createPickerModule({ postMessage: createMockPostMessage() });
picker.installPickerApi();
document.body.innerHTML =
'<div id="scene"><div id="grain-tex" style="opacity:0.07;background-image:url(grain.svg)"></div><p id="copy">Hello</p></div>';
const el = (id: string) => document.getElementById(id)!;
expect(candidatesUnder(() => [el("grain-tex"), el("copy"), el("scene")])).toEqual([
"#copy",
"#scene",
]);
});
it("keeps a click-through overlay host passing through to what is under it", () => {
const picker = createPickerModule({ postMessage: createMockPostMessage() });
picker.installPickerApi();
+10
View File
@@ -127,11 +127,21 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule {
return () => style.remove();
}
function isDecorativeVeil(el: HTMLElement, win: Window): boolean {
const style = win.getComputedStyle(el);
const opacity = Number.parseFloat(style.opacity || "1");
if (!Number.isFinite(opacity) || opacity >= 0.2) return false;
if ((el.textContent ?? "").trim()) return false;
const image = style.backgroundImage;
return Boolean(image && image !== "none");
}
function isEffectivelyHidden(el: HTMLElement): boolean {
const win = el.ownerDocument.defaultView;
if (!win) return false;
let current: HTMLElement | null = el;
if (win.getComputedStyle(el).visibility === "hidden") return true;
if (isDecorativeVeil(el, win)) return true;
while (current && current !== document.body && current !== document.documentElement) {
const computed = win.getComputedStyle(current);
if (computed.display === "none") return true;
@@ -151,6 +151,7 @@ export function inspectTimelineRuntime(
const resolver = createRuntimeStartTimeResolver({
timelineRegistry: registry,
documentRef: scope.ownerDocument,
includeAuthoredTimingAttrs: true,
});
const rootElement = findRootCompositionElement(scope.ownerDocument);
const clocks = new Map<TimedAnimation, Element>();
@@ -212,7 +213,11 @@ export function inspectTimelineRuntime(
const properties = Object.keys(piece.vars ?? {}).filter(
(k) => !reserved.has(k) && !k.startsWith("on"),
);
const values = animationValues(piece.vars ?? {}, properties);
const values = animationValues(
piece.vars ?? {},
properties,
internal && a.vars?.keyframes ? a.vars.ease : undefined,
);
const localIntervals = internal
? globalIntervals(
a,
+31 -10
View File
@@ -20,22 +20,43 @@ function declared(value: unknown): DeclaredValue {
}
return unknown();
}
const record = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
function declaredEase(ease: unknown): DeclaredValue {
return typeof ease === "function"
? { kind: "expression", value: "custom function" }
: declared(ease);
}
/** Authored endpoints, never sampled values or inferred previous-tween state. */
export function animationValues(
vars: Record<string, unknown>,
properties: string[],
keyframesEase?: unknown,
): AnimationValues {
const startAt =
vars.startAt && typeof vars.startAt === "object"
? (vars.startAt as Record<string, unknown>)
: {};
const startAt = record(vars.startAt);
const endpoints = (property: string, end: unknown, start: unknown) => ({
property,
from: declared(vars.runBackwards ? end : start),
to: vars.runBackwards ? unknown() : declared(end),
});
const segment = declaredEase(vars.ease);
const whole = keyframesEase === undefined ? undefined : declaredEase(keyframesEase);
return {
basis: "authored-tween",
properties: properties.map((property) => ({
property,
from: declared(vars.runBackwards ? vars[property] : startAt[property]),
to: vars.runBackwards ? unknown() : declared(vars[property]),
})),
ease: declared(vars.ease),
properties: properties.flatMap((property) => {
const nested = record(vars[property]);
const keys = property === "attr" || property === "css" ? Object.keys(nested) : [];
if (!keys.length) return [endpoints(property, vars[property], startAt[property])];
const nestedStart = record(startAt[property]);
return keys.map((key) => endpoints(`${property}.${key}`, nested[key], nestedStart[key]));
}),
ease: whole
? {
kind: "expression",
value: `${whole.value ?? "?"} across keyframes, ${segment.value ?? "?"} per segment`,
}
: segment,
};
}
+330
View File
@@ -0,0 +1,330 @@
import type { ElementDom, ElementNode } from "./elementMap.js";
import type { AuthoredHtml, HtmlTag as SourceTag } from "./sources";
const PLAIN_CLASS = /^[a-zA-Z_-][\w-]*$/;
export function withAuthoredState(snapshot: ElementDom, sources: AuthoredHtml): ElementDom {
const byHfId = group(sources.htmlTags, (tag) => tag.hfId);
const byId = group(sources.htmlTags, (tag) => tag.id);
const live = snapshot.nodes;
const matched = live.map((node) => sourceTag(node, byHfId, byId));
const generated = generatedInsideText(live, matched, sources);
const nodes = live.map((node, index) =>
generated.has(index)
? steadyGenerated(node, live, generated)
: authoredNode(node, matched[index], sources, live),
);
return { ...snapshot, nodes };
}
function authoredNode(
node: ElementNode,
tag: SourceTag | undefined,
sources: AuthoredHtml,
live: ElementNode[],
): ElementNode {
if (!tag) return node;
const text = directText(sources, tag) ?? node.text;
const id = node.id || tag.id || "";
const classes = authoredClasses(node, tag, live);
const next = { ...node, text, id };
return classes ? { ...next, ...classes } : next;
}
function generatedInsideText(
live: ElementNode[],
matched: (SourceTag | undefined)[],
sources: AuthoredHtml,
) {
const generated = new Set<number>();
for (const [index, tag] of matched.entries()) {
if (!tag || !directText(sources, tag)) continue;
for (const child of live[index]!.children)
if (!matched[child]) markGenerated(child, live, matched, generated);
}
return generated;
}
function markGenerated(
index: number,
live: ElementNode[],
matched: (SourceTag | undefined)[],
generated: Set<number>,
) {
if (generated.has(index) || matched[index]) return;
generated.add(index);
for (const child of live[index]!.children) markGenerated(child, live, matched, generated);
}
function steadyGenerated(
node: ElementNode,
live: ElementNode[],
generated: Set<number>,
): ElementNode {
const siblings =
node.parent === null ? [node] : live[node.parent]!.children.map((index) => live[index]!);
const siblingMatchCount = siblings.filter(
(other) => generated.has(other.index) && other.tag === node.tag,
).length;
return {
...node,
id: "",
classes: [],
text: "",
generated: true,
groupSelector: node.tag,
relative: `${node.tag}:nth-child(${node.position})`,
siblingMatchCount,
};
}
function group(tags: SourceTag[], key: (tag: SourceTag) => string | undefined) {
const groups = new Map<string, SourceTag[]>();
for (const tag of tags) {
const value = key(tag);
if (value) groups.set(value, [...(groups.get(value) ?? []), tag]);
}
return groups;
}
function sourceTag(
node: ElementNode,
byHfId: Map<string, SourceTag[]>,
byId: Map<string, SourceTag[]>,
): SourceTag | undefined {
const named = (node.hfId && byHfId.get(node.hfId)) || (node.id && byId.get(node.id)) || [];
const matches = named.filter((tag) => tag.tag === node.tag);
return matches.length === 1 ? matches[0] : undefined;
}
const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "noembed", "noframes"]);
const VOID = new Set([
"area",
"base",
"br",
"col",
"embed",
"hr",
"img",
"input",
"link",
"meta",
"param",
"source",
"track",
"wbr",
]);
function directText(sources: AuthoredHtml, tag: SourceTag): string | null {
const text = sources.files.get(tag.file)?.text;
if (text === undefined) return null;
if (VOID.has(tag.tag)) return "";
return normalizedInner(text, tag);
}
function normalizedInner(text: string, tag: SourceTag): string | null {
const inner = sliceUntilClose(text, tag.end, tag.tag);
if (inner === null) return null;
return decodeEntities(textNodes(inner)).replace(/\s+/g, " ").trim();
}
function sliceUntilClose(html: string, from: number, tag: string): string | null {
const end = closeOffset(html, from, tag, 1);
return end === null ? null : html.slice(from, end);
}
function closeOffset(html: string, from: number, tag: string, depth: number): number | null {
let cursor = from;
let level = depth;
for (;;) {
const step = closeStep(html, cursor, tag, level);
if (!step) return null;
if (step.done) return step.at;
cursor = step.at;
level = step.depth;
}
}
function closeStep(html: string, from: number, tag: string, depth: number): Step | null {
const found = nextMarkup(html, from);
if (!found) return null;
if (found.kind !== "tag") return { at: found.end, depth, done: false };
if (isRawOpen(found)) return rawStep(html, found, depth);
return tagStep(found, tag, depth);
}
function isRawOpen(found: TagMarkup): boolean {
return !found.closing && RAW_TEXT.has(found.name);
}
type Step = { at: number; depth: number; done: boolean };
function rawStep(html: string, found: TagMarkup, depth: number): Step | null {
const rawEnd = rawElementEnd(html.toLowerCase(), found.name, found.bracket);
if (rawEnd < 0) return null;
return { at: rawEnd, depth, done: false };
}
function tagStep(found: TagMarkup, tag: string, depth: number): Step {
const same = found.name === tag && !VOID.has(found.name);
if (!same || !found.closing) return openStep(found, depth, same);
return closingStep(found, depth);
}
function closingStep(found: TagMarkup, depth: number): Step {
if (depth === 1) return { at: found.start, depth, done: true };
return { at: found.bracket + 1, depth: depth - 1, done: false };
}
function openStep(found: TagMarkup, depth: number, same: boolean): Step {
const nextDepth = same ? depth + 1 : depth;
return { at: found.bracket + 1, depth: nextDepth, done: false };
}
function textNodes(inner: string): string {
const found = nextMarkup(inner, 0);
if (!found) return inner;
return inner.slice(0, found.start) + textAfter(inner, found);
}
function textAfter(inner: string, found: Markup): string {
if (found.kind !== "tag") return textNodes(inner.slice(found.end));
return textAfterTag(inner, found);
}
function textAfterTag(inner: string, found: TagMarkup): string {
if (skipsTag(found)) return textNodes(inner.slice(found.bracket + 1));
if (RAW_TEXT.has(found.name)) return textAfterRaw(inner, found);
return textAfterChild(inner, found);
}
function skipsTag(found: TagMarkup): boolean {
return found.closing || VOID.has(found.name);
}
function textAfterRaw(inner: string, found: TagMarkup): string {
const rawEnd = rawElementEnd(inner.toLowerCase(), found.name, found.bracket);
return textNodes(inner.slice(rawEnd < 0 ? inner.length : rawEnd));
}
function textAfterChild(inner: string, found: TagMarkup): string {
const child = sliceUntilClose(inner, found.bracket + 1, found.name);
if (child === null) return "";
const closeBracket = inner.indexOf(">", found.bracket + 1 + child.length);
return textNodes(inner.slice(closeBracket < 0 ? inner.length : closeBracket + 1));
}
type SkipMarkup = { kind: "skip"; start: number; end: number };
type TagMarkup = { kind: "tag"; start: number; name: string; closing: boolean; bracket: number };
type Markup = SkipMarkup | TagMarkup;
function nextMarkup(html: string, from: number): Markup | null {
const start = html.indexOf("<", from);
if (start < 0) return null;
return markupAt(html, start);
}
function markupAt(html: string, start: number): Markup | null {
const skipped = skippedMarkup(html, start);
if (skipped) return skipped;
return tagMarkup(html, start);
}
function skippedMarkup(html: string, start: number): SkipMarkup | null {
if (html.startsWith("<!--", start)) return boundedSkip(html, start, "-->", 4);
if (html.startsWith("<!", start) || html.startsWith("<?", start))
return boundedSkip(html, start, ">", 1);
return null;
}
function boundedSkip(html: string, start: number, marker: string, from: number): SkipMarkup | null {
const end = html.indexOf(marker, start + from);
return end < 0 ? null : { kind: "skip", start, end: end + marker.length };
}
function tagMarkup(html: string, start: number): Markup | null {
const name = /^<\/?([a-zA-Z][^\s/>]*)/.exec(html.slice(start, start + 128));
if (!name) return { kind: "skip", start, end: start + 1 };
const bracket = closingBracket(html, start + name[0].length);
if (bracket < 0) return null;
return {
kind: "tag",
start,
name: name[1]!.toLowerCase(),
closing: html[start + 1] === "/",
bracket,
};
}
function rawElementEnd(lower: string, tag: string, openBracket: number): number {
const marker = `</${tag}`;
const at = nextRawClose(lower, marker, openBracket + 1);
if (at < 0) return -1;
const bracket = lower.indexOf(">", at + marker.length);
return bracket < 0 ? -1 : bracket + 1;
}
function nextRawClose(lower: string, marker: string, from: number): number {
const at = lower.indexOf(marker, from);
if (at < 0) return -1;
const next = lower[at + marker.length];
if (closesRawText(next)) return at;
return nextRawClose(lower, marker, at + marker.length);
}
function closesRawText(next: string | undefined): boolean {
return next !== undefined && /[\s/>]/.test(next);
}
function closingBracket(html: string, from: number): number {
let quote = "";
for (let i = from; i < html.length; i++) {
const c = html[i];
if (quote) quote = c === quote ? "" : quote;
else if (c === '"' || c === "'") quote = c;
else if (c === ">") return i;
}
return -1;
}
function decodeEntities(value: string): string {
return value.replace(
/&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi,
(entity, body: string) => {
const named: Record<string, string> = {
amp: "&",
lt: "<",
gt: ">",
quot: '"',
apos: "'",
nbsp: " ",
};
const lower = body.toLowerCase();
if (lower in named) return named[lower]!;
const code = Number.parseInt(
lower.slice(lower.startsWith("#x") ? 2 : 1),
lower.startsWith("#x") ? 16 : 10,
);
return Number.isInteger(code) && code >= 0 && code <= 0x10ffff
? String.fromCodePoint(code)
: entity;
},
);
}
function authoredClasses(node: ElementNode, tag: SourceTag, live: ElementNode[]) {
const classes = [...tag.classes];
if (classes.join(" ") === node.classes.join(" ")) return null;
if (!classes.every((c) => PLAIN_CLASS.test(c) && node.classes.includes(c))) return null;
const simple = `${node.tag}${classes.map((c) => `.${c}`).join("")}`;
const siblings = node.parent === null ? [node] : live[node.parent]!.children.map((i) => live[i]!);
const siblingMatchCount = siblings.filter(
(other) => other.tag === node.tag && classes.every((c) => other.classes.includes(c)),
).length;
const relative = node.relative.startsWith("#")
? node.relative
: siblingMatchCount === 1
? simple
: `${node.tag}:nth-child(${node.position})`;
return { classes, groupSelector: simple, siblingMatchCount, relative };
}
+2
View File
@@ -13,8 +13,10 @@ export interface ElementNode {
text: string;
media: string | null;
relative: string;
position: number;
groupSelector: string;
siblingMatchCount: number;
generated?: boolean;
children: number[];
}
export interface ElementDom {
+22 -3
View File
@@ -74,6 +74,7 @@ export function collectElementDom() {
: siblingCount(element, simple) === 1
? simple
: `${CSS.escape(tag)}:nth-child(${siblings.indexOf(element) + 1})`,
position: siblings.indexOf(element) + 1,
groupSelector: simple,
siblingMatchCount: siblingCount(element, simple),
children: [],
@@ -120,10 +121,27 @@ function mediaIdentifier(source) {
return source;
}
function describe(node, selector, count) {
function childPlaces(members) {
const runs = [];
let start = members[0].position;
let previous = start;
for (const member of members.slice(1)) {
if (member.position === previous + 1) {
previous = member.position;
continue;
}
runs.push(start === previous ? `${start}` : `${start}-${previous}`);
start = previous = member.position;
}
runs.push(start === previous ? `${start}` : `${start}-${previous}`);
return runs.join(",");
}
function describe(node, selector, count, places = "") {
const fields = [selector];
if (!selector.startsWith(node.tag)) fields.push(node.tag);
if (count > 1) fields.push(`×${count}`);
if (count > 1) fields.push(`×${count}${places ? ` @${places}` : ""}`);
if (node.generated) fields.push("(generated)");
if (node.id && selector !== `#${node.id}`) fields.push(`id=${JSON.stringify(node.id)}`);
if (node.classes.length && !selector.includes("."))
fields.push(`class=${JSON.stringify(node.classes.join(" "))}`);
@@ -159,7 +177,8 @@ export function formatElementMap(snapshot, { file = "index.html" } = {}) {
function emit(members, depth, repeatedParents = false) {
const node = members[0];
const selector = members.length > 1 && !repeatedParents ? node.groupSelector : node.relative;
lines.push(`${" ".repeat(depth)}${describe(node, selector, members.length)}`);
const places = members.length > 1 && !repeatedParents ? childPlaces(members) : "";
lines.push(`${" ".repeat(depth)}${describe(node, selector, members.length, places)}`);
entries.push({
line: lines.length,
selectors: members.map((member) => member.selector),
+7 -2
View File
@@ -1,8 +1,9 @@
import type { TimelineRequest } from "../../core/src/runtime/timelineInspection";
import { withAuthoredState } from "./authoredState";
import { collectElementDom, formatElementMap, type ElementMap } from "./elementMap.js";
import { mapInspection } from "./mapInspection";
import { queryPage, timelinePage, type PageRunner } from "./pageQueries";
import type { Sources } from "./sources";
import type { AuthoredHtml, Sources } from "./sources";
import { formatTimeline, type TimelineReport } from "./timelineReport";
import type { InspectRequest, Snippet } from "./types";
@@ -16,6 +17,7 @@ export interface InspectionView {
readonly sourceVersion: string;
readonly pageGeneration?: string;
sourceContext(): SourceContext;
authoredHtml?(): AuthoredHtml;
assertCurrent(): Promise<void>;
}
@@ -78,7 +80,10 @@ export async function inspectElementMap(
): Promise<ElementMapReport> {
const snapshot = await runner.run(collectElementDom, undefined);
assertPageGeneration(view, snapshot.generation);
const map = formatElementMap(snapshot, { file });
const authored = view.authoredHtml?.();
const map = formatElementMap(authored ? withAuthoredState(snapshot, authored) : snapshot, {
file,
});
await view.assertCurrent();
return { ...map, sourceVersion: view.sourceVersion, generation: view.generation };
}
+5 -1
View File
@@ -14,7 +14,7 @@ import {
} from "./engine";
import { liveGenerationPage, type PageRunner } from "./pageQueries";
import { assertGeneration, validateInspectRequest, validateTimelineRequest } from "./requests";
import { Sources, fingerprint } from "./sources";
import { Sources, authoredHtml, fingerprint, type AuthoredHtml } from "./sources";
import type { InspectRequest, Snippet } from "./types";
export interface LiveInspection {
@@ -42,6 +42,7 @@ export function prepareLiveInspection(
const prepared = html.replace(/<head\b[^>]*>/i, (head) => `${head}<script>${script}</script>`);
let document = prepared;
let context: SourceContext | undefined;
let authored: AuthoredHtml | undefined;
const catalog = new Map<string, Snippet>();
const view: InspectionView = {
generation,
@@ -54,6 +55,9 @@ export function prepareLiveInspection(
}
return context;
},
authoredHtml() {
return (authored ??= authoredHtml(project));
},
async assertCurrent() {
if (fingerprint(project) !== sourceVersion)
throw new Error("stale_preview: the project changed; query again once the preview reloads");
+19 -14
View File
@@ -54,8 +54,8 @@ export function mapInspection(
return {
target: index,
status: result.status,
candidates: result.candidates.slice(0, 5),
candidatesOmitted: Math.max(0, result.candidates.length - 5),
candidates: result.candidates,
candidatesOmitted: 0,
animations: result.animations.length,
coTargets: [
...new Map(
@@ -97,27 +97,32 @@ export function mapInspection(
const offset = request.offset ?? 0;
if (!Number.isInteger(offset) || offset < 0)
throw new Error("offset must be a non-negative integer");
const size = () => Buffer.byteLength(JSON.stringify(response));
const trimCandidates = (limit: number, keep: number) => {
while (size() > limit) {
const longest = response.results.reduce((a, b) =>
b.candidates.length > a.candidates.length ? b : a,
);
if (longest.candidates.length <= keep) return;
longest.candidates.pop();
longest.candidatesOmitted++;
}
};
trimCandidates(maxBytes / 2, 5);
for (let i = offset; i < selected.length; i++) {
const snippet = selected[i];
response.snippets.push(snippet);
if (Buffer.byteLength(JSON.stringify(response)) > maxBytes - 700) {
response.snippets.push(selected[i]);
if (size() > maxBytes - 700) {
response.snippets.pop();
response.omittedCount = selected.length - i;
response.omitted = selected
.slice(i, i + 8)
.map((item) => ({ id: item.id, bytes: Buffer.byteLength(item.code) }));
response.nextOffset = i;
const nothingFit = response.snippets.length === 0;
response.nextOffset = nothingFit ? (i + 1 < selected.length ? i + 1 : null) : i;
break;
}
}
while (
Buffer.byteLength(JSON.stringify(response)) > maxBytes &&
response.results.some((r) => r.candidates.length)
) {
const r = response.results.find((r) => r.candidates.length)!;
r.candidates.pop();
r.candidatesOmitted++;
}
trimCandidates(maxBytes, 0);
if (Buffer.byteLength(JSON.stringify(response)) > maxBytes)
throw new Error("metadata_exceeds_budget: use fewer targets or increase maxBytes");
if (request.debug) return { ...response, debug: raw };
+4 -2
View File
@@ -12,6 +12,7 @@ export interface PageRunner {
export interface PageTimeline extends TimelineRuntimeResult {
following: TimelineEvent[];
generation: string | null;
duration?: number;
}
export interface PageRuntimeResponse extends RuntimeResponse {
@@ -64,7 +65,8 @@ export function timelinePage(request: TimelineRequest): PageTimeline {
const result = inspection.timeline(request);
const following: TimelineEvent[] = [];
const generation = inspected.__hfLiveInspection?.generation ?? null;
if (request.boundary === undefined) return { ...result, following, generation };
const duration = inspected.__player?.getDuration();
if (request.boundary === undefined) return { ...result, following, generation, duration };
const boundary = request.boundary;
const schedule = inspection.timeline({ ...request, boundary: undefined });
for (const crossing of result.events) {
@@ -82,7 +84,7 @@ export function timelinePage(request: TimelineRequest): PageTimeline {
if (next && !following.includes(next)) following.push(next);
}
}
return { ...result, following, generation };
return { ...result, following, generation, duration };
}
export function liveGenerationPage(): string | null {
+53 -30
View File
@@ -141,20 +141,55 @@ function rawTextElements(text: string, tag: "script" | "style") {
}
return elements;
}
export interface HtmlTag {
file: string;
start: number;
end: number;
id?: string;
hfId?: string;
classes: string[];
tag: string;
}
function htmlTagsOf(file: string, text: string): HtmlTag[] {
return [...text.matchAll(/<([a-z][\w-]*)\b[^>]*>/gi)].map((m) => {
const attr = (name: string) => {
const quoted = new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(m[0]);
if (quoted) return quoted[1];
return new RegExp(`\\s${name}\\s*=\\s*([^\\s"'=<>]+)`, "i").exec(m[0])?.[1];
};
return {
file,
start: m.index,
end: m.index + m[0].length,
tag: m[1].toLowerCase(),
id: attr("id"),
hfId: attr("data-hf-id"),
classes: (attr("class") ?? "").split(/\s+/).filter(Boolean),
};
});
}
export interface AuthoredHtml {
files: Map<string, { text: string }>;
htmlTags: HtmlTag[];
}
export function authoredHtml(project: string): AuthoredHtml {
const html: AuthoredHtml = { files: new Map(), htmlTags: [] };
for (const path of sourceFiles(project)) {
const file = relative(project, path);
if (!file.endsWith(".html") || /(?:^|\/)(?:vendor|lib)\//.test(file)) continue;
const text = readFileSync(path, "utf8");
if (text.length > 2_000_000) continue;
html.files.set(file, { text });
html.htmlTags.push(...htmlTagsOf(file, text));
}
return html;
}
export class Sources {
readonly files = new Map<string, SourceFile>();
readonly chunks: Chunk[] = [];
readonly cssRules: { id: string; selector: string; file: string; start: number; end: number }[] =
[];
readonly htmlTags: {
file: string;
start: number;
end: number;
id?: string;
hfId?: string;
classes: string[];
tag: string;
}[] = [];
readonly htmlTags: HtmlTag[] = [];
constructor(readonly project: string) {
for (const path of sourceFiles(project)) {
const file = relative(project, path);
@@ -176,19 +211,7 @@ export class Sources {
this.addChunk(file, script.content, script.contentStart);
for (const style of rawTextElements(text, "style"))
this.addCss(file, style.content, style.contentStart);
for (const m of text.matchAll(/<([a-z][\w-]*)\b[^>]*>/gi)) {
const attr = (name: string) =>
new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, "i").exec(m[0])?.[1];
this.htmlTags.push({
file,
start: m.index,
end: m.index + m[0].length,
tag: m[1].toLowerCase(),
id: attr("id"),
hfId: attr("data-hf-id"),
classes: (attr("class") ?? "").split(/\s+/).filter(Boolean),
});
}
this.htmlTags.push(...htmlTagsOf(file, text));
}
}
}
@@ -250,15 +273,15 @@ export class Sources {
};
}
markup(element: ElementRef, target: number) {
const tags = this.htmlTags.filter(
(tag) =>
tag.tag === element.tag &&
(element.hfId
? tag.hfId === element.hfId
: element.id
const sameTag = this.htmlTags.filter((tag) => tag.tag === element.tag);
const byHfId = element.hfId ? sameTag.filter((tag) => tag.hfId === element.hfId) : [];
const tags = byHfId.length
? byHfId
: sameTag.filter((tag) =>
element.id
? tag.id === element.id
: element.classes.length > 0 && element.classes.every((c) => tag.classes.includes(c))),
);
: element.classes.length > 0 && element.classes.every((c) => tag.classes.includes(c)),
);
return tags.length === 1
? this.slice(tags[0].file, tags[0].start, tags[0].end, "html opening tag", target)
: null;
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import type { TimelineEvent } from "../../core/src/runtime/timelineInspection";
import { formatTimeline } from "./timelineReport";
function event(target: TimelineEvent["targets"][number]): TimelineEvent {
return {
start: 0.3,
end: 1.2,
kind: "animation",
relation: "at",
component: null,
targets: [target],
properties: ["opacity"],
stack: null,
};
}
describe("timeline target labels", () => {
it("names the nearest ancestor id when the element has none", () => {
const report = formatTimeline(
{
status: "found",
candidates: [],
events: [
event({
ref: "hfi:1",
tag: "span",
id: "",
hfId: null,
classes: ["a-ch"],
composition: "s06",
withinId: "a-type",
}),
],
warnings: [],
following: [],
generation: "g",
},
{},
{ generation: "g", map: () => [], reused: false },
);
expect(report.text).toContain(".a-ch in #a-type");
});
});
+49 -11
View File
@@ -37,13 +37,13 @@ export function formatTimeline(
view: { generation: string; map: SourceMap; reused: boolean },
): TimelineReport {
const offset = request.offset ?? 0;
const allGroups = groupEvents(raw.events);
const allGroups = byComponent(groupEvents(raw.events));
const groups = allGroups.slice(offset, offset + GROUPS_PER_PAGE);
const label = labeler();
const events = groups.flat().map((event, index) => ({
...event,
stack: undefined,
sources: (event.stack ? view.map(event.stack, index) : []).map((s) => ({
sources: onePerSourceLine(event.stack ? view.map(event.stack, index) : []).map((s) => ({
file: s.file,
line: s.startLine,
})),
@@ -52,11 +52,17 @@ export function formatTimeline(
const lines = [
`Timeline · global seconds · ${raw.status}`,
`generation: ${view.generation}`,
...(raw.status !== "found" ? raw.candidates.map((c) => `${c.ref} #${c.id} ${c.tag}`) : []),
...(raw.status !== "found"
? raw.candidates.map(
(c) =>
`${c.ref} ${targetSummary([c])[0]} ${c.tag}${c.composition ? ` in ${c.composition}` : ""}`,
)
: []),
...pastTheEnd(request, raw.duration),
...(request.boundary !== undefined ? [`Boundary ${request.boundary}s: ${BOUNDARY_NOTE}`] : []),
...describeGroups(groups, request, view.map, label),
...describeFollowing(raw, view.map, label),
...describeRelations(events),
...describeRelations(groups),
...(nextOffset !== null
? [`More groups: repeat with offset=${nextOffset} and generation.`]
: []),
@@ -103,6 +109,25 @@ function groupEvents(events: TimelineEvent[]): TimelineEvent[][] {
return [...groups.values()];
}
const headingOf = (event: TimelineEvent) =>
event.component?.id ? `#${event.component.id}` : (event.component?.composition ?? "unassigned");
function byComponent(groups: TimelineEvent[][]): TimelineEvent[][] {
const owners = new Map<string, TimelineEvent[][]>();
for (const group of groups) {
const heading = headingOf(group[0]!);
owners.set(heading, [...(owners.get(heading) ?? []), group]);
}
return [...owners.values()].flat();
}
function pastTheEnd(request: TimelineRequest, duration: number | undefined): string[] {
const from = request.range?.[0] ?? request.at ?? request.boundary;
const filmLength = duration !== undefined && duration > 0 ? duration : undefined;
if (filmLength === undefined || from === undefined || from < filmLength) return [];
return [`Requested time is past the end: the film is ${Number(filmLength.toFixed(4))}s long.`];
}
function labeler(): Labeler {
const labels = new Map<string, string>();
return (ref) => {
@@ -112,12 +137,19 @@ function labeler(): Labeler {
}
const at = (seconds: number) => seconds.toFixed(4);
function onePerSourceLine<T extends { file: string; startLine: number }>(sources: T[]): T[] {
return [...new Map(sources.map((s) => [`${s.file}:${s.startLine}`, s])).values()];
}
const located = (sources: { file: string; startLine: number }[]) =>
sources.map((s) => `${s.file}:${s.startLine}`).join(", ");
onePerSourceLine(sources)
.map((s) => `${s.file}:${s.startLine}`)
.join(", ");
function describeValue(value: DeclaredValue): string {
if (value.kind === "unknown") return "?";
return `${value.value}${value.kind === "literal" ? "" : ` (${value.kind})`}`;
if (value.kind === "expression" && value.value === "custom function") return "custom function";
const shown = typeof value.value === "number" ? Number(value.value.toFixed(4)) : value.value;
return `${shown}${value.kind === "literal" ? "" : ` (${value.kind})`}`;
}
function describeValues(values: AnimationValues): string {
return ` authored: ${values.properties.map((p) => `${p.property}: ${describeValue(p.from)} → ${describeValue(p.to)}`).join("; ")}; ease: ${describeValue(values.ease)}`;
@@ -140,7 +172,7 @@ function describeGroups(
targets: [...new Map(members.flatMap((e) => e.targets).map((t) => [t.ref, t])).values()],
};
const component = event.component;
const heading = component?.id ? `#${component.id}` : (component?.composition ?? "unassigned");
const heading = headingOf(event);
if (owner !== heading) {
lines.push(`\n${heading}${component ? ` [${component.ref}]` : ""}`);
owner = heading;
@@ -170,9 +202,10 @@ function describeGroups(
function targetSummary(targets: TimelineEvent["targets"]): string[] {
const counts = new Map<string, number>();
for (const target of targets) {
const name = target.id
const own = target.id
? `#${target.id}`
: (target.hfId ?? (target.classes.length ? `.${target.classes.join(".")}` : target.tag));
const name = !target.id && target.withinId ? `${own} in #${target.withinId}` : own;
counts.set(name, (counts.get(name) ?? 0) + 1);
}
return [...counts].map(([name, count]) => (count > 1 ? `${name} ×${count}` : name));
@@ -233,12 +266,16 @@ function describeFollowing(raw: PageTimeline, map: SourceMap, label: Labeler): s
return lines;
}
function describeRelations(events: TimelineReport["events"]): string[] {
function describeRelations(groups: TimelineEvent[][]): string[] {
const lines: string[] = [];
const events = groups.flatMap((members, group) => members.map((event) => ({ event, group })));
const described = new Set<string>();
for (let a = 0; a < events.length; a++)
for (let b = a + 1; b < events.length; b++) {
const left = events[a]!;
const right = events[b]!;
const left = events[a]!.event;
const right = events[b]!.event;
const pair = `${events[a]!.group}:${events[b]!.group}`;
if (described.has(pair)) continue;
if (left.kind !== "animation" || right.kind !== "animation") continue;
if (left.end < right.start || right.end < left.start) continue;
const shared = left.targets.some((t) => right.targets.some((r) => r.ref === t.ref));
@@ -250,6 +287,7 @@ function describeRelations(events: TimelineReport["events"]): string[] {
: Math.min(left.end, right.end) > Math.max(left.start, right.start)
? "intervals overlap"
: "touch at an endpoint only, no positive-duration overlap";
described.add(pair);
lines.push(
`Relation: ${at(left.start)}–${at(left.end)} and ${at(right.start)}–${at(right.end)} share targets and ${properties.join(",")}; ${kind}; not necessarily an error.`,
);
+169
View File
@@ -2,6 +2,11 @@ import { test } from "vitest";
import assert from "node:assert/strict";
import { collectElementDom, formatElementMap } from "../src/elementMap.js";
import { Window } from "happy-dom";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { withAuthoredState } from "../src/authoredState";
import { authoredHtml } from "../src/sources";
function collect(html: string) {
const window = new Window();
@@ -98,3 +103,167 @@ test("blob URLs identify runtime media without claiming a persistent asset ident
);
assert.match(map('<img src="blob:https://example.com/one">').text, /blob:runtime/);
});
test("classes and text the timeline sets at the current frame do not change the map", () => {
const authored =
'<div id="s"><i data-hf-id="hf-a" class="ch">0.0</i><i data-hf-id="hf-b" class="ch">0.0</i><i data-hf-id="hf-c" class="ch">0.0</i></div>';
const playing = authored
.replace('class="ch">0.0</i><i data-hf-id="hf-b"', 'class="ch on">5.2</i><i data-hf-id="hf-b"')
.replace('"hf-c" class="ch">0.0', '"hf-c" class="ch on cur">5.2');
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(join(dir, "index.html"), authored);
const sources = authoredHtml(dir);
const atRest = formatElementMap(withAuthoredState(collect(authored), sources)).text;
assert.match(atRest, /i\.ch ×3 @1-3 "0\.0"/);
assert.equal(formatElementMap(withAuthoredState(collect(playing), sources)).text, atRest);
assert.notEqual(map(playing).text, atRest);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("script-built letters inside a source text stay one stable line", () => {
const authored =
'<span id="a-type" data-hf-id="hf-t" class="a-type">Hi</span><b id="km" data-hf-id="hf-k">0.0</b>';
const playing = (on: boolean) =>
`<span id="a-type" data-hf-id="hf-t" class="a-type"><span class="a-ch${on ? " on" : ""}">H</span><span class="a-ch${on ? " on cur" : ""}">i</span></span><b id="km" data-hf-id="hf-k">${on ? "5.2" : "0.0"}</b>`;
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(join(dir, "index.html"), authored);
const sources = authoredHtml(dir);
const early = formatElementMap(withAuthoredState(collect(playing(false)), sources)).text;
const later = formatElementMap(withAuthoredState(collect(playing(true)), sources)).text;
assert.equal(early, later);
assert.match(early, /#a-type[^\n]*"Hi"/);
assert.match(early, /span ×2 @1-2 \(generated\)/);
assert.match(early, /"0\.0"/);
assert.doesNotMatch(early, /a-ch|5\.2/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an empty source element keeps the elements a script appended", () => {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(join(dir, "index.html"), '<div id="box" data-hf-id="hf-b"></div>');
const text = formatElementMap(
withAuthoredState(
collect('<div id="box" data-hf-id="hf-b"><i class="e-p"></i><i class="e-p"></i></div>'),
authoredHtml(dir),
),
).text;
assert.match(text, /i\.e-p ×2/);
assert.doesNotMatch(text, /generated/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("class names stay in source order", () => {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(join(dir, "index.html"), '<i data-hf-id="hf-a" class="word light">x</i>');
const text = formatElementMap(
withAuthoredState(
collect('<i data-hf-id="hf-a" class="word light">x</i>'),
authoredHtml(dir),
),
).text;
assert.match(text, /i\.word\.light/);
assert.doesNotMatch(text, /i\.light\.word/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an id the runtime removed stays visible without becoming the selector", () => {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(join(dir, "index.html"), '<div id="root" data-hf-id="hf-r"></div>');
const text = formatElementMap(
withAuthoredState(collect('<div data-hf-id="hf-r"></div>'), authoredHtml(dir)),
).text;
assert.match(text, /id="root"/);
assert.doesNotMatch(text, /#root/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an unquoted authored class stays on the map", () => {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(
join(dir, "index.html"),
'<div id="counter" data-hf-id="hf-c" class=card>0.0</div>',
);
const text = formatElementMap(
withAuthoredState(
collect('<div id="counter" data-hf-id="hf-c" class="card">5.2</div>'),
authoredHtml(dir),
),
).text;
assert.match(text, /card/);
assert.match(text, /"0\.0"/);
assert.doesNotMatch(text, /5\.2/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an empty source element keeps its text empty while a script fills it", () => {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(join(dir, "index.html"), '<span id="n" data-hf-id="hf-n"></span>');
const sources = authoredHtml(dir);
const at = (value: string) =>
formatElementMap(
withAuthoredState(collect(`<span id="n" data-hf-id="hf-n">${value}</span>`), sources),
).text;
assert.equal(at("0.0"), at("5.2"));
assert.doesNotMatch(at("5.2"), /0\.0|5\.2/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("mixed source text and entities stay on the authored words", () => {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(
join(dir, "index.html"),
'<span id="n" data-hf-id="hf-n"><b data-hf-id="hf-b">Go</b> 0.0</span><span id="amp" data-hf-id="hf-a">A &amp; B</span>',
);
const sources = authoredHtml(dir);
const playing = formatElementMap(
withAuthoredState(
collect(
'<span id="n" data-hf-id="hf-n"><b data-hf-id="hf-b">Go</b> 5.2</span><span id="amp" data-hf-id="hf-a">A &amp; C</span>',
),
sources,
),
).text;
assert.match(playing, /#n[^\n]*"0\.0"/);
assert.match(playing, /"Go"/);
assert.match(playing, /#amp[^\n]*"A & B"/);
assert.doesNotMatch(playing, /5\.2|A & C/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("an element whose live classes drop an authored one keeps its live state", () => {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
try {
writeFileSync(join(dir, "index.html"), '<p data-hf-id="hf-a" class="hidden">x</p>');
const live = withAuthoredState(
collect('<p data-hf-id="hf-a" class="shown">x</p>'),
authoredHtml(dir),
);
assert.equal(live.nodes.at(-1)?.classes.join(" "), "shown");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
+8
View File
@@ -105,6 +105,14 @@ try {
assert(timeline.text.includes("index.html:"), "timeline maps its animations to source lines");
const map = await live.elementMap(runner);
assert(map.text.includes("#title") && map.generation === live.generation);
const advance = (on: boolean) => {
const h1 = document.querySelector("#other")!;
h1.classList.toggle("on", on);
h1.textContent = on ? "9.9" : "Two";
};
await runner.run(advance, true);
assert.equal((await live.elementMap(runner)).text, map.text, "runtime class and text");
await runner.run(advance, false);
const reloadDuringQuery = async (
run: (inspection: typeof live, racingRunner: PageRunner) => Promise<unknown>,
) => {
@@ -0,0 +1,121 @@
import { describe, it, expect } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { mapInspection } from "../src/mapInspection";
import { Sources } from "../src/sources";
import type { ElementRef, RuntimeResponse, Snippet } from "../src/types";
const element = (n: number): ElementRef => ({
ref: `hfi:g:${n}`,
tag: "i",
id: "",
hfId: null,
classes: ["w-dot"],
composition: "s03-weeks",
});
function project(css: string) {
const dir = mkdtempSync(join(tmpdir(), "inspect-map-"));
writeFileSync(
join(dir, "index.html"),
`<html><head><style>\n${css}\n</style></head><body></body></html>`,
);
return dir;
}
function inspect(dir: string, candidates: number, request: Parameters<typeof mapInspection>[1]) {
const sources = new Sources(dir);
const raw: RuntimeResponse = {
results: [
{
target: { selector: ".w-dot" },
status: "found",
candidates: Array.from({ length: candidates }, (_, n) => element(n)),
animations: [],
elements: [],
css: sources.cssRules.map((rule) => rule.id),
truncated: false,
},
],
recorded: 0,
recordLimitReached: false,
hooked: true,
};
return mapInspection(raw, request, {
sources,
map: () => [],
catalog: new Map<string, Snippet>(),
generation: "g",
sourceVersion: "v",
reused: false,
start: performance.now(),
});
}
describe("components report budget", () => {
it("never answers a page that fits no snippet with a continuation to that same page", () => {
const big = `.big{${"color:red;".repeat(300)}}`;
const dir = project(big);
try {
const page = inspect(dir, 1, { targets: [{ selector: ".w-dot" }], maxBytes: 2048 });
expect(page.snippets).toHaveLength(0);
expect(page.nextOffset).toBeNull();
expect(page.omitted).toHaveLength(1);
expect(page.omitted[0].bytes).toBeGreaterThan(2048);
const fetched = inspect(dir, 1, {
targets: [],
snippets: page.omitted.map((o) => o.id),
maxBytes: 12000,
});
expect(fetched.snippets.map((s) => s.code.trim())).toEqual([big]);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("returns every candidate the budget holds instead of five", () => {
const dir = project(".w-dot{color:red}");
try {
const report = inspect(dir, 25, { targets: [{ selector: ".w-dot" }] });
expect(report.results[0].candidates).toHaveLength(25);
expect(report.results[0].candidatesOmitted).toBe(0);
expect(report.snippets).toHaveLength(1);
const tight = inspect(dir, 25, { targets: [{ selector: ".w-dot" }], maxBytes: 4096 });
expect(Buffer.byteLength(JSON.stringify(tight))).toBeLessThanOrEqual(4096);
expect(tight.results[0].candidates.length).toBeGreaterThanOrEqual(5);
expect(tight.results[0].candidates.length + tight.results[0].candidatesOmitted).toBe(25);
expect(tight.snippets).toHaveLength(1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("continues past a snippet that does not fit so later snippets stay reachable", () => {
const big = `.big{${"color:red;".repeat(300)}}`;
const small = Array.from({ length: 20 }, (_, n) => `.s${n}{color:red}`);
const dir = project([big, ...small].join("\n\n"));
try {
const first = inspect(dir, 1, { targets: [{ selector: ".w-dot" }], maxBytes: 2048 });
expect(first.snippets).toHaveLength(0);
expect(first.nextOffset).toBe(1);
expect(first.omitted[0]?.bytes).toBeGreaterThan(2048);
const seen = new Set<string>();
let offset: number | null = first.nextOffset;
for (let guard = 0; offset !== null && guard < 25; guard++) {
const page = inspect(dir, 1, {
targets: [{ selector: ".w-dot" }],
maxBytes: 2048,
offset,
});
for (const snippet of page.snippets) seen.add(snippet.code.trim());
expect(page.nextOffset === null || page.nextOffset > offset).toBe(true);
offset = page.nextOffset;
}
expect(offset).toBeNull();
expect([...seen].sort()).toEqual([...small].sort());
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
+23
View File
@@ -89,4 +89,27 @@ describe("source records", () => {
dirs.forEach((dir) => rmSync(dir, { recursive: true, force: true }));
}
});
it("finds an element's tag before its served data-hf-id reaches the source", () => {
const dir = mkdtempSync(join(tmpdir(), "inspect-source-"));
try {
writeFileSync(
join(dir, "scene.html"),
'<div data-composition-id="s"><span id="t" class="w">A</span><i class="dot"></i><i class="dot"></i></div>',
);
const source = new Sources(dir);
const served = {
ref: "r",
tag: "span",
id: "t",
hfId: "hf-ab12",
classes: ["w"],
composition: "s",
};
expect(source.markup(served, 0)?.code).toContain('<span id="t" class="w">');
const twin = { ...served, tag: "i", id: "", classes: ["dot"] };
expect(source.markup(twin, 0)).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
@@ -13,7 +13,7 @@ writeFileSync(
`<html><head><script src="gsap.js"></script></head><body>
<div data-composition-id="main" data-duration="30" data-width="1920" data-height="1080">
<div data-composition-id="card" data-start="2.39" data-duration="4">
<div id="icon"></div><div id="word"></div><div id="paused"></div>
<div id="icon"></div><div id="word"></div><div id="paused"></div><i class="cand"></i><i class="cand"></i>
</div>
<div data-composition-id="fast" data-start="10" data-duration="5" data-media-start="1" data-playback-rate="2">
<div id="fast"></div><div id="trimmed"></div><div id="partial"></div>
@@ -21,7 +21,13 @@ writeFileSync(
<div data-composition-id="ramp" data-start="20" data-duration="5" data-automation='{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":1},{"t":2,"v":3}]}]}'>
<div id="ramp"></div>
</div>
<div id="grain"></div>
<div data-composition-id="over" data-start="0" data-duration="2"><div id="over"></div></div>
<div data-composition-id="loop" data-start="26" data-duration="3">
<div id="loopa"></div><div id="loopb"></div>
</div>
</div>
<script>(()=>{const loop=gsap.timeline({paused:true});["#loopa","#loopb"].forEach(s=>loop.to(s,{x:1,duration:1},0));window.__timelines={loop:loop};})();</script>
<script>
const main = gsap.timeline({paused:true});
const card = gsap.timeline({paused:true});
@@ -38,7 +44,12 @@ fast.to('#partial',{opacity:1,duration:1},.5);
main.add(fast,99);
const ramp = gsap.timeline({paused:true});
ramp.to('#ramp',{x:100,duration:3.641},0);
window.__timelines = {main:main, card:card, fast:fast, ramp:ramp};
main.add(window.__timelines.loop,99);
const over = gsap.timeline({paused:true});
over.to('#over',{x:1,duration:1},0);
main.add(over,99);
main.to('#grain',{x:5,duration:.1,repeat:40},0);
window.__timelines = {...window.__timelines, main:main, over:over, card:card, fast:fast, ramp:ramp};
</script></body></html>`,
);
const inspector = new Inspector(project);
@@ -61,6 +72,26 @@ try {
close(await intervals("#trimmed"), []);
close(await intervals("#partial"), [[10, 10.25]]);
close(await intervals("#ramp"), [[20, 22]]);
// The runtime strips a nested host's data-duration once loaded; its item keeps the authored window.
const hostItem = async (composition: string) =>
(
await inspector.timeline({
component: { selector: `[data-composition-id="${composition}"]` },
})
).events
.filter((e) => e.kind === "item" && e.targets[0]?.composition === composition)
.map((e) => [e.start, e.end]);
close(await hostItem("card"), [[2.39, 6.39]]);
close(await hostItem("fast"), [[10, 15]]);
const loop = await inspector.timeline({ component: { selector: "#loopa" } });
const loopAnimation = loop.events.find((e) => e.kind === "animation")!;
assert.equal(loopAnimation.sources.length, 1);
assert.equal(
loop.text
.split("\n")
.filter((line) => line.trim() === `index.html:${loopAnimation.sources[0].line}`).length,
1,
);
const paused = await inspector.timeline({ component: { selector: "#paused" } });
assert.equal(paused.events.length, 0);
assert(paused.warnings.some((w) => w.includes("Paused or zero-speed")));
@@ -73,8 +104,25 @@ try {
(await inspector.timeline({ at: 2.71, component: { selector: "#icon" } })).events.length,
0,
);
const whole = await inspector.timeline({});
const headings = whole.text.split("\n").filter((line) => /^\S.* \[hfi:/.test(line));
assert.equal(new Set(headings).size, headings.length, headings.join("\n"));
const grainRelations = (await inspector.timeline({ range: [0, 2] })).text
.split("\n")
.filter((line) => line.startsWith("Relation:") && line.includes(" x;"));
assert.equal(grainRelations.length, 1);
assert.match(
(await inspector.timeline({ range: [40, 50] })).text,
/past the end: the film is 30s long/,
);
const candidates = await inspector.timeline({ component: { selector: ".cand" } });
assert.notEqual(candidates.status, "found");
assert.equal(
candidates.text.split("\n").filter((line) => / \.cand i in card$/.test(line)).length,
2,
);
console.log(
"PASS managed paused compositions, host offsets, playback offsets/rates/ramps, paused children, boundary/at",
"PASS managed paused compositions, host offsets, playback offsets/rates/ramps, paused children, boundary/at, authored host windows, one location per source line, one heading per component, one relation per repeating action, past-the-end ranges, named candidates",
);
} finally {
await inspector.dispose();
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { formatTimeline } from "../src/timelineReport";
import type { PageTimeline } from "../src/pageQueries";
const view = { generation: "g", map: () => [], reused: false };
function report(duration: number | undefined, at: number) {
const raw: PageTimeline = {
status: "found",
candidates: [],
events: [],
warnings: [],
following: [],
generation: null,
duration,
};
return formatTimeline(raw, { at }, view).text;
}
describe("a query past the end of the film", () => {
it("does not call an unknown duration a film that ends at 0s", () => {
expect(report(0, 0)).not.toMatch(/past the end/);
expect(report(undefined, 0)).not.toMatch(/past the end/);
expect(report(30, 40)).toMatch(/past the end: the film is 30s long/);
});
});
+12 -2
View File
@@ -13,7 +13,7 @@ const project = mkdtempSync(join(tmpdir(), "hf-timeline-values-"));
copyFileSync(process.argv[2], join(project, "gsap.js"));
writeFileSync(
join(project, "index.html"),
`<html><head><script src="gsap.js"></script></head><body><div data-composition-id="main" data-duration="10" data-width="1920" data-height="1080"><div id="bubble"></div><div id="from"></div><div id="rel"></div><div class="member"></div><div class="member"></div></div><script>
`<html><head><script src="gsap.js"></script></head><body><div data-composition-id="main" data-duration="10" data-width="1920" data-height="1080"><div id="bubble"></div><div id="from"></div><div id="rel"></div><div class="member"></div><div class="member"></div><svg><rect id="attr"></rect></svg><div id="fn"></div><div id="kf"></div><div id="round"></div></div><script>
const tl=gsap.timeline({paused:true});
tl.fromTo('#bubble',{scale:0},{scale:1.18,duration:.16,ease:'power2.out'},1.8);
tl.to('#bubble',{scale:.95,duration:.11,ease:'sine.inOut'},1.96);
@@ -22,6 +22,10 @@ tl.to('#bubble',{scale:1,duration:.09,ease:'sine.out'},2.16);
tl.from('#from',{x:100,duration:1},3);
tl.to('#rel',{x:'+=10',duration:1},3);
tl.to('.member',{x:i=>100+i,duration:1,stagger:.2},4);
tl.fromTo('#attr',{attr:{width:0}},{attr:{width:50},duration:1},6);
tl.to('#fn',{x:10,duration:1,ease:t=>t*t},6);
tl.to('#kf',{keyframes:{x:[0,10,20]},ease:'steps(8)',duration:1},7);
tl.to('#round',{rotation:2.6999999999999997,duration:1},8);
window.__timelines={main:tl};
</script></body></html>`,
);
@@ -49,9 +53,15 @@ try {
const animations = members.events.filter((e) => e.kind === "animation");
assert.equal(animations.length, 2);
assert(animations.every((e) => e.values?.properties[0].to.kind === "unknown"));
const textOf = async (selector: string) =>
(await inspector.timeline({ component: { selector } })).text;
assert.match(await textOf("#attr"), /attr\.width: 0 → 50/);
assert.match(await textOf("#fn"), /ease: custom function/);
assert.match(await textOf("#kf"), /ease: steps\(8\) across keyframes/);
assert.match(await textOf("#round"), /rotation: \? → 2\.7;/);
console.log(result.text);
console.log(
"PASS authored endpoints, unknown starts, from direction, ease, relative values, function values and stagger",
"PASS authored endpoints, unknown starts, from direction, ease, relative values, function values, stagger, attr values, custom and keyframe eases and rounding",
);
} finally {
await inspector.dispose();
+32 -2
View File
@@ -2531,8 +2531,8 @@ ${body}
// One frame carried the same `z-index: -1` band under isolation:isolate,
// transform, opacity<1, filter, contain:paint and will-change -- all six
// PRESENT at full coverage, the no-stacking-context control ABSENT at zero.
// This rule matches CSS text and cannot resolve the cascade, so it fires on
// both shapes; the message must therefore stay conditional.
// Without a stacking-context ancestor the message must stay conditional.
// A parent this file sets to isolation:isolate is covered separately and stays quiet.
it("states the stacking-context CONDITION rather than asserting absence", async () => {
const result = await lintHyperframeHtml(
wrap(
@@ -2578,6 +2578,36 @@ ${body}
expect(finding?.selector).toBe("#under");
});
it("still flags a negative z-index hidden by a card under an isolated stage", async () => {
const result = await lintHyperframeHtml(
wrap(
".stage { isolation: isolate; } .card { background: #fff; } .bg { position: absolute; z-index: -1; }",
` <div class="stage"><div class="card"><i class="bg"></i></div></div>`,
),
);
expect(result.findings.some((f) => f.code === "negative_z_index")).toBe(true);
});
it("stays quiet when a parent in the same file forms a stacking context", async () => {
const result = await lintHyperframeHtml(
wrap(
".wrap { position: relative; isolation: isolate; } .bar { position: absolute; z-index: -1; }",
` <span class="wrap">product.<i class="bar"></i></span>`,
),
);
expect(result.findings.some((f) => f.code === "negative_z_index")).toBe(false);
});
it("still flags a negative z-index when isolation is on an unrelated element", async () => {
const result = await lintHyperframeHtml(
wrap(
".wrap { isolation: isolate; } .bar { z-index: -1; }",
` <span class="wrap"></span><i class="bar"></i>`,
),
);
expect(result.findings.some((f) => f.code === "negative_z_index")).toBe(true);
});
it("does not flag zero or positive z-index", async () => {
const result = await lintHyperframeHtml(
wrap(
+86 -10
View File
@@ -558,6 +558,77 @@ function elementSelector(tag: OpenTag): string | undefined {
return elementId ? `#${elementId}` : undefined;
}
// A parent with one of these is a stacking context, measured at 0.8.72: the
// negative-z child paints above that parent's background and is visible.
const STACKING_CONTEXT_DECLARATION =
/(?:^|[;{])\s*(?:isolation\s*:\s*isolate|opacity\s*:\s*(?:0\b|0?\.\d+)|transform\s*:\s*(?!none\b)[^;]+|filter\s*:\s*(?!none\b)[^;]+|backdrop-filter\s*:\s*(?!none\b)[^;]+|-webkit-backdrop-filter\s*:\s*(?!none\b)[^;]+|contain\s*:[^;]*\bpaint\b|will-change\s*:[^;]*(?:transform|opacity|filter))/i;
function stackingContextSelectors(styles: readonly ExtractedBlock[]): Set<string> {
const selectors = new Set<string>();
for (const style of styles) {
const content = stripCssComments(style.content);
const rule = /([^{}]+)\{([^{}]*)\}/g;
let match: RegExpExecArray | null;
while ((match = rule.exec(content)) !== null) {
if (!STACKING_CONTEXT_DECLARATION.test(match[2] ?? "")) continue;
for (const part of (match[1] ?? "").split(",")) {
const selector = part.trim();
if (/^[.#][\w-]+$/.test(selector)) selectors.add(selector);
}
}
}
return selectors;
}
function classTokens(tag: OpenTag): string[] {
return (readAttr(tag.raw, "class") ?? "").split(/\s+/).filter(Boolean);
}
function tagMatchesSimpleSelector(tag: OpenTag, selector: string): boolean {
if (selector.startsWith("#")) return readAttr(tag.raw, "id") === selector.slice(1);
if (selector.startsWith(".")) return classTokens(tag).includes(selector.slice(1));
return false;
}
function nearestParentTag(tag: OpenTag, tags: readonly OpenTag[]): OpenTag | undefined {
let parent: OpenTag | undefined;
for (const candidate of tags) {
if (candidate.closeIndex == null) continue;
if (tag.index <= candidate.index || tag.index >= candidate.closeIndex) continue;
if (!parent || candidate.index > parent.index) parent = candidate;
}
return parent;
}
function parentHasStackingContext(
tag: OpenTag,
tags: readonly OpenTag[],
stacking: ReadonlySet<string>,
): boolean {
const parent = nearestParentTag(tag, tags);
if (!parent) return false;
return elementFormsStackingContext(parent, stacking);
}
function elementFormsStackingContext(tag: OpenTag, stacking: ReadonlySet<string>): boolean {
const id = readAttr(tag.raw, "id");
if (id && stacking.has(`#${id}`)) return true;
if (classTokens(tag).some((name) => stacking.has(`.${name}`))) return true;
const inline = readAttr(tag.raw, "style");
return Boolean(inline && STACKING_CONTEXT_DECLARATION.test(inline));
}
function negativeZIndexIsContained(
selector: string | undefined,
tags: readonly OpenTag[],
stacking: ReadonlySet<string>,
): boolean {
if (!selector || !/^[.#][\w-]+$/.test(selector)) return false;
const matched = tags.filter((tag) => tagMatchesSimpleSelector(tag, selector));
if (matched.length === 0) return false;
return matched.every((tag) => parentHasStackingContext(tag, tags, stacking));
}
function negativeZIndexFinding(
match: RegExpExecArray,
selector: string | undefined,
@@ -1005,9 +1076,9 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
// is in the DOM, laid out, and invisible. The message therefore names the CAUSE
// and the remedy rather than implying the tool failed.
//
// THE CONDITION IS LOAD-BEARING AND THE MESSAGE STATES IT. The element is only
// dropped when its nearest ancestor stacking context is the composition root,
// which is the shape #4366 reports. Give any ancestor a stacking context and it
// The element is only dropped when its nearest ancestor stacking context is
// the composition root,
// which is the shape #4366 reports. Give its parent a stacking context and it
// renders correctly: measured at 0.8.72 on ONE frame carrying the same
// `z-index: -1` band under six triggers -- isolation:isolate, transform,
// opacity below 1, filter, contain:paint, will-change -- all six PRESENT at full
@@ -1015,19 +1086,22 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
// That is ordinary CSS painting order: a negative-z child paints above its
// stacking context root's own background, so only the root case is hidden.
//
// This rule matches CSS TEXT and does not resolve the cascade, so it cannot know
// whether an ancestor forms a stacking context and fires on both shapes. That is
// why the message is CONDITIONAL rather than an assertion of absence: an
// unconditional "silently dropped" is false for every isolated case, and a lint
// message that overclaims is how authors learn to disregard the rule.
// A simple class or id parent that this file itself sets as a stacking
// context (isolation, transform, opacity below 1, filter, contain:paint,
// will-change) is enough to know the element paints. Anything the cascade
// could still hide stays a finding, and that message stays CONDITIONAL:
// an unconditional "silently dropped" is false for every isolated case.
({ tags, styles }) => {
const findings: HyperframeLintFinding[] = [];
const stacking = stackingContextSelectors(styles);
for (const style of styles) {
const content = stripCssComments(style.content);
NEGATIVE_Z_INDEX.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = NEGATIVE_Z_INDEX.exec(content)) !== null) {
findings.push(negativeZIndexFinding(match, cssOwnerSelector(content, match.index)));
const selector = cssOwnerSelector(content, match.index);
if (negativeZIndexIsContained(selector, tags, stacking)) continue;
findings.push(negativeZIndexFinding(match, selector));
}
}
for (const tag of tags) {
@@ -1035,7 +1109,9 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding
if (!inline) continue;
NEGATIVE_Z_INDEX.lastIndex = 0;
const match = NEGATIVE_Z_INDEX.exec(inline);
if (match) findings.push(negativeZIndexFinding(match, elementSelector(tag)));
if (!match) continue;
if (parentHasStackingContext(tag, tags, stacking)) continue;
findings.push(negativeZIndexFinding(match, elementSelector(tag)));
}
return findings;
},
+61
View File
@@ -1194,6 +1194,47 @@ describe("GSAP rules", () => {
expect(finding).toBeDefined();
});
it("does not report overlapping_gsap_tweens when tweens only meet at an endpoint", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="w-weeks"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#w-weeks", { scaleY: 0.18, scaleX: 1.25, duration: 0.22 }, 2.35);
tl.to("#w-weeks", { scaleX: 0, scaleY: 0, duration: 0.14 }, 2.57);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
expect(finding).toBeUndefined();
});
it("reports overlapping_gsap_tweens when shared properties overlap for real", async () => {
const html = `
<html><body>
<div data-composition-id="c1" data-width="1920" data-height="1080">
<div id="w-weeks"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#w-weeks", { scaleX: 1.25, duration: 0.5 }, 2.35);
tl.to("#w-weeks", { scaleX: 0, duration: 0.14 }, 2.57);
window.__timelines["c1"] = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "overlapping_gsap_tweens");
expect(finding).toBeDefined();
expect(finding?.message).toContain("scaleX");
});
it("reports motionPath usage without MotionPathPlugin", async () => {
const html = `
<html><body>
@@ -3103,6 +3144,26 @@ describe("SVG draw-on rules", () => {
expect(finding?.fixHint).toContain("gsap_timeline_set_initial_hide");
});
it("does not warn when two fromTo calls on one element animate different properties", async () => {
const html = `
<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080"><div id="card"></div></div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.fromTo("#card", { y: 80, rotation: 8 }, { y: 0, rotation: 0, duration: 0.8 }, 0.3);
tl.fromTo("#card", { scale: 0 }, { scale: 1, duration: 0.65 }, 0.3);
window.__timelines.main = tl;
</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find(
(candidate) => candidate.code === "gsap_repeated_fromto_without_baseline",
);
expect(finding).toBeUndefined();
});
it("gsap_repeated_fromto_without_baseline: accepts the hinted shape when the later tween is authored first", async () => {
const html = `
<html><body>
+14 -2
View File
@@ -66,6 +66,7 @@ type GsapWindow = {
};
const SCENE_BOUNDARY_EPSILON_SECONDS = 0.05;
const TWEEN_ENDPOINT_EPSILON_SECONDS = 0.001;
// Sentinel the GSAP parser assigns to a tween whose target it cannot statically
// resolve to a concrete element (a computed variable, a helper call, etc.). It is
@@ -180,6 +181,17 @@ function stringValue(value: string | number | undefined): string | null {
return null;
}
function fromToGroupSharesFromProperty(windows: GsapWindow[]): boolean {
if (windows.some((win) => win.fromPropertyValues === undefined)) return true;
const counts = new Map<string, number>();
for (const win of windows) {
for (const key of Object.keys(win.fromPropertyValues ?? {})) {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
}
return [...counts.values()].some((count) => count >= 2);
}
function zeroValue(value: string | number | undefined): boolean {
if (typeof value === "number") return value === 0;
if (typeof value !== "string") return false;
@@ -1091,7 +1103,7 @@ export const gsapRules: LintRule<LintContext>[] = [
if (leftIdentity !== rightIdentity) continue;
const overlapStart = Math.max(left.position, right.position);
const overlapEnd = Math.min(left.end, right.end);
if (overlapEnd <= overlapStart) continue;
if (overlapEnd - overlapStart <= TWEEN_ENDPOINT_EPSILON_SECONDS) continue;
if (left.overwriteAuto || right.overwriteAuto) continue;
const sharedProperties = left.properties.filter((prop) =>
right.properties.includes(prop),
@@ -1119,7 +1131,7 @@ export const gsapRules: LintRule<LintContext>[] = [
}
const repeatedFromToGroups = [...fromToWindowsBySelector.values()].filter(
(windows) => windows.length >= 2,
(windows) => windows.length >= 2 && fromToGroupSharesFromProperty(windows),
);
for (const fromToWindows of repeatedFromToGroups) {
const firstFromTo = fromToWindows[0];
+1 -1
View File
@@ -108,7 +108,7 @@ export function mintHfId(el: Element, assigned: Set<string>): string {
* inner id would be duplicated across every clone. Form B is distinguished from
* a clone-source by the presence of a direct `[data-composition-id]` child.
*/
function getChildElements(parent: Element): Element[] {
export function getChildElements(parent: Element): Element[] {
const directChildren = Array.from(parent.children);
if (directChildren.length || parent.tagName.toLowerCase() !== "template") return directChildren;
const content = (parent as HTMLTemplateElement).content;
+60 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { ensureHfIds, mintHfId, walkCompositionDescendants } from "./hfIds.js";
import { ensureHfIds, mintHfId, stampHfIdsInPlace, walkCompositionDescendants } from "./hfIds.js";
import { parseHTML } from "linkedom";
import { assignHfIds } from "./hfIdAssignment.js";
@@ -43,6 +43,65 @@ describe("ensureHfIds fragment detection", () => {
});
});
describe("stampHfIdsInPlace keeps the source's own formatting", () => {
it("only inserts ids into a composition written without whitespace", () => {
const html =
'<template id="s-template"><div data-composition-id="s" data-start="0" data-duration="4"><style>#t{color:red}</style><img src="a.png"/><input disabled><p class=\'t\' id=t>Hi<br></p></div></template>';
const out = stampHfIdsInPlace(html);
expect(out.replace(/ data-hf-id="hf-[a-z0-9]{4}"/g, "")).toBe(html);
expect(ids(out.replace(/<\/?template[^>]*>/g, ""))).toHaveLength(5);
expect(stampHfIdsInPlace(out)).toBe(out);
});
it("only inserts ids into a full document with comments, scripts and SVG", () => {
const html = `<!DOCTYPE html>\n<html>\n<head><meta charset="UTF-8"><script>if(a<b){x="<div>"}</script></head>\n<body>\n <!-- <span> -->\n <div id="root" data-composition-id="main"><svg viewBox="0 0 10 10"><path d="M0 0"/></svg></div>\n</body>\n</html>\n`;
const out = stampHfIdsInPlace(html);
expect(out.replace(/ data-hf-id="hf-[a-z0-9]{4}"/g, "")).toBe(html);
expect(ids(out)).toEqual(ids(ensureHfIds(html)));
expect(idOf(out, "path")).toMatch(/^hf-/);
});
it("keeps an astral character from shifting the minted id", () => {
const html = `<div title='😀' data-hf-id=""><span>Hello</span></div>`;
const out = stampHfIdsInPlace(html);
expect(out).toContain(`title='😀' data-hf-id="`);
expect(out).not.toMatch(/data-hf-id=hf-/);
expect(ids(out)).toEqual(ids(ensureHfIds(html)));
});
it("does not mint an id inside another attribute's quotes", () => {
const html = `<div title=' data-hf-id=""' data-hf-id=""><p data-hf-id="">Hi</p></div>`;
const out = stampHfIdsInPlace(html);
expect(out).toContain(`title=' data-hf-id=""'`);
expect(out.replace(/data-hf-id="hf-[a-z0-9]{4}"/g, 'data-hf-id=""')).toBe(html);
expect(ids(out)).toEqual(ids(ensureHfIds(html)));
});
it("fills an empty data-hf-id instead of leaving it blank", () => {
const html = `<div data-hf-id=""><p data-hf-id="">Hello</p></div>`;
const out = stampHfIdsInPlace(html);
expect(out.replace(/data-hf-id="hf-[a-z0-9]{4}"/g, 'data-hf-id=""')).toBe(html);
expect(out).not.toMatch(/data-hf-id=""\s+data-hf-id=/);
expect(ids(out)).toEqual(ids(ensureHfIds(html)));
expect(ids(out).every((id) => /^hf-[a-z0-9]{4}$/.test(id))).toBe(true);
});
it("does not treat a longer tag name as the end of a script or style", () => {
const script = `<div><script>s="</scripts>"</script><span>x</span></div>`;
const styled = `<div><style>/* </styles */</style><span>x</span></div>`;
for (const html of [script, styled]) {
const out = stampHfIdsInPlace(html);
expect(out.replace(/ data-hf-id="hf-[a-z0-9]{4}"/g, "")).toBe(html);
expect(ids(out)).toEqual(ids(ensureHfIds(html)));
}
const stray = `<div><script>s="</scripts><p>"</script></p><span>x</span></div>`;
const stamped = stampHfIdsInPlace(stray);
expect(stamped).toContain(`s="</scripts><p>"</script>`);
expect(stamped).not.toMatch(/scripts><p>" data-hf-id/);
expect(ids(stamped)).toEqual(ids(ensureHfIds(stray)));
});
});
describe("ensureHfIds", () => {
it("ignores HTML attribute case and editor state while retaining pinned IDs", () => {
const lower = doc(`<div id="x" data-start="2" data-hf-state="a">hello</div>`);
+191 -2
View File
@@ -9,7 +9,7 @@
* so inserting a non-identical sibling never shifts another element's id.
*/
import { parseHTML } from "linkedom";
import { assignHfIds } from "./hfIdAssignment.js";
import { assignHfIds, getChildElements } from "./hfIdAssignment.js";
export {
EXCLUDED_TAGS,
mintHfId,
@@ -18,6 +18,13 @@ export {
} from "./hfIdAssignment.js";
export function ensureHfIds(html: string): string {
const { document, wrapped } = parseFragmentOrDocument(html);
if (!document.body) return html;
assignHfIds(document.body);
return wrapped ? document.body.innerHTML || "" : document.toString();
}
function parseFragmentOrDocument(html: string) {
// Wrap fragments so every body element participates in stable ID assignment.
const wrapped = !/^(?:<!doctype|<html[\s>/])/i.test(
html.replace(/^(?:\s|<!--(?:>|->|[\s\S]*?-->))*/, ""),
@@ -25,7 +32,189 @@ export function ensureHfIds(html: string): string {
const { document } = wrapped
? parseHTML(`<!DOCTYPE html><html><head></head><body>${html}</body></html>`)
: parseHTML(html);
return { document, wrapped };
}
/** ensureHfIds for a file someone edits: the same ids, inserted into the source text so nothing else in it changes.
* Markup whose tags the parser did not keep one-to-one falls back to ensureHfIds. */
export function stampHfIdsInPlace(html: string): string {
const { document, wrapped } = parseFragmentOrDocument(html);
if (!document.body) return html;
const elements = wrapped
? getChildElements(document.body).flatMap(inDocumentOrder)
: inDocumentOrder(document.documentElement);
const existing = elements.map((el) => el.getAttribute("data-hf-id"));
const pinned = existing.map((id) => id !== null && id.length > 0);
assignHfIds(document.body);
return wrapped ? document.body.innerHTML || "" : document.toString();
const minted = elements.map((el, n) => (pinned[n] ? null : el.getAttribute("data-hf-id")));
const tags = startTags(html);
const inserts = tags && alignInserts(html, tags, elements, minted, existing);
return inserts ? spliceIds(html, inserts) : ensureHfIds(html);
}
function inDocumentOrder(el: Element): Element[] {
return [el, ...getChildElements(el).flatMap(inDocumentOrder)];
}
const RAW_TEXT = new Set(["script", "style", "textarea", "title", "xmp", "noembed", "noframes"]);
const IMPLIED = new Set(["html", "head", "body", "tbody"]);
type StartTag = { name: string; nameEnd: number; bracket: number };
function startTags(html: string): StartTag[] | null {
const lower = html.toLowerCase();
const tags: StartTag[] = [];
let i = html.indexOf("<");
while (i >= 0) {
const name = html.startsWith("<!--", i)
? null
: /^<([a-zA-Z][^\s/>]*)/.exec(html.slice(i, i + 128));
if (!name) {
i = nextTagAfter(html, i);
continue;
}
const end = closingBracket(html, i + name[0].length);
if (end < 0) return null;
const tag = name[1]!.toLowerCase();
tags.push({ name: tag, nameEnd: i + name[0].length, bracket: end });
const after = RAW_TEXT.has(tag) ? rawTextEnd(lower, tag, end) : end;
i = after < 0 ? -1 : html.indexOf("<", after + 1);
}
return tags;
}
function rawTextEnd(lower: string, tag: string, from: number): number {
const marker = `</${tag}`;
for (
let at = lower.indexOf(marker, from);
at >= 0;
at = lower.indexOf(marker, at + marker.length)
) {
const next = lower[at + marker.length];
if (next !== undefined && /[\s/>]/.test(next)) return at;
}
return -1;
}
function nextTagAfter(html: string, lt: number): number {
const comment = html.startsWith("<!--", lt);
const end = comment ? html.indexOf("-->", lt + 4) : html.indexOf(">", lt);
return end < 0 ? -1 : html.indexOf("<", end + 1);
}
function closingBracket(html: string, from: number): number {
let quote = "";
for (let i = from; i < html.length; i++) {
const c = html[i];
if (quote) quote = c === quote ? "" : quote;
else if (c === '"' || c === "'") quote = c;
else if (c === ">") return i;
}
return -1;
}
type Insert = { at: number; text: string };
function alignInserts(
html: string,
tags: StartTag[],
elements: Element[],
minted: (string | null)[],
existing: (string | null)[],
) {
const walked = walkAlign(html, tags, elements, minted, existing);
if (!walked) return null;
return walked.next === tags.length ? walked.inserts : null;
}
function walkAlign(
html: string,
tags: StartTag[],
elements: Element[],
minted: (string | null)[],
existing: (string | null)[],
) {
const state = { inserts: [] as Insert[], next: 0 };
for (const [n, el] of elements.entries()) {
if (!take(state, html, tags, el, minted[n] ?? null, existing[n] === "")) return null;
}
return state;
}
function take(
state: { inserts: Insert[]; next: number },
html: string,
tags: StartTag[],
el: Element,
id: string | null,
fillEmpty: boolean,
) {
const step = alignOne(html, tags[state.next], el, id, fillEmpty);
if (!step) return false;
if (step.insert) state.inserts.push(step.insert);
if (step.next) state.next += 1;
return true;
}
function alignOne(
html: string,
tag: StartTag | undefined,
el: Element,
id: string | null,
fillEmpty: boolean,
) {
if (tag?.name === el.tagName.toLowerCase()) return matched(html, tag, id, fillEmpty);
if (IMPLIED.has(el.tagName.toLowerCase()) && !id) return { insert: null, next: false };
return null;
}
function matched(html: string, tag: StartTag, id: string | null, fillEmpty: boolean) {
if (!id) return { insert: null, next: true };
const placed = placeId(html, tag, id, fillEmpty);
if (!placed) return null;
return { insert: placed, next: true };
}
function placeId(html: string, tag: StartTag, id: string, fillEmpty: boolean) {
if (!fillEmpty) return { at: tag.nameEnd, text: ` data-hf-id="${id}"` };
const emptyAt = emptyHfIdValue(html, tag);
if (emptyAt === null) return null;
return { at: emptyAt, text: id };
}
function emptyHfIdValue(html: string, tag: StartTag): number | null {
const open = outsideQuotes(html.slice(tag.nameEnd, tag.bracket));
const match = /\sdata-hf-id\s*=\s*(["'])\1/i.exec(open);
if (!match) return null;
return tag.nameEnd + match.index + match[0].length - 1;
}
function outsideQuotes(open: string): string {
let quote = "";
let out = "";
for (const c of open) {
out += quotedChar(quote, c);
quote = quoteOf(quote, c);
}
return out;
}
function quoteOf(quote: string, c: string): string {
if (quote) return c === quote ? "" : quote;
return c === '"' || c === "'" ? c : "";
}
function quotedChar(quote: string, c: string): string {
if (!quote || c === quote) return c;
return " ".repeat(c.length);
}
function spliceIds(html: string, inserts: Insert[]): string {
let out = "";
let from = 0;
for (const { at, text } of inserts) {
out += html.slice(from, at) + text;
from = at;
}
return out + html.slice(from);
}
@@ -17,9 +17,9 @@ vi.mock("@hyperframes/parsers/hf-ids", async (importOriginal) => {
const actual = await importOriginal<typeof import("@hyperframes/parsers/hf-ids")>();
return {
...actual,
ensureHfIds: (html: string) => {
stampHfIdsInPlace: (html: string) => {
hooks.minting?.();
return actual.ensureHfIds(html);
return actual.stampHfIdsInPlace(html);
},
};
});
@@ -48,6 +48,15 @@ describe("stampFileHfIds", () => {
expect(readFileSync(file, "utf-8")).toBe(returned);
});
it("adds only the ids to the file, keeping its own formatting", () => {
const html = `<div class='clip' data-start=0 data-end="3"><img src="a.png"/><input disabled>Hi</div>`;
const file = tmpFile(html);
stampFileHfIds(file);
const written = readFileSync(file, "utf-8");
expect(written).toContain('data-hf-id="hf-');
expect(written.replace(/ data-hf-id="hf-[a-z0-9]{4}"/g, "")).toBe(html);
});
it("replaces the stamped file and removes its temporary sibling", () => {
const file = tmpFile(`<div class="clip" data-start="0" data-end="3">Hi</div>`);
@@ -1,4 +1,4 @@
import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { stampHfIdsInPlace } from "@hyperframes/parsers/hf-ids";
import { createHash } from "node:crypto";
import { closeSync, constants, fstatSync, openSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
@@ -94,7 +94,7 @@ export function stampFileHfIds(filePath: string): string | null {
try {
if (!fstatSync(fd).isFile()) return null;
const html = readFileSync(fd, "utf-8");
const normalized = ensureHfIds(html);
const normalized = stampHfIdsInPlace(html);
// Attribute count, not string equality — linkedom serialization normalizes
// quote style/whitespace even when no ids were minted.
const idsBefore = (html.match(/\bdata-hf-id=/g) ?? []).length;