Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
77 changes: 77 additions & 0 deletions packages/core/src/compiler/timingCompiler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { JSDOM } from "jsdom";
import { describe, it, expect } from "vitest";
import {
compileTimingAttrs,
Expand All @@ -18,6 +19,82 @@ 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 -->`,
`<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(["-->", "--!>"])("recognizes %j as the first comment end like the browser", (end) => {
const hidden = '<video id="hidden" data-duration="1">';
const visible = '<video id="visible" data-start="1" data-duration="2">';
const comment = `<!-- ${hidden} ${end}`;
// The trailing delimiter must not extend the comment over visible media.
const html = comment + visible + " -->";
const dom = new JSDOM(html);
expect([...dom.window.document.querySelectorAll("video")].map((el) => el.id)).toEqual([
"visible",
]);
dom.window.close();
expect(compileTimingAttrs(html)).toEqual({
html: comment + compileTimingAttrs(visible).html + " -->",
unresolved: [],
});
expect(extractResolvedMedia(html).map((el) => el.id)).toEqual(["visible"]);
});

it("closes an end-bang comment without a later standard delimiter", () => {
const comment = '<!-- <video id="hidden" data-duration="1"> --!>';
expect(compileTimingAttrs(comment + media)).toEqual({
html: comment + compileTimingAttrs(media).html,
unresolved: [],
});
expect(extractResolvedMedia(comment + 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
35 changes: 28 additions & 7 deletions packages/core/src/compiler/timingCompiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,18 +113,39 @@ 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([
["<!--", /--!?>/g],
["<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;
closing.lastIndex = opening.lastIndex;
const 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