fix(core): bound timing compiler opening tag scans (#3719)

* fix(core): bound timing compiler opening tag scans

* fix(core): bound ID-targeted duration tag scans
This commit is contained in:
James Russo
2026-09-05 18:02:36 -04:00
committed by GitHub
parent 3610d94a98
commit b94b5bde58
2 changed files with 145 additions and 18 deletions
@@ -95,6 +95,88 @@ describe("inert region scanning", () => {
});
});
describe("opening tag scanning", () => {
it.each([injectDurations, clampDurations])(
"keeps long unclosed ID-targeted tags unchanged in %p",
(write) => {
const html = '<video id="target" '.repeat(100_000);
expect(write(html, [{ id: "target", duration: 3 }])).toBe(html);
},
);
it.each(["target", "a>b", "a<b", "a.b[0]"])(
"preserves substring-ID matches and delimiter characters for %j",
(id) => {
const html = `<video data-id="${id}" data-start="1" data-duration="bad" data-end="4">`;
expect(injectDurations(html, [{ id, duration: 3 }])).toBe(
`<video data-id="${id}" data-start="1" data-duration="3" data-end="4">`,
);
expect(clampDurations(html, [{ id, duration: 3 }])).toBe(
`<video data-id="${id}" data-start="1" data-duration="3" data-end="4">`,
);
},
);
it("leaves similar IDs alone and applies repeated resolutions in order", () => {
const html = '<video id="a.b" data-start="1" data-duration="bad"><video id="axb">';
const resolutions = [
{ id: "a.b", duration: 3 },
{ id: "a.b", duration: 5 },
];
expect(injectDurations(html, resolutions)).toBe(
'<video id="a.b" data-start="1" data-duration="3" data-end="4"><video id="axb">',
);
expect(clampDurations(html, resolutions)).toBe(
'<video id="a.b" data-start="1" data-duration="5"><video id="axb">',
);
});
it.each(["<video", "<audio", "<div", "<section", "<video<audio<div<section"])(
"preserves an unclosed %j suffix after compiling complete media",
(prefix) => {
const media = '<video id="v" data-start="1" data-duration="2">';
const suffix = prefix.repeat(100_000);
expect(compileTimingAttrs(media + suffix)).toEqual({
html: compileTimingAttrs(media).html + suffix,
unresolved: [],
});
expect(extractResolvedMedia(media + suffix).map((el) => el.id)).toEqual(["v"]);
},
);
it("keeps separate media ID counters and video/audio/composition resolution order", () => {
const html = '<audio><VIDEO><section id="scene" data-start="0"><audio>';
const result = compileTimingAttrs(html);
expect(result.unresolved.map((el) => el.id)).toEqual([
"hf-video-0",
"hf-audio-0",
"hf-audio-1",
"scene",
]);
expect(result.html.indexOf('id="hf-audio-0"')).toBeLessThan(
result.html.indexOf('id="hf-video-0"'),
);
});
it("extracts mixed-case media in source order", () => {
const html = '<AUDIO id="a" data-duration="2"><video id="v" data-duration="3">';
expect(extractResolvedMedia(html).map((el) => [el.id, el.tagName, el.duration])).toEqual([
["a", "audio", 2],
["v", "video", 3],
]);
});
it("retains the existing first-greater-than boundary even inside a quoted value", () => {
const html = '<video title="a>b" data-duration="2">';
const result = compileTimingAttrs(html);
expect(result.html).toBe(
'<video title="a id="hf-video-0" data-start="0" data-hf-auto-start="" data-has-audio="true">b" data-duration="2">',
);
expect(result.unresolved.map((el) => el.id)).toEqual(["hf-video-0"]);
expect(extractResolvedMedia(html)).toEqual([]);
});
});
describe("compileTimingAttrs", () => {
it.each(["", " ", "0s", "0abc", "0px", "-1s", "Infinity", "NaN"])(
"does not partially parse invalid literal data-duration=%j",
+63 -18
View File
@@ -154,6 +154,60 @@ function maskInertRegions(html: string): { masked: string; restore: (s: string)
// ── Core compilation ─────────────────────────────────────────────────────
function* iterateOpeningTags(html: string, prefix: RegExp) {
let match: RegExpExecArray | null;
while ((match = prefix.exec(html)) !== null) {
const closing = html.indexOf(">", prefix.lastIndex);
// Without a closer, no later prefix can form a complete tag either.
if (closing < 0) break;
const end = closing + 1;
yield { tag: html.slice(match.index, end), index: match.index, end };
prefix.lastIndex = end;
}
}
function replaceOpeningTags(
html: string,
prefix: RegExp,
replace: (tag: string) => string,
): string {
const parts: string[] = [];
let cursor = 0;
for (const { tag, index, end } of iterateOpeningTags(html, prefix)) {
parts.push(html.slice(cursor, index), replace(tag));
cursor = end;
}
parts.push(html.slice(cursor));
return parts.join("");
}
function replaceIdTags(html: string, id: string, replace: (tag: string) => string): string {
const idPattern = new RegExp(`id=["']${escapeRegex(id)}["']`, "gi");
const lastClosing = html.lastIndexOf(">");
const parts: string[] = [];
let cursor = 0;
let candidate = idPattern.exec(html);
for (const { index, end } of iterateOpeningTags(html, /</g)) {
if (index < cursor) continue;
let targetEnd = -1;
while (candidate && candidate.index < end) {
const candidateEnd = candidate.index + candidate[0].length;
if (candidate.index > index && candidateEnd <= lastClosing) targetEnd = candidateEnd;
// Preserve the old greedy prefix's last matching ID, including overlaps.
idPattern.lastIndex = candidate.index + 1;
candidate = idPattern.exec(html);
}
if (targetEnd < 0) continue;
// An authored ID can itself contain '>', so its closer may follow the
// initial span. The lastClosing check guarantees a closing delimiter.
const closing = html.indexOf(">", targetEnd) + 1;
parts.push(html.slice(cursor, index), replace(html.slice(index, closing)));
cursor = closing;
}
parts.push(html.slice(cursor));
return parts.join("");
}
function compileTag(
tag: string,
isVideo: boolean,
@@ -228,14 +282,14 @@ export function compileTimingAttrs(html: string): CompilationResult {
html = masked;
// Process <video ...> tags
html = html.replace(/<video[^>]*>/gi, (match) => {
html = replaceOpeningTags(html, /<video/gi, (match) => {
const { tag, unresolved: u } = compileTag(match, true, () => nextVideoId++);
if (u) unresolved.push(u);
return tag;
});
// Process <audio ...> tags
html = html.replace(/<audio[^>]*>/gi, (match) => {
html = replaceOpeningTags(html, /<audio/gi, (match) => {
const { tag, unresolved: u } = compileTag(match, false, () => nextAudioId++);
if (u) unresolved.push(u);
return tag;
@@ -243,9 +297,9 @@ export function compileTimingAttrs(html: string): CompilationResult {
// Identify unresolved timed elements (divs with data-start but no data-end/data-duration)
// These are typically compositions whose duration depends on GSAP timelines
html.replace(/<(?:div|section)[^>]*>/gi, (match) => {
if (!hasAttr(match, "data-start")) return match;
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) return match;
for (const { tag: match } of iterateOpeningTags(html, /<(?:div|section)/gi)) {
if (!hasAttr(match, "data-start")) continue;
if (hasAttr(match, "data-end") || hasAttr(match, "data-duration")) continue;
const id = getAttr(match, "id");
const compositionSrc = getAttr(match, "data-composition-src");
@@ -260,9 +314,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
compositionSrc: compositionSrc ?? undefined,
});
}
return match;
});
}
return { html: restore(html), unresolved };
}
@@ -277,9 +329,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
export function injectDurations(html: string, resolutions: ResolvedDuration[]): string {
for (const { id, duration } of resolutions) {
// Match the element's opening tag by id
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
html = html.replace(idPattern, (tag) => {
html = replaceIdTags(html, id, (tag) => {
let result = tag;
// Add data-duration if missing
@@ -314,10 +364,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
const resolved: ResolvedMediaElement[] = [];
html = maskInertRegions(html).masked;
const mediaRegex = /<(?:video|audio)[^>]*>/gi;
let match: RegExpExecArray | null;
while ((match = mediaRegex.exec(html)) !== null) {
const tag = match[0];
for (const { tag } of iterateOpeningTags(html, /<(?:video|audio)/gi)) {
const id = getAttr(tag, "id");
const durationStr = getAttr(tag, "data-duration");
if (!id || durationStr === null) continue;
@@ -351,9 +398,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
*/
export function clampDurations(html: string, clamps: ResolvedDuration[]): string {
for (const { id, duration } of clamps) {
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
html = html.replace(idPattern, (tag) => {
html = replaceIdTags(html, id, (tag) => {
// Replace data-duration value
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);