Files
hyperframes/packages/parsers/src/topLevelElements.ts
Miguel Ángel 02c415dd8b feat(parsers): share top-level timeline element and track kind definitions (#4124)
* feat(parsers): share top-level timeline element and track kind definitions

One pure definition of which elements own a timeline row (sub-composition
hosts and timed elements; untimed wrappers are descended) and of a row's
track kind, so the linter and Studio's timeline agree.

* fix(parsers): align top-level rows with the runtime clip selector

Rows now follow the runtime's clip selector (data-start, data-track-index,
media tags), match tags case-insensitively for DOM adapters, and skip noscript.

* fix(parsers): treat a track-index layer without a duration as a wrapper

Matches the runtime, which skips such elements as clips, so their timed
children stay visible to the lint.
2026-09-18 18:47:18 -04:00

63 lines
2.5 KiB
TypeScript

/** Structural view of a markup node; lint (source tags) and Studio (DOM) adapt to it. */
export type StructureNode<N = unknown> = {
tag: string;
attrs: Readonly<Record<string, string | undefined>>;
children: readonly N[];
};
export type TrackKind = "video" | "audio" | "captions" | "graphics";
export type TrackKindSource = "attribute" | "tag" | "legacy-captions" | "sub-composition";
const NON_RENDERED_TAGS = new Set(["script", "style", "template", "noscript", "link", "meta"]);
const MEDIA_TAGS = new Set(["video", "audio", "img"]);
const TRACK_KINDS: readonly string[] = ["video", "audio", "captions", "graphics"];
export function isSubCompositionHost(node: StructureNode): boolean {
return (
node.attrs["data-composition-src"] !== undefined ||
node.attrs["data-composition-id"] !== undefined
);
}
/** Mirrors the runtime's clip selector; a data-track-index layer container without a duration is not a clip. */
export function isTimedElement(node: StructureNode): boolean {
const { attrs } = node;
return (
attrs["data-start"] !== undefined ||
(attrs["data-track-index"] !== undefined && attrs["data-duration"] !== undefined) ||
MEDIA_TAGS.has(node.tag.toLowerCase())
);
}
/**
* The elements that own a timeline row: a sub-composition host or timed element is one row
* and is never descended into; untimed wrappers are descended. The root itself is not a row.
*/
export function topLevelElements<N extends StructureNode<N>>(root: N): N[] {
const rows: N[] = [];
const visit = (node: N): void => {
if (NON_RENDERED_TAGS.has(node.tag.toLowerCase())) return;
if (isSubCompositionHost(node) || isTimedElement(node)) rows.push(node);
else node.children.forEach(visit);
};
root.children.forEach(visit);
return rows;
}
export function trackKindOf(node: StructureNode): { kind: TrackKind; source: TrackKindSource } {
const explicit = node.attrs["data-track-kind"]?.trim().toLowerCase();
if (explicit && TRACK_KINDS.includes(explicit))
return { kind: explicit as TrackKind, source: "attribute" };
const tag = node.tag.toLowerCase();
if (tag === "video" || tag === "audio") return { kind: tag, source: "tag" };
if (isSubCompositionHost(node)) {
const legacy =
node.attrs["data-composition-id"] === "captions" ||
/(?:^|\s)caption[-_]/.test(node.attrs.class ?? "");
return legacy
? { kind: "captions", source: "legacy-captions" }
: { kind: "graphics", source: "sub-composition" };
}
return { kind: "graphics", source: "tag" };
}