mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
@@ -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;
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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 & 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 & 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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user