@hyperframes/core 0.8.181 → 0.8.184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -89,21 +89,24 @@ function planOps(lane, scale, timing, map) {
89
89
  }
90
90
  return ops;
91
91
  }
92
+ /** Rebuild a refused plan with ramps so clamped curves cannot replay past audio. */
93
+ function emitRamps(param, ops) {
94
+ param.cancelScheduledValues(0);
95
+ for (const op of ops) {
96
+ if (op.kind === "set")
97
+ param.setValueAtTime(op.value, op.time);
98
+ else if (op.kind === "ramp")
99
+ param.linearRampToValueAtTime(op.value, op.time);
100
+ else {
101
+ param.setValueAtTime(op.values[0] ?? 0, op.time);
102
+ param.linearRampToValueAtTime(op.values[op.values.length - 1] ?? 0, op.time + op.duration);
103
+ }
104
+ }
105
+ }
92
106
  /**
93
- * Hand the plan to the AudioParam, degrading a curve the browser will not take.
94
- *
95
- * A value curve may not overlap another, and this has been seen refused in the
96
- * field while rescheduling mid-playback — two passes landing a render quantum
97
- * apart, which is what applying a carve while the transport runs does. Every
98
- * caller here cancels the parameter first, and measured against Chrome that is
99
- * enough to free even a curve already under way, so the mechanism behind those
100
- * reports is not understood and does not reproduce. This catch is the backstop
101
- * for it: unhandled, the exception reached the console and abandoned every
102
- * remaining segment of the envelope.
103
- *
104
- * A ramp to the same destination is always accepted, since it books no span. The
105
- * segment loses its bend until the next full reschedule, which is a far better
106
- * failure than the rest of the envelope never being scheduled at all.
107
+ * Hand the plan to the AudioParam. A clamped curve can overlap the next segment.
108
+ * On refusal, rebuild the whole lane as ramps: retrying later curves would clamp
109
+ * them forward too, replaying values that should already have elapsed.
107
110
  */
108
111
  function emit(param, ops) {
109
112
  for (const op of ops) {
@@ -116,8 +119,8 @@ function emit(param, ops) {
116
119
  param.setValueCurveAtTime(op.values, op.time, op.duration);
117
120
  }
118
121
  catch {
119
- const end = op.values[op.values.length - 1] ?? 0;
120
- param.linearRampToValueAtTime(end, op.time + op.duration);
122
+ emitRamps(param, ops);
123
+ return;
121
124
  }
122
125
  }
123
126
  }
@@ -1,7 +1,11 @@
1
- export declare function scopeCssToComposition(css: string, compositionId: string, scopeSelectorOverride?: string, authoredRootId?: string | null, options?: {
1
+ type ScopeOptions = {
2
2
  compoundAuthoredRoot?: boolean;
3
3
  scopeRootSelectors?: boolean;
4
- }): string;
4
+ };
5
+ export declare function scopeCssToComposition(css: string, compositionId: string, scopeSelectorOverride?: string, authoredRootId?: string | null, options?: ScopeOptions): string;
6
+ /** `scopeCssToComposition` for sheets mounted many times: each sheet is scoped once around a
7
+ * placeholder scope, which each mount's own scope then replaces. */
8
+ export declare function createCompositionCssScoper(): typeof scopeCssToComposition;
5
9
  /** Drops repeats of an identical `@font-face` across the given style texts, keeping the last copy:
6
10
  * the last matching rule is the one the browser uses, so a rule in between never gains precedence. */
7
11
  export declare const dedupeFontFaceRules: (styleTexts: string[]) => string[];
@@ -30,3 +34,4 @@ export declare function wrapInlineScriptWithErrorBoundary(source: string, errorL
30
34
  * table goes through `jsonScriptLiteral` — see there for why.
31
35
  */
32
36
  export declare function buildVariablesByCompScript(variablesByComp: Record<string, Record<string, unknown>>): string | null;
37
+ export {};
@@ -132,22 +132,86 @@ export function scopeCssToComposition(css, compositionId, scopeSelectorOverride,
132
132
  return css;
133
133
  const scope = scopeSelectorOverride ||
134
134
  `[data-composition-id="${escapeCssAttributeValue(trimmedCompositionId)}"]`;
135
- let root;
135
+ const root = parseCss(css);
136
+ return root ? scopeParsedCss(root, scope, trimmedCompositionId, authoredRootId, options) : "";
137
+ }
138
+ function parseCss(css) {
136
139
  try {
137
- root = postcss.parse(css);
140
+ return postcss.parse(css);
138
141
  }
139
142
  catch {
140
- return "";
143
+ return null;
141
144
  }
145
+ }
146
+ function forEachScopedRule(root, visit) {
142
147
  root.walkRules((rule) => {
143
148
  if (isInsideGlobalAtRule(rule))
144
149
  return;
145
150
  if (isNestedInsideAnotherRule(rule))
146
151
  return;
147
- rule.selectors = rule.selectors.map((selector) => scopeSelector(selector, scope, trimmedCompositionId, authoredRootId, options?.compoundAuthoredRoot, options?.scopeRootSelectors));
152
+ visit(rule);
153
+ });
154
+ }
155
+ function scopeParsedCss(root, scope, compositionId, authoredRootId, options) {
156
+ forEachScopedRule(root, (rule) => {
157
+ rule.selectors = rule.selectors.map((selector) => scopeSelector(selector, scope, compositionId, authoredRootId, options?.compoundAuthoredRoot, options?.scopeRootSelectors));
148
158
  });
149
159
  return root.toResult({ map: false }).css;
150
160
  }
161
+ // Shaped like every scope it stands in for, so each selector test treats the two alike; its only `[` is
162
+ // its first character, so no occurrence of it overlaps another or the text around it.
163
+ const PLACEHOLDER_ID = "hf-scope-placeholder";
164
+ const PLACEHOLDER_SCOPE = `[data-composition-id="${PLACEHOLDER_ID}"]`;
165
+ // No quote, bracket, backslash, space or `$` (a String.replace pattern): such a scope lands verbatim.
166
+ const PLAIN_ID = /^[\w.:-]+$/;
167
+ const PLAIN_SCOPE = /^\[data-composition-id="([\w.:-]+)"\]$/;
168
+ /** `scopeCssToComposition` for sheets mounted many times: each sheet is scoped once around a
169
+ * placeholder scope, which each mount's own scope then replaces. */
170
+ export function createCompositionCssScoper() {
171
+ const templates = new Map();
172
+ return (css, compositionId, scopeSelectorOverride, authoredRootId, options) => {
173
+ const trimmedId = compositionId.trim();
174
+ const scope = scopeSelectorOverride ?? "";
175
+ const scopeId = PLAIN_SCOPE.exec(scope)?.[1];
176
+ if (!scopeId ||
177
+ scopeId === trimmedId ||
178
+ !PLAIN_ID.test(trimmedId) ||
179
+ trimmedId === PLACEHOLDER_ID)
180
+ return scopeCssToComposition(css, compositionId, scopeSelectorOverride, authoredRootId, options);
181
+ const key = JSON.stringify([
182
+ trimmedId,
183
+ authoredRootId ?? null,
184
+ Boolean(options?.compoundAuthoredRoot),
185
+ Boolean(options?.scopeRootSelectors),
186
+ css,
187
+ ]);
188
+ let template = templates.get(key);
189
+ if (template === undefined) {
190
+ template = scopeTemplate(css, trimmedId, authoredRootId, options);
191
+ templates.set(key, template);
192
+ }
193
+ return template === null
194
+ ? scopeCssToComposition(css, compositionId, scopeSelectorOverride, authoredRootId, options)
195
+ : template.split(PLACEHOLDER_SCOPE).join(scope);
196
+ };
197
+ }
198
+ /** Null when the placeholder is already in the sheet, or a selector keeps its raw text (it holds a comment). */
199
+ function scopeTemplate(css, compositionId, authoredRootId, options) {
200
+ if (css.includes(PLACEHOLDER_SCOPE))
201
+ return null;
202
+ if (!css)
203
+ return css;
204
+ const root = parseCss(css);
205
+ if (!root)
206
+ return "";
207
+ let rawSelector = false;
208
+ forEachScopedRule(root, (rule) => {
209
+ rawSelector ||= Boolean(rule.raws.selector);
210
+ });
211
+ return rawSelector
212
+ ? null
213
+ : scopeParsedCss(root, PLACEHOLDER_SCOPE, compositionId, authoredRootId, options);
214
+ }
151
215
  function isFontFaceAtRule(node) {
152
216
  return node.type === "atrule" && node.name.toLowerCase() === "font-face";
153
217
  }
@@ -55,6 +55,7 @@ export interface BundleOptions {
55
55
  sceneParts?: boolean;
56
56
  /** Warn when the compiled HTML breaks the HyperFrames contract (default true). */
57
57
  staticGuard?: boolean;
58
+ /** Called once per build for each project file the build reads or looks for. */
58
59
  onRead?: (filePath: string) => void;
59
60
  }
60
61
  export declare function bundleToSingleHtml(projectDir: string, options?: BundleOptions): Promise<string>;
@@ -16,7 +16,6 @@ import { resolve, relative, dirname, isAbsolute, sep } from "path";
16
16
  import { extractStandaloneEntryFromIndex, isTemplateEntry } from "./standaloneEntry.js";
17
17
  import { decodeCssEscapes, decodeWellFormedEscapes, encodeUrlPath, decodedUrlPath, splitUrlSuffix, } from "@hyperframes/parsers/asset-paths";
18
18
  import { CSS_URL_RE, isNonRelativeUrl } from "./assetPaths.js";
19
- import { transform } from "esbuild";
20
19
  import { compileHtml } from "./htmlCompiler.js";
21
20
  import { RUNTIME_BOOTSTRAP_ATTR, escapeInlineScriptSource, insertRuntimeTag, parseHTMLContent, stripEmbeddedRuntimeScripts, } from "./htmlDocument.js";
22
21
  // rewriteSubCompPaths functions are used by inlineSubCompositions (shared module)
@@ -313,33 +312,49 @@ function warnAssetTooLargeToInline(assetPath, byteLength) {
313
312
  const mb = (byteLength / (1024 * 1024)).toFixed(1);
314
313
  console.warn(`[HyperFrames] Not inlining "${assetPath}" (${mb} MB exceeds the ${MAX_INLINE_ASSET_BYTES / (1024 * 1024)} MB inline limit). The bundle may not be self-contained.`);
315
314
  }
316
- function maybeInlineRelativeAssetUrl(urlValue, projectDir, inlineAssets) {
317
- if (!inlineAssets)
318
- return null;
319
- if (!urlValue || !isRelativeUrl(urlValue))
320
- return null;
321
- const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
322
- if (!basePath)
323
- return null;
324
- const filePath = resolveWithinProject(projectDir, decodeWellFormedEscapes(basePath));
325
- if (!filePath)
326
- return null;
327
- const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
328
- const mimeType = INLINE_MIME[ext];
329
- if (!mimeType)
330
- return null;
331
- // Size-check before reading: an oversized asset must not be pulled into memory
332
- // just to be discarded.
333
- const byteLength = safeStatSize(filePath);
334
- if (byteLength !== null && byteLength > MAX_INLINE_ASSET_BYTES) {
335
- warnAssetTooLargeToInline(basePath, byteLength);
336
- return null;
337
- }
338
- const content = safeReadFileBuffer(filePath);
339
- if (content == null)
340
- return null;
341
- const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
342
- return appendSuffixToUrl(dataUrl, suffix);
315
+ /** Inlines relative asset URLs as data: URLs, reading each project file once per build. */
316
+ function createAssetInliner(resolveProjectPath, inlineAssets) {
317
+ const assets = new Map();
318
+ const readAsset = (filePath, mimeType) => {
319
+ // Size-check before reading: an oversized asset must not be pulled into memory
320
+ // just to be discarded.
321
+ const byteLength = safeStatSize(filePath);
322
+ if (byteLength !== null && byteLength > MAX_INLINE_ASSET_BYTES)
323
+ return { tooLarge: byteLength };
324
+ const content = safeReadFileBuffer(filePath);
325
+ return {
326
+ dataUrl: content === null ? null : `data:${mimeType};base64,${content.toString("base64")}`,
327
+ };
328
+ };
329
+ return (urlValue) => {
330
+ if (!inlineAssets)
331
+ return null;
332
+ if (!urlValue || !isRelativeUrl(urlValue))
333
+ return null;
334
+ const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
335
+ if (!basePath)
336
+ return null;
337
+ const filePath = resolveProjectPath(decodeWellFormedEscapes(basePath));
338
+ if (!filePath)
339
+ return null;
340
+ const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
341
+ const mimeType = INLINE_MIME[ext];
342
+ if (!mimeType)
343
+ return null;
344
+ let asset = assets.get(filePath);
345
+ if (asset === undefined) {
346
+ asset = readAsset(filePath, mimeType);
347
+ assets.set(filePath, asset);
348
+ }
349
+ else if ("dataUrl" in asset) {
350
+ noteRead(filePath);
351
+ }
352
+ if ("tooLarge" in asset) {
353
+ warnAssetTooLargeToInline(basePath, asset.tooLarge);
354
+ return null;
355
+ }
356
+ return asset.dataUrl === null ? null : appendSuffixToUrl(asset.dataUrl, suffix);
357
+ };
343
358
  }
344
359
  function isExternalSvgFragmentUse(el, attr, urlValue) {
345
360
  if (el.tagName.toLowerCase() !== "use")
@@ -361,7 +376,7 @@ function warnColorGradingLutNotInlined(lutSrc) {
361
376
  console.warn(`[HyperFrames] Could not inline color grading LUT "${trimmed}". The rendered bundle may not be self-contained.`);
362
377
  }
363
378
  // fallow-ignore-next-line complexity
364
- function rewriteColorGradingLutWithInlinedAssets(value, projectDir) {
379
+ function rewriteColorGradingLutWithInlinedAssets(value, inlineAsset) {
365
380
  if (!value.trim().startsWith("{"))
366
381
  return value;
367
382
  let parsed;
@@ -377,7 +392,7 @@ function rewriteColorGradingLutWithInlinedAssets(value, projectDir) {
377
392
  if (typeof lut === "string") {
378
393
  // Gated by inlineAssets and inlineColorGradingLuts at the call site above;
379
394
  // this call only runs once both have already passed.
380
- const inlined = maybeInlineRelativeAssetUrl(lut, projectDir, true);
395
+ const inlined = inlineAsset(lut);
381
396
  if (!inlined) {
382
397
  warnColorGradingLutNotInlined(lut);
383
398
  return value;
@@ -390,7 +405,7 @@ function rewriteColorGradingLutWithInlinedAssets(value, projectDir) {
390
405
  const lutSrc = Reflect.get(lut, "src");
391
406
  if (typeof lutSrc !== "string")
392
407
  return value;
393
- const inlined = maybeInlineRelativeAssetUrl(lutSrc, projectDir, true);
408
+ const inlined = inlineAsset(lutSrc);
394
409
  if (!inlined) {
395
410
  warnColorGradingLutNotInlined(lutSrc);
396
411
  return value;
@@ -398,7 +413,7 @@ function rewriteColorGradingLutWithInlinedAssets(value, projectDir) {
398
413
  Reflect.set(lut, "src", inlined);
399
414
  return JSON.stringify(parsed);
400
415
  }
401
- function rewriteSrcsetWithInlinedAssets(srcsetValue, projectDir, inlineAssets) {
416
+ function rewriteSrcsetWithInlinedAssets(srcsetValue, inlineAsset) {
402
417
  if (!srcsetValue)
403
418
  return srcsetValue;
404
419
  return srcsetValue
@@ -410,18 +425,18 @@ function rewriteSrcsetWithInlinedAssets(srcsetValue, projectDir, inlineAssets) {
410
425
  const parts = candidate.split(/\s+/);
411
426
  if (parts.length === 0)
412
427
  return candidate;
413
- const maybeInlined = maybeInlineRelativeAssetUrl(parts[0] ?? "", projectDir, inlineAssets);
428
+ const maybeInlined = inlineAsset(parts[0] ?? "");
414
429
  if (maybeInlined)
415
430
  parts[0] = maybeInlined;
416
431
  return parts.join(" ");
417
432
  })
418
433
  .join(", ");
419
434
  }
420
- function rewriteCssUrlsWithInlinedAssets(cssText, projectDir, inlineAssets) {
435
+ function rewriteCssUrlsWithInlinedAssets(cssText, inlineAsset) {
421
436
  if (!cssText)
422
437
  return cssText;
423
438
  return cssText.replace(/\burl\(\s*(["']?)([^)"']+)\1\s*\)/g, (_full, quote, rawUrl) => {
424
- const maybeInlined = maybeInlineRelativeAssetUrl((rawUrl || "").trim(), projectDir, inlineAssets);
439
+ const maybeInlined = inlineAsset((rawUrl || "").trim());
425
440
  if (!maybeInlined)
426
441
  return _full;
427
442
  return `url(${quote || ""}${maybeInlined}${quote || ""})`;
@@ -744,6 +759,7 @@ export function parsesAsScript(source) {
744
759
  async function stripJsCommentsParserSafe(source, provenance) {
745
760
  if (!source)
746
761
  return source;
762
+ const { transform } = await import("esbuild");
747
763
  try {
748
764
  const result = await transform(source, {
749
765
  loader: "js",
@@ -830,7 +846,17 @@ function hoistCompositionScripts(container, opts) {
830
846
  }
831
847
  export function bundleToSingleHtml(projectDir, options) {
832
848
  const bundle = () => bundleProject(projectDir, options);
833
- return options?.onRead ? bundleReads.run(options.onRead, bundle) : bundle();
849
+ const onRead = options?.onRead;
850
+ if (!onRead)
851
+ return bundle();
852
+ const noted = new Set();
853
+ const noteOnce = (filePath) => {
854
+ if (noted.has(filePath))
855
+ return;
856
+ noted.add(filePath);
857
+ onRead(filePath);
858
+ };
859
+ return bundleReads.run(noteOnce, bundle);
834
860
  }
835
861
  // A <template> scene has no root or GSAP of its own: bundle it inside the index that mounts it, as render does.
836
862
  function readMountedSceneShell(projectDir, entryFile, entryHtml) {
@@ -851,9 +877,29 @@ async function bundleProject(projectDir, options) {
851
877
  const entryHtml = readFileSync(indexPath, "utf-8");
852
878
  const mountedShell = readMountedSceneShell(projectDir, entryFile, entryHtml);
853
879
  const sourceDir = mountedShell ? projectDir : dirname(indexPath);
880
+ // Every mount of a sub-composition asks again for each of its url()s; isSafePath realpaths the whole chain.
881
+ const safePaths = new Map();
882
+ const isSafeProjectPath = (resolved) => {
883
+ let safe = safePaths.get(resolved);
884
+ if (safe === undefined) {
885
+ safe = isSafePath(projectDir, resolved);
886
+ safePaths.set(resolved, safe);
887
+ }
888
+ return safe;
889
+ };
890
+ const entryPaths = new Map();
854
891
  const resolveEntryPath = (relativePath) => {
855
- const resolved = resolve(sourceDir, relativePath);
856
- return isSafePath(projectDir, resolved) ? resolved : null;
892
+ let entry = entryPaths.get(relativePath);
893
+ if (entry === undefined) {
894
+ const resolved = resolve(sourceDir, relativePath);
895
+ entry = isSafeProjectPath(resolved) ? resolved : null;
896
+ entryPaths.set(relativePath, entry);
897
+ }
898
+ return entry;
899
+ };
900
+ const resolveProjectPath = (relativePath) => {
901
+ const resolved = resolve(projectDir, relativePath);
902
+ return isSafeProjectPath(resolved) ? resolved : null;
857
903
  };
858
904
  const resolveEntryUrl = (url) => resolveEntryPath(decodedUrlPath(url));
859
905
  const readSource = options?.stampHfIds ? ensureHfIds : (html) => html;
@@ -908,13 +954,24 @@ async function bundleProject(projectDir, options) {
908
954
  const trackedCompositionHosts = getBundledTrackedCompositionHosts(document);
909
955
  const hostIdentityByElement = assignBundledRuntimeCompositionIds(trackedCompositionHosts);
910
956
  const subCompositionHosts = trackedCompositionHosts.filter((host) => host.hasAttribute("data-composition-src"));
957
+ const sceneSources = new Map();
911
958
  const subCompResult = inlineSubCompositions(document, subCompositionHosts, {
912
959
  resolveHtml: (srcPath) => {
913
960
  if (!isRelativeUrl(srcPath))
914
961
  return null;
915
962
  const compPath = resolveEntryPath(srcPath);
916
- const html = compPath ? safeReadFile(compPath) : null;
917
- return html === null ? null : readSource(html);
963
+ if (!compPath)
964
+ return null;
965
+ let source = sceneSources.get(compPath);
966
+ if (source === undefined) {
967
+ const html = safeReadFile(compPath);
968
+ source = html === null ? null : readSource(html);
969
+ sceneSources.set(compPath, source);
970
+ }
971
+ else {
972
+ noteRead(compPath);
973
+ }
974
+ return source;
918
975
  },
919
976
  parseHtml: parseHTMLContent,
920
977
  hostIdentityMap: hostIdentityByElement,
@@ -1150,6 +1207,7 @@ async function bundleProject(projectDir, options) {
1150
1207
  injectTextRenderingRule(document);
1151
1208
  // Inline textual assets
1152
1209
  const inlineAssets = options?.inlineAssets !== false;
1210
+ const inlineAsset = createAssetInliner(resolveProjectPath, inlineAssets);
1153
1211
  for (const el of [...document.querySelectorAll("[src], [href], [poster], [xlink\\:href]")]) {
1154
1212
  for (const attr of ["src", "href", "poster", "xlink:href"]) {
1155
1213
  const value = el.getAttribute(attr);
@@ -1161,7 +1219,7 @@ async function bundleProject(projectDir, options) {
1161
1219
  // Keep the project-relative URL; render/check servers already expose it.
1162
1220
  if (isExternalSvgFragmentUse(el, attr, value))
1163
1221
  continue;
1164
- const inlined = maybeInlineRelativeAssetUrl(value, projectDir, inlineAssets);
1222
+ const inlined = inlineAsset(value);
1165
1223
  if (inlined)
1166
1224
  el.setAttribute(attr, inlined);
1167
1225
  }
@@ -1169,7 +1227,7 @@ async function bundleProject(projectDir, options) {
1169
1227
  for (const el of [...document.querySelectorAll("[srcset]")]) {
1170
1228
  const srcset = el.getAttribute("srcset");
1171
1229
  if (srcset)
1172
- el.setAttribute("srcset", rewriteSrcsetWithInlinedAssets(srcset, projectDir, inlineAssets));
1230
+ el.setAttribute("srcset", rewriteSrcsetWithInlinedAssets(srcset, inlineAsset));
1173
1231
  }
1174
1232
  // Before inlining, so postcss reads paths not font bytes.
1175
1233
  const liveStyles = [...document.querySelectorAll("style")].filter(isAlwaysAppliedStyle);
@@ -1187,16 +1245,16 @@ async function bundleProject(projectDir, options) {
1187
1245
  document.head.prepend(holder);
1188
1246
  }
1189
1247
  for (const styleEl of document.querySelectorAll("style")) {
1190
- styleEl.textContent = rewriteCssUrlsWithInlinedAssets(styleEl.textContent || "", projectDir, inlineAssets);
1248
+ styleEl.textContent = rewriteCssUrlsWithInlinedAssets(styleEl.textContent || "", inlineAsset);
1191
1249
  }
1192
1250
  for (const el of [...document.querySelectorAll("[style]")]) {
1193
- el.setAttribute("style", rewriteCssUrlsWithInlinedAssets(el.getAttribute("style") || "", projectDir, inlineAssets));
1251
+ el.setAttribute("style", rewriteCssUrlsWithInlinedAssets(el.getAttribute("style") || "", inlineAsset));
1194
1252
  }
1195
1253
  if (inlineAssets && options?.inlineColorGradingLuts !== false) {
1196
1254
  for (const el of [...document.querySelectorAll(`[${HF_COLOR_GRADING_ATTR}]`)]) {
1197
1255
  const value = el.getAttribute(HF_COLOR_GRADING_ATTR);
1198
1256
  if (value) {
1199
- el.setAttribute(HF_COLOR_GRADING_ATTR, rewriteColorGradingLutWithInlinedAssets(value, projectDir));
1257
+ el.setAttribute(HF_COLOR_GRADING_ATTR, rewriteColorGradingLutWithInlinedAssets(value, inlineAsset));
1200
1258
  }
1201
1259
  }
1202
1260
  }
@@ -1277,7 +1335,23 @@ function authoredDefinesPredicate(document) {
1277
1335
  const authoredCss = [...document.querySelectorAll("style:not([data-hf-composition-variables])")]
1278
1336
  .map((s) => s.textContent || "")
1279
1337
  .join("\n");
1280
- return (id) => new RegExp(`${cssVariableName(id)}\\s*:`).test(authoredCss);
1338
+ const defined = namesBeforeAColon(authoredCss);
1339
+ return (id) => defined.has(cssVariableName(id));
1340
+ }
1341
+ /** Every `--name` written right before a `:`, including the tail of a longer word (`.a--b:hover`), in one pass. */
1342
+ function namesBeforeAColon(css) {
1343
+ const names = new Set();
1344
+ const word = /[\w-]+/g;
1345
+ const colonNext = /\s*:/y;
1346
+ for (let match = word.exec(css); match; match = word.exec(css)) {
1347
+ colonNext.lastIndex = word.lastIndex;
1348
+ if (!colonNext.test(css))
1349
+ continue;
1350
+ const text = match[0];
1351
+ for (let at = text.indexOf("--"); at !== -1; at = text.indexOf("--", at + 1))
1352
+ names.add(text.slice(at));
1353
+ }
1354
+ return names;
1281
1355
  }
1282
1356
  /**
1283
1357
  * Layering for one declarer: authored stylesheet definitions win over
@@ -13,7 +13,7 @@ import { compositionStyle, cssStyleMergeKey, hasSameLink, } from "./scriptRuns.j
13
13
  */
14
14
  import { rewriteAssetPath, rewriteAssetPaths, rewriteCssAssetUrls, rewriteInlineStyleAssetUrls, } from "./rewriteSubCompPaths.js";
15
15
  import { warnUnknownEnumValues } from "../runtime/getVariables.js";
16
- import { scopeCssToComposition, wrapInlineScriptWithErrorBoundary, scopedModulePrelude, wrapScopedCompositionScript, } from "./compositionScoping.js";
16
+ import { createCompositionCssScoper, wrapInlineScriptWithErrorBoundary, scopedModulePrelude, wrapScopedCompositionScript, } from "./compositionScoping.js";
17
17
  import { namespaceCollidingSvgIds, rewriteSvgIdReferencesInCss, } from "./svgIdNamespacing.js";
18
18
  import { checkSubCompositionUsability, resolveSubCompositionContent, } from "@hyperframes/parsers/sub-composition-validity";
19
19
  import { enumerateNestedCompositionHosts, planCompositionAssembly, extractedCompositionAssets, } from "./compositionAssembly.js";
@@ -110,6 +110,7 @@ export function ensureExternalLinkTag(doc, link) {
110
110
  // ---------------------------------------------------------------------------
111
111
  // Default helpers
112
112
  // ---------------------------------------------------------------------------
113
+ const isDocument = (node) => node.nodeType === 9;
113
114
  function defaultBuildScopeSelector(compId) {
114
115
  const escaped = compId.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
115
116
  return `[data-composition-id="${escaped}"]`;
@@ -146,6 +147,24 @@ export function inlineSubCompositions(document, hosts, options) {
146
147
  const externalLinks = [];
147
148
  const variablesByComp = {};
148
149
  const svgIdScopes = [];
150
+ // A file mounted many times reads the same; only its scope, ids and variables differ per mount.
151
+ // Each file is parsed once and only read; a mount edits its own copy.
152
+ const parsed = new Map();
153
+ const parseOnce = (html) => {
154
+ let doc = parsed.get(html);
155
+ if (!doc) {
156
+ doc = parseHtml(html);
157
+ parsed.set(html, doc);
158
+ }
159
+ return doc;
160
+ };
161
+ const parseCopy = (html) => {
162
+ const copy = parseOnce(html).cloneNode(true);
163
+ return isDocument(copy) ? copy : parseHtml(html);
164
+ };
165
+ const usability = new Map();
166
+ const sheetsWithAssetUrls = new Map();
167
+ const scopeCssToComposition = createCompositionCssScoper();
149
168
  const sceneHosts = new Map();
150
169
  const queue = hosts.map((element) => ({
151
170
  element,
@@ -164,7 +183,12 @@ export function inlineSubCompositions(document, hosts, options) {
164
183
  // tolerant (skip, don't throw) — preview and studio must keep bundling
165
184
  // around a scene that's still being authored. Lint and the render
166
185
  // pre-flight check use the same helper to fail loudly instead.
167
- const validity = checkSubCompositionUsability(compHtml, parseHtml);
186
+ let validity = compHtml == null ? undefined : usability.get(compHtml);
187
+ if (!validity) {
188
+ validity = checkSubCompositionUsability(compHtml, parseOnce);
189
+ if (compHtml != null)
190
+ usability.set(compHtml, validity);
191
+ }
168
192
  if (!validity.ok) {
169
193
  onMissingComposition?.(src, validity.detail);
170
194
  continue;
@@ -176,7 +200,7 @@ export function inlineSubCompositions(document, hosts, options) {
176
200
  onMissingComposition?.(src);
177
201
  continue;
178
202
  }
179
- const compDoc = parseHtml(compHtml);
203
+ const compDoc = parseCopy(compHtml);
180
204
  // Determine composition IDs
181
205
  let compId;
182
206
  let runtimeCompId;
@@ -194,7 +218,7 @@ export function inlineSubCompositions(document, hosts, options) {
194
218
  onMissingComposition?.(src);
195
219
  continue;
196
220
  }
197
- const contentDoc = parseHtml(contentHtml);
221
+ const contentDoc = parseCopy(contentHtml);
198
222
  if (!contentDoc.documentElement) {
199
223
  onMissingComposition?.(src);
200
224
  continue;
@@ -259,7 +283,12 @@ export function inlineSubCompositions(document, hosts, options) {
259
283
  // sub-comp's html/body/:root rules from clobbering the host document (they
260
284
  // are remapped to the composition box); see compositionScoping.
261
285
  const scopeSubStyle = (raw) => {
262
- const css = rewriteCssAssetUrls(raw, src, assetExists);
286
+ const sheetKey = JSON.stringify([src, raw]);
287
+ let css = sheetsWithAssetUrls.get(sheetKey);
288
+ if (css === undefined) {
289
+ css = rewriteCssAssetUrls(raw, src, assetExists);
290
+ sheetsWithAssetUrls.set(sheetKey, css);
291
+ }
263
292
  return scopeCompId
264
293
  ? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
265
294
  compoundAuthoredRoot: compoundAuthoredRoot === true,
@@ -105,7 +105,7 @@ export interface FontFetchRetryPolicy {
105
105
  /** Initial full-jitter backoff ceiling. Default: 250 ms. */
106
106
  baseDelayMs: number;
107
107
  }
108
- /** Test-only reset — the cache is otherwise process-lifetime, shared across calls. */
108
+ /** Test-only reset of the process and disk caches, so the next lookup reaches `fetchImpl`. */
109
109
  export declare function _clearGoogleFontCssCacheForTests(): void;
110
110
  /**
111
111
  * Options for {@link injectDeterministicFontFaces}.
@@ -1,5 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
3
  import { homedir, tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { FONT_ALIAS_MAP, resolveAliasDisplayName } from "./aliases.js";
@@ -1131,9 +1131,44 @@ async function ensureWoff2DataUri(cachePath, woff2Url, familyName, weight, style
1131
1131
  return `data:font/woff2;base64,${readFileSync(cachePath).toString("base64")}`;
1132
1132
  }
1133
1133
  const googleFontCssCache = new Map();
1134
- /** Test-only reset — the cache is otherwise process-lifetime, shared across calls. */
1134
+ const GOOGLE_FONT_CSS_CACHE_DIR = "_css";
1135
+ /** Test-only reset of the process and disk caches, so the next lookup reaches `fetchImpl`. */
1135
1136
  export function _clearGoogleFontCssCacheForTests() {
1136
1137
  googleFontCssCache.clear();
1138
+ rmSync(join(resolveFontCacheRoot(), GOOGLE_FONT_CSS_CACHE_DIR), { recursive: true, force: true });
1139
+ }
1140
+ // The lookup names the woff2 files, so it is cached on disk beside them; otherwise each new process
1141
+ // asks the network first and, offline, never reaches them. No family slug has an underscore.
1142
+ function googleFontCssCachePath(url) {
1143
+ return join(fontCacheDir(GOOGLE_FONT_CSS_CACHE_DIR), `${createHash("sha256").update(url).digest("hex")}.json`);
1144
+ }
1145
+ function readCachedGoogleFontCss(url) {
1146
+ let cached;
1147
+ try {
1148
+ cached = JSON.parse(readFileSync(googleFontCssCachePath(url), "utf8"));
1149
+ }
1150
+ catch {
1151
+ return null;
1152
+ }
1153
+ if (typeof cached !== "object" || cached === null)
1154
+ return null;
1155
+ const body = Reflect.get(cached, "body");
1156
+ const status = Reflect.get(cached, "status");
1157
+ return typeof body === "string" && typeof status === "number" ? { ok: true, body, status } : null;
1158
+ }
1159
+ /** Keeps only a lookup that named faces; written aside and renamed, so a reader never sees half of it. */
1160
+ function writeCachedGoogleFontCss(url, body, status) {
1161
+ if (!/@font-face/i.test(body))
1162
+ return;
1163
+ const path = googleFontCssCachePath(url);
1164
+ const partial = `${path}.${process.pid}.tmp`;
1165
+ try {
1166
+ writeFileSync(partial, JSON.stringify({ status, body }), { mode: 0o644 });
1167
+ renameSync(partial, path);
1168
+ }
1169
+ catch {
1170
+ rmSync(partial, { force: true });
1171
+ }
1137
1172
  }
1138
1173
  /** Rejects with `signal`'s own abort reason without cancelling `promise` itself. */
1139
1174
  function raceAgainstAbort(promise, signal) {
@@ -1152,13 +1187,20 @@ function fetchGoogleFontCss(url, familyName, options, attempt) {
1152
1187
  const mode = options.failClosedFontFetch ? `closed${options.retryPolicy.maxAttempts}` : "open";
1153
1188
  const key = `${mode}:${url}`;
1154
1189
  let shared = googleFontCssCache.get(key);
1190
+ const cached = shared ? null : readCachedGoogleFontCss(url);
1191
+ if (cached) {
1192
+ shared = Promise.resolve(cached);
1193
+ googleFontCssCache.set(key, shared);
1194
+ }
1155
1195
  attempt.cssCache = shared ? "hit" : "fresh";
1156
1196
  if (!shared) {
1157
1197
  // The shared fetch must not carry any one caller's abortSignal, or that
1158
1198
  // caller cancelling would fail the lookup for every other waiter.
1159
1199
  shared = fetchFontResource(url, { headers: { "User-Agent": WOFF2_USER_AGENT } }, (response) => response.text(), familyName, "Google Fonts CSS", { ...options, abortSignal: undefined }).then((result) => {
1160
- if (result.ok)
1200
+ if (result.ok) {
1201
+ writeCachedGoogleFontCss(url, result.body, result.response.status);
1161
1202
  return { ok: true, body: result.body, status: result.response.status };
1203
+ }
1162
1204
  // A transient status must not stick for the process lifetime.
1163
1205
  if (isRetryableFontFetchStatus(result.response.status))
1164
1206
  googleFontCssCache.delete(key);
@@ -1415,8 +1457,14 @@ function extractGoogleFontsText(html) {
1415
1457
  // Intentional over-approximation: raw html includes base64, scripts, and
1416
1458
  // class names, but they collapse in the Set and the budget gate catches bloat.
1417
1459
  const uniqueCharacters = new Set();
1418
- for (const character of new Set([...Array.from(html), ...Array.from(decodedBodyText)])) {
1419
- addCaseClosure(uniqueCharacters, character, locales);
1460
+ const seen = new Set();
1461
+ for (const text of [html, decodedBodyText]) {
1462
+ for (const character of text) {
1463
+ if (seen.has(character))
1464
+ continue;
1465
+ seen.add(character);
1466
+ addCaseClosure(uniqueCharacters, character, locales);
1467
+ }
1420
1468
  }
1421
1469
  if (hasTextTransformKeyword(html, FULL_WIDTH_KEYWORD_RE))
1422
1470
  addFullwidthVariants(uniqueCharacters);