fix(core): preserve SVG icons in loaded compositions (#5096)

* fix(core): namespace SVG ids in runtime-mounted compositions

* fix(core): keep SVG selectors and captured ids working after loaded scenes rename them

* chore(core): generated artifacts carry a plain do-not-edit banner

* test(catalog): record the font subsets the new scene-script installer requests

* test(catalog): keep earlier mirror recordings, as re-records only add
This commit is contained in:
Miguel Ángel
2026-10-06 11:02:51 +00:00
committed by GitHub
parent 43d07781d8
commit eff786383d
48 changed files with 2556 additions and 662 deletions
+3 -1
View File
@@ -33,6 +33,8 @@
// Same arrangement for the audio FX graph builders, bundled by
// build-audio-fx-runtime.ts for the offline render page.
"packages/core/stubs/audio-fx-runtime-entry.ts",
// And for the SVG selector alias installer compiled scene scripts carry.
"packages/core/stubs/svg-selector-aliases-entry.ts",
// In-page audit scripts read as raw strings and injected via
// page.addScriptTag (see layout.ts / validate.ts) — referenced by file
// path, never imported, so they have no import-graph referrer.
@@ -938,7 +940,7 @@
"packages/studio/src/player/components/TimelineClipDiamonds.tsx",
// Sub-composition html/body scoping fix: these files carry pre-existing
// CRITICAL/HIGH functions (inlineSubCompositions, bundleToSingleHtml,
// mountCompositionContent/loadExternalCompositions, scopeSelector/
// mountCompositionContent/loadCompositions, scopeSelector/
// replaceAuthoredRootIdSelectors) that pre-date this PR. The change only
// threads a scopeRootSelectors option through the existing scoping calls;
// the line-shift fingerprint re-flags the inherited complexity.
+2
View File
@@ -75,6 +75,8 @@ tmp/
packages/core/src/generated/*
!packages/core/src/generated/position-edits-render-inline.ts
!packages/core/src/generated/position-edits-render-inline.test.ts
!packages/core/src/generated/svg-selector-aliases-inline.ts
!packages/core/src/generated/svg-selector-aliases-inline.test.ts
packages/core/src/fonts/fontData.generated.ts
# Plan and contract documents (working artifacts, not committed)
+4 -2
View File
@@ -848,9 +848,9 @@
"types": "./dist/index.d.ts"
},
"scripts": {
"build": "bun run build:fonts && bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts",
"build": "bun run build:fonts && bun run build:svg-selector-aliases && bun run build:hyperframes-runtime && bun run build:position-edits-render && bun run build:audio-fx-runtime && tsc && tsx scripts/rewrite-esm-extensions.ts",
"build:fonts": "node scripts/build-fonts.mjs",
"test": "bun run check:position-edits-render && bun run build:audio-fx-runtime && vitest run",
"test": "bun run check:position-edits-render && bun run check:svg-selector-aliases && bun run build:audio-fx-runtime && vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:runtime-coverage": "vitest run --coverage src/runtime",
@@ -861,6 +861,8 @@
"build:hyperframes-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
"build:position-edits-render": "tsx scripts/build-position-edits-render.ts",
"check:position-edits-render": "bun run build:position-edits-render && git diff --exit-code -- src/generated/position-edits-render-inline.ts",
"build:svg-selector-aliases": "tsx scripts/build-svg-selector-aliases.ts",
"check:svg-selector-aliases": "bun run build:svg-selector-aliases && git diff --exit-code -- src/generated/svg-selector-aliases-inline.ts",
"build:hyperframes-runtime:modular": "SANDBOX_RUNTIME_VARIANT=modular tsx scripts/build-hyperframes-runtime-artifact.ts",
"build:hyperframe-runtime": "tsx scripts/build-hyperframes-runtime-artifact.ts",
"test:hyperframe-runtime-contract": "tsx scripts/test-hyperframe-runtime-contract.ts",
@@ -0,0 +1,13 @@
/** Build the SVG selector alias installer that compiled scene scripts carry. */
import { buildInjectedArtifact } from "./buildInjectedArtifact.js";
buildInjectedArtifact({
scriptUrl: import.meta.url,
entry: "stubs/svg-selector-aliases-entry.ts",
out: "svg-selector-aliases-inline.ts",
constName: "SVG_SELECTOR_ALIASES_IIFE",
fnName: "getSvgSelectorAliasesScript",
what: "SVG selector alias installer IIFE",
event: "svg_selector_aliases_generated",
});
@@ -52,7 +52,7 @@ export function buildInjectedArtifact(spec: InjectedArtifact): void {
if (!iife) throw new Error(`esbuild produced no output for ${spec.entry.split("/").pop()}`);
const source = [
`// AUTO-GENERATED by scripts/${scriptName} - do not edit`,
`// Generated by scripts/${scriptName}; do not edit.`,
`const ${spec.constName}: string = ${JSON.stringify(iife)};`,
"",
`/** Returns the pre-built ${spec.what} as a string constant. */`,
@@ -168,6 +168,19 @@ body { margin: 0; }
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("keeps a classic authored script in sloppy mode", () => {
const { document } = parseHTML(`<div data-composition-id="scene"></div>`);
const fakeWindow: Record<string, unknown> = { document, __timelines: {} };
const wrapped = wrapScopedCompositionScript(
`window.__captured = (function() { return this !== undefined; })();`,
"scene",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toBe(true);
});
it("gives a mounted module script its composition's own __hyperframes", () => {
const { document } = parseHTML(`<div></div>`);
Object.defineProperty(document, "baseURI", { value: "https://p.test/preview/" });
@@ -1302,8 +1315,9 @@ describe("composition scoping – renamed-id selector runtime", () => {
const window = dom.window as unknown as Window & typeof globalThis & Record<string, unknown>;
window.__captured = {};
if (gsap) window.gsap = gsap;
const nativeQuery = window.Element.prototype.querySelector;
window.eval(wrapScopedCompositionScript(script, compId));
return { window, captured: window.__captured as Record<string, unknown> };
return { window, nativeQuery, captured: window.__captured as Record<string, unknown> };
}
const TWO_INSTANCES = `
@@ -1313,17 +1327,17 @@ describe("composition scoping – renamed-id selector runtime", () => {
</div>`;
it("leaves Element.prototype untouched when no id was renamed", () => {
const { window, captured } = bootWindow(
const { window, captured, nativeQuery } = bootWindow(
`<div data-composition-id="scene"><svg><path id="shape"/></svg></div>`,
`window.__captured.hit = document.querySelector("svg").querySelector("#shape");`,
"scene",
);
expect(window.__hfRenamedIdSelectorShim).toBeUndefined();
expect(window.Element.prototype.querySelector).toBe(nativeQuery);
expect(captured.hit).toBe(window.document.querySelector("path"));
});
it("rewrites #authoredId to match a renamed element from element and document lookups", () => {
const { window, captured } = bootWindow(
const { window, captured, nativeQuery } = bootWindow(
TWO_INSTANCES,
`var svg = document.querySelector("svg.art");
window.__captured.viaElement = svg.querySelector("#shape");
@@ -1336,7 +1350,7 @@ describe("composition scoping – renamed-id selector runtime", () => {
);
const renamed = window.document.querySelector('[data-hf-authored-id="shape"]');
const other = window.document.querySelector('[data-composition-id="other"] path');
expect(window.__hfRenamedIdSelectorShim).toBe(true);
expect(window.Element.prototype.querySelector).not.toBe(nativeQuery);
expect(captured.viaElement).toBe(renamed);
expect(captured.viaElementAll).toBe(1);
expect(captured.viaDocument).toBe(renamed);
@@ -1399,10 +1413,11 @@ describe("composition scoping – renamed-id selector runtime", () => {
expect(captured.suffix).toBeNull();
});
it("refreshes renamed ids after a scene is replaced and its script runs again", () => {
it("uses the lifecycle alias refresh before a replaced scene script runs", () => {
const { window } = bootWindow(TWO_INSTANCES, "", "scene");
window.document.querySelector('[data-composition-id="scene"]')!.innerHTML =
'<svg><path id="scene--new" data-hf-authored-id="new"/></svg>';
window.__hfSvgSelectorAliases!.refresh();
window.eval(
wrapScopedCompositionScript(
'window.__captured.hit = document.querySelector("svg").querySelector("#new").id;',
@@ -1,3 +1,4 @@
import { getSvgSelectorAliasesScript } from "../generated/svg-selector-aliases-inline";
import postcss, { type AtRule, type Node, type Rule } from "postcss";
import { escapeCssIdentifier, replaceSelectorIdTokens } from "./selectorIdTokens";
import { SCENE_PARTS_META } from "../sceneParts";
@@ -312,7 +313,9 @@ export function wrapScopedCompositionScript(
const timingSelectorPatternLiteral = jsonScriptLiteral(
String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`,
);
const aliasInstallerOutsideAuthoredStrictness = `(function(){ ${getSvgSelectorAliasesScript()} })();`;
return `(function(){
${aliasInstallerOutsideAuthoredStrictness}
var __hfCompId = ${compositionIdLiteral};
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
var __hfErrorLabel = ${errorLabelLiteral};
@@ -331,131 +334,15 @@ export function wrapScopedCompositionScript(
var __hfAuthoredRootSelector = __hfAuthoredRootId
? "[" + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(__hfAuthoredRootId) + '"]'
: "";
var __hfCssEscape = function(value) {
var text = value + "";
if (typeof CSS !== "undefined" && CSS && typeof CSS.escape === "function") {
try { return CSS.escape(text); } catch {}
}
return text.replace(/[^a-zA-Z0-9_-]/g, function(char) { return "\\\\" + char; });
};
// Decode complete CSS identifiers before comparing authored ids.
var __hfRewriteIdSelectors = function(selector, entries) {
if (!entries || !entries.length || typeof selector !== "string" || selector.indexOf("#") === -1) {
return selector;
}
var result = "";
var bracketDepth = 0;
var quote = null;
for (var index = 0; index < selector.length; index += 1) {
var char = selector[index];
if (char === "\\\\") {
var escape = selector.slice(index).match(/^\\\\(?:[0-9a-fA-F]{1,6}(?:\\r\\n|[ \\t\\r\\n\\f])?|[\\s\\S])/);
if (escape) {
result += escape[0];
index += escape[0].length - 1;
continue;
}
}
if (quote) {
result += char;
if (char === quote) {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
result += char;
continue;
}
if (char === "[") {
bracketDepth += 1;
result += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
result += char;
continue;
}
if (char === "#" && bracketDepth === 0) {
var token = selector.slice(index + 1).match(/^(?:[\\w\\u0080-\\uffff-]|\\\\(?:[0-9a-fA-F]{1,6}(?:\\r\\n|[ \\t\\r\\n\\f])?|[^\\r\\n\\f]))+/);
if (token) {
var decoded = token[0].replace(/\\\\([0-9a-fA-F]{1,6})(?:\\r\\n|[ \\t\\r\\n\\f])?|\\\\([^\\r\\n\\f])/g, function(_, hex, escaped) {
if (!hex) return escaped;
var code = parseInt(hex, 16);
return String.fromCodePoint(!code || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff) ? 0xfffd : code);
});
var matched = entries.find(function(entry) { return entry.id === decoded; });
result += matched ? matched.replacement : "#" + token[0];
index += token[0].length;
continue;
}
}
result += char;
}
return result;
};
var __hfAuthoredRootEntries = __hfAuthoredRootSelector
? [{ id: __hfAuthoredRootId, replacement: __hfAuthoredRootSelector }]
: [];
// Include both renamed and untouched instances. Each wrapper refreshes this
// document-wide cache because preview scene swaps can introduce new ids.
var __hfRenamedIdEntries = function() {
if (window.__hfRenamedIdSelectorEntries) return window.__hfRenamedIdSelectorEntries;
var entries = [];
var seen = Object.create(null);
var nodes;
try {
nodes = window.document.querySelectorAll("[" + __hfAuthoredRootAttr + "][id]");
} catch {
nodes = [];
}
for (var i = 0; i < nodes.length; i += 1) {
var authored = nodes[i].getAttribute(__hfAuthoredRootAttr);
if (!authored || seen[authored] || authored === nodes[i].id) continue;
seen[authored] = true;
var escaped = __hfCssEscape(authored);
entries.push({
id: authored,
replacement: ":is(#" + escaped + ", [" + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(authored) + '"])',
});
}
window.__hfRenamedIdSelectorEntries = entries;
return window.__hfRenamedIdSelectorEntries;
};
// element.querySelector("#authored") on an arbitrary Element never passes
// through the scoped document proxy, so the same rewrite is installed once
// per document on Element.prototype — only when at least one id was
// actually renamed, so a document without collisions runs untouched
// natives. Document.prototype is deliberately NOT patched: an unscoped
// document-wide lookup cannot know which instance it means, and rewriting
// it would only widen the ambiguity.
var __hfInstallRenamedIdSelectorShim = function() {
if (window.__hfRenamedIdSelectorShim) return;
var entries = __hfRenamedIdEntries();
if (!entries.length) return;
var proto = window.Element && window.Element.prototype;
if (!proto || typeof proto.querySelector !== "function" || typeof proto.querySelectorAll !== "function") {
return;
}
var nativeQuerySelector = proto.querySelector;
var nativeQuerySelectorAll = proto.querySelectorAll;
proto.querySelector = function(selector) {
return nativeQuerySelector.call(this, __hfRewriteIdSelectors(selector, __hfRenamedIdEntries()));
};
proto.querySelectorAll = function(selector) {
return nativeQuerySelectorAll.call(this, __hfRewriteIdSelectors(selector, __hfRenamedIdEntries()));
};
window.__hfRenamedIdSelectorShim = true;
};
var __hfNormalizeSelector = function(selector) {
if (!__hfCompId || typeof selector !== "string") return selector;
var normalized = selector
.replace(new RegExp(__hfRootSelectorPattern + '(?:' + __hfTimingSelectorPattern + ')+', 'g'), __hfRootSelector)
.replace(new RegExp('(?:' + __hfTimingSelectorPattern + ')+' + __hfRootSelectorPattern, 'g'), __hfRootSelector);
normalized = __hfRewriteIdSelectors(normalized, __hfAuthoredRootEntries);
return __hfRewriteIdSelectors(normalized, __hfRenamedIdEntries());
return window.__hfSvgSelectorAliases.rewrite(normalized, __hfAuthoredRootEntries);
};
var __hfFindRoot = function() {
if (!__hfRoot && __hfRootSelector) {
@@ -718,8 +605,6 @@ ${source.replace(/<\/(script)/gi, "<\\/$1")}
});
};
__hfFindRoot();
window.__hfRenamedIdSelectorEntries = null;
__hfInstallRenamedIdSelectorShim();
__hfRecordAnimations(__hfRun);
})();`;
}
@@ -906,12 +906,13 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
const window = dom.window as unknown as Window & typeof globalThis & Record<string, unknown>;
window.__captured = {};
if (fakeGsap) (window as Record<string, unknown>).gsap = fakeGsap(window);
const nativeQuery = window.Element.prototype.querySelector;
for (const script of result.scripts) window.eval(script);
return { window, result, captured: window.__captured as Record<string, unknown> };
return { window, result, nativeQuery, captured: window.__captured as Record<string, unknown> };
}
it("single composition: native and script references to the same element both keep resolving", () => {
const { window, captured } = inlineAndBoot(
const { window, captured, nativeQuery } = inlineAndBoot(
{
"scene.html": scriptedScene(
"scene",
@@ -929,7 +930,7 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
expect(path.getAttribute("id")).toBe("shape");
expect(path.hasAttribute("data-hf-authored-id")).toBe(false);
expect(window.document.querySelector("use")!.getAttribute("href")).toBe("#shape");
expect((window as Record<string, unknown>).__hfRenamedIdSelectorShim).toBeUndefined();
expect(window.Element.prototype.querySelector).toBe(nativeQuery);
expect(captured.viaDocument).toBe(path);
expect(captured.viaElement).toBe(path);
expect(captured.byId).toBe(path);
@@ -946,7 +947,7 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
gsapTargets: gsap.to(["#shape", ".ref"], {}),
toArray: gsap.utils.toArray("#shape"),
};`;
const { window, captured } = inlineAndBoot(
const { window, captured, nativeQuery } = inlineAndBoot(
{
"scene-a.html": scriptedScene("scene-a", `var __hfProbeName = "a"; ${probe}`),
"scene-b.html": scriptedScene("scene-b", `var __hfProbeName = "b"; ${probe}`),
@@ -990,7 +991,7 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
expect(b.gsapTargets).toEqual([pathB, rootB!.querySelector("use.ref")]);
expect(a.toArray).toEqual([pathA]);
expect(b.toArray).toEqual([pathB]);
expect((window as Record<string, unknown>).__hfRenamedIdSelectorShim).toBe(true);
expect(window.Element.prototype.querySelector).not.toBe(nativeQuery);
});
it("escaped CSS id: a styled element that is also natively referenced keeps its rule after rename", () => {
@@ -1027,13 +1028,11 @@ describe("inlineSubCompositions – renamed SVG ids stay reachable from author s
expect(cssA).toContain("url(#fx.1)");
expect(cssB).toContain(String.raw`#scene-b--fx\.1`);
expect(cssB).toContain("url(#scene-b--fx.1)");
expect(cssB).not.toContain(String.raw`#fx\.1`);
expect(cssB).toContain(String.raw`:is(#fx\.1, #scene-b--fx\.1)`);
// The rewritten selector actually matches the renamed element in a real
// selector engine.
const ruleSelector = /(\[data-composition-id="scene-b"\][^{]*#scene-b--fx\\\.1)\s*\{/.exec(
cssB!,
)?.[1];
const ruleSelector = cssB!.slice(0, cssB!.indexOf("{")).trim();
expect(ruleSelector).toBeTruthy();
expect(window.document.querySelector(ruleSelector!)).toBe(filterB);
@@ -1,3 +1,4 @@
import { SVG_REFERENCE_ALIASES_ATTR, readSvgReferenceAliases } from "./svgSelectorAliases";
import { readExternalScriptAttributes, type ExternalScriptAttributes } from "./externalScripts";
import { parseImportMap, type ImportMap } from "./importMaps";
import {
@@ -332,6 +333,7 @@ export function inlineSubCompositions(
tagScenes = false,
} = options;
const rootStyles = [...document.querySelectorAll("style")];
const styles: CompositionStyle[] = [];
const styleScenes: string[] = [];
const scripts: string[] = [];
@@ -676,23 +678,44 @@ export function inlineSubCompositions(
// `<style>` text gets the same substitution its DOM just received. See
// svgIdNamespacing.ts for why this is a rename, unlike the sibling
// getElementById/media-id fixes.
const svgIdMaps = namespaceCollidingSvgIds(
document,
svgIdScopes.map(({ root, namespace, exclude, styleStart, styleEnd }) => ({
const svgIdMaps = namespaceCollidingSvgIds(document, [
...svgIdScopes.map(({ root, namespace, exclude, styleStart, styleEnd }) => ({
root,
namespace,
exclude,
cssTexts: styles.slice(styleStart, styleEnd).map((style) => style.css),
})),
);
svgIdMaps.forEach((idMap, index) => {
if (idMap.size === 0) return;
{
root: document.documentElement,
namespace: "",
referenceTarget: "document",
exclude: svgIdScopes.map(({ root }) => root),
cssTexts: rootStyles.map((style) => style.textContent ?? ""),
},
]);
svgIdMaps.slice(0, svgIdScopes.length).forEach((idMap, index) => {
const { styleStart, styleEnd } = svgIdScopes[index]!;
const references = readSvgReferenceAliases(
svgIdScopes[index]!.root,
SVG_REFERENCE_ALIASES_ATTR,
);
for (let i = styleStart; i < styleEnd; i += 1) {
styles[i]!.css = rewriteSvgIdReferencesInCss(styles[i]!.css, idMap);
styles[i]!.css = rewriteSvgIdReferencesInCss(styles[i]!.css, idMap, references);
}
});
const rootReferences = readSvgReferenceAliases(
document.documentElement,
SVG_REFERENCE_ALIASES_ATTR,
);
for (const style of rootStyles) {
style.textContent = rewriteSvgIdReferencesInCss(
style.textContent ?? "",
svgIdMaps[svgIdScopes.length]!,
rootReferences,
);
}
return {
styles,
styleScenes,
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
decodeCssIdentifierAt,
escapeCssAttributeValue,
escapeCssIdentifier,
replaceSelectorAttributeTokens,
replaceSelectorIdTokens,
} from "./selectorIdTokens";
@@ -20,6 +22,12 @@ describe("decodeCssIdentifierAt", () => {
expect(decodeCssIdentifierAt(String.raw`\31 st`, 0)).toEqual({ value: "1st", end: 6 });
});
it("rejects CSS newline escapes in identifiers", () => {
for (const newline of ["\n", "\r", "\f", "\r\n"]) {
expect(decodeCssIdentifierAt("\\" + newline, 0)).toBeNull();
}
});
it("maps out-of-range and surrogate code points to U+FFFD", () => {
expect(decodeCssIdentifierAt(String.raw`a\0 b`, 0)?.value).toBe("a�b");
expect(decodeCssIdentifierAt(String.raw`a\d800 b`, 0)?.value).toBe("a�b");
@@ -87,3 +95,18 @@ it("does not rewrite escaped hashes inside class names", () => {
String.raw`.foo\[bar #new`,
);
});
it("serializes control characters inside CSS attribute string values", () => {
expect(escapeCssAttributeValue('a\nb\rc\fd"e\\f')).toBe('a\\a b\\d c\\c d\\"e\\\\f');
});
it("keeps escaped closing brackets inside unquoted attribute tokens", () => {
expect(
replaceSelectorAttributeTokens(String.raw`use[href=\#sym\]bol]`, () => '[href="#renamed"]'),
).toBe('use[href="#renamed"]');
});
it("keeps commented brackets bounded when the closing bracket is missing", () => {
const selector = "[" + "/*x*/".repeat(2000);
expect(replaceSelectorAttributeTokens(selector, () => "changed")).toBe(selector);
});
+145 -3
View File
@@ -23,7 +23,7 @@
*/
/** Unescaped name code points: `[A-Za-z0-9_-]` plus any non-ASCII. */
function isNameChar(char: string | undefined): boolean {
function isNameChar(char: string | undefined): char is string {
if (!char) return false;
return /[\w-]/.test(char) || char.charCodeAt(0) >= 0x80;
}
@@ -56,7 +56,7 @@ function skipEscapeWhitespace(text: string, index: number): number {
*/
function consumeCssEscape(text: string, index: number): { value: string; end: number } | null {
const next = text[index + 1];
if (next === undefined || next === "\n") return null;
if (next === undefined || /[\n\r\f]/.test(next)) return null;
const hex = HEX_ESCAPE_RE.exec(text.slice(index + 1, index + 7));
if (!hex) return { value: next, end: index + 2 };
const codePoint = sanitizeCodePoint(Number.parseInt(hex[0], 16));
@@ -66,6 +66,29 @@ function consumeCssEscape(text: string, index: number): { value: string; end: nu
};
}
function startsCssIdentifier(text: string, start: number): boolean {
const isStart = (char: string | undefined) => isNameChar(char) && !/[0-9-]/.test(char);
const escape = (index: number) => text[index] === "\\" && consumeCssEscape(text, index) !== null;
if (text[start] === "-")
return text[start + 1] === "-" || isStart(text[start + 1]) || escape(start + 1);
return isStart(text[start]) || escape(start);
}
function skipCssComments(text: string, start: number): number {
let index = start;
while (text.startsWith("/*", index)) {
const end = text.indexOf("*/", index + 2);
index = end < 0 ? text.length : end + 2;
}
return index;
}
function skipCssTrivia(text: string, start: number): number {
let index = skipCssComments(text, start);
while (isCssWhitespace(text[index])) index = skipCssComments(text, index + 1);
return index;
}
/**
* Read one CSS identifier starting at `start` and return its decoded value
* plus the offset just past it, or `null` when no identifier starts there.
@@ -75,6 +98,7 @@ export function decodeCssIdentifierAt(
text: string,
start: number,
): { value: string; end: number } | null {
if (!startsCssIdentifier(text, start)) return null;
let index = start;
let value = "";
while (index < text.length) {
@@ -152,7 +176,7 @@ export function escapeCssIdentifier(value: string): string {
* branch (no ambiguous backtracking).
*/
const GUARDED_SELECTOR_SEGMENT_RE =
/\\(?:[0-9a-fA-F]{1,6}(?:\r\n|[ \t\r\n\f])?|[\s\S])|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|\[(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\]"'])*\]/g;
/\/\*(?:[^*]|\*(?!\/))*\*\/|\\(?:[0-9a-fA-F]{1,6}(?:\r\n|[ \t\r\n\f])?|[\s\S])|"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'|\[(?:\/\*(?:[^*]|\*(?!\/))*\*\/|\\[\s\S]|"(?:[^"\\]|\\[\s\S])*"|'(?:[^'\\]|\\[\s\S])*'|[^\]"'\\/]|\/(?!\*))*\]/g;
/**
* `mask[i]` is `true` when `selector[i]` sits outside both a quoted string
@@ -206,3 +230,121 @@ export function replaceSelectorIdTokens(
return result;
}
function decodeCssString(value: string): string {
let result = "";
for (let index = 0; index < value.length; index += 1) {
if (value[index] === "\\") {
const next = value[index + 1];
if (next !== undefined && /[\n\r\f]/.test(next)) {
index = skipEscapeWhitespace(value, index + 1) - 1;
continue;
}
const escaped = consumeCssEscape(value, index);
if (escaped) {
result += escaped.value;
index = escaped.end - 1;
continue;
}
}
result += value[index];
}
return result;
}
export function escapeCssAttributeValue(value: string): string {
return Array.from(value, (char) => {
const code = char.charCodeAt(0);
if (code === 0) return "\uFFFD";
if (code < 0x20 || code === 0x7f) return `\\${code.toString(16)} `;
return char === '"' || char === "\\" ? `\\${char}` : char;
}).join("");
}
type AttributeNamespace = { kind: "any" } | { kind: "none" } | { kind: "named"; prefix: string };
function attributeNamespace(wildcard: boolean, prefix: string | undefined): AttributeNamespace {
if (wildcard) return { kind: "any" };
return prefix === undefined ? { kind: "none" } : { kind: "named", prefix };
}
function isNamespaceSeparator(text: string, offset: number): boolean {
return text[offset] === "|" && text[offset + 1] !== "=";
}
function readQualifiedAttributeName(text: string, start: number) {
const wildcard = text[start] === "*";
const first = wildcard ? { value: "*", end: start + 1 } : decodeCssIdentifierAt(text, start);
const separator = skipCssComments(text, first?.end ?? start);
if (!isNamespaceSeparator(text, separator)) {
if (wildcard) return null;
return first ? { token: first, namespace: { kind: "none" } as AttributeNamespace } : null;
}
const token = decodeCssIdentifierAt(text, skipCssComments(text, separator + 1));
if (!token) return null;
return { token, namespace: attributeNamespace(wildcard, first?.value) };
}
function readAttributeName(predicate: string) {
if (predicate[0] !== "[") return null;
const start = skipCssTrivia(predicate, 1);
const qualified = readQualifiedAttributeName(predicate, start);
if (!qualified) return null;
const { token, namespace } = qualified;
return {
name: token.value,
rawName: predicate.slice(start, token.end),
namespace,
end: token.end,
};
}
function readAttributeValue(predicate: string, start: number) {
const quoted = /^(?:"((?:\\[\s\S]|[^"\\])*)"|'((?:\\[\s\S]|[^'\\])*)')/.exec(
predicate.slice(start),
);
if (quoted)
return { value: decodeCssString(quoted[1] ?? quoted[2]!), end: start + quoted[0].length };
return decodeCssIdentifierAt(predicate, start);
}
function readAttributeEnding(predicate: string, start: number) {
let offset = skipCssTrivia(predicate, start);
const token = decodeCssIdentifierAt(predicate, offset);
if (token) {
if (!/^[is]$/i.test(token.value)) return null;
offset = skipCssTrivia(predicate, token.end);
}
if (predicate[offset] !== "]" || offset !== predicate.length - 1) return null;
return { flag: token?.value };
}
export function parseAttributeSelector(predicate: string) {
const name = readAttributeName(predicate);
if (!name) return null;
const offset = skipCssTrivia(predicate, name.end);
const operators = ["=", "~=", "|=", "^=", "$=", "*="] as const;
const operator = operators.find((candidate) => predicate.startsWith(candidate, offset));
if (!operator) return null;
const value = readAttributeValue(predicate, skipCssTrivia(predicate, offset + operator.length));
if (!value) return null;
const ending = readAttributeEnding(predicate, value.end);
if (!ending) return null;
return {
name: name.name,
rawName: name.rawName,
namespace: name.namespace,
operator,
value: value.value,
flag: ending.flag,
};
}
export function replaceSelectorAttributeTokens(
selector: string,
rewrite: (predicate: string) => string,
): string {
return selector.replace(GUARDED_SELECTOR_SEGMENT_RE, (segment) =>
segment.startsWith("[") ? rewrite(segment) : segment,
);
}
@@ -317,10 +317,10 @@ describe("rewriteSvgIdReferencesInCss", () => {
expect(rewriteSvgIdReferencesInCss(css, new Map())).toBe(css);
});
it("rewrites a bare id selector to the namespaced id", () => {
it("keeps authored and namespaced targets in a bare id selector", () => {
const idMap = new Map([["clip", "scene-a--clip"]]);
const css = "#clip rect { fill: red; }";
expect(rewriteSvgIdReferencesInCss(css, idMap)).toContain("#scene-a--clip rect");
expect(rewriteSvgIdReferencesInCss(css, idMap)).toContain(":is(#clip, #scene-a--clip) rect");
});
it("rewrites a url(#id) declaration value", () => {
@@ -353,7 +353,7 @@ describe("rewriteSvgIdReferencesInCss", () => {
const idMap = new Map([["fx.1", "scene-b--fx.1"]]);
const css = String.raw`#fx\.1 { opacity: .5; } .x { filter: url(#fx.1); }`;
const result = rewriteSvgIdReferencesInCss(css, idMap);
expect(result).toContain(String.raw`#scene-b--fx\.1 { opacity: .5; }`);
expect(result).toContain(String.raw`:is(#fx\.1, #scene-b--fx\.1) { opacity: .5; }`);
expect(result).toContain("filter: url(#scene-b--fx.1)");
});
@@ -363,14 +363,16 @@ describe("rewriteSvgIdReferencesInCss", () => {
const css = String.raw`#fx\2e\31 { opacity: .5; } #fx\2e 1 { color: red }`;
const result = rewriteSvgIdReferencesInCss(css, idMap);
// The whitespace after `\31 ` belongs to the escape, so it is consumed.
expect(result).toMatch(/#scene-b--fx\\\.1\s*\{ opacity: \.5; \}/);
expect(result).toMatch(/#scene-b--fx\\\.1\s*\{ color: red \}/);
expect(result).toMatch(/#scene-b--fx\\\.1\)\s*\{ opacity: \.5; \}/);
expect(result).toMatch(/#scene-b--fx\\\.1\)\s*\{ color: red \}/);
});
it("re-escapes characters the renamed id still carries", () => {
const idMap = new Map([["a b", "scene-b--a b"]]);
const css = String.raw`#a\ b { opacity: .5; }`;
expect(rewriteSvgIdReferencesInCss(css, idMap)).toContain(String.raw`#scene-b--a\ b {`);
expect(rewriteSvgIdReferencesInCss(css, idMap)).toContain(
String.raw`:is(#a\ b, #scene-b--a\ b) {`,
);
});
it("leaves a #id inside an attribute selector or string untouched", () => {
@@ -394,3 +396,36 @@ it("rewrites case-insensitive CSS URL functions in attributes and styles", () =>
"Url(#b--clip)",
);
});
it("finalizes initially referenced keepers while allowing first-use references after scripts", () => {
const { document } = parseHTML(`<!doctype html><html><body>
<div class="a"><svg><clipPath id="clip"/><g clip-path="url(#clip)"/><path id="late"/><path id="script-only"/></svg></div>
<div class="b"><svg><clipPath id="clip"/><g clip-path="url(#clip)"/><path id="late"/><path id="script-only"/></svg></div>
</body></html>`);
const roots = [document.querySelector(".a")!, document.querySelector(".b")!];
const scopes = roots.map((root, index) => ({ root, namespace: index === 0 ? "a" : "b" }));
const finalized = new Set<Element>();
namespaceCollidingSvgIds(document, scopes, finalized);
const definitions = roots.map((root) => root.querySelector("clipPath")!);
const captured = definitions.map((target) => target.id);
const html = document.createElement("div");
html.id = "clip";
document.body.appendChild(html);
for (const root of roots) {
const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", "#late");
root.querySelector("svg")!.appendChild(use);
}
namespaceCollidingSvgIds(document, scopes, finalized);
expect(definitions.map((target) => target.id)).toEqual(captured);
expect(captured).toEqual(["clip", "b--clip"]);
expect(roots.map((root) => root.querySelector("use")!.getAttribute("href"))).toEqual([
"#late",
"#b--late",
]);
expect(roots.map((root) => root.querySelector('path[id="script-only"]')?.id)).toEqual([
"script-only",
"script-only",
]);
expect(roots[1]!.querySelector("g")!.getAttribute("clip-path")).toBe("url(#b--clip)");
});
+181 -81
View File
@@ -7,7 +7,13 @@
*/
import postcss from "postcss";
import { escapeCssIdentifier, replaceSelectorIdTokens } from "./selectorIdTokens";
import { escapeCssIdentifier } from "./selectorIdTokens";
import {
SVG_REFERENCE_ALIASES_ATTR,
readSvgReferenceAliases,
rewriteSvgSelectors,
type SvgReferenceAlias,
} from "./svgSelectorAliases";
const ID_ATTR = "id";
@@ -53,33 +59,79 @@ function rewriteUrlHashRefs(value: string, idMap: ReadonlyMap<string, string>):
});
}
function rewriteHrefValue(value: string, idMap: ReadonlyMap<string, string>): string | null {
if (!value.startsWith("#")) return null;
const mapped = idMap.get(value.slice(1));
return mapped ? `#${mapped}` : null;
type ReferenceDecision =
| { kind: "skip" }
| { kind: "retain"; alias: SvgReferenceAlias }
| { kind: "managed"; before: string; after: string };
function nativeReferenceValue(
name: string,
value: string,
idMap: ReadonlyMap<string, string>,
): string | null {
if (isHrefAttrName(name) && value.startsWith("#")) {
const mapped = idMap.get(value.slice(1));
return mapped ? `#${mapped}` : value;
}
URL_HASH_REF_RE.lastIndex = 0;
const hasUrl = URL_HASH_REF_RE.test(value);
URL_HASH_REF_RE.lastIndex = 0;
return hasUrl ? rewriteUrlHashRefs(value, idMap) : null;
}
/** Rewrite every id reference carried on one element's attributes. CSS text
* inside a `<style>` element is handled separately by
* `rewriteSvgIdReferencesInCss` — it is extracted and scoped as a raw
* string elsewhere in the inline pipeline, never visited by this walk. */
function decideReferenceWrite(
attr: Attr,
previous: SvgReferenceAlias | undefined,
idMap: ReadonlyMap<string, string>,
): ReferenceDecision {
if (attr.name === SVG_REFERENCE_ALIASES_ATTR || attr.name === ID_ATTR) return { kind: "skip" };
const before = previous && attr.value === previous.after ? previous.before : attr.value;
const after = nativeReferenceValue(attr.name, before, idMap);
if (after !== null) return { kind: "managed", before, after };
return previous ? { kind: "retain", alias: previous } : { kind: "skip" };
}
function referenceAlias(
attr: Attr,
before: string,
after: string,
inSvg: boolean,
): SvgReferenceAlias {
// The compiler DOM omits Attr namespace APIs; serialized SVG restores XLink.
const serializedXlink = attr.namespaceURI === undefined && attr.name === "xlink:href" && inSvg;
return {
name: attr.name,
localName: serializedXlink ? "href" : attr.localName,
namespaceURI: serializedXlink ? "http://www.w3.org/1999/xlink" : (attr.namespaceURI ?? null),
before,
after,
};
}
/** Apply managed reference writes while retaining provenance for later assembly phases. */
function rewriteElementIdReferences(el: Element, idMap: ReadonlyMap<string, string>): void {
const attrs = el.attributes ? Array.from(el.attributes) : [];
for (const attr of attrs) {
const { name, value } = attr;
if (!value) continue;
if (isHrefAttrName(name)) {
const rewritten = rewriteHrefValue(value, idMap);
if (rewritten) {
el.setAttribute(name, rewritten);
continue;
}
const prior = new Map(
readSvgReferenceAliases(el, SVG_REFERENCE_ALIASES_ATTR, false).map((alias) => [
alias.name,
alias,
]),
);
const aliases = [...prior.values()].filter((alias) => alias.name === ID_ATTR);
const inSvg = !!el.closest("svg");
for (const attr of el.attributes ? Array.from(el.attributes) : []) {
const decision = decideReferenceWrite(attr, prior.get(attr.name), idMap);
if (decision.kind === "skip") continue;
if (decision.kind === "retain") {
aliases.push(decision.alias);
continue;
}
if (value.toLowerCase().includes("url(")) {
const rewritten = rewriteUrlHashRefs(value, idMap);
if (rewritten !== value) el.setAttribute(name, rewritten);
if (decision.after !== attr.value) {
if (attr.namespaceURI) el.setAttributeNS(attr.namespaceURI, attr.name, decision.after);
else el.setAttribute(attr.name, decision.after);
}
aliases.push(referenceAlias(attr, decision.before, decision.after, inSvg));
}
if (aliases.length) el.setAttribute(SVG_REFERENCE_ALIASES_ATTR, JSON.stringify(aliases));
}
/** Structural shape both a linkedom/live-DOM `Element` and `Document`
@@ -96,11 +148,13 @@ export interface SvgIdScope {
root: Element;
/**
* Document-unique prefix for ids renamed in this scope — the instance's
* runtime composition id. An empty namespace makes the scope read-only:
* runtime composition id. An empty namespace freezes IDs while still repairing references:
* an anonymous host has no identity to prefix with, the same guard
* `scopeCssToComposition` and `wrapScopedCompositionScript` apply.
*/
namespace: string;
/** Root document references follow document order; instances prefer local definitions. */
referenceTarget?: "document";
/**
* Nested composition hosts inside `root` whose content belongs to their
* OWN scope. Their subtrees are skipped both when collecting this scope's
@@ -166,7 +220,7 @@ function collectNativelyReferencedIds(
const referenced = new Set<string>();
for (const el of elements) {
for (const attr of el.attributes ? Array.from(el.attributes) : []) {
if (!attr.value) continue;
if (!attr.value || attr.name === SVG_REFERENCE_ALIASES_ATTR) continue;
collectHrefFragmentRef(attr, svgIds, referenced);
if (attr.value.toLowerCase().includes("url("))
collectUrlHashRefsFromText(attr.value, svgIds, referenced);
@@ -234,6 +288,15 @@ interface IdCensus {
usedIds: Set<string>;
}
function currentAuthoredSvgId(el: Element): string | null {
if (!el.closest("svg")) return null;
const idWrite = readSvgReferenceAliases(el, SVG_REFERENCE_ALIASES_ATTR, false).find(
(alias) => alias.name === ID_ATTR,
);
if (idWrite) return el.getAttribute(ID_ATTR) === idWrite.after ? idWrite.before : null;
return el.getAttribute(SVG_AUTHORED_ID_ATTR);
}
function buildIdCensus(document: SvgIdQueryable): IdCensus {
const elementsById = new Map<string, Element[]>();
const usedIds = new Set<string>();
@@ -244,17 +307,16 @@ function buildIdCensus(document: SvgIdQueryable): IdCensus {
const list = elementsById.get(id);
if (list) list.push(el);
else elementsById.set(id, [el]);
const authored = currentAuthoredSvgId(el);
if (authored && authored !== id) {
const aliases = elementsById.get(authored);
if (aliases) aliases.push(el);
else elementsById.set(authored, [el]);
}
}
return { elementsById, usedIds };
}
function hasAnyCollision(census: IdCensus): boolean {
for (const elements of census.elementsById.values()) {
if (elements.length > 1) return true;
}
return false;
}
/** A namespaced id that is not already taken anywhere in the document. */
function mintNamespacedId(namespace: string, originalId: string, usedIds: Set<string>): string {
const base = `${sanitizeNamespaceSegment(namespace)}--${originalId}`;
@@ -264,6 +326,17 @@ function mintNamespacedId(namespace: string, originalId: string, usedIds: Set<st
return candidate;
}
function collisionScopes(
id: string,
elements: readonly Element[],
scopeIndexByRenamable: ReadonlyMap<Element, number>,
): Set<number> {
const keeper = elements.find((el) => !scopeIndexByRenamable.has(el)) ?? elements[0];
const candidates = elements.filter((el) => el !== keeper && el.getAttribute(ID_ATTR) === id);
const indices = candidates.map((el) => scopeIndexByRenamable.get(el));
return new Set(indices.filter((index): index is number => index !== undefined));
}
/**
* Decide, per scope, which ids get renamed and to what. For every colliding
* id the keeper is the first element in document order that no scope can
@@ -278,20 +351,46 @@ function planRenames(
const idMaps = scopes.map(() => new Map<string, string>());
for (const [id, elements] of census.elementsById) {
if (elements.length < 2) continue;
const keeper = elements.find((el) => !scopeIndexByRenamable.has(el)) ?? elements[0]!;
for (const el of elements) {
if (el === keeper) continue;
const scopeIndex = scopeIndexByRenamable.get(el);
if (scopeIndex === undefined) continue;
const idMap = idMaps[scopeIndex]!;
if (!idMap.has(id)) {
idMap.set(id, mintNamespacedId(scopes[scopeIndex]!.namespace, id, census.usedIds));
}
for (const index of collisionScopes(id, elements, scopeIndexByRenamable)) {
idMaps[index]!.set(id, mintNamespacedId(scopes[index]!.namespace, id, census.usedIds));
}
}
return idMaps;
}
function documentReferenceTargets(
census: IdCensus,
idMaps: readonly ReadonlyMap<string, string>[],
scopeIndexByRenamable: ReadonlyMap<Element, number>,
): Map<string, string> {
const targets = new Map<string, string>();
for (const [id, elements] of census.elementsById) {
const target = elements[0]!;
const actual = target.getAttribute(ID_ATTR)!;
if (actual !== id) {
targets.set(id, actual);
continue;
}
const index = scopeIndexByRenamable.get(target);
const renamed = index === undefined ? undefined : idMaps[index]!.get(id);
if (renamed) targets.set(id, renamed);
}
return targets;
}
function localReferenceBindings(elements: readonly Element[]) {
const ids = new Set(elements.map((el) => el.getAttribute(ID_ATTR)));
const renamed = new Map<string, string>();
for (const el of elements) {
const authored = currentAuthoredSvgId(el);
const current = el.getAttribute(ID_ATTR);
if (!authored || !current) continue;
ids.add(authored);
renamed.set(authored, current);
}
return { ids, renamed };
}
/** Rename the planned elements in one scope and rewrite every reference in
* that scope's attributes to match. */
function applyRenames(resolved: ResolvedScope, idMap: ReadonlyMap<string, string>): void {
@@ -299,7 +398,17 @@ function applyRenames(resolved: ResolvedScope, idMap: ReadonlyMap<string, string
const currentId = el.getAttribute(ID_ATTR);
if (currentId && resolved.renamable.has(el) && idMap.has(currentId)) {
if (!el.hasAttribute(SVG_AUTHORED_ID_ATTR)) el.setAttribute(SVG_AUTHORED_ID_ATTR, currentId);
el.setAttribute(ID_ATTR, idMap.get(currentId)!);
const after = idMap.get(currentId)!;
const aliases = readSvgReferenceAliases(el, SVG_REFERENCE_ALIASES_ATTR, false);
aliases.push({
name: ID_ATTR,
localName: ID_ATTR,
namespaceURI: null,
before: currentId,
after,
});
el.setAttribute(SVG_REFERENCE_ALIASES_ATTR, JSON.stringify(aliases));
el.setAttribute(ID_ATTR, after);
}
rewriteElementIdReferences(el, idMap);
}
@@ -325,60 +434,40 @@ function applyRenames(resolved: ResolvedScope, idMap: ReadonlyMap<string, string
export function namespaceCollidingSvgIds(
document: SvgIdQueryable,
scopes: readonly SvgIdScope[],
finalizedIds?: Set<Element>,
): Map<string, string>[] {
const untouched = scopes.map(() => new Map<string, string>());
if (scopes.length === 0) return untouched;
if (scopes.length === 0) return [];
const census = buildIdCensus(document);
if (!hasAnyCollision(census)) return untouched;
const resolved = scopes.map(resolveScope);
const newlyEligible = resolved.flatMap(({ renamable }) => [...renamable]);
if (finalizedIds) {
for (const scope of resolved) {
for (const el of scope.renamable) if (finalizedIds.has(el)) scope.renamable.delete(el);
}
}
const scopeIndexByRenamable = new Map<Element, number>();
resolved.forEach(({ renamable }, index) => {
for (const el of renamable) scopeIndexByRenamable.set(el, index);
});
if (scopeIndexByRenamable.size === 0) return untouched;
const idMaps = planRenames(census, scopes, scopeIndexByRenamable);
// A reference without a local definition keeps its original document-order target,
// even if that target was renamed in an ancestor or another composition.
const documentTargets = documentReferenceTargets(census, idMaps, scopeIndexByRenamable);
const referenceMaps = resolved.map((scope, index) => {
const references = new Map(idMaps[index]);
const localIds = new Set(scope.elements.map((el) => el.getAttribute(ID_ATTR)));
for (const [id, elements] of census.elementsById) {
if (localIds.has(id)) continue;
const targetScope = scopeIndexByRenamable.get(elements[0]!);
if (targetScope === undefined) continue;
const mapped = idMaps[targetScope]!.get(id);
if (mapped) references.set(id, mapped);
}
return references;
const local = localReferenceBindings(
scopes[index]!.referenceTarget === "document" ? [] : scope.elements,
);
const inherited = [...documentTargets].filter(([id]) => !local.ids.has(id));
return new Map([...inherited, ...local.renamed, ...idMaps[index]!]);
});
referenceMaps.forEach((idMap, index) => {
if (idMap.size > 0) applyRenames(resolved[index]!, idMap);
applyRenames(resolved[index]!, idMap);
});
for (const el of newlyEligible) finalizedIds?.add(el);
return referenceMaps;
}
/**
* Whole-token `#id` replacement inside a CSS selector, generalized to many
* ids at once and to a literal `#newId` swap instead of an attribute-selector
* expansion (a plain descendant/id selector already resolves correctly once
* the id itself is unique — no extra scoping needed). Shares its quote- and
* bracket-aware scan with `compositionScoping.ts`'s
* `replaceAuthoredRootIdSelectors` via `selectorIdTokens.ts`, rather than a
* second copy of the same state machine. The scanner decodes CSS identifier
* escapes before matching, so `#fx\.1` matches the raw id `fx.1`, and the
* replacement is re-escaped so the emitted selector stays valid.
*/
function renameIdTokensInSelector(selector: string, idMap: ReadonlyMap<string, string>): string {
return replaceSelectorIdTokens(
selector,
[...idMap.keys()],
(matchedId) => `#${escapeCssIdentifier(idMap.get(matchedId)!)}`,
);
}
/**
* Apply the same id substitution `namespaceCollidingSvgIds` computed to a
* composition's `<style>` text.
@@ -395,9 +484,10 @@ function renameIdTokensInSelector(selector: string, idMap: ReadonlyMap<string, s
export function rewriteSvgIdReferencesInCss(
css: string,
idMap: ReadonlyMap<string, string>,
references: readonly SvgReferenceAlias[] = [],
): string {
if (!css || idMap.size === 0) return css;
if (!css.includes("#") && !css.toLowerCase().includes("url(")) return css;
if (!css || (idMap.size === 0 && references.length === 0)) return css;
if (!css.includes("#") && !css.includes("[") && !css.toLowerCase().includes("url(")) return css;
let root: postcss.Root;
try {
@@ -408,9 +498,19 @@ export function rewriteSvgIdReferencesInCss(
return css;
}
let mutated = false;
const recordedIds = new Set(
references.filter((alias) => alias.name === ID_ATTR).map((alias) => alias.before),
);
const ids = [...idMap]
.filter(([id]) => !recordedIds.has(id))
.map(([id, renamed]) => ({
id,
replacement: `:is(#${escapeCssIdentifier(id)}, #${escapeCssIdentifier(renamed)})`,
}));
root.walkRules((rule) => {
const rewritten = rule.selectors.map((selector) => renameIdTokensInSelector(selector, idMap));
const rewritten = rule.selectors.map((selector) =>
rewriteSvgSelectors(selector, ids, references),
);
if (rewritten.some((selector, index) => selector !== rule.selectors[index])) {
rule.selectors = rewritten;
mutated = true;
@@ -0,0 +1,257 @@
import * as tokens from "./selectorIdTokens";
export const SVG_REFERENCE_ALIASES_ATTR = "data-hf-svg-reference-aliases";
export interface SvgReferenceAlias {
name: string;
localName: string;
namespaceURI: string | null;
before: string;
after: string;
}
interface IdSelectorAlias {
id: string;
replacement: string;
}
/**
* With `namespaces` (a live sheet's bindings) only prefixed attribute selectors are
* rewritten; without it they stay as authored and everything else is rewritten.
*/
export function rewriteSvgSelectors(
selector: string,
ids: readonly IdSelectorAlias[],
references: readonly SvgReferenceAlias[],
namespaces?: ReadonlyMap<string, string | null>,
): string {
type AttributeOperator = NonNullable<
ReturnType<typeof tokens.parseAttributeSelector>
>["operator"];
const attributeOperators = {
"=": (before, value) => before === value,
"~=": (before, value) => before.split(/[ \t\r\n\f]+/).includes(value),
"|=": (before, value) => before === value || before.startsWith(value + "-"),
"^=": (before, value) => before.startsWith(value),
"$=": (before, value) => before.endsWith(value),
"*=": (before, value) => before.includes(value),
} satisfies Record<AttributeOperator, (before: string, value: string) => boolean>;
const matchesValue = (before: string, value: string, operator: AttributeOperator): boolean => {
if (!value && operator !== "=" && operator !== "|=") return false;
return attributeOperators[operator](before, value);
};
const lower = (text: string) => text.replace(/[A-Z]/g, (char) => char.toLowerCase());
const rewriteAttribute = (predicate: string): string => {
const parsed = tokens.parseAttributeSelector(predicate);
if (!parsed) return predicate;
const { name, rawName, namespace, operator, value, flag } = parsed;
if ((namespace.kind === "named") !== (namespaces !== undefined)) return predicate;
const fold = flag?.toLowerCase() === "i" ? lower : (text: string) => text;
const attributeName = lower(name);
const candidates = references.filter((reference) => {
const actualName = namespace.kind === "none" ? reference.name : reference.localName;
const uri = namespace.kind === "named" ? namespaces!.get(namespace.prefix) : null;
return (
(namespace.kind === "any" || reference.namespaceURI === uri) &&
lower(actualName) === attributeName
);
});
const alternatives = new Set([predicate]);
for (const reference of candidates) {
if (!matchesValue(fold(reference.before), fold(value), operator)) continue;
alternatives.add(
`[${rawName}="${tokens.escapeCssAttributeValue(reference.after)}"${flag ? ` ${flag}` : ""}]`,
);
}
return alternatives.size === 1 ? predicate : `:is(${[...alternatives].join(", ")})`;
};
if (namespaces) return tokens.replaceSelectorAttributeTokens(selector, rewriteAttribute);
const replacements = new Map(ids.map(({ id, replacement }) => [id, replacement]));
const targets = new Map<string, Set<string>>();
for (const reference of references) {
if (
reference.name !== "id" ||
reference.before === reference.after ||
replacements.has(reference.before)
)
continue;
let alternatives = targets.get(reference.before);
if (!alternatives) {
alternatives = new Set([`#${tokens.escapeCssIdentifier(reference.before)}`]);
targets.set(reference.before, alternatives);
}
alternatives.add(`#${tokens.escapeCssIdentifier(reference.after)}`);
}
for (const [id, alternatives] of targets)
replacements.set(id, `:is(${[...alternatives].join(", ")})`);
const rewrittenIds = tokens.replaceSelectorIdTokens(
selector,
[...replacements.keys()],
(id) => replacements.get(id)!,
);
return tokens.replaceSelectorAttributeTokens(rewrittenIds, rewriteAttribute);
}
/** Metadata records actual writes; selectors still match current native attribute values. */
export function readSvgReferenceAliases(
root: {
querySelectorAll(selector: string): Iterable<Element>;
getAttribute?: (name: string) => string | null;
},
attribute: string,
includeDescendants = true,
): SvgReferenceAlias[] {
const aliases: SvgReferenceAlias[] = [];
const nodes = includeDescendants ? [...root.querySelectorAll(`[${attribute}]`)] : [];
const texts = nodes.map((node) => node.getAttribute(attribute)!);
const own = root.getAttribute?.(attribute);
if (own) texts.unshift(own);
for (const text of texts) {
let value: unknown;
try {
value = JSON.parse(text);
} catch {
continue; // authored HTML can carry this attribute; a bad value must not stop boot
}
if (!Array.isArray(value)) continue;
const entries: unknown[] = value;
for (const entry of entries) {
if (!isSvgReferenceAlias(entry)) continue;
const { name, localName, namespaceURI, before, after } = entry;
aliases.push({ name, localName, namespaceURI, before, after });
}
}
return aliases;
}
function isSvgReferenceAlias(entry: unknown): entry is SvgReferenceAlias {
if (!entry || typeof entry !== "object") return false;
const { name, localName, namespaceURI, before, after } = entry as Record<string, unknown>;
return (
typeof name === "string" &&
typeof localName === "string" &&
(namespaceURI === null || typeof namespaceURI === "string") &&
typeof before === "string" &&
typeof after === "string"
);
}
export interface SvgSelectorAliases {
refresh(): void;
rewrite(selector: string, additional?: readonly IdSelectorAlias[]): string;
}
type AliasWindow = Pick<Window, "document"> & {
Element: typeof Element;
__hfSvgSelectorAliases?: SvgSelectorAliases;
};
function installSvgSelectorAliases(win: AliasWindow): void {
let ids: IdSelectorAlias[] = [];
let references: SvgReferenceAlias[] = [];
let installed = false;
let validateSyntax: ((selector: string) => boolean) | undefined;
const ruleSelectors = new WeakMap<CSSStyleRule, { authored: string; applied: string }>();
const repairRules = (rules: CSSRuleList, namespaces: ReadonlyMap<string, string | null>) => {
for (const rule of rules) {
if ("selectorText" in rule) {
const styleRule = rule as CSSStyleRule;
const record = ruleSelectors.get(styleRule);
const current = styleRule.selectorText;
const authored = record?.applied === current ? record.authored : current;
const next = rewriteSvgSelectors(authored, [], references, namespaces);
if (next !== current) styleRule.selectorText = next;
if (next === authored) ruleSelectors.delete(styleRule);
else ruleSelectors.set(styleRule, { authored, applied: styleRule.selectorText });
}
if ("cssRules" in rule) repairRules((rule as CSSGroupingRule).cssRules, namespaces);
}
};
// Only the browser knows which @namespace rules a sheet kept, so prefixed attribute
// selectors are repaired in the live CSSOM; compiled CSS text leaves them as authored.
const repairNamespacedSelectors = () => {
if (!references.length) return;
for (const sheet of win.document.styleSheets) {
let rules: CSSRuleList;
try {
rules = sheet.cssRules;
} catch {
continue; // cross-origin sheets are unreadable by design
}
const namespaces = new Map<string, string | null>();
for (const rule of rules) {
if (!("namespaceURI" in rule)) continue;
const { prefix, namespaceURI } = rule as CSSNamespaceRule;
if (prefix) namespaces.set(prefix, namespaceURI || null);
}
if (namespaces.size) repairRules(rules, namespaces);
}
};
const aliases: SvgSelectorAliases = {
rewrite: (selector, additional = []) => {
if (typeof selector !== "string" || (!selector.includes("#") && !selector.includes("[")))
return selector;
const rewritten = rewriteSvgSelectors(
selector,
additional.length ? [...additional, ...ids] : ids,
references,
);
if (rewritten !== selector) {
if (!validateSyntax) {
const syntaxProbe = win.document.createElement("div");
validateSyntax = syntaxProbe.matches.bind(syntaxProbe);
}
validateSyntax(selector);
}
return rewritten;
},
refresh() {
references = readSvgReferenceAliases(win.document, SVG_REFERENCE_ALIASES_ATTR).filter(
(alias) => alias.before !== alias.after,
);
const recordedIds = new Set(
references.filter((alias) => alias.name === "id").map((alias) => alias.before),
);
const seen = new Set<string>();
ids = [];
for (const node of win.document.querySelectorAll("[data-hf-authored-id][id]")) {
const authored = node.getAttribute("data-hf-authored-id")!;
if (!authored || authored === node.id || seen.has(authored) || recordedIds.has(authored))
continue;
seen.add(authored);
const value = tokens.escapeCssAttributeValue(authored);
ids.push({
id: authored,
replacement: `:is(#${tokens.escapeCssIdentifier(authored)}, [data-hf-authored-id="${value}"])`,
});
}
repairNamespacedSelectors();
if (installed || (!ids.length && !references.length)) return;
const proto = win.Element.prototype;
proto.querySelector = new Proxy(proto.querySelector, {
apply(target, thisArg, args) {
return Reflect.apply(target, thisArg, [aliases.rewrite(args[0])]);
},
});
proto.querySelectorAll = new Proxy(proto.querySelectorAll, {
apply(target, thisArg, args) {
return Reflect.apply(target, thisArg, [aliases.rewrite(args[0])]);
},
});
installed = true;
},
};
win.__hfSvgSelectorAliases = aliases;
aliases.refresh();
}
export function refreshSvgSelectorAliases(): void {
const win: AliasWindow = window;
if (win.__hfSvgSelectorAliases) win.__hfSvgSelectorAliases.refresh();
else installSvgSelectorAliases(win);
}
/** Compiled scene scripts install once; refreshes stay with the runtime's lifecycle. */
export function ensureSvgSelectorAliases(): void {
const win: AliasWindow = window;
if (!win.__hfSvgSelectorAliases) installSvgSelectorAliases(win);
}
@@ -1,4 +1,4 @@
// AUTO-GENERATED by scripts/build-position-edits-render.ts - do not edit
// Generated by scripts/build-position-edits-render.ts; do not edit.
const POSITION_EDITS_RENDER_IIFE: string =
'"use strict";(()=>{function T(){return globalThis}function P(e,t){if(typeof window>"u")return;let o=T(),r=o.__hf?.onSwallowed;if(r)try{r({label:e,error:t})}catch(s){}(o.__hfDebug||o.__HYPERFRAMES_DEBUG)&&console.debug(`[hyperframes] ${e} swallowed:`,t)}var x=null;function R(e,t){if(x)try{x({source:"hf-preview",type:"analytics",event:e,properties:t??{}})}catch(o){P("runtime.analytics.site1",o)}}var W="http://www.w3.org/1999/xhtml";function N(e){return e!=null&&e.nodeType===1}function _(e){return N(e)&&(e.namespaceURI===W||e.namespaceURI==="http://www.w3.org/2000/svg")}var w="data-hf-edit-base-x",y="data-hf-edit-base-y",m="data-hf-edit-original-translate",k=e=>{let t=parseFloat(e??"");return Number.isFinite(t)?t:0},O=e=>{let t=[],o=0,r="";for(let s of e.trim())s==="("&&(o+=1),s===")"&&(o=Math.max(0,o-1)),/\\s/.test(s)&&o===0?(r&&t.push(r),r=""):r+=s;return r&&t.push(r),t},L=/^-?(?:\\d+(?:\\.\\d+)?|\\.\\d+)px$/,E=(e,t)=>L.test(e)&&L.test(t)?`${parseFloat(e)+parseFloat(t)}px`:`calc(${e} + ${t})`,V=(e,t,o)=>{if(!e||e==="none")return`${t} ${o}`;let[r,s,a]=O(e);if(r===void 0)return`${t} ${o}`;if(s===void 0)return`${E(r,t)} ${o}`;let i=a===void 0?"":` ${a}`;return`${E(r,t)} ${E(s,o)}${i}`},j=e=>{try{e.ownerDocument.defaultView?.gsap?.getProperty?.(e,"x")}catch{}},G=e=>{let t=e.style.getPropertyValue("translate").trim();if(t)return t==="none"?"":t;try{let o=e.ownerDocument.defaultView,r=o?o.getComputedStyle(e).getPropertyValue("translate").trim():"";return r==="none"?"":r}catch{return""}},b=new WeakMap;function B(e,t){let o=b.get(e);if(!t?.force&&o!==void 0&&e.style.getPropertyValue("translate")!==o){R("position_edit_fold_skipped",{hfId:e.getAttribute("data-hf-id")});return}let r=k(e.getAttribute("data-x"))-k(e.getAttribute(w)),s=k(e.getAttribute("data-y"))-k(e.getAttribute(y));e.hasAttribute(m)||e.setAttribute(m,G(e)),o===void 0&&j(e);let a=e.getAttribute(m)??"",i=V(a,`${r}px`,`${s}px`);e.style.setProperty("translate",i),b.set(e,e.style.getPropertyValue("translate"))}function S(e,t){let o=e.querySelectorAll(`[${m}]:not([${w}]):not([${y}])`);for(let a=0;a<o.length;a++){let i=o[a];if(i===void 0||!_(i))continue;let g=i.getAttribute(m)??"";g===""?i.style.removeProperty("translate"):i.style.setProperty("translate",g),i.removeAttribute(m),b.delete(i)}let r=e.querySelectorAll(`[${w}], [${y}]`),s=0;for(let a=0;a<r.length;a++){let i=r[a];i===void 0||!_(i)||(B(i,t),s+=1)}return s}var $="__hfPositionEditsSeekReapplyWrapped",D=new WeakSet,M=new WeakMap,H=new WeakMap;function F(e){let t=e,o=()=>{try{S(t.document)}catch{}},r=n=>typeof n=="function"&&(D.has(n)||!!n[$]),s=n=>{D.add(n);try{Object.defineProperty(n,$,{value:!0})}catch{}},a=n=>{if(typeof n!="function"||r(n))return n;let l=function(...u){let d=n.apply(this,u);return o(),d};return s(l),l},i=(n,l)=>{let u=M.get(n);if(u?.has(l))return!0;let d=Object.getOwnPropertyDescriptor(n,l);if(d?.configurable===!1){let c=n[l];return typeof c=="function"&&(n[l]=a(c),o()),!1}let p=n[l],f=d?.set;return Object.defineProperty(n,l,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>p,set:c=>{p=a(c),f?.call(n,c)}}),p=a(p),u??(u=new Set),u.add(l),M.set(n,u),o(),!0},g=(n,l)=>{let u=H.get(t),d=Object.getOwnPropertyDescriptor(t,n);if(!u?.has(n)){if(d?.configurable===!1){let c=t[n];return c?i(c,l):!1}let f=t[n];Object.defineProperty(t,n,{configurable:!0,enumerable:d?.enumerable??!0,get:()=>f,set:c=>{f=c,f&&i(f,l)}}),u??(u=new Set),u.add(n),H.set(t,u)}let p=t[n];return p?i(p,l):!1},v=()=>{let n=g("__hf","seek"),l=g("__player","renderSeek");return n&&l};if(v())return;let h=120,A=t.setInterval(()=>{if(v()){t.clearInterval(A);return}h-=1,h<=0&&t.clearInterval(A)},50)}function I(){document.querySelector(`[${w}], [${y}]`)&&(S(document),F(window))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",I,{once:!0}):I();})();\n';
@@ -0,0 +1,25 @@
import { afterEach, describe, expect, it } from "vitest";
import { getSvgSelectorAliasesScript } from "./svg-selector-aliases-inline";
const nativeQuerySelector = Element.prototype.querySelector;
const renamedUse = `<symbol id="scene--icon"/><use href="#scene--icon" data-hf-svg-reference-aliases='[{"name":"href","localName":"href","namespaceURI":null,"before":"#icon","after":"#scene--icon"}]'/>`;
afterEach(() => {
Element.prototype.querySelector = nativeQuerySelector;
delete window.__hfSvgSelectorAliases;
});
describe("getSvgSelectorAliasesScript", () => {
it("installs aliases that follow a renamed SVG reference", () => {
document.body.innerHTML = `<svg>${renamedUse}</svg>`;
new Function(getSvgSelectorAliasesScript())();
expect(document.body.querySelector('use[href="#icon"]')).toBe(document.querySelector("use"));
});
it("skips malformed alias attributes from authored HTML", () => {
const wrongType = `[{"name":1,"localName":"href","namespaceURI":null,"before":"#icon","after":"#x"}]`;
document.body.innerHTML = `<svg><g data-hf-svg-reference-aliases="not json"/><g data-hf-svg-reference-aliases='${wrongType}'/>${renamedUse}</svg>`;
new Function(getSvgSelectorAliasesScript())();
expect(document.body.querySelector('use[href="#icon"]')).toBe(document.querySelector("use"));
});
});
File diff suppressed because one or more lines are too long
+14
View File
@@ -47,6 +47,20 @@ Determinism baseline:
- `renderSeek` is the producer-canonical seek path.
- 30fps quantization and readiness gates are correctness requirements.
- Preview automation uses `?hf-capture=1`, `hyperframes snapshot`, or runtime readiness
(`window.__renderReady` and the seek contract), never a wait on all of `document.images`.
Interactive preview leaves images in hidden future clips unloaded until they are needed.
Runtime-loaded compositions finalize IDs already used by native SVG references before scene
scripts run. IDs used only by JavaScript keep their authored values. If an initial script
introduces an ID's first native `url()` or `href` reference, that ID can be renamed after
initial scripts finish. A string captured before that first native use is not guaranteed
stable; read the target element's current `id` after runtime readiness for deferred writes.
Attribute selectors with a declared namespace prefix (`@namespace xl url(...)` plus
`[xl|href="#id"]`) follow renamed references through the live CSSOM before scene scripts run,
in runtime and compiled output alike. Compiled CSS text leaves them as authored, so these
selectors need JavaScript; with scripts disabled they match only unrenamed values.
## Build
File diff suppressed because it is too large Load Diff
+252 -106
View File
@@ -1,3 +1,8 @@
import {
SVG_REFERENCE_ALIASES_ATTR,
readSvgReferenceAliases,
refreshSvgSelectorAliases,
} from "../compiler/svgSelectorAliases";
import {
planCompositionAssembly,
EXTRACTED_COMPOSITION_ASSET_SELECTOR,
@@ -9,6 +14,10 @@ import {
} from "../compiler/compositionScoping";
import { parseImportMap } from "../compiler/importMaps";
import { hasSameLink } from "../compiler/scriptRuns";
import {
namespaceCollidingSvgIds,
rewriteSvgIdReferencesInCss,
} from "../compiler/svgIdNamespacing";
import { waitForFonts } from "./afterFonts";
import { parseLayoutDimension } from "./compositionDimension";
import { markFlattenedInnerRoot } from "./flattenedRoot";
@@ -23,7 +32,7 @@ import {
} from "./getVariables";
import { isElementNode, isHtmlElement, isLinkElement, isStyleElement } from "./domRealm";
type LoadExternalCompositionsParams = {
type LoadCompositionsParams = {
injectedStyles: HTMLStyleElement[];
injectedScripts: HTMLScriptElement[];
injectedLinks: HTMLLinkElement[];
@@ -34,6 +43,18 @@ type LoadExternalCompositionsParams = {
}) => void;
};
type MountedSvgScope = {
host: Element;
namespace: string;
styles: Array<{ element: HTMLStyleElement; authored: string; applied: string }>;
referenceTarget?: "document";
};
type MountedComposition = MountedSvgScope & {
isCurrent: () => boolean;
runScripts: () => Promise<void>;
};
type PendingScript =
| {
kind: "inline";
@@ -329,15 +350,28 @@ function cleanupDetachedScopedVariables() {
function assignRuntimeCompositionIds(
hosts: Element[],
hostCountsByCompositionId: Map<string, number> = countAuthoredCompositionIds(hosts),
mountedHosts: ReadonlySet<Element>,
): Map<Element, HostCompositionIdentity> {
const hostCountsByCompositionId = countAuthoredCompositionIds(hosts);
const reserved = new Set(
hosts.flatMap((host) => {
const identity = getHostCompositionIdentity(host);
const ids = identity.authoredCompositionId ? [identity.authoredCompositionId] : [];
if (
(mountedHosts.has(host) || !shouldAssignRuntimeCompositionId(host)) &&
identity.runtimeCompositionId
)
ids.push(identity.runtimeCompositionId);
return ids;
}),
);
const hostInstanceByCompositionId = new Map<string, number>();
const hostIdentityByElement = new Map<Element, HostCompositionIdentity>();
for (const host of hosts) {
const { authoredCompositionId, runtimeCompositionId: previousRuntimeCompositionId } =
getHostCompositionIdentity(host);
const shouldAssign = shouldAssignRuntimeCompositionId(host);
const shouldAssign = !mountedHosts.has(host) && shouldAssignRuntimeCompositionId(host);
if (!authoredCompositionId) {
hostIdentityByElement.set(host, {
authoredCompositionId: null,
@@ -349,16 +383,18 @@ function assignRuntimeCompositionIds(
const duplicateInstance = (hostCountsByCompositionId.get(authoredCompositionId) || 0) > 1;
let runtimeCompositionId = previousRuntimeCompositionId || authoredCompositionId;
if (shouldAssign) {
const instanceIndex = duplicateInstance
let instanceIndex = duplicateInstance
? (hostInstanceByCompositionId.get(authoredCompositionId) || 0) + 1
: 0;
if (duplicateInstance) {
while (reserved.has(uniqueCompositionId(authoredCompositionId, instanceIndex)))
instanceIndex += 1;
hostInstanceByCompositionId.set(authoredCompositionId, instanceIndex);
}
runtimeCompositionId = duplicateInstance
? uniqueCompositionId(authoredCompositionId, instanceIndex)
: authoredCompositionId;
reserved.add(runtimeCompositionId);
if (duplicateInstance) {
host.setAttribute("data-hf-original-composition-id", authoredCompositionId);
@@ -366,13 +402,6 @@ function assignRuntimeCompositionIds(
host.removeAttribute("data-hf-original-composition-id");
}
host.setAttribute("data-composition-id", runtimeCompositionId);
if (
previousRuntimeCompositionId &&
previousRuntimeCompositionId !== runtimeCompositionId &&
window.__hfVariablesByComp
) {
delete window.__hfVariablesByComp[previousRuntimeCompositionId];
}
}
hostIdentityByElement.set(host, {
@@ -381,6 +410,7 @@ function assignRuntimeCompositionIds(
});
}
cleanupDetachedScopedVariables();
return hostIdentityByElement;
}
@@ -408,7 +438,7 @@ async function mountCompositionContent(params: {
* `<html data-composition-variables="...">` attribute. Layered under the
* host element's `data-variable-values` to produce the per-instance
* variables visible inside the sub-comp's scoped `getVariables()`.
* Populated only by `loadExternalCompositions`; inline templates have no
* Populated only by `loadCompositions`; inline templates have no
* separate document root so no declared defaults are passed.
*/
declaredVariableDefaults?: Record<string, unknown>;
@@ -422,7 +452,7 @@ async function mountCompositionContent(params: {
code: string;
details: Record<string, string | number | boolean | null | string[]>;
}) => void;
}): Promise<void> {
}): Promise<MountedComposition> {
// Which node is the composition root, which id its CSS scopes to, which id
// its scripts scope to, where its assets come from and in what order: the
// shared assembly module answers all of it, so this path and the compiler's
@@ -467,6 +497,7 @@ async function mountCompositionContent(params: {
params.injectedLinks.push(clonedLink);
}
const styles: MountedComposition["styles"] = [];
const injectScopedStyles = (styleEls: Iterable<Element>): void => {
for (const style of styleEls) {
const clonedStyle = style.cloneNode(true);
@@ -486,6 +517,8 @@ async function mountCompositionContent(params: {
}
document.head.appendChild(clonedStyle);
params.injectedStyles.push(clonedStyle);
const authored = clonedStyle.textContent || "";
styles.push({ element: clonedStyle, authored, applied: authored });
}
};
// Already in injection order: <head> styles from a non-template composition
@@ -555,71 +588,87 @@ async function mountCompositionContent(params: {
stashInstanceVariables(params, contentNode, runtimeScopeCompositionId);
}
if (scriptPayloads.length > 0) await waitForFonts();
for (const scriptPayload of scriptPayloads) {
const injectedScript = document.createElement("script");
if (scriptPayload.type) {
injectedScript.type = scriptPayload.type;
}
// Preserve deterministic script execution order across injected composition scripts.
injectedScript.async = false;
if (scriptPayload.kind === "external") {
injectedScript.src = scriptPayload.src;
} else if (scriptPayload.type.toLowerCase() === "importmap") {
const map = parseImportMap(scriptPayload.content, (url) =>
resolveScriptSourceUrl(url, params.compositionUrl),
);
injectedScript.textContent = map ? JSON.stringify(map) : scriptPayload.content;
} else if (scriptPayload.type.toLowerCase() === "module") {
const prelude = scriptPayload.scopeCompositionId
? scopedModulePrelude(
const mountedNodes = Array.from(params.host.childNodes);
const isCurrent = () =>
params.host.isConnected &&
(mountedNodes.length > 0 || params.host.childNodes.length === 0) &&
mountedNodes.every((node) => node.parentNode === params.host);
return {
host: params.host,
namespace: runtimeScopeCompositionId || authoredScopeCompositionId || "",
styles,
isCurrent,
runScripts: async () => {
if (scriptPayloads.length > 0) await waitForFonts();
if (!isCurrent()) return;
for (const scriptPayload of scriptPayloads) {
const injectedScript = document.createElement("script");
if (scriptPayload.type) {
injectedScript.type = scriptPayload.type;
}
// Preserve deterministic script execution order across injected composition scripts.
injectedScript.async = false;
if (scriptPayload.kind === "external") {
injectedScript.src = scriptPayload.src;
} else if (scriptPayload.type.toLowerCase() === "importmap") {
const map = parseImportMap(scriptPayload.content, (url) =>
resolveScriptSourceUrl(url, params.compositionUrl),
);
injectedScript.textContent = map ? JSON.stringify(map) : scriptPayload.content;
} else if (scriptPayload.type.toLowerCase() === "module") {
const prelude = scriptPayload.scopeCompositionId
? scopedModulePrelude(
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
params.compositionUrl?.href,
)
: "";
injectedScript.textContent = prelude + scriptPayload.content;
} else if (scriptPayload.scopeCompositionId) {
injectedScript.textContent = wrapScopedCompositionScript(
scriptPayload.content,
scriptPayload.scopeCompositionId,
"[HyperFrames] composition script error:",
runtimeScopeSelector,
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
authoredRootId,
params.compositionUrl?.href,
)
: "";
injectedScript.textContent = prelude + scriptPayload.content;
} else if (scriptPayload.scopeCompositionId) {
injectedScript.textContent = wrapScopedCompositionScript(
scriptPayload.content,
scriptPayload.scopeCompositionId,
"[HyperFrames] composition script error:",
runtimeScopeSelector,
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
authoredRootId,
params.compositionUrl?.href,
);
} else {
injectedScript.textContent = `(function(){${scriptPayload.content}})();`;
}
document.body.appendChild(injectedScript);
params.injectedScripts.push(injectedScript);
if (scriptPayload.kind === "external") {
const loadResult = await waitForExternalScriptLoad(injectedScript);
if (loadResult.status !== "load") {
params.onDiagnostic?.({
code: "external_composition_script_load_issue",
details: {
hostCompositionId: params.authoredCompositionId,
runtimeCompositionId: params.runtimeCompositionId,
hostCompositionSrc: params.hostCompositionSrc,
resolvedScriptSrc: scriptPayload.src,
loadStatus: loadResult.status,
elapsedMs: loadResult.elapsedMs,
},
});
);
} else {
injectedScript.textContent = `(function(){${scriptPayload.content}})();`;
}
document.body.appendChild(injectedScript);
params.injectedScripts.push(injectedScript);
if (scriptPayload.kind === "external") {
const loadResult = await waitForExternalScriptLoad(injectedScript);
if (loadResult.status !== "load") {
params.onDiagnostic?.({
code: "external_composition_script_load_issue",
details: {
hostCompositionId: params.authoredCompositionId,
runtimeCompositionId: params.runtimeCompositionId,
hostCompositionSrc: params.hostCompositionSrc,
resolvedScriptSrc: scriptPayload.src,
loadStatus: loadResult.status,
elapsedMs: loadResult.elapsedMs,
},
});
}
}
}
}
}
},
};
}
export async function loadInlineTemplateCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
async function mountInlineTemplateCompositions(
params: LoadCompositionsParams,
mountedHosts: ReadonlySet<Element>,
): Promise<MountedComposition[]> {
const trackedHosts = getTrackedCompositionHosts();
cleanupDetachedScopedVariables();
if (trackedHosts.length === 0) return;
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts);
if (trackedHosts.length === 0) return [];
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts, mountedHosts);
const hosts = trackedHosts.filter((host) => {
if (mountedHosts.has(host)) return false;
if (host.hasAttribute("data-composition-src")) return false;
if (host.children.length > 0) return false;
const compId = hostIdentityByElement.get(host)?.authoredCompositionId;
@@ -627,8 +676,7 @@ export async function loadInlineTemplateCompositions(
return !!document.querySelector(`template#${CSS.escape(compId)}-template`);
});
if (hosts.length === 0) return;
const mounted: MountedComposition[] = [];
for (const host of hosts) {
const hostIdentity = hostIdentityByElement.get(host);
const compId = hostIdentity?.authoredCompositionId;
@@ -638,7 +686,7 @@ export async function loadInlineTemplateCompositions(
)!;
resetCompositionHost(host);
await mountCompositionContent({
const composition = await mountCompositionContent({
host,
authoredCompositionId: compId,
runtimeCompositionId: hostIdentity?.runtimeCompositionId || compId,
@@ -653,23 +701,24 @@ export async function loadInlineTemplateCompositions(
parseDimensionPx: params.parseDimensionPx,
onDiagnostic: params.onDiagnostic,
});
mounted.push(composition);
}
return mounted;
}
export async function loadExternalCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
async function mountExternalCompositions(
params: LoadCompositionsParams,
): Promise<MountedComposition[]> {
const trackedHosts = getTrackedCompositionHosts();
cleanupDetachedScopedVariables();
if (trackedHosts.length === 0) return;
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts);
if (trackedHosts.length === 0) return [];
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts, new Set());
const hosts = trackedHosts.filter((host) => host.hasAttribute("data-composition-src"));
if (hosts.length === 0) return;
await Promise.all(
hosts.map(async (host) => {
const mounted = await Promise.all(
hosts.map(async (host): Promise<MountedComposition | null> => {
const src = host.getAttribute("data-composition-src");
if (!src) return;
if (!src) return null;
const hostIdentity = hostIdentityByElement.get(host);
const authoredCompositionId = hostIdentity?.authoredCompositionId || null;
const runtimeCompositionId =
@@ -681,6 +730,31 @@ export async function loadExternalCompositions(
compositionUrl = null;
}
resetCompositionHost(host);
const failed = (error: unknown) => {
params.onDiagnostic?.({
code: "external_composition_load_failed",
details: {
hostCompositionId: authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
errorMessage: error instanceof Error ? error.message : "unknown_error",
},
});
// Keep host empty on load failures to avoid rendering escaped fallback HTML.
resetCompositionHost(host);
};
const mount = async (mountParams: Parameters<typeof mountCompositionContent>[0]) => {
const composition = await mountCompositionContent(mountParams);
const runScripts = composition.runScripts;
composition.runScripts = async () => {
try {
await runScripts();
} catch (error) {
failed(error);
}
};
return composition;
};
try {
const localTemplate =
authoredCompositionId != null
@@ -689,7 +763,7 @@ export async function loadExternalCompositions(
)
: null;
if (localTemplate) {
await mountCompositionContent({
return await mount({
host,
authoredCompositionId,
runtimeCompositionId,
@@ -704,7 +778,6 @@ export async function loadExternalCompositions(
parseDimensionPx: params.parseDimensionPx,
onDiagnostic: params.onDiagnostic,
});
return;
}
const response = await fetch(src);
if (!response.ok) {
@@ -713,15 +786,7 @@ export async function loadExternalCompositions(
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Rewrite project-root-traversing (`../`) asset paths against the
// sub-composition's URL before extracting any nodes. Without this,
// `<video src="../../assets/x.mp4">` authored from
// `compositions/frames/scene.html` resolves against the main
// document's base (the project preview root) and climbs above it
// to 404 — the Studio-preview-vs-render divergence reported by
// OSS users. The server-side bundler already does this for the
// baked render via `inlineSubCompositions`; this is the runtime
// mirror so live preview matches.
// Resolve parent-relative assets against the fetched composition before mounting.
rewriteSubCompositionAssetPaths(doc, compositionUrl);
const template =
(authoredCompositionId
@@ -730,7 +795,7 @@ export async function loadExternalCompositions(
)
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
const sourceNode = template ? template.content : doc.body;
await mountCompositionContent({
return await mount({
host,
authoredCompositionId,
runtimeCompositionId,
@@ -757,20 +822,101 @@ export async function loadExternalCompositions(
onDiagnostic: params.onDiagnostic,
});
} catch (error) {
params.onDiagnostic?.({
code: "external_composition_load_failed",
details: {
hostCompositionId: authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
errorMessage: error instanceof Error ? error.message : "unknown_error",
},
});
// Keep host empty on load failures to avoid rendering escaped fallback HTML.
resetCompositionHost(host);
failed(error);
return null;
}
}),
);
return mounted.filter((composition): composition is MountedComposition => composition !== null);
}
/** Finalize authored DOM before scripts observe ids, then retain script-created inline discovery. */
export async function loadCompositions(params: LoadCompositionsParams): Promise<void> {
const finalizedIds = new Set<Element>();
const rootScope: MountedSvgScope = {
host: document.documentElement,
namespace: "",
referenceTarget: "document",
styles: [...document.querySelectorAll("style")].map((element) => ({
element,
authored: element.textContent ?? "",
applied: element.textContent ?? "",
})),
};
const external = await mountExternalCompositions(params);
const inline = await mountInlineTemplateCompositions(
params,
new Set(external.map(({ host }) => host)),
);
const initial = [...external, ...inline];
namespaceMountedSvgIds([...initial, rootScope], finalizedIds);
await Promise.all(external.map((composition) => composition.runScripts()));
const activeInline = inline.filter((composition) => {
if (composition.isCurrent()) return true;
for (const style of composition.styles) style.element.remove();
if (window.__hfVariablesByComp) delete window.__hfVariablesByComp[composition.namespace];
return false;
});
const live = [...external.filter((composition) => composition.host.isConnected), ...activeInline];
const discovered = await mountInlineTemplateCompositions(
params,
new Set(live.map(({ host }) => host)),
);
if (discovered.length) {
namespaceMountedSvgIds([...live, ...discovered, rootScope], finalizedIds);
}
const byHost = new Map(
[...activeInline, ...discovered].map((composition) => [composition.host, composition]),
);
for (const host of getTrackedCompositionHosts()) {
const composition = byHost.get(host);
if (composition) await composition.runScripts();
}
// Newly referenced IDs retain the initial-script repair; earlier eligible IDs stay final.
const finalScopes = [...live, ...discovered].filter(
(composition) => composition.host.isConnected,
);
namespaceMountedSvgIds([...finalScopes, rootScope], finalizedIds);
}
/** Reuse one finalization set across authored mounting and initial-script discovery. */
function namespaceMountedSvgIds(
mounted: readonly MountedSvgScope[],
finalizedIds: Set<Element>,
): void {
for (const { styles } of mounted) {
for (const style of styles) {
const current = style.element.textContent ?? "";
if (current !== style.applied) {
style.authored = current;
style.applied = current;
}
}
}
const idMaps = namespaceCollidingSvgIds(
document,
mounted.map(({ host, namespace, styles, referenceTarget }) => ({
root: host,
namespace,
referenceTarget,
exclude: mounted
.filter((nested) => nested.host !== host && host.contains(nested.host))
.map((nested) => nested.host),
cssTexts: styles.map((style) => style.authored),
})),
finalizedIds,
);
idMaps.forEach((idMap, index) => {
const references = readSvgReferenceAliases(mounted[index]!.host, SVG_REFERENCE_ALIASES_ATTR);
if (idMap.size === 0 && references.length === 0) return;
for (const style of mounted[index]!.styles) {
const rewritten = rewriteSvgIdReferencesInCss(style.authored, idMap, references);
if (rewritten !== style.applied) style.element.textContent = rewritten;
style.applied = rewritten;
}
});
refreshSvgSelectorAliases();
}
/**
+2
View File
@@ -1,3 +1,4 @@
import { refreshSvgSelectorAliases } from "../compiler/svgSelectorAliases";
import {
initSandboxRuntimeModular,
installAuthoredMediaCapture,
@@ -65,6 +66,7 @@ function bootstrapHyperframeRuntime(): void {
// Compiled composition scripts wait for web fonts, so what they measure matches every run.
function startAfterCompositionScripts(): void {
refreshSvgSelectorAliases();
const deferred = Array.from(document.querySelectorAll(AFTER_FONTS_SCRIPTS));
if (deferred.length === 0) bootstrapHyperframeRuntime();
else void runScriptsAfterFonts(deferred, bootstrapHyperframeRuntime);
+9 -9
View File
@@ -1,4 +1,5 @@
// fallow-ignore-file code-duplication complexity
import { refreshSvgSelectorAliases } from "../compiler/svgSelectorAliases";
import { RUNTIME_FILLER } from "./protocol";
import { preloadMedia, releaseMedia, lengthIsAuthored, stopMediaDownload } from "./preloadMedia";
import { installRuntimeControlBridge, postRuntimeMessage, setRuntimeProtocolFps } from "./bridge";
@@ -65,7 +66,7 @@ import {
} from "./compositionLength";
import { createRuntimeStartTimeResolver } from "./startResolver";
import { createClipTree } from "./clipTree";
import { loadExternalCompositions, loadInlineTemplateCompositions } from "./compositionLoader";
import { loadCompositions } from "./compositionLoader";
import { runScriptsAfterFonts } from "./afterFonts";
import {
applyCaptionOverrides,
@@ -3385,13 +3386,11 @@ export function initSandboxRuntimeModular(): void {
});
},
};
void loadExternalCompositions(compositionLoaderParams)
.then(() => loadInlineTemplateCompositions(compositionLoaderParams))
.finally(() => {
externalCompositionsReady = true;
void settleSceneDom();
maybePublishRenderReady();
});
void loadCompositions(compositionLoaderParams).finally(() => {
externalCompositionsReady = true;
void settleSceneDom();
maybePublishRenderReady();
});
} else {
// No external/inline compositions to load — apply caption overrides immediately
void applyCaptionOverrides();
@@ -3666,6 +3665,7 @@ export function initSandboxRuntimeModular(): void {
if (host.querySelector(".caption-group")) captionHosts.push(host);
}
// Run once every host is replaced, so no new script binds to a scene still to be swapped.
refreshSvgSelectorAliases();
const sceneScripts = swaps.flatMap(({ newParts }) =>
newParts
.filter((el) => el.tagName === "SCRIPT")
@@ -4129,7 +4129,7 @@ export function initSandboxRuntimeModular(): void {
maybePublishRenderReady();
// When the bundler inlines compositions, data-composition-src is removed so
// loadExternalCompositions() is skipped. But inline scripts registering child
// loadCompositions() is skipped. But inline scripts registering child
// timelines in __timelines haven't executed yet (they run in the browser's next
// microtask). Defer a rebinding attempt to catch them.
if (externalCompositionsReady) {
+2
View File
@@ -9,6 +9,7 @@ import type { RuntimeColorGradingApi } from "./colorGrading";
import type { HyperframePickerApi } from "../inline-scripts/pickerApi";
import type { PlayerAPI } from "../core.types";
import type { ClipTree } from "./clipTree";
import type { SvgSelectorAliases } from "../compiler/svgSelectorAliases";
type ThreeClockLike = {
elapsedTime: number;
@@ -49,6 +50,7 @@ declare global {
};
__clipManifest?: RuntimeTimelineMessage;
__clipTree?: ClipTree;
__hfSvgSelectorAliases?: SvgSelectorAliases;
__hf?: {
colorGrading?: RuntimeColorGradingApi;
onSwallowed?: (label: string, err: unknown) => void;
@@ -0,0 +1,3 @@
import { ensureSvgSelectorAliases } from "../src/compiler/svgSelectorAliases";
ensureSvgSelectorAliases();
+1 -1
View File
@@ -519,7 +519,7 @@ export const coreRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// root. `hyperframes check`'s layout audits can't see it: they measure
// against the root's own (already-correct) rect, not the body's.
//
// Sub-compositions are exempt: loadExternalCompositions (packages/core/src/
// Sub-compositions are exempt: loadCompositions (packages/core/src/
// runtime/compositionLoader.ts) mounts only the matched <template>/<body>
// subtree, so a sub-comp's own <html>/<head>/<meta viewport> never reach
// the rendering document, even when it's a full standalone document.
@@ -1,5 +1,7 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { bundleToSingleHtml } from "@hyperframes/core/compiler";
import { resolve } from "node:path";
import puppeteer, { type Browser, type Page } from "puppeteer";
@@ -266,6 +268,722 @@ describe("core runtime browser contract", () => {
}
}, 30_000);
it("keeps each loaded sub-composition's SVG clip ids pointing into its own section", async () => {
// Figma exports restart clip ids per file, so both scenes declare clip0_1_2..clip2_1_2.
const origin = "https://fixture.test/";
const icon = (clip: string | null) =>
clip
? `<svg viewBox="0 0 24 24"><g clip-path="url(#${clip})"><path d="M0 0h24v24H0z"/></g>` +
`<defs><clipPath id="${clip}"><rect width="24" height="24" rx="6"/></clipPath></defs></svg>`
: `<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>`;
const clipAt = new Map([
[0, "clip0_1_2"],
[1, "clip1_1_2"],
[15, "clip2_1_2"],
]);
const deferredLookup = (section: string) => `<script>
var svg = document.querySelector("svg");
var target = svg.querySelector("clipPath");
window.__svgLookups = window.__svgLookups || {};
window.__svgLookups[${JSON.stringify(section)}] = function() {
return {
document: document.querySelector("#clip0_1_2") === target,
documentAll: document.querySelectorAll("#clip0_1_2")[0] === target,
element: svg.querySelector("#clip0_1_2") === target,
elementAll: svg.querySelectorAll("#clip0_1_2")[0] === target,
};
};
</script>`;
const scenes: Record<string, string> = {
"scenes/intro.html": `<template id="intro-template"><div data-composition-id="intro" data-width="320" data-height="180" data-duration="3">${[...clipAt.values()].map(icon).join("")}${deferredLookup("intro")}</div></template>`,
"scenes/grid.html": `<template id="grid-template"><div data-composition-id="grid" data-width="320" data-height="180" data-duration="5">${Array.from(
{ length: 16 },
(_, i) => `<div class="tile">${icon(clipAt.get(i) ?? null)}</div>`,
).join("")}${deferredLookup("grid")}</div></template>`,
"index.html": `<!doctype html><html><body>
<div data-composition-id="main" data-start="0" data-duration="8" data-width="320" data-height="180">
<div id="intro" class="clip" data-composition-id="intro" data-composition-src="scenes/intro.html" data-start="0" data-duration="3" data-track-index="0"></div>
<div id="grid" class="clip" data-composition-id="grid" data-composition-src="scenes/grid.html" data-start="3" data-duration="5" data-track-index="0"></div>
</div>
<script src="${origin}runtime.js"></script></body></html>`,
};
const scenePage = await browser.newPage();
try {
await scenePage.setRequestInterception(true);
scenePage.on("request", (request) => {
const path = request.url().slice(origin.length);
if (path === "runtime.js")
void request.respond({
contentType: "text/javascript",
body: readFileSync(RUNTIME_PATH),
});
else if (scenes[path])
void request.respond({ contentType: "text/html", body: scenes[path] });
else void request.continue();
});
await scenePage.goto(`${origin}index.html`);
await scenePage.waitForFunction(
() => (window as unknown as { __renderReady?: boolean }).__renderReady === true,
);
const strays = await scenePage.evaluate(() =>
["intro", "grid"].map((section) => {
const host = document.getElementById(section)!;
const refs = [...host.querySelectorAll("[clip-path]")].map(
(el) => /#([^)"']+)/.exec(el.getAttribute("clip-path")!)![1]!,
);
return {
section,
refs: refs.length,
outside: refs.filter((id) => !host.contains(document.getElementById(id))),
lookups: (
window as unknown as {
__svgLookups: Record<string, () => Record<string, boolean>>;
}
).__svgLookups[section]!(),
};
}),
);
expect(strays).toEqual([
{
section: "intro",
refs: 3,
outside: [],
lookups: { document: true, documentAll: true, element: true, elementAll: true },
},
{
section: "grid",
refs: 3,
outside: [],
lookups: { document: true, documentAll: true, element: true, elementAll: true },
},
]);
} finally {
await scenePage.close();
}
}, 30_000);
const cssTokenSelectors = [
'use[/**/href="#symbol"]',
'use[href/**/="#symbol"]',
'use[href=/**/"#symbol"]',
'use[href="#symbol"/**/i]',
'use[href="#symbol"/**/]',
'use[href/* ] " */="#symbol"]',
String.raw`use[href="#SYMBOL" \69]`,
];
const invalidCssSelectors = [
"[id=1]",
"#1",
"#-1",
'use[hr/**/ef="#symbol"]',
"use[href=foo/**/bar]",
'use[href="#symbol" s]',
'use[xl|href="#symbol"]',
];
const svgReviewCases: Array<{
name: string;
external: boolean;
content: string;
script: string;
expected: Record<string, string | boolean>;
lateHtml?: boolean;
}> = [
{
name: "CSS token grammar and native errors",
external: false,
content: `<style>${cssTokenSelectors.map((selector, index) => `${selector} { --case-${index}: yes }`).join("\n")}</style><svg><symbol id="symbol"/><path id="1"/><path id="-1"/><use href="#symbol"/><use href="#1"/><use href="#-1"/></svg><div id="1"></div><div id="-1"></div>`,
script: `var svg = document.querySelector('svg');
var target = svg.querySelector('use');
window.__svgReviewProbe = function() {
var selectors = ${JSON.stringify(cssTokenSelectors)};
var invalid = ${JSON.stringify(invalidCssSelectors)};
var native = window.document.createElementNS('http://www.w3.org/2000/svg', 'use');
native.setAttribute('href', '#symbol');
function syntaxError(fn) { try { fn(); return false; } catch(error) { return error.name === 'SyntaxError'; } }
function bits(values) { return values.map(function(value) { return value ? '1' : '0'; }).join(''); }
return {
native: bits(selectors.map(function(selector) { return native.matches(selector); })),
scoped: bits(selectors.map(function(selector) { return svg.querySelector(selector) === target; })),
css: bits(selectors.map(function(_, index) { return getComputedStyle(target).getPropertyValue('--case-' + index).trim() === 'yes'; })),
invalidNative: bits(invalid.map(function(selector) { return syntaxError(function() { native.matches(selector); }); })),
invalidScoped: bits(invalid.map(function(selector) { return syntaxError(function() { svg.querySelector(selector); }); })),
};
};`,
expected: {
native: "1111111",
scoped: "1111111",
css: "1111111",
invalidNative: "1111111",
invalidScoped: "1111111",
},
},
{
name: "Declared attribute namespace CSS",
external: false,
content: `<style>@charset "UTF-8"; @unknown foo; @layer empty; #1 {} @NAMESPACE xl url("http://www.w3.org/1999/xlink"); @namespace ed "urn:editor"; use[xl|href="#symbol"] { opacity: .4 } use[ed|href="#symbol"] { --wrong-namespace: yes } @media all { use[xl|href="#symbol"] { --nested: yes } } @media all { @namespace xl "urn:ignored"; } @namespace xl "urn:late";</style><svg xmlns:xlink="http://www.w3.org/1999/xlink"><symbol id="symbol"/><use xlink:href="#symbol" href="#symbol"/></svg>`,
script: `var target = document.querySelector('svg use');
target.setAttributeNS('urn:editor', 'ed:href', target.getAttribute('href'));
window.__svgReviewProbe = function() {
var style = window.document.createElement('style');
style.textContent = '@namespace xl url("http://www.w3.org/1999/xlink"); .namespace-native[xl|href="#symbol"] { opacity: .4 }';
window.document.head.appendChild(style);
var svg = window.document.createElementNS('http://www.w3.org/2000/svg', 'svg');
var use = window.document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('class', 'namespace-native');
use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '#symbol');
svg.appendChild(use); window.document.body.appendChild(svg);
var repaired = Array.from(window.document.styleSheets).flatMap(function(sheet) { return Array.from(sheet.cssRules); })
.find(function(rule) { var text = rule.selectorText || ''; return text.indexOf('xl|href') !== -1 && !/namespace-native/.test(text) && /opacity/.test(rule.cssText); });
var repairedBefore = repaired.selectorText;
window.__hfSvgSelectorAliases.refresh();
var stable = repaired.selectorText === repairedBefore;
repaired.selectorText = 'svg > use[xl|href="#symbol"]';
window.__hfSvgSelectorAliases.refresh();
var result = {
stable: stable,
authorEdit: repaired.selectorText.indexOf('svg > use') === 0,
native: getComputedStyle(use).opacity,
css: getComputedStyle(target).opacity,
nested: getComputedStyle(target).getPropertyValue('--nested').trim(),
crossNamespace: getComputedStyle(target).getPropertyValue('--wrong-namespace').trim(),
};
svg.remove(); style.remove();
return result;
};`,
expected: {
stable: true,
authorEdit: true,
native: "0.4",
css: "0.4",
nested: "yes",
crossNamespace: "",
},
},
{
name: "Initial script retargets owned styles",
external: false,
content: `<style>.rebound { clip-path: url(#clip) }</style><svg><clipPath id="clip"/><clipPath id="late"/><g class="rebound"/></svg>`,
script: `var svg = document.querySelector('svg');
var style = Array.from(window.document.querySelectorAll('style')).find(function(style) { return style.textContent.includes('.rebound'); });
style.textContent = style.textContent.replace(/url\\([^)]*\\)/, 'url(#late)');
window.__svgReviewProbe = function() {
var id = /#([^)]*)/.exec(style.textContent)[1];
return { own: window.document.getElementById(id) === svg.querySelector('clipPath:last-of-type') };
};`,
expected: { own: true },
},
{
name: "Initial script retargets native references",
external: false,
content: `<svg><clipPath id="clip"/><clipPath id="late"/><g clip-path="url(#clip)"/></svg>`,
script: `var svg = document.querySelector('svg');
var group = svg.querySelector('g');
group.setAttribute('clip-path', 'url(#late)');
window.__svgReviewProbe = function() {
var id = /#([^)]*)/.exec(group.getAttribute('clip-path'))[1];
return { own: window.document.getElementById(id) === svg.querySelector('clipPath:last-of-type') };
};`,
expected: { own: true },
},
{
name: "CSS string continuations",
external: false,
content: `<style>use[href="#sym${String.fromCharCode(92, 13, 10)}bol"] { opacity: .4 }</style><svg><symbol id="symbol"/><use href="#symbol"/></svg>`,
script: `var svg = document.querySelector("svg");
var use = svg.querySelector("use");
window.__svgReviewProbe = function() {
var endings = [[10], [13], [12], [13, 10]];
var selectors = endings.map(function(ending) { return 'use[href="#sym' + String.fromCharCode(92) + String.fromCharCode.apply(null, ending) + 'bol"]'; });
return {
native: selectors.every(function(selector) { return window.document.querySelector('#intro ' + selector) !== null; }),
scoped: selectors.every(function(selector) { return svg.querySelector(selector) === use; }),
css: getComputedStyle(use).opacity,
invalid: endings.every(function(ending) {
try { svg.querySelector('#sym' + String.fromCharCode(92) + String.fromCharCode.apply(null, ending) + 'bol'); return false; }
catch (error) { return error.name === 'SyntaxError'; }
})
};
};`,
expected: { native: true, scoped: true, css: "0.4", invalid: true },
},
{
name: "Escaped closing bracket attribute values",
external: false,
content: `<style>${String.raw`use[href=\#sym\]bol]`} { opacity: .4 }</style><svg><symbol id="sym]bol"/><use href="#sym]bol"/></svg><div id="sym]bol"></div>`,
script: `var svg = document.querySelector('svg');
var target = svg.querySelector('use');
window.__svgReviewProbe = function() {
var selector = 'use[href=' + String.fromCharCode(92) + '#sym' + String.fromCharCode(92) + ']bol]';
var native = window.document.createElementNS('http://www.w3.org/2000/svg', 'use');
native.setAttribute('href', '#sym]bol');
return { native: native.matches(selector), scoped: svg.querySelector(selector) === target,
css: getComputedStyle(target).opacity };
};`,
expected: { native: true, scoped: true, css: "0.4" },
},
{
name: "Multiline native reference attributes",
external: false,
content: `<style>.multiline[style*="url(#clip)"] { opacity: .4 }</style><svg><clipPath id="clip"/><g clip-path="url(#clip)"/></svg><div class="multiline" style="\n clip-path:url(#clip);\n"></div>`,
script: `var target = document.querySelector('.multiline');
window.__svgReviewProbe = function() {
var native = window.document.createElement('div');
native.setAttribute('style', String.fromCharCode(10) + 'clip-path:url(#clip);' + String.fromCharCode(10));
return { native: native.matches('[style*="url(#clip)"]'),
scoped: document.querySelector('.multiline[style*="url(#clip)"]') === target,
css: getComputedStyle(target).opacity };
};`,
expected: { native: true, scoped: true, css: "0.4" },
},
{
name: "HTML attribute name casing",
external: false,
content: `<style>.html-reference[STYLE*="url(#clip)"] { opacity: .4 }</style><svg><clipPath id="clip"/><g clip-path="url(#clip)"/></svg><div class="html-reference" style="clip-path:url(#clip)"></div>`,
script: `var target = document.querySelector('.html-reference');
window.__svgReviewProbe = function() {
var native = window.document.createElement('div');
native.setAttribute('style', 'clip-path:url(#clip)');
return { native: native.matches('[STYLE*="url(#clip)"]'),
scoped: document.querySelector('.html-reference[STYLE*="url(#clip)"]') === target,
css: getComputedStyle(target).opacity };
};`,
expected: { native: true, scoped: true, css: "0.4" },
},
{
name: "external-only scripts retain authored SVG selectors",
external: true,
content: `<svg><clipPath id="clip"><rect width="24" height="24"/></clipPath><g clip-path="url(#clip)"></g></svg>`,
script: `var host = document.getElementById("grid");
var svg = host.querySelector("svg");
var target = svg.querySelector("clipPath");
window.__svgReviewProbe = function() {
return { single: svg.querySelector("#clip") === target,
all: svg.querySelectorAll("#clip")[0] === target };
};`,
expected: { single: true, all: true },
},
{
name: "authored native-reference attribute selectors work in scripts and CSS",
external: false,
content: `<style>
use[href="#symbol"] { opacity: .4; }
use.xlink[*|href="#symbol"] { stroke-width: 7px; }
g[clip-path="url(#clip)"] { opacity: .6; }
</style><svg xmlns:xlink="http://www.w3.org/1999/xlink">
<symbol id="symbol"><rect width="24" height="24"/></symbol><symbol id="Ā"/><use class="unicode" href="#Ā"/>
<clipPath id="clip"><rect width="24" height="24"/></clipPath>
<use href="#symbol"></use><use class="xlink" xlink:href="#symbol"></use>
<g clip-path="url(#clip)"></g><text data-ref="#symbol">label</text>
</svg><div id="Ā"></div>`,
script: `var svg = document.querySelector("svg");
var uses = svg.querySelectorAll("use:not(.unicode)");
var clipped = svg.querySelector("g");
window.__svgReviewProbe = function() {
var result = {
operators: ['use[href^="#sym"]', 'use[href$="symbol"]', 'use[href*="symb"]', 'use[href~="#symbol"]', 'use[href|="#symbol"]', 'use[href="#SYMBOL" i]', 'use[href=' + String.fromCharCode(92) + '23 symbol]'].every(function(selector) { return svg.querySelector(selector) === uses[0]; }),
unicode: svg.querySelector('use.unicode[href="#ā" i]') === null,
href: document.querySelector('use[href="#symbol"]') === uses[0],
xlink: svg.querySelector('use.xlink[*|href="#symbol"]') === uses[1],
url: svg.querySelector('g[clip-path="url(#clip)"]') === clipped,
arbitrary: svg.querySelector('text[data-ref="#symbol"]') !== null,
hrefCss: getComputedStyle(uses[0]).opacity,
xlinkCss: getComputedStyle(uses[1]).strokeWidth,
urlCss: getComputedStyle(clipped).opacity,
};
uses[0].setAttribute("href", "#unrelated");
result.afterChange = svg.querySelector('use[href="#symbol"]') === null;
result.afterChangeCss = getComputedStyle(uses[0]).opacity;
uses[1].setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", "#unrelated");
result.xlinkAfterChange = svg.querySelector('use.xlink[*|href="#symbol"]') === null;
result.xlinkAfterChangeCss = getComputedStyle(uses[1]).strokeWidth;
return result;
};`,
expected: {
operators: true,
unicode: true,
href: true,
xlink: true,
url: true,
arbitrary: true,
hrefCss: "0.4",
xlinkCss: "7px",
urlCss: "0.6",
afterChange: true,
afterChangeCss: "1",
xlinkAfterChange: true,
xlinkAfterChangeCss: "1px",
},
},
{
name: "scripts capture the final SVG id before a deferred reference write",
external: false,
content: `<svg><clipPath id="clip"><rect width="24" height="24"/></clipPath><g clip-path="url(#clip)"></g></svg><div id="clip"></div>`,
script: `var svg = document.querySelector("svg");
var target = svg.querySelector("clipPath");
var capturedId = target.id;
window.__svgReviewProbe = function() {
svg.querySelector("g").setAttribute("clip-path", "url(#" + capturedId + ")");
return { capturedStable: capturedId === target.id,
correctTarget: window.document.getElementById(capturedId) === target };
};`,
expected: { capturedStable: true, correctTarget: true },
},
{
name: "an earlier HTML host finishes mounting before a later SVG script captures its id",
external: false,
lateHtml: true,
content: `<div id="clip">later HTML</div>`,
script: `var svg = document.querySelector("svg");
var target = svg.querySelector("clipPath");
var capturedId = target.id;
window.__svgReviewProbe = function() {
svg.querySelector("g").setAttribute("clip-path", "url(#" + capturedId + ")");
return { capturedStable: capturedId === target.id,
correctTarget: window.document.getElementById(capturedId) === target };
};`,
expected: { capturedStable: true, correctTarget: true },
},
{
name: "initial inline hosts retain borrowed parent SVG references",
external: false,
content: `<svg><linearGradient id="paint"><stop stop-color="red"/></linearGradient><rect fill="url(#paint)"/></svg>
<template id="child-template"><div data-composition-id="child"><style>.borrowed { fill: url(#paint) }</style><svg><rect class="borrowed" fill="url(#paint)"/></svg></div></template>
<div data-composition-id="child"></div>`,
script: `var target = document.querySelector("linearGradient");
window.__svgReviewProbe = function() {
var child = document.querySelector(".borrowed");
var attr = /#([^)'"]+)/.exec(child.getAttribute("fill"))[1];
var css = /#([^)'"]+)/.exec(getComputedStyle(child).fill)[1];
return { attribute: window.document.getElementById(attr) === target,
css: window.document.getElementById(css) === target };
};`,
expected: { attribute: true, css: true },
},
{
name: "external scripts still create inline hosts that borrow finalized SVG references",
external: false,
content: `<svg><linearGradient id="paint"><stop stop-color="red"/></linearGradient><rect fill="url(#paint)"/></svg>`,
script: `var target = document.querySelector("linearGradient");
var captured = target.id;
var template = document.createElement("template");
template.id = "dynamic-template";
template.innerHTML = '<div data-composition-id="dynamic"><style>.borrowed { fill: url(#paint) }</style><svg><clipPath id="clip"/><g clip-path="url(#clip)"/><rect class="borrowed" fill="url(#paint)"/></svg></div>';
var payload = document.createElement("script");
payload.textContent = 'window.__dynamicMounted = true;';
template.content.appendChild(payload);
document.body.appendChild(template);
var host = document.createElement("div");
host.setAttribute("data-composition-id", "dynamic");
document.querySelector("svg").parentElement.appendChild(host);
window.__svgReviewProbe = function() {
var child = host.querySelector(".borrowed");
var attr = /#([^)'"]+)/.exec(child.getAttribute("fill"))[1];
var css = /#([^)'"]+)/.exec(getComputedStyle(child).fill)[1];
var clip = /#([^)'"]+)/.exec(host.querySelector("g").getAttribute("clip-path"))[1];
return { mounted: window.__dynamicMounted === true,
capturedStable: captured === target.id,
attribute: window.document.getElementById(attr) === target,
css: window.document.getElementById(css) === target,
ownClip: host.contains(window.document.getElementById(clip)) };
};`,
expected: { mounted: true, capturedStable: true, attribute: true, css: true, ownClip: true },
},
{
name: "external replacement of an inline host does not run its stale queued script",
external: false,
content: `<template id="child-template"><div data-composition-id="child"><style>.replacement { color: red }</style><script>window.__staleInlineRan = true;</script><span>old</span></div></template><div data-composition-id="child"></div>`,
script: `var child = document.querySelector('[data-composition-id="child"]');
child.innerHTML = '<span class="replacement">new</span>';
window.__svgReviewProbe = function() {
return { replaced: !!child.querySelector(".replacement"), staleRan: window.__staleInlineRan === true,
color: getComputedStyle(child.querySelector(".replacement")).color };
};`,
expected: { replaced: true, staleRan: false, color: "rgb(0, 0, 0)" },
},
{
name: "an inline host emptied by an external script is mounted before its script runs",
external: false,
content: `<template id="child-template"><div data-composition-id="child"><script>window.__emptyInlineRan = true;</script><span class="restored">restored</span></div></template><div data-composition-id="child"></div>`,
script: `var child = document.querySelector('[data-composition-id="child"]');
child.innerHTML = '';
window.__svgReviewProbe = function() {
return { restored: !!child.querySelector(".restored"), ran: window.__emptyInlineRan === true };
};`,
expected: { restored: true, ran: true },
},
{
name: "a script-created duplicate preserves the first inline instance identity and variables",
external: false,
content: `<template id="scene-template"><div data-composition-id="scene"><style>.instance-probe { color: red }</style><script>window.__instanceValues = window.__instanceValues || []; window.__instanceValues.push(__hyperframes.getVariables().label);</script><span class="instance-probe">instance</span></div></template><div data-composition-id="scene" data-variable-values='{"label":"first"}'></div>`,
script: `var first = document.querySelector('[data-composition-id="scene"]');
var second = document.createElement("div");
second.setAttribute("data-composition-id", "scene");
second.setAttribute("data-variable-values", JSON.stringify({ label: "second" }));
first.parentElement.appendChild(second);
window.__svgReviewProbe = function() {
return { values: (window.__instanceValues || []).join(","),
colors: [first, second].every(function(host) { return getComputedStyle(host.querySelector(".instance-probe")).color === "rgb(255, 0, 0)"; }),
distinct: first.getAttribute("data-composition-id") !== second.getAttribute("data-composition-id") };
};`,
expected: { values: "first,second", colors: true, distinct: true },
},
{
name: "a discovered earlier definition preserves existing authored borrowed references",
external: false,
content: `<style>.borrowed { fill: url(#paint) }</style><svg><rect class="borrowed" fill="url(#paint)"/></svg><template id="local-template"><div data-composition-id="local"><svg><linearGradient id="paint"><stop stop-color="green"/></linearGradient><rect class="owned-paint" fill="url(#paint)"/></svg></div></template><div data-composition-id="local"></div>`,
script: `var borrowed = document.querySelector(".borrowed");
var local = document.querySelector(".owned-paint");
var localTarget = local.parentElement.querySelector("linearGradient");
var localId = localTarget.id;
var template = document.createElement("template");
template.id = "newpaint-template";
template.innerHTML = '<div data-composition-id="newpaint"><svg><linearGradient id="paint"><stop stop-color="red"/></linearGradient><rect fill="url(#paint)"/></svg></div>';
document.body.appendChild(template);
var host = document.createElement("div");
host.setAttribute("data-composition-id", "newpaint");
var own = document.getElementById("grid");
own.parentElement.insertBefore(host, own);
window.__svgReviewProbe = function() {
var target = host.querySelector("linearGradient");
var attr = /#([^)'"]+)/.exec(borrowed.getAttribute("fill"))[1];
var css = /#([^)'"]+)/.exec(getComputedStyle(borrowed).fill)[1];
return { attribute: window.document.getElementById(attr) === target,
css: window.document.getElementById(css) === target,
local: localTarget.id === localId && window.document.getElementById(/#([^ )]+)/.exec(local.getAttribute("fill"))[1]) === localTarget };
};`,
expected: { attribute: true, css: true, local: true },
},
{
name: "root attributes and root CSS retain the original mounted SVG target",
external: false,
content: `<svg><linearGradient id="paint"><stop stop-color="red"/></linearGradient><rect fill="url(#paint)"/></svg>`,
script: `var target = document.querySelector("linearGradient");
window.__svgReviewProbe = function() {
var borrowed = window.document.querySelector(".root-borrowed");
var attr = /#([^)'"]+)/.exec(borrowed.getAttribute("fill"))[1];
var css = /#([^)'"]+)/.exec(getComputedStyle(borrowed).fill)[1];
return { attribute: window.document.getElementById(attr) === target,
css: window.document.getElementById(css) === target,
rootId: window.document.querySelector(".outside-paint").id };
};`,
expected: { attribute: true, css: true, rootId: "paint" },
},
{
name: "first native use of a resource",
external: false,
content: `<svg><clipPath id="late"/></svg>`,
script: `var svg = document.querySelector("svg");
var target = svg.querySelector("clipPath");
var group = window.document.createElementNS("http://www.w3.org/2000/svg", "g");
group.setAttribute("clip-path", "url(#late)");
svg.appendChild(group);
window.__svgReviewProbe = function() {
var reference = /#([^)'"]+)/.exec(group.getAttribute("clip-path"))[1];
return { own: window.document.getElementById(reference) === target };
};`,
expected: { own: true },
},
{
name: "first native use of a path",
external: false,
content: `<svg><path id="late-path"/></svg>`,
script: `var svg = document.querySelector("svg");
var target = svg.querySelector("path");
var use = window.document.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", "#late-path");
svg.appendChild(use);
window.__svgReviewProbe = function() {
return { own: window.document.getElementById(use.getAttribute("href").slice(1)) === target };
};`,
expected: { own: true },
},
{
name: "initial script native writes resolve within their already-namespaced instance",
external: false,
content: `<svg><clipPath id="clip"/><symbol id="symbol"/><g clip-path="url(#clip)"/><use href="#symbol"/></svg>`,
script: `var svg = document.querySelector("svg");
var clip = svg.querySelector("clipPath");
var symbol = svg.querySelector("symbol");
var captured = clip.id;
svg.querySelector("g").setAttribute("clip-path", "url(#clip)");
var use = window.document.createElementNS("http://www.w3.org/2000/svg", "use");
use.setAttribute("href", "#symbol");
svg.appendChild(use);
window.__svgReviewProbe = function() {
var clipRef = /#([^)'"]+)/.exec(svg.querySelector("g").getAttribute("clip-path"))[1];
return { clip: window.document.getElementById(clipRef) === clip,
symbol: window.document.getElementById(use.getAttribute("href").slice(1)) === symbol,
stable: captured === clip.id };
};`,
expected: { clip: true, symbol: true, stable: true },
},
{
name: "ID selectors follow actual renamed IDs and stop matching unrelated writes",
external: false,
content: `<style>clipPath[id="clip"] { color: red } #clip { opacity: .4 }</style><svg><clipPath id="clip"/><g clip-path="url(#clip)"/></svg>`,
script: `var svg = document.querySelector("svg");
var target = svg.querySelector("clipPath");
window.__svgReviewProbe = function() {
var result = { id: svg.querySelector("#clip") === target,
attribute: svg.querySelector('[id="clip"]') === target,
color: getComputedStyle(target).color, opacity: getComputedStyle(target).opacity };
target.id = "unrelated";
result.afterId = svg.querySelector("#clip") === null;
result.afterAttribute = svg.querySelector('[id="clip"]') === null;
result.afterColor = getComputedStyle(target).color;
result.afterOpacity = getComputedStyle(target).opacity;
return result;
};`,
expected: {
id: true,
attribute: true,
color: "rgb(255, 0, 0)",
opacity: "0.4",
afterId: true,
afterAttribute: true,
afterColor: "rgb(0, 0, 0)",
afterOpacity: "1",
},
},
{
name: "root ID rules keep every renamed definition when the first target keeps its ID",
external: false,
content: `<svg><clipPath id="clip"/><g clip-path="url(#clip)"/></svg><template id="child-template"><div data-composition-id="child"><svg><clipPath id="clip"/><g clip-path="url(#clip)"/></svg></div></template><div data-composition-id="child"></div>`,
script: `window.__svgReviewProbe = function() {
var definitions = [...window.document.querySelectorAll('clipPath:not([id="late"])')];
return { count: String(definitions.length), colors: definitions.every(function(target) {
return getComputedStyle(target).stroke === "rgb(0, 128, 0)";
}) };
};`,
expected: { count: "3", colors: true },
},
{
name: "replacement of a script-only inline host discards its empty staged queue",
external: false,
content: `<template id="empty-template"><script>window.__emptyStale = true;</script></template><div data-composition-id="empty"></div>`,
script: `var host = document.querySelector('[data-composition-id="empty"]');
host.innerHTML = '<span class="replacement">new</span>';
window.__svgReviewProbe = function() { return { stale: window.__emptyStale === true, replaced: !!host.querySelector(".replacement") }; };`,
expected: { stale: false, replaced: true },
},
{
name: "initial inline discovery freezes the external owner's composition identity",
external: false,
content: `<style>.external-probe { color: red }</style><span class="external-probe">external</span><template id="grid-template"><div data-composition-id="grid"><span class="child-probe">child</span></div></template><div data-composition-id="grid"></div>`,
script: `window.__svgReviewProbe = function() { return {
scoped: !!document.querySelector(".external-probe"),
color: getComputedStyle(document.querySelector(".external-probe")).color,
child: !!document.querySelector(".child-probe") }; };`,
expected: { scoped: true, color: "rgb(255, 0, 0)", child: true },
},
{
name: "CSS id rules keep unchanged HTML and renamed SVG matches",
external: false,
content: `<style>#fx { color: rgb(255, 0, 0) }</style>
<svg><filter id="fx"></filter><rect filter="url(#fx)"/></svg><div id="fx">text</div>`,
script: `var svg = document.querySelector("svg");
var html = document.querySelector("div#fx");
window.__svgReviewProbe = function() {
return { htmlColor: getComputedStyle(html).color,
svgColor: getComputedStyle(svg.querySelector("filter")).color };
};`,
expected: { htmlColor: "rgb(255, 0, 0)", svgColor: "rgb(255, 0, 0)" },
},
];
it.each(
svgReviewCases.flatMap((fixture) => [
{ ...fixture, compiled: false },
...([
"CSS string continuations",
"CSS token grammar and native errors",
"Declared attribute namespace CSS",
"HTML attribute name casing",
"Multiline native reference attributes",
"Escaped closing bracket attribute values",
"external-only scripts retain authored SVG selectors",
"authored native-reference attribute selectors work in scripts and CSS",
"root attributes and root CSS retain the original mounted SVG target",
"root ID rules keep every renamed definition when the first target keeps its ID",
"ID selectors follow actual renamed IDs and stop matching unrelated writes",
].includes(fixture.name)
? [{ ...fixture, compiled: true }]
: []),
]),
)(
"SVG revision: $name (compiled=$compiled)",
async ({ content, script, external, expected, lateHtml = false, compiled }) => {
const origin = "https://svg-review.test/";
let gridPayload = `<script>${script}</script>`;
if (external) gridPayload = '<script src="scene.js"></script>';
if (lateHtml) gridPayload = "";
const introHost =
'<div id="intro" data-composition-id="intro" data-composition-src="intro.html"></div>';
const gridHost =
'<div id="grid" data-composition-id="grid" data-composition-src="grid.html"></div>';
const hosts = lateHtml ? gridHost + introHost : introHost + gridHost;
const assets: Record<string, { contentType: string; body: string | Buffer }> = {
"runtime.js": { contentType: "text/javascript", body: readFileSync(RUNTIME_PATH) },
"scene.js": { contentType: "text/javascript", body: script },
"intro.html": {
contentType: "text/html",
body: `<template id="intro-template"><div data-composition-id="intro" data-duration="2"><svg><clipPath id="clip"/><clipPath id="late"/><path id="late-path"/><symbol id="symbol"/><g clip-path="url(#clip)"/><use href="#symbol"/></svg>${lateHtml ? `<script>${script}</script>` : ""}</div></template>`,
},
"grid.html": {
contentType: "text/html",
body: `<template id="grid-template"><div data-composition-id="grid" data-duration="2">${content}${gridPayload}</div></template>`,
},
"index.html": {
contentType: "text/html",
body: `<!doctype html><html><body><div data-composition-id="main" data-duration="2" data-width="320" data-height="180">${hosts}</div><style>.root-borrowed { fill: url(#paint) } #clip { stroke: green }</style><svg><linearGradient class="outside-paint" id="paint"><stop stop-color="blue"/></linearGradient><rect class="root-borrowed" fill="url(#paint)"/></svg><script src="runtime.js"></script></body></html>`,
},
};
let compiledDirectory: string | undefined;
if (compiled) {
compiledDirectory = mkdtempSync(resolve(tmpdir(), "svg-alias-browser-"));
for (const [path, asset] of Object.entries(assets))
writeFileSync(resolve(compiledDirectory, path), asset.body);
assets["index.html"]!.body = await bundleToSingleHtml(compiledDirectory);
}
const scenePage = await browser.newPage();
try {
await scenePage.setRequestInterception(true);
let releaseGrid: ((release: () => void) => void) | undefined;
const gridResponse = new Promise<() => void>((resolve) => {
releaseGrid = resolve;
});
scenePage.on("request", (request) => {
const path = request.url().slice(origin.length);
const asset = assets[path];
if (lateHtml && path === "grid.html") {
releaseGrid!(() => {
void request.respond(asset!);
});
} else if (asset) void request.respond(asset);
else void request.continue();
});
await scenePage.goto(`${origin}index.html`);
if (lateHtml) {
await scenePage.waitForFunction(() => document.querySelector("#intro clipPath") !== null);
(await gridResponse)();
}
await scenePage.waitForFunction(
() => (window as unknown as { __renderReady?: boolean }).__renderReady === true,
);
const result = await scenePage.evaluate(() =>
(window as unknown as { __svgReviewProbe: () => unknown }).__svgReviewProbe(),
);
expect(result).toEqual(expected);
} finally {
await scenePage.close();
if (compiledDirectory) rmSync(compiledDirectory, { recursive: true, force: true });
}
},
30_000,
);
it("removes the control bridge during teardown", async () => {
const result = await page.evaluate(async () => {
const runtimeWindow = window as unknown as {
@@ -11,12 +11,20 @@ import {
// Deep import: the mount path is not part of core's published export map (it is
// bundled into the runtime IIFE, not imported by consumers). Same shape as
// engine/src/services/videoFrameExtractor.test.ts reaching into core's runtime.
import { loadExternalCompositions } from "../../../core/src/runtime/compositionLoader.js";
import { loadCompositions } from "../../../core/src/runtime/compositionLoader.js";
import { compileForRender } from "./htmlCompiler.js";
import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js";
const tempDirs: string[] = [];
const nativeQuerySelector = Element.prototype.querySelector;
const nativeQuerySelectorAll = Element.prototype.querySelectorAll;
afterEach(() => {
Element.prototype.querySelector = nativeQuerySelector;
Element.prototype.querySelectorAll = nativeQuerySelectorAll;
delete window.__hfSvgSelectorAliases;
});
beforeAll(() => {
// The mount path scopes CSS with CSS.escape, which the DOM stub omits.
if (typeof globalThis.CSS === "undefined") {
@@ -66,7 +74,7 @@ async function mountContract(dir: string, indexHtml: string): Promise<ParityCont
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) =>
Promise.resolve(new Response(readFileSync(join(dir, String(input)), "utf8"), { status: 200 })),
);
await loadExternalCompositions({
await loadCompositions({
injectedStyles: [],
injectedScripts: [],
injectedLinks: [],
@@ -413,11 +421,10 @@ const subCompositions = (files: Record<string, string>) =>
*
* - `runtimeBootstrap` / `variableBootstrap` — the runtime IIFE and the variable
* bootstrap script are injected by the player and the producer AROUND a mount,
* never by `loadExternalCompositions`. Comparing them would compare harnesses.
* never by `loadCompositions`. Comparing them would compare harnesses.
*
* Nothing else is excluded. The templated-head-`<link>` divergence this file
* used to carve out is closed and gated by a fixture above; so is the
* anonymous-host one. Two divergences remain ungated HERE rather than
* Nothing else is excluded. The templated-head-`<link>` and anonymous-host
* divergences are gated by fixtures above. Two divergences remain ungated HERE rather than
* unfixed — an inline `<head>` script and the split between the CSS scope id
* and the script scope id both live in script bodies, and this contract
* carries no script-body field. Their gates are the unit suites in
@@ -0,0 +1,77 @@
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z4sX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJof6ThkzLT3FAzwFJXA&skey=e5dfd0c3910c7ec9&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z4sX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJof6ThkzLT3FAzwFJXA&skey=e5dfd0c3910c7ec9&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@@ -0,0 +1,77 @@
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-ZxsX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJof6Thg&skey=e5dfd0c3910c7ec9&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-ZxsX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJof6Thg&skey=e5dfd0c3910c7ec9&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@@ -0,0 +1,88 @@
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmUBHMdazTqPu8GDmixiyv7sHsO9kUd_1ZZwvfxd8PedhYMCPK-7KsgrD_GTTHR6jtWpE1QSC-n0tGj5Y0Iea5lPneAWMuTqRLj9_emvoF8BoqCq3MRviSSf0DASiOS6kszpAyy8pOPJOwPd1c&skey=c608c610063635f9&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmUBHMdazTqPu8GDmixiyv7sHsO9kUd_1ZZwvfxd8PedhYMCPK-7KsgrD_GTTHR6jtWpE1QSC-n0tGj5Y0Iea5lPneAWMuTqRLj9_emvoF8BoqCq3MRviSSf0DASiOS6kszpAyy8pOPJOwPd1c&skey=c608c610063635f9&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 200;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014;
}
@@ -0,0 +1,70 @@
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=tDbp2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-LfjGbsVNLG7DGdF6OZ1Pcwgknk-6tesCuTUT9YD4b2KmIZSE1FNq_PdUTe-vhgNSBv5wANctW9XhQvuMvrqbZ4ujEG3DkCVl51v9aNp3Rr_aV3gtb0mHrFFQra5onpxICOnxkknmsw6RLuooi33FCy6LBrS7g4a_qRL8O8GTwUkRLlR8&skey=74ed26de24979d51&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=tDbp2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-LfjGbsVNLG7DGdF6OZ1Pcwgknk-6tesCuTUT9YD4b2KmIZSE1FNq_PdUTe-vhgNSBv5wANctW9XhQvuMvrqbZ4ujEG3DkCVl51v9aNp3Rr_aV3gtb0mHrFFQra5onpxICOnxkknmsw6RLuooi33FCy6LBrS7g4a_qRL8O8GTwUkRLlR8&skey=74ed26de24979d51&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 100;
src: url(https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 500;
src: url(https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@@ -0,0 +1,88 @@
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmUBHMdazTqN-8GDmixiyv7sHsO9kUd_1ZZwvfxd8PedhYMCPK-7KsgrD_GTTHR6jtWpE1QSC-n0tGj5Y0Iea5lPneAWMuTqRLj9_emvoF8BoqCq3MRviSSf0DASiOS6kszpAyy8pOPJOwPd1dN0IOI3ReiouI&skey=c608c610063635f9&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: italic;
font-weight: 700;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmUBHMdazTqN-8GDmixiyv7sHsO9kUd_1ZZwvfxd8PedhYMCPK-7KsgrD_GTTHR6jtWpE1QSC-n0tGj5Y0Iea5lPneAWMuTqRLj9_emvoF8BoqCq3MRviSSf0DASiOS6kszpAyy8pOPJOwPd1dN0IOI3ReiouI&skey=c608c610063635f9&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 100;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 200;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 300;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 600;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 800;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 900;
font-stretch: 100%;
src: url(https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51) format('woff2');
unicode-range: U+9-a, U+20-7e, U+2014, U+203a, U+2304, U+25c7;
}
@@ -0,0 +1,77 @@
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-ZzsX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJod6k8C7f&skey=e5dfd0c3910c7ec9&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: italic;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-ZzsX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJod6k8C7f&skey=e5dfd0c3910c7ec9&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 100;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 200;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 800;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 900;
src: url(https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20) format('woff2');
unicode-range: U+9-a, U+20-7e, U+b7, U+2014;
}
+50 -60
View File
@@ -4,124 +4,114 @@
"contentType": "application/javascript; charset=utf-8",
"file": "c174bfce53a729418d57a8ad.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23%5B%5D()6zZqQ'75%25_%7C%2B%5C%3F%5E%26%E2%80%94%60": {
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23%5B%5D()6zZqQ'75%25_%7C%3F%5C%09%60%5E%26%24%2B~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "21363c70fe0f5ada0387fc10.bin"
"file": "13ea284e2ea4ac4ab37c98e5.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23%5B%5DzZ(5)6%257%3F_%7C%2B%5C'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23%5B%5DzZ(5)6%257%3F_%7C%5C%09%60%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "41acfa6f109d3a5946f742d9.bin"
"file": "13ea284e2ea4ac4ab37c98e5.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%3F%5C%09%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "d46936f54ebde49ebb44d3a9.bin"
"file": "0529bdbdcf740f78d5b9e331.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B6%5D()75%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B6%5D()75%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%3F%5C%09%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "d46936f54ebde49ebb44d3a9.bin"
"file": "0529bdbdcf740f78d5b9e331.bin"
},
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034zZ%3B%23kK5%25%5B%5D()76%C2%B7_%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034zZ%3B%23kK5%25%5B%5D()76%C2%B7_%7C%3F%5C%09%60%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "73f0f6259c8c64e613d401f4.bin"
"file": "bed471e981d73b3db0c86a41.bin"
},
"https://fonts.googleapis.com/css2?family=JetBrains%20Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=JetBrains%20Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%3F%5C%09%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "c01c545a99d9d5e629e4e793.bin"
"file": "89a83af9f5863c5898d0ba50.bin"
},
"https://fonts.googleapis.com/css2?family=JetBrains%20Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B6%5D()75%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=JetBrains%20Mono:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B6%5D()75%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%3F%5C%09%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "c01c545a99d9d5e629e4e793.bin"
"file": "89a83af9f5863c5898d0ba50.bin"
},
"https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23%5B%5DzZ(5)6%257%3F_%7C%2B%5C'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23%5B%5DzZ(5)6%257%3F_%7C%5C%09%60%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "4319419b25fdd00a2b54650a.bin"
"file": "852b31c7d0aecc0f1843bc09.bin"
},
"https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B%5D()765%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%3F%5C%09%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "30d4b72c02fd3924be31f70d.bin"
"file": "ae13656ef251eb2b9f869407.bin"
},
"https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B6%5D()75%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%2B%5C%3F'%5E%26qQ%E2%80%94": {
"https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,700&text=%3C!DdOoCcTtYyPpEe%20hHmMlL%3E%0AaAnNgG%3D%22sS-rRfFxXiIuU%2CbB*%7B%3A%7D%2F8vVwW1920.jJ%4034%3BkK%23zZ%5B6%5D()75%25%60_%E2%8C%84%E2%97%87%E2%80%BA%7C%3F%5C%09%5E%26%24%2B'Qq~%E2%80%94": {
"status": 200,
"contentType": "text/css; charset=utf-8",
"file": "30d4b72c02fd3924be31f70d.bin"
"file": "ae13656ef251eb2b9f869407.bin"
},
"https://fonts.gstatic.com/l/font?kit=KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmUBHMdazTqMOwsD2uwjSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JUR2QYPd3lN3L2IxhQ&skey=c608c610063635f9&v=v51": {
"https://fonts.gstatic.com/l/font?kit=KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmUBHMdazTqN-8GDmixiyv7sHsO9kUd_1ZZwvfxd8PedhYMCPK-7KsgrD_GTTHR6jtWpE1QSC-n0tGj5Y0Iea5lPneAWMuTqRLj9_emvoF8BoqCq3MRviSSf0DASiOS6kszpAyy8pOPJOwPd1dN0IOI3ReiouI&skey=c608c610063635f9&v=v51": {
"status": 200,
"contentType": "font/woff2",
"file": "95061b23401efc9ba7409666.bin"
"file": "33e36a8542f94c359351e55c.bin"
},
"https://fonts.gstatic.com/l/font?kit=KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmUBHMdazTqOuwsD2uwjSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDcyvSjuYR_C4WPqHYWuyePYF3DTySX6UY8qQ-39QpzzQ&skey=c608c610063635f9&v=v51": {
"https://fonts.gstatic.com/l/font?kit=KFO5CnqEu92Fr1Mu53ZEC9_Vu3r1gIhOszmUBHMdazTqPu8GDmixiyv7sHsO9kUd_1ZZwvfxd8PedhYMCPK-7KsgrD_GTTHR6jtWpE1QSC-n0tGj5Y0Iea5lPneAWMuTqRLj9_emvoF8BoqCq3MRviSSf0DASiOS6kszpAyy8pOPJOwPd1c&skey=c608c610063635f9&v=v51": {
"status": 200,
"contentType": "font/woff2",
"file": "36535127950d69fb03da1a51.bin"
"file": "fa10510792f83e658fd729ad.bin"
},
"https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUV7SescuDWy1jyf3vHcK8kEZ40pF3vP1c8fSehoADPa66Ndc0EPCSTXV5jdaqElUTCu7zs2_4YkMfaJpMnuEXM-XlS7fy_OiuoVwCoaOr3cVujiOY1zETieW5kc_qAi2amhnuxJXFU8rsq7t&skey=a0a0114a1dcab3ac&v=v51": {
"status": 200,
"contentType": "font/woff2",
"file": "e3c9ceb1da81254fab3982d1.bin"
},
"https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVTSescuDWy1jyf3vHcK8kEZ40pF3vP1c8fSehoADPa66Ndc0EPCSTXV5jdaqElUTCu7zs2_4YkMfaJpMnuEXM-XlC3ezPKhu4pxCYeJrnQUpTmNYlvFTSaZ50Q-rwkpCHw&skey=a0a0114a1dcab3ac&v=v51": {
"https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVDRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5&skey=a0a0114a1dcab3ac&v=v51": {
"status": 200,
"contentType": "text/html; charset=utf-8",
"file": "788a403350b85d98aec587a3.bin"
"file": "b7ee14896537ab8f9ffaa90b.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z0slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4tk10Sw&skey=e5dfd0c3910c7ec9&v=v20": {
"https://fonts.gstatic.com/l/font?kit=KFO7CnqEu92Fr1ME7kSn66aGLdTylUAMW36eA1EfUVnRUMYtDGq3jSn5vnUM9EMb_VRH3PXzccXcdBgCCvC46qki0kHETzfX6DlYqk9STiml0M-9548Of6xnMHmCWs2VqxDdyfWkuId-BISMqXEXuCaQYV7CSCWU6Ek9qg6w9JWNuxJ5FUMVsrXuswTH&skey=a0a0114a1dcab3ac&v=v51": {
"status": 200,
"contentType": "font/woff2",
"file": "f664c5b9216de5d0d4ece5a0.bin"
"file": "bf2af2f207d8289f5d95f4ba.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z1slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXRK6VTJsLDadXye5CxHf1GTkcRElUQpEyoQb0y5KS9g&skey=e5dfd0c3910c7ec9&v=v20": {
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z4sX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJof6ThkzLT3FAzwFJXA&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "68c23b7290680c2b6ef37032.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-ZxsX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJof6Thg&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "d3b6c159f462183ba7796270.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-ZzsX78MLpiqZCmvMf9UxqhNKllH1cNgx0bTsIBOBs7SNX-j4s4ylNm2UCwCZNIPBLj_BHVI3u89wkVczLFGLd6G0t36lHOtbPXenGd4StCfEyMjMdBkkEqHiUdbEm-sNOJod6k8C7f&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "text/html; charset=utf-8",
"file": "b13fa08305318ea628453cec.bin"
"file": "9fca1e9095ed294968aa18f7.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z3slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXRK6VTJsLDadXye5CxHf1GTkcRElUQpEyoQb0y5CRgWX4g&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "2427868cae6e8cf32d2e8d8b.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC53FwrK3iLTcvneQg7Ca725JhhKnNqk5L7W0Q5n-Z_slT9M7tkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4tk10S_6TqEzHcXFbzA&skey=e5dfd0c3910c7ec9&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "ae40a5f8f9f604f443e21213.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD9d41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzsWX6bxniqpYhQ&skey=c491285d6722e4fa&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "42e5ad0b3cee71d5f01953d8.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_941mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNHJHZM71bLvr7Yd3qY5i5ZYVORl8JGl0onESgWadJEXw&skey=c491285d6722e4fa&v=v20": {
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-90fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4g&skey=c491285d6722e4fa&v=v20": {
"status": 200,
"contentType": "text/html; charset=utf-8",
"file": "5905d90fd449470dac1b291d.bin"
"file": "8bfba0d6e85d9f059d9b5982.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_d41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNHJHZM71bLvr7Yd3qY5i5ZYVORl8JGl0onESgWafJzKS9g&skey=c491285d6722e4fa&v=v20": {
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD-d0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttE2aP6Thg&skey=c491285d6722e4fa&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "e59d60b150022666efbc6da6.bin"
"file": "57dacd2ef96ca543d3e0e6a3.bin"
},
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD_t41mVb_NL5mpZyqsMP5Vx69KLV5G1MJhxEXQs4FPB8_NKmC8488zldq1Uy8DZdMOA7_4A3RJ3-4-wUZfzbBHLNGJ3dL7lXKsb_bdn2Z5S9GYFCQkMNFlkUmEikRaE0mSzs&skey=c491285d6722e4fa&v=v20": {
"https://fonts.gstatic.com/l/font?kit=UcC73FwrK3iLTeHuS_nVMrMxCp50SjIq15j8eUYD8t0fmFX-Mrxkq5KossX_VRyjNrd7HVULhR8ZQMwDOh09SteA8Yk6zFVk206-C5FOOhDh4g_XIX269QsbfTDHHrF4GXVJ6FPIs7HVdH-f4y1Efk6SksVDlEcoHCsTbku4ttEWX4jxkhSpeRcu3GQ&skey=c491285d6722e4fa&v=v20": {
"status": 200,
"contentType": "font/woff2",
"file": "47d5160342b9998840916131.bin"
"file": "e3292e3f83690316b015865b.bin"
},
"https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC-7jDdIsxmG1S4kI2wqibvTm6_HSTI8hUSo9elLPWZU1qfAlh9usvmC0NXcrzdX6ttGYmH5rbj0fJiV60wlxpOoMENiID7wMbqOVLCL3tb6MF4FLv3jdS78Cl59EfJNtbKnRvDeJbp1veZfg&skey=48ad01c60053c2ae&v=v24": {
"https://fonts.gstatic.com/l/font?kit=tDbV2o-flEEny0FZhsfKu5WU4yD5MQaPTXC57RrcIc1gGVa2no-yrCDtTHChHybO9BcQrdmnLvOfUVjhfFp_vM3kCU1ZcL7bWalvB5eF5LDl0_BsWa8ykRxMov8zioL9xsToN1zALX1d6sNmCrn1i9K58id39kXPMEg3dxvDVpbl6PeCffdWKg&skey=48ad01c60053c2ae&v=v24": {
"status": 200,
"contentType": "font/woff2",
"file": "c356c27cca02fa3bfd0c2bef.bin"
"file": "6ea12fa77b5d9e7cef868d13.bin"
},
"https://fonts.gstatic.com/l/font?kit=tDbp2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-LfjGbsVNLG7DGdF6OZ1Pcwgknk-6tfcOETEf8ZjwZ1qeKZyczFtihI9cRfe3jgtqPvZ4GM8lUiwZSvOUprKTX7OrGHXbmC0dn1P1cMJ_TofiX3A1d0GPVKlYpbZwlpRwMOH5ilHuu3bpJuIwk3XNMxaDDqyh-HEXqRJEO_FrwSUc&skey=74ed26de24979d51&v=v24": {
"https://fonts.gstatic.com/l/font?kit=tDbp2o-flEEny0FZhsfKu5WU4xD-IQ-PuZJJXxfpAO-LfjGbsVNLG7DGdF6OZ1Pcwgknk-6tesCuTUT9YD4b2KmIZSE1FNq_PdUTe-vhgNSBv5wANctW9XhQvuMvrqbZ4ujEG3DkCVl51v9aNp3Rr_aV3gtb0mHrFFQra5onpxICOnxkknmsw6RLuooi33FCy6LBrS7g4a_qRL8O8GTwUkRLlR8&skey=74ed26de24979d51&v=v24": {
"status": 200,
"contentType": "font/woff2",
"file": "728492d30bf60f21ae04abd1.bin"
"file": "d446a78da686ab7e4cd84394.bin"
}
}
+5 -2
View File
@@ -10,9 +10,12 @@ import { commentBlocks } from "./check-comment-citations.mjs";
const MAX_BLOCK_LINES = 12;
// Product source only: a test's table of cases reads as comment and is the test's content.
// Product source only: a test's table of cases reads as comment and is the test's content, and a
// generated file's comments are its generator's.
export const isSource = (path) =>
/^packages\/[^/]+\/src\/.+\.tsx?$/.test(path) && !/\.(d|test|spec)\.tsx?$/.test(path);
/^packages\/[^/]+\/src\/.+\.tsx?$/.test(path) &&
!/\.(d|test|spec)\.tsx?$/.test(path) &&
!/^packages\/[^/]+\/src\/generated\//.test(path);
const packageOf = (path) => path.split("/").slice(0, 2).join("/");
+2
View File
@@ -176,6 +176,8 @@ test("only package source is graded, never tests or declarations", () => {
assert.equal(isSource("packages/core/src/runtime/init.test.ts"), false);
assert.equal(isSource("packages/core/src/types.d.ts"), false);
assert.equal(isSource("packages/core/scripts/build.ts"), false);
assert.equal(isSource("packages/core/src/generated/svg-selector-aliases-inline.ts"), false);
assert.equal(isSource("packages/studio/src/editor/generated/labels.ts"), true);
assert.equal(isSource("scripts/contrast.ts"), false);
});