Skip to content

Commit 513ec8b

Browse files
committed
fix(core): bound ID-targeted duration tag scans
1 parent 5bc19a3 commit 513ec8b

2 files changed

Lines changed: 64 additions & 6 deletions

File tree

‎packages/core/src/compiler/timingCompiler.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,41 @@ describe("inert region scanning", () => {
9696
});
9797

9898
describe("opening tag scanning", () => {
99+
it.each([injectDurations, clampDurations])(
100+
"keeps long unclosed ID-targeted tags unchanged in %p",
101+
(write) => {
102+
const html = '<video id="target" '.repeat(100_000);
103+
expect(write(html, [{ id: "target", duration: 3 }])).toBe(html);
104+
},
105+
);
106+
107+
it.each(["target", "a>b", "a<b", "a.b[0]"])(
108+
"preserves substring-ID matches and delimiter characters for %j",
109+
(id) => {
110+
const html = `<video data-id="${id}" data-start="1" data-duration="bad" data-end="4">`;
111+
expect(injectDurations(html, [{ id, duration: 3 }])).toBe(
112+
`<video data-id="${id}" data-start="1" data-duration="3" data-end="4">`,
113+
);
114+
expect(clampDurations(html, [{ id, duration: 3 }])).toBe(
115+
`<video data-id="${id}" data-start="1" data-duration="3" data-end="4">`,
116+
);
117+
},
118+
);
119+
120+
it("leaves similar IDs alone and applies repeated resolutions in order", () => {
121+
const html = '<video id="a.b" data-start="1" data-duration="bad"><video id="axb">';
122+
const resolutions = [
123+
{ id: "a.b", duration: 3 },
124+
{ id: "a.b", duration: 5 },
125+
];
126+
expect(injectDurations(html, resolutions)).toBe(
127+
'<video id="a.b" data-start="1" data-duration="3" data-end="4"><video id="axb">',
128+
);
129+
expect(clampDurations(html, resolutions)).toBe(
130+
'<video id="a.b" data-start="1" data-duration="5"><video id="axb">',
131+
);
132+
});
133+
99134
it.each(["<video", "<audio", "<div", "<section", "<video<audio<div<section"])(
100135
"preserves an unclosed %j suffix after compiling complete media",
101136
(prefix) => {

‎packages/core/src/compiler/timingCompiler.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,33 @@ function replaceOpeningTags(
181181
return parts.join("");
182182
}
183183

184+
function replaceIdTags(html: string, id: string, replace: (tag: string) => string): string {
185+
const idPattern = new RegExp(`id=["']${escapeRegex(id)}["']`, "gi");
186+
const lastClosing = html.lastIndexOf(">");
187+
const parts: string[] = [];
188+
let cursor = 0;
189+
let candidate = idPattern.exec(html);
190+
for (const { index, end } of iterateOpeningTags(html, /</g)) {
191+
if (index < cursor) continue;
192+
let targetEnd = -1;
193+
while (candidate && candidate.index < end) {
194+
const candidateEnd = candidate.index + candidate[0].length;
195+
if (candidate.index > index && candidateEnd <= lastClosing) targetEnd = candidateEnd;
196+
// Preserve the old greedy prefix's last matching ID, including overlaps.
197+
idPattern.lastIndex = candidate.index + 1;
198+
candidate = idPattern.exec(html);
199+
}
200+
if (targetEnd < 0) continue;
201+
// An authored ID can itself contain '>', so its closer may follow the
202+
// initial span. The lastClosing check guarantees a closing delimiter.
203+
const closing = html.indexOf(">", targetEnd) + 1;
204+
parts.push(html.slice(cursor, index), replace(html.slice(index, closing)));
205+
cursor = closing;
206+
}
207+
parts.push(html.slice(cursor));
208+
return parts.join("");
209+
}
210+
184211
function compileTag(
185212
tag: string,
186213
isVideo: boolean,
@@ -302,9 +329,7 @@ export function compileTimingAttrs(html: string): CompilationResult {
302329
export function injectDurations(html: string, resolutions: ResolvedDuration[]): string {
303330
for (const { id, duration } of resolutions) {
304331
// Match the element's opening tag by id
305-
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
306-
307-
html = html.replace(idPattern, (tag) => {
332+
html = replaceIdTags(html, id, (tag) => {
308333
let result = tag;
309334

310335
// Add data-duration if missing
@@ -373,9 +398,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] {
373398
*/
374399
export function clampDurations(html: string, clamps: ResolvedDuration[]): string {
375400
for (const { id, duration } of clamps) {
376-
const idPattern = new RegExp(`(<[^>]*id=["']${escapeRegex(id)}["'][^>]*>)`, "gi");
377-
378-
html = html.replace(idPattern, (tag) => {
401+
html = replaceIdTags(html, id, (tag) => {
379402
// Replace data-duration value
380403
tag = tag.replace(/data-duration=["'][^"']*["']/, `data-duration="${duration}"`);
381404

0 commit comments

Comments
 (0)