Files
hyperframes/packages/inspector/src/authoredState.ts
Xuanru Li 45825faa5d 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.
2026-10-14 09:16:20 +00:00

331 lines
11 KiB
TypeScript

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 };
}