Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,56 @@ it("source contains no raw NUL bytes", () => {
expect(src.includes("\x00")).toBe(false);
});

describe("inert region scanning", () => {
const media = '<video id="v" data-start="1" data-duration="2">';

it.each([
`<!-- ${media} <!-- nested -->`,
`<!-- --!> ${media} -->`,
`<ScRiPt type="text/javascript">${media}</sCrIpT \n>`,
`<STYLE>${media}</STYLE\u00a0>`,
`<script-data>${media}</script>`,
`<style:${media}</style>`,
`<!-- <script>${media} -->`,
`<script><!-- ${media}</script>`,
`<style><script>${media}</style>`,
])("preserves the existing complete-region boundaries in %j", (region) => {
const result = compileTimingAttrs(region + media);
expect(result).toEqual({ html: region + compileTimingAttrs(media).html, unresolved: [] });
expect(extractResolvedMedia(region + media)).toEqual(extractResolvedMedia(media));
});

it.each(["<!--", "<!-- --!>", "<script>", "<style>", "<scripture>", "<stylesheet>"])(
"keeps media outside a complete inert region after %j visible",
(prefix) => {
expect(compileTimingAttrs(prefix + media).html).toBe(prefix + compileTimingAttrs(media).html);
expect(extractResolvedMedia(prefix + media)).toEqual(extractResolvedMedia(media));
},
);

it.each(["<!--", "<script", "<style"])(
"handles many unclosed %j prefixes while masking other region kinds",
(prefix) => {
const unclosed = prefix.repeat(100_000);
const hidden = prefix === "<!--" ? `<style>${media}</style>` : `<!--${media}-->`;
expect(compileTimingAttrs(unclosed + hidden + media)).toEqual({
html: unclosed + hidden + compileTimingAttrs(media).html,
unresolved: [],
});
expect(extractResolvedMedia(unclosed + hidden + media)).toEqual(extractResolvedMedia(media));
},
);

it("uses the first closing delimiter and resumes scanning after it", () => {
const hidden = `<script>${media}</script>`;
const html = hidden + media + `</script><!--${media}--><style>${media}</style>`;
expect(compileTimingAttrs(html).html).toBe(
hidden + compileTimingAttrs(media).html + `</script><!--${media}--><style>${media}</style>`,
);
expect(extractResolvedMedia(html)).toEqual(extractResolvedMedia(media));
});
});

describe("compileTimingAttrs", () => {
it.each(["", " ", "0s", "0abc", "0px", "-1s", "Infinity", "NaN"])(
"does not partially parse invalid literal data-duration=%j",
Expand Down
41 changes: 34 additions & 7 deletions packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,45 @@ function setAttr(tag: string, attr: string, value: string): string {
// `<video>`/`<audio>` gets rewritten as if it were a real element (issue #1938).
// Mask those inert regions with placeholders (no `<`, so the tag regexes skip
// them) before scanning, then restore them verbatim.
const INERT_REGION_RE =
/<!--[\s\S]*?-->|<script\b[\s\S]*?<\/script\s*>|<style\b[\s\S]*?<\/style\s*>/gi;

// The NUL delimiters must stay as \u0000 escapes: raw 0x00 bytes make this file
// binary to git and are corrupted by Bun's transpiler when bundled (issue #2139).
function maskInertRegions(html: string): { masked: string; restore: (s: string) => string } {
const stash: string[] = [];
const masked = html.replace(INERT_REGION_RE, (region) => {
const parts: string[] = [];
const opening = /<!--|<script\b|<style\b/gi;
const closings = new Map<string, string | RegExp>([
["<!--", "-->"],
["<script", /<\/script\s*>/gi],
["<style", /<\/style\s*>/gi],
]);
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = opening.exec(html)) !== null) {
const kind = match[0].toLowerCase();
const closing = closings.get(kind);
if (!closing) continue;
let end: number;
if (typeof closing === "string") {
const index = html.indexOf(closing, opening.lastIndex);
end = index < 0 ? -1 : index + closing.length;
} else {
closing.lastIndex = opening.lastIndex;
end = closing.exec(html) ? closing.lastIndex : -1;
}
if (end < 0) {
// No later opener of this kind can close either. Search each unmatched
// suffix only once, while still allowing other kinds of inert regions.
closings.delete(kind);
continue;
}
const token = `\u0000HFMASK${stash.length}\u0000`;
stash.push(region);
return token;
});
parts.push(html.slice(cursor, match.index), token);
stash.push(html.slice(match.index, end));
cursor = end;
opening.lastIndex = cursor;
}
parts.push(html.slice(cursor));
const masked = parts.join("");
const restore = (s: string): string =>
// oxlint-disable-next-line no-control-regex -- NUL cannot appear in HTML, which is what makes it a safe mask delimiter
s.replace(/\u0000HFMASK(\d+)\u0000/g, (_, i) => stash[Number(i)] ?? "");
Expand Down
Loading