@hyperframes/core 0.8.183 → 0.8.185

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.
@@ -16,10 +16,10 @@
16
16
  * fills the rest from the effect's own defaults. That keeps each entry readable
17
17
  * as an intent rather than a dump of every knob.
18
18
  *
19
- * Node ORDER is load-bearing — the chain is serial, so a limiter first and a
19
+ * Node ORDER matters — the chain is serial, so a limiter first and a
20
20
  * limiter last are different sounds. Every preset below runs
21
- * subtractive filtering → dynamics → tone → character → limiter, the order
22
- * `skills/hyperframes-audio` already teaches.
21
+ * subtractive filtering → dynamics → tone → character → limiter, as
22
+ * `skills/media-use/references/mixing.md` teaches.
23
23
  */
24
24
  import { type HfAudioFxChain, type HfAudioFxNode, type HfAudioFxParamValues } from "./audioFx.js";
25
25
  /**
@@ -16,10 +16,10 @@
16
16
  * fills the rest from the effect's own defaults. That keeps each entry readable
17
17
  * as an intent rather than a dump of every knob.
18
18
  *
19
- * Node ORDER is load-bearing — the chain is serial, so a limiter first and a
19
+ * Node ORDER matters — the chain is serial, so a limiter first and a
20
20
  * limiter last are different sounds. Every preset below runs
21
- * subtractive filtering → dynamics → tone → character → limiter, the order
22
- * `skills/hyperframes-audio` already teaches.
21
+ * subtractive filtering → dynamics → tone → character → limiter, as
22
+ * `skills/media-use/references/mixing.md` teaches.
23
23
  */
24
24
  import { HF_AUDIO_FX_CHAIN_VERSION, mintAudioFxNodeId, normalizeAudioFxParams, } from "./audioFx.js";
25
25
  const preset = (id, family, label, description, nodes) => ({ id, label, family, description, nodes });
@@ -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>;
@@ -312,33 +312,49 @@ function warnAssetTooLargeToInline(assetPath, byteLength) {
312
312
  const mb = (byteLength / (1024 * 1024)).toFixed(1);
313
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.`);
314
314
  }
315
- function maybeInlineRelativeAssetUrl(urlValue, projectDir, inlineAssets) {
316
- if (!inlineAssets)
317
- return null;
318
- if (!urlValue || !isRelativeUrl(urlValue))
319
- return null;
320
- const { basePath, suffix } = splitUrlSuffix(urlValue.trim());
321
- if (!basePath)
322
- return null;
323
- const filePath = resolveWithinProject(projectDir, decodeWellFormedEscapes(basePath));
324
- if (!filePath)
325
- return null;
326
- const ext = filePath.toLowerCase().match(/\.[^.]+$/)?.[0] ?? "";
327
- const mimeType = INLINE_MIME[ext];
328
- if (!mimeType)
329
- return null;
330
- // Size-check before reading: an oversized asset must not be pulled into memory
331
- // just to be discarded.
332
- const byteLength = safeStatSize(filePath);
333
- if (byteLength !== null && byteLength > MAX_INLINE_ASSET_BYTES) {
334
- warnAssetTooLargeToInline(basePath, byteLength);
335
- return null;
336
- }
337
- const content = safeReadFileBuffer(filePath);
338
- if (content == null)
339
- return null;
340
- const dataUrl = `data:${mimeType};base64,${content.toString("base64")}`;
341
- 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
+ };
342
358
  }
343
359
  function isExternalSvgFragmentUse(el, attr, urlValue) {
344
360
  if (el.tagName.toLowerCase() !== "use")
@@ -360,7 +376,7 @@ function warnColorGradingLutNotInlined(lutSrc) {
360
376
  console.warn(`[HyperFrames] Could not inline color grading LUT "${trimmed}". The rendered bundle may not be self-contained.`);
361
377
  }
362
378
  // fallow-ignore-next-line complexity
363
- function rewriteColorGradingLutWithInlinedAssets(value, projectDir) {
379
+ function rewriteColorGradingLutWithInlinedAssets(value, inlineAsset) {
364
380
  if (!value.trim().startsWith("{"))
365
381
  return value;
366
382
  let parsed;
@@ -376,7 +392,7 @@ function rewriteColorGradingLutWithInlinedAssets(value, projectDir) {
376
392
  if (typeof lut === "string") {
377
393
  // Gated by inlineAssets and inlineColorGradingLuts at the call site above;
378
394
  // this call only runs once both have already passed.
379
- const inlined = maybeInlineRelativeAssetUrl(lut, projectDir, true);
395
+ const inlined = inlineAsset(lut);
380
396
  if (!inlined) {
381
397
  warnColorGradingLutNotInlined(lut);
382
398
  return value;
@@ -389,7 +405,7 @@ function rewriteColorGradingLutWithInlinedAssets(value, projectDir) {
389
405
  const lutSrc = Reflect.get(lut, "src");
390
406
  if (typeof lutSrc !== "string")
391
407
  return value;
392
- const inlined = maybeInlineRelativeAssetUrl(lutSrc, projectDir, true);
408
+ const inlined = inlineAsset(lutSrc);
393
409
  if (!inlined) {
394
410
  warnColorGradingLutNotInlined(lutSrc);
395
411
  return value;
@@ -397,7 +413,7 @@ function rewriteColorGradingLutWithInlinedAssets(value, projectDir) {
397
413
  Reflect.set(lut, "src", inlined);
398
414
  return JSON.stringify(parsed);
399
415
  }
400
- function rewriteSrcsetWithInlinedAssets(srcsetValue, projectDir, inlineAssets) {
416
+ function rewriteSrcsetWithInlinedAssets(srcsetValue, inlineAsset) {
401
417
  if (!srcsetValue)
402
418
  return srcsetValue;
403
419
  return srcsetValue
@@ -409,18 +425,18 @@ function rewriteSrcsetWithInlinedAssets(srcsetValue, projectDir, inlineAssets) {
409
425
  const parts = candidate.split(/\s+/);
410
426
  if (parts.length === 0)
411
427
  return candidate;
412
- const maybeInlined = maybeInlineRelativeAssetUrl(parts[0] ?? "", projectDir, inlineAssets);
428
+ const maybeInlined = inlineAsset(parts[0] ?? "");
413
429
  if (maybeInlined)
414
430
  parts[0] = maybeInlined;
415
431
  return parts.join(" ");
416
432
  })
417
433
  .join(", ");
418
434
  }
419
- function rewriteCssUrlsWithInlinedAssets(cssText, projectDir, inlineAssets) {
435
+ function rewriteCssUrlsWithInlinedAssets(cssText, inlineAsset) {
420
436
  if (!cssText)
421
437
  return cssText;
422
438
  return cssText.replace(/\burl\(\s*(["']?)([^)"']+)\1\s*\)/g, (_full, quote, rawUrl) => {
423
- const maybeInlined = maybeInlineRelativeAssetUrl((rawUrl || "").trim(), projectDir, inlineAssets);
439
+ const maybeInlined = inlineAsset((rawUrl || "").trim());
424
440
  if (!maybeInlined)
425
441
  return _full;
426
442
  return `url(${quote || ""}${maybeInlined}${quote || ""})`;
@@ -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);