@turntrout/subfont 1.5.1 → 1.7.0

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.
@@ -7,6 +7,7 @@ const compileQuery = require('assetgraph/lib/compileQuery');
7
7
  const findCustomPropertyDefinitions = require('./findCustomPropertyDefinitions');
8
8
  const extractReferencedCustomPropertyNames = require('./extractReferencedCustomPropertyNames');
9
9
  const injectSubsetDefinitions = require('./injectSubsetDefinitions');
10
+ const { makePhaseTracker } = require('./progress');
10
11
  const cssFontParser = require('css-font-parser');
11
12
  const cssListHelpers = require('css-list-helpers');
12
13
  const unquote = require('./unquote');
@@ -15,6 +16,7 @@ const unicodeRange = require('./unicodeRange');
15
16
  const getFontInfo = require('./getFontInfo');
16
17
  const collectTextsByPage = require('./collectTextsByPage');
17
18
 
19
+ const escapeJsStringLiteral = require('./escapeJsStringLiteral');
18
20
  const {
19
21
  maybeCssQuote,
20
22
  getFontFaceDeclarationText,
@@ -54,16 +56,14 @@ function getParents(asset, assetQuery) {
54
56
  return parents;
55
57
  }
56
58
 
57
- // Escape a value for safe inclusion in any JS string context (single-quoted,
58
- // double-quoted, or template literal). Uses JSON.stringify for robust escaping
59
- // of backslashes, quotes, newlines, U+2028, U+2029, etc.
60
- // The < escape prevents </script> from closing an inline script tag.
61
- function escapeJsStringLiteral(str) {
62
- return JSON.stringify(str)
63
- .slice(1, -1)
64
- .replace(/'/g, "\\'")
65
- .replace(/`/g, '\\x60')
66
- .replace(/</g, '\\x3c');
59
+ function countUniqueFontUrls(htmlOrSvgAssetTextsWithProps) {
60
+ const urls = new Set();
61
+ for (const item of htmlOrSvgAssetTextsWithProps) {
62
+ for (const fu of item.fontUsages) {
63
+ if (fu.fontUrl) urls.add(fu.fontUrl);
64
+ }
65
+ }
66
+ return urls.size;
67
67
  }
68
68
 
69
69
  function asyncLoadStyleRelationWithFallback(
@@ -214,8 +214,8 @@ const validFontDisplayValues = [
214
214
 
215
215
  const warnAboutMissingGlyphs = require('./warnAboutMissingGlyphs');
216
216
 
217
- // Extract subset CSS asset creation/caching into a standalone function
218
- // to reduce cyclomatic complexity of the main injection loop.
217
+ // Create (or retrieve from disk cache) the subset CSS asset for a set of
218
+ // fontUsages, relocating the font binary to its hashed URL under subsetUrl.
219
219
  async function getOrCreateSubsetCssAsset({
220
220
  assetGraph,
221
221
  subsetCssText,
@@ -311,7 +311,8 @@ async function getOrCreateSubsetCssAsset({
311
311
  return cssAsset;
312
312
  }
313
313
 
314
- // Extract subset font preload insertion to reduce injection loop complexity.
314
+ // Insert <link rel="preload"> hints for every woff2 subset flagged as
315
+ // preload-worthy, so the browser starts fetching them during HTML parse.
315
316
  function addSubsetFontPreloads({
316
317
  cssAsset,
317
318
  fontUsages,
@@ -362,6 +363,104 @@ function addSubsetFontPreloads({
362
363
  return insertionPoint;
363
364
  }
364
365
 
366
+ // Skip Google Fonts populate when no Google Fonts references exist —
367
+ // otherwise assetgraph spends ~30s network-walking for nothing on sites
368
+ // that only self-host. Returns whether the populate ran so callers can
369
+ // annotate their phase timing.
370
+ async function populateGoogleFontsIfPresent(assetGraph) {
371
+ const hasGoogleFonts =
372
+ assetGraph.findRelations({
373
+ to: { url: { $regex: googleFontsCssUrlRegex } },
374
+ }).length > 0;
375
+ if (!hasGoogleFonts) return false;
376
+
377
+ await assetGraph.populate({
378
+ followRelations: {
379
+ $or: [
380
+ { to: { url: { $regex: googleFontsCssUrlRegex } } },
381
+ {
382
+ type: 'CssFontFaceSrc',
383
+ from: { url: { $regex: googleFontsCssUrlRegex } },
384
+ },
385
+ ],
386
+ },
387
+ });
388
+ return true;
389
+ }
390
+
391
+ // Strip every original @font-face rule when --no-fallbacks is set. The
392
+ // severed assets are returned via the `potentiallyOrphanedAssets` set so
393
+ // the final orphan sweep can remove anything left dangling.
394
+ function removeOriginalFontFaceRules(
395
+ htmlOrSvgAssets,
396
+ fontFaceDeclarationsByHtmlOrSvgAsset,
397
+ potentiallyOrphanedAssets
398
+ ) {
399
+ for (const htmlOrSvgAsset of htmlOrSvgAssets) {
400
+ const accumulatedFontFaceDeclarations =
401
+ fontFaceDeclarationsByHtmlOrSvgAsset.get(htmlOrSvgAsset);
402
+ for (const { relations } of accumulatedFontFaceDeclarations) {
403
+ for (const relation of relations) {
404
+ potentiallyOrphanedAssets.add(relation.to);
405
+ if (relation.node.parentNode) {
406
+ relation.node.parentNode.removeChild(relation.node);
407
+ }
408
+ relation.remove();
409
+ }
410
+ }
411
+ htmlOrSvgAsset.markDirty();
412
+ }
413
+ }
414
+
415
+ // Rewrite CSS source-map relations to the caller's chosen hrefType so they
416
+ // align with the rest of the emitted assets. Only invoked when sourceMaps is
417
+ // enabled — subsetFonts normally skips source-map serialization for speed.
418
+ async function rewriteCssSourceMaps(assetGraph, hrefType) {
419
+ await assetGraph.serializeSourceMaps(undefined, {
420
+ type: 'Css',
421
+ outgoingRelations: {
422
+ $where: (relations) =>
423
+ relations.some((relation) => relation.type === 'CssSourceMappingUrl'),
424
+ },
425
+ });
426
+ for (const relation of assetGraph.findRelations({
427
+ type: 'SourceMapSource',
428
+ })) {
429
+ relation.hrefType = hrefType;
430
+ }
431
+ for (const relation of assetGraph.findRelations({
432
+ type: 'CssSourceMappingUrl',
433
+ hrefType: { $in: ['relative', 'inline'] },
434
+ })) {
435
+ relation.hrefType = hrefType;
436
+ }
437
+ }
438
+
439
+ // Remove assets whose last incoming relation was severed during subset
440
+ // injection (original @font-face rules, merged Google Fonts CSS, etc.) so
441
+ // the emitted site doesn't ship with dangling files.
442
+ function removeOrphanedAssets(assetGraph, potentiallyOrphanedAssets) {
443
+ for (const asset of potentiallyOrphanedAssets) {
444
+ if (asset.incomingRelations.length === 0) {
445
+ assetGraph.removeAsset(asset);
446
+ }
447
+ }
448
+ }
449
+
450
+ // Shape the per-page fontUsages into the external fontInfo report: strip
451
+ // internal bookkeeping (subsets buffer, feature-tag scratch) and flatten
452
+ // each page to { assetFileName, fontUsages }.
453
+ function buildFontInfoReport(htmlOrSvgAssetTextsWithProps) {
454
+ return htmlOrSvgAssetTextsWithProps.map(({ fontUsages, htmlOrSvgAsset }) => ({
455
+ assetFileName: htmlOrSvgAsset.nonInlineAncestor.urlOrDescription,
456
+ fontUsages: fontUsages.map((fontUsage) =>
457
+ (({ subsets, hasFontFeatureSettings, fontFeatureTags, ...rest }) => rest)(
458
+ fontUsage
459
+ )
460
+ ),
461
+ }));
462
+ }
463
+
365
464
  async function subsetFonts(
366
465
  assetGraph,
367
466
  {
@@ -396,50 +495,19 @@ async function subsetFonts(
396
495
  const subsetUrl = urltools.ensureTrailingSlash(assetGraph.root + subsetPath);
397
496
 
398
497
  const timings = {};
498
+ const trackPhase = makePhaseTracker(console, debug);
399
499
 
400
- let phaseStart = Date.now();
500
+ const applySourceMapsPhase = trackPhase('applySourceMaps');
401
501
  if (sourceMaps) {
402
502
  await assetGraph.applySourceMaps({ type: 'Css' });
403
503
  }
404
- timings.applySourceMaps = Date.now() - phaseStart;
405
- if (debug && console)
406
- console.log(
407
- `[subfont timing] applySourceMaps: ${timings.applySourceMaps}ms`
408
- );
409
-
410
- phaseStart = Date.now();
411
- // Only run Google Fonts populate if there are actually Google Fonts
412
- // references in the graph. This avoids ~30s of wasted work on sites
413
- // that use only self-hosted fonts.
414
- const hasGoogleFonts =
415
- assetGraph.findRelations({
416
- to: { url: { $regex: googleFontsCssUrlRegex } },
417
- }).length > 0;
504
+ timings.applySourceMaps = applySourceMapsPhase.end();
418
505
 
419
- if (hasGoogleFonts) {
420
- await assetGraph.populate({
421
- followRelations: {
422
- $or: [
423
- {
424
- to: {
425
- url: { $regex: googleFontsCssUrlRegex },
426
- },
427
- },
428
- {
429
- type: 'CssFontFaceSrc',
430
- from: {
431
- url: { $regex: googleFontsCssUrlRegex },
432
- },
433
- },
434
- ],
435
- },
436
- });
437
- }
438
- timings['populate (google fonts)'] = Date.now() - phaseStart;
439
- if (debug && console)
440
- console.log(
441
- `[subfont timing] populate (google fonts): ${timings['populate (google fonts)']}ms${hasGoogleFonts ? '' : ' (skipped, no Google Fonts found)'}`
442
- );
506
+ const googlePopulatePhase = trackPhase('populate (google fonts)');
507
+ const hasGoogleFonts = await populateGoogleFontsIfPresent(assetGraph);
508
+ timings['populate (google fonts)'] = googlePopulatePhase.end(
509
+ hasGoogleFonts ? null : 'skipped, no Google Fonts found'
510
+ );
443
511
 
444
512
  const htmlOrSvgAssets = assetGraph.findAssets({
445
513
  $or: [
@@ -453,11 +521,9 @@ async function subsetFonts(
453
521
  ],
454
522
  });
455
523
 
456
- if (debug && console)
457
- console.log(
458
- `[subfont timing] Starting collectTextsByPage for ${htmlOrSvgAssets.length} pages`
459
- );
460
- const collectStart = Date.now();
524
+ const collectPhase = trackPhase(
525
+ `collectTextsByPage (${htmlOrSvgAssets.length} pages)`
526
+ );
461
527
  const {
462
528
  htmlOrSvgAssetTextsWithProps,
463
529
  fontFaceDeclarationsByHtmlOrSvgAsset,
@@ -470,40 +536,21 @@ async function subsetFonts(
470
536
  concurrency,
471
537
  chromeArgs,
472
538
  });
473
- timings.collectTextsByPage = Date.now() - collectStart;
539
+ timings.collectTextsByPage = collectPhase.end();
474
540
  timings.collectTextsByPageDetails = subTimings;
475
- if (debug && console)
476
- console.log(
477
- `[subfont timing] collectTextsByPage finished in ${timings.collectTextsByPage}ms`
478
- );
479
-
480
- phaseStart = Date.now();
481
541
 
542
+ const omitFallbacksPhase = trackPhase('omitFallbacks processing');
482
543
  const potentiallyOrphanedAssets = new Set();
483
544
  if (omitFallbacks) {
484
- for (const htmlOrSvgAsset of htmlOrSvgAssets) {
485
- const accumulatedFontFaceDeclarations =
486
- fontFaceDeclarationsByHtmlOrSvgAsset.get(htmlOrSvgAsset);
487
- // Remove the original @font-face rules:
488
- for (const { relations } of accumulatedFontFaceDeclarations) {
489
- for (const relation of relations) {
490
- potentiallyOrphanedAssets.add(relation.to);
491
- if (relation.node.parentNode) {
492
- relation.node.parentNode.removeChild(relation.node);
493
- }
494
- relation.remove();
495
- }
496
- }
497
- htmlOrSvgAsset.markDirty();
498
- }
545
+ removeOriginalFontFaceRules(
546
+ htmlOrSvgAssets,
547
+ fontFaceDeclarationsByHtmlOrSvgAsset,
548
+ potentiallyOrphanedAssets
549
+ );
499
550
  }
551
+ timings['omitFallbacks processing'] = omitFallbacksPhase.end();
500
552
 
501
- timings['omitFallbacks processing'] = Date.now() - phaseStart;
502
- if (debug && console)
503
- console.log(
504
- `[subfont timing] omitFallbacks processing: ${timings['omitFallbacks processing']}ms`
505
- );
506
- phaseStart = Date.now();
553
+ const codepointPhase = trackPhase('codepoint generation');
507
554
 
508
555
  if (fontDisplay) {
509
556
  for (const htmlOrSvgAssetTextWithProps of htmlOrSvgAssetTextsWithProps) {
@@ -602,12 +649,7 @@ async function subsetFonts(
602
649
  }
603
650
  }
604
651
 
605
- timings['codepoint generation'] = Date.now() - phaseStart;
606
- if (debug && console)
607
- console.log(
608
- `[subfont timing] codepoint generation: ${timings['codepoint generation']}ms`
609
- );
610
- phaseStart = Date.now();
652
+ timings['codepoint generation'] = codepointPhase.end();
611
653
 
612
654
  if (onlyInfo) {
613
655
  return {
@@ -625,62 +667,61 @@ async function subsetFonts(
625
667
  };
626
668
  }
627
669
 
670
+ const variationPhase = trackPhase('variation axis usage');
628
671
  const { seenAxisValuesByFontUrlAndAxisName } = getVariationAxisUsage(
629
672
  htmlOrSvgAssetTextsWithProps,
630
673
  parseFontWeightRange,
631
674
  parseFontStretchRange
632
675
  );
633
-
634
- timings['variation axis usage'] = Date.now() - phaseStart;
635
- if (debug && console)
636
- console.log(
637
- `[subfont timing] variation axis usage: ${timings['variation axis usage']}ms`
638
- );
639
- phaseStart = Date.now();
676
+ timings['variation axis usage'] = variationPhase.end();
640
677
 
641
678
  // Generate subsets:
679
+ if (console) {
680
+ const uniqueFontUrls = countUniqueFontUrls(htmlOrSvgAssetTextsWithProps);
681
+ if (uniqueFontUrls > 0) {
682
+ console.log(
683
+ ` Subsetting ${uniqueFontUrls} unique font file${uniqueFontUrls === 1 ? '' : 's'}...`
684
+ );
685
+ }
686
+ }
687
+ const subsetPhase = trackPhase('getSubsetsForFontUsage');
642
688
  await getSubsetsForFontUsage(
643
689
  assetGraph,
644
690
  htmlOrSvgAssetTextsWithProps,
645
691
  formats,
646
692
  seenAxisValuesByFontUrlAndAxisName,
647
693
  cacheDir,
648
- console
694
+ console,
695
+ debug
649
696
  );
697
+ timings.getSubsetsForFontUsage = subsetPhase.end();
650
698
 
651
- timings.getSubsetsForFontUsage = Date.now() - phaseStart;
652
- if (debug && console)
653
- console.log(
654
- `[subfont timing] getSubsetsForFontUsage: ${timings.getSubsetsForFontUsage}ms`
655
- );
656
- phaseStart = Date.now();
657
-
699
+ const warnGlyphsPhase = trackPhase('warnAboutMissingGlyphs');
658
700
  await warnAboutMissingGlyphs(htmlOrSvgAssetTextsWithProps, assetGraph);
659
- timings.warnAboutMissingGlyphs = Date.now() - phaseStart;
660
- if (debug && console)
661
- console.log(
662
- `[subfont timing] warnAboutMissingGlyphs: ${timings.warnAboutMissingGlyphs}ms`
663
- );
664
- phaseStart = Date.now();
701
+ timings.warnAboutMissingGlyphs = warnGlyphsPhase.end();
665
702
 
666
703
  // Insert subsets:
667
704
 
668
- // Pre-compute which fontUrls are used (with text) on every page,
669
- // so we can avoid O(pages × fontUsages) checks inside the font loop.
705
+ // Pre-compute which fontUrls are used (with text) on every page.
706
+ // Set intersection: O(pages × fonts_per_page) vs the old every+some approach.
670
707
  const fontUrlsUsedOnEveryPage = new Set();
671
708
  if (htmlOrSvgAssetTextsWithProps.length > 0) {
672
- // Start with all fontUrls from the first page
673
709
  const firstPageFontUrls = new Set();
674
710
  for (const fu of htmlOrSvgAssetTextsWithProps[0].fontUsages) {
675
711
  if (fu.pageText) firstPageFontUrls.add(fu.fontUrl);
676
712
  }
677
713
  for (const fontUrl of firstPageFontUrls) {
678
- if (
679
- htmlOrSvgAssetTextsWithProps.every(({ fontUsages }) =>
680
- fontUsages.some((fu) => fu.pageText && fu.fontUrl === fontUrl)
681
- )
682
- ) {
683
- fontUrlsUsedOnEveryPage.add(fontUrl);
714
+ fontUrlsUsedOnEveryPage.add(fontUrl);
715
+ }
716
+ for (let i = 1; i < htmlOrSvgAssetTextsWithProps.length; i++) {
717
+ const pageFontUrls = new Set();
718
+ for (const fu of htmlOrSvgAssetTextsWithProps[i].fontUsages) {
719
+ if (fu.pageText) pageFontUrls.add(fu.fontUrl);
720
+ }
721
+ for (const fontUrl of fontUrlsUsedOnEveryPage) {
722
+ if (!pageFontUrls.has(fontUrl)) {
723
+ fontUrlsUsedOnEveryPage.delete(fontUrl);
724
+ }
684
725
  }
685
726
  }
686
727
  }
@@ -689,6 +730,12 @@ async function subsetFonts(
689
730
  // addAsset/minify/removeAsset cycles for pages sharing identical CSS.
690
731
  const subsetCssAssetCache = new Map();
691
732
 
733
+ // Cache the heavy CSS-text assembly (including base64-encoded font data)
734
+ // keyed by the shared accumulatedFontFaceDeclarations array. Pages grouped
735
+ // under the same stylesheet config produce byte-identical output, so this
736
+ // collapses the per-page string build from O(pages) to O(unique configs).
737
+ const subsetCssTextCache = new WeakMap();
738
+
692
739
  // Pre-index relations by source asset to avoid O(allRelations) scans
693
740
  // in the per-page injection loop below. Build indices once, then use
694
741
  // O(1) lookups per page instead of repeated assetGraph.findRelations.
@@ -711,6 +758,9 @@ async function subsetFonts(
711
758
  index.get(from).push(relation);
712
759
  }
713
760
 
761
+ const insertPhase = trackPhase(
762
+ `insert subsets loop (${htmlOrSvgAssetTextsWithProps.length} pages)`
763
+ );
714
764
  let numFontUsagesWithSubset = 0;
715
765
  for (const {
716
766
  htmlOrSvgAsset,
@@ -783,11 +833,19 @@ async function subsetFonts(
783
833
  }
784
834
  numFontUsagesWithSubset += subsetFontUsages.length;
785
835
 
786
- let subsetCssText = getFontUsageStylesheet(subsetFontUsages);
787
- const unusedVariantsCss = getUnusedVariantsStylesheet(
788
- fontUsages,
789
- accumulatedFontFaceDeclarations
790
- );
836
+ let cssTextParts = subsetCssTextCache.get(accumulatedFontFaceDeclarations);
837
+ if (!cssTextParts) {
838
+ cssTextParts = {
839
+ subset: getFontUsageStylesheet(subsetFontUsages),
840
+ unused: getUnusedVariantsStylesheet(
841
+ fontUsages,
842
+ accumulatedFontFaceDeclarations
843
+ ),
844
+ };
845
+ subsetCssTextCache.set(accumulatedFontFaceDeclarations, cssTextParts);
846
+ }
847
+ let subsetCssText = cssTextParts.subset;
848
+ const unusedVariantsCss = cssTextParts.unused;
791
849
  if (!inlineCss && !omitFallbacks) {
792
850
  // This can go into the same stylesheet because we won't reload all __subset suffixed families in the JS preload fallback
793
851
  subsetCssText += unusedVariantsCss;
@@ -845,17 +903,13 @@ async function subsetFonts(
845
903
  }
846
904
  }
847
905
 
848
- timings['insert subsets loop'] = Date.now() - phaseStart;
849
- if (debug && console)
850
- console.log(
851
- `[subfont timing] insert subsets loop: ${timings['insert subsets loop']}ms`
852
- );
853
- phaseStart = Date.now();
906
+ timings['insert subsets loop'] = insertPhase.end();
854
907
 
855
908
  if (numFontUsagesWithSubset === 0) {
856
909
  return { fontInfo: [], timings };
857
910
  }
858
911
 
912
+ const lazyFallbackPhase = trackPhase('lazy load fallback CSS');
859
913
  const relationsToRemove = new Set();
860
914
 
861
915
  // Lazy load the original @font-face declarations of self-hosted fonts (unless omitFallbacks)
@@ -956,12 +1010,9 @@ async function subsetFonts(
956
1010
  relationsToRemove.add(fallbackHtmlStyle);
957
1011
  }
958
1012
 
959
- timings['lazy load fallback CSS'] = Date.now() - phaseStart;
960
- if (debug && console)
961
- console.log(
962
- `[subfont timing] lazy load fallback CSS: ${timings['lazy load fallback CSS']}ms`
963
- );
964
- phaseStart = Date.now();
1013
+ timings['lazy load fallback CSS'] = lazyFallbackPhase.end();
1014
+
1015
+ const removeFontFacePhase = trackPhase('remove original @font-face');
965
1016
 
966
1017
  // Remove the original @font-face blocks, and don't leave behind empty stylesheets:
967
1018
  const maybeEmptyCssAssets = new Set();
@@ -984,18 +1035,20 @@ async function subsetFonts(
984
1035
  }
985
1036
  }
986
1037
 
987
- timings['remove original @font-face'] = Date.now() - phaseStart;
988
- if (debug && console)
989
- console.log(
990
- `[subfont timing] remove original @font-face: ${timings['remove original @font-face']}ms`
991
- );
992
- phaseStart = Date.now();
1038
+ timings['remove original @font-face'] = removeFontFacePhase.end();
993
1039
 
994
- // Async load Google Web Fonts CSS
995
- const googleFontStylesheets = assetGraph.findAssets({
996
- type: 'Css',
997
- url: { $regex: googleFontsCssUrlRegex },
998
- });
1040
+ const googleCleanupPhase = trackPhase('Google Fonts + cleanup');
1041
+
1042
+ // Async load Google Web Fonts CSS. Skip the regex findAssets scan and
1043
+ // the surrounding loop entirely when no Google Fonts were detected up
1044
+ // front — the final detach loop below still runs because other phases
1045
+ // (lazy fallback CSS) populate relationsToRemove.
1046
+ const googleFontStylesheets = hasGoogleFonts
1047
+ ? assetGraph.findAssets({
1048
+ type: 'Css',
1049
+ url: { $regex: googleFontsCssUrlRegex },
1050
+ })
1051
+ : [];
999
1052
  const selfHostedGoogleCssByUrl = new Map();
1000
1053
  for (const googleFontStylesheet of googleFontStylesheets) {
1001
1054
  const seenPages = new Set(); // Only do the work once for each font on each page
@@ -1067,16 +1120,13 @@ async function subsetFonts(
1067
1120
  relation.detach();
1068
1121
  }
1069
1122
 
1070
- timings['Google Fonts + cleanup'] = Date.now() - phaseStart;
1071
- if (debug && console)
1072
- console.log(
1073
- `[subfont timing] Google Fonts + cleanup: ${timings['Google Fonts + cleanup']}ms`
1074
- );
1075
- phaseStart = Date.now();
1123
+ timings['Google Fonts + cleanup'] = googleCleanupPhase.end();
1124
+
1125
+ const injectPhase = trackPhase('inject subset font-family into CSS/SVG');
1076
1126
 
1077
1127
  // Use subsets in font-family:
1078
1128
 
1079
- const webfontNameMap = {};
1129
+ const webfontNameMap = Object.create(null);
1080
1130
 
1081
1131
  for (const { fontUsages } of htmlOrSvgAssetTextsWithProps) {
1082
1132
  for (const { subsets, fontFamilies, props } of fontUsages) {
@@ -1090,6 +1140,7 @@ async function subsetFonts(
1090
1140
  }
1091
1141
 
1092
1142
  let customPropertyDefinitions; // Avoid computing this unless necessary
1143
+ const cssAssetsDirtiedByCustomProps = new Set();
1093
1144
  // Inject subset font name before original webfont in SVG font-family attributes
1094
1145
  const svgAssets = assetGraph.findAssets({ type: 'Svg' });
1095
1146
  for (const svgAsset of svgAssets) {
@@ -1128,7 +1179,10 @@ async function subsetFonts(
1128
1179
  type: 'Css',
1129
1180
  isLoaded: true,
1130
1181
  });
1131
- let changesMadeToCustomPropertyDefinitions = false;
1182
+ const parseTreeToAsset = new Map();
1183
+ for (const cssAsset of cssAssets) {
1184
+ parseTreeToAsset.set(cssAsset.parseTree, cssAsset);
1185
+ }
1132
1186
  for (const cssAsset of cssAssets) {
1133
1187
  let changesMade = false;
1134
1188
  cssAsset.eachRuleInParseTree((cssRule) => {
@@ -1156,7 +1210,10 @@ async function subsetFonts(
1156
1210
  );
1157
1211
  if (modifiedValue !== relatedCssRule.value) {
1158
1212
  relatedCssRule.value = modifiedValue;
1159
- changesMadeToCustomPropertyDefinitions = true;
1213
+ const ownerAsset = parseTreeToAsset.get(relatedCssRule.root());
1214
+ if (ownerAsset) {
1215
+ cssAssetsDirtiedByCustomProps.add(ownerAsset);
1216
+ }
1160
1217
  }
1161
1218
  }
1162
1219
  }
@@ -1213,68 +1270,23 @@ async function subsetFonts(
1213
1270
  }
1214
1271
  }
1215
1272
 
1216
- // This is a bit crude, could be more efficient if we tracked the containing asset in findCustomPropertyDefinitions
1217
- if (changesMadeToCustomPropertyDefinitions) {
1218
- for (const cssAsset of cssAssets) {
1219
- cssAsset.markDirty();
1220
- }
1273
+ for (const dirtiedAsset of cssAssetsDirtiedByCustomProps) {
1274
+ dirtiedAsset.markDirty();
1221
1275
  }
1222
1276
 
1223
- timings['inject subset font-family'] = Date.now() - phaseStart;
1224
- if (debug && console)
1225
- console.log(
1226
- `[subfont timing] inject subset font-family into CSS/SVG: ${timings['inject subset font-family']}ms`
1227
- );
1228
- phaseStart = Date.now();
1277
+ timings['inject subset font-family'] = injectPhase.end();
1229
1278
 
1279
+ const orphanCleanupPhase = trackPhase('source maps + orphan cleanup');
1230
1280
  if (sourceMaps) {
1231
- await assetGraph.serializeSourceMaps(undefined, {
1232
- type: 'Css',
1233
- outgoingRelations: {
1234
- $where: (relations) =>
1235
- relations.some((relation) => relation.type === 'CssSourceMappingUrl'),
1236
- },
1237
- });
1238
- for (const relation of assetGraph.findRelations({
1239
- type: 'SourceMapSource',
1240
- })) {
1241
- relation.hrefType = hrefType;
1242
- }
1243
- for (const relation of assetGraph.findRelations({
1244
- type: 'CssSourceMappingUrl',
1245
- hrefType: { $in: ['relative', 'inline'] },
1246
- })) {
1247
- relation.hrefType = hrefType;
1248
- }
1281
+ await rewriteCssSourceMaps(assetGraph, hrefType);
1249
1282
  }
1283
+ removeOrphanedAssets(assetGraph, potentiallyOrphanedAssets);
1284
+ timings['source maps + orphan cleanup'] = orphanCleanupPhase.end();
1250
1285
 
1251
- for (const asset of potentiallyOrphanedAssets) {
1252
- if (asset.incomingRelations.length === 0) {
1253
- assetGraph.removeAsset(asset);
1254
- }
1255
- }
1256
-
1257
- timings['source maps + orphan cleanup'] = Date.now() - phaseStart;
1258
- if (debug && console)
1259
- console.log(
1260
- `[subfont timing] source maps + orphan cleanup: ${timings['source maps + orphan cleanup']}ms`
1261
- );
1262
-
1263
- // Hand out some useful info about the detected subsets:
1264
1286
  return {
1265
- fontInfo: htmlOrSvgAssetTextsWithProps.map(
1266
- ({ fontUsages, htmlOrSvgAsset }) => ({
1267
- assetFileName: htmlOrSvgAsset.nonInlineAncestor.urlOrDescription,
1268
- fontUsages: fontUsages.map((fontUsage) =>
1269
- (({ subsets, hasFontFeatureSettings, fontFeatureTags, ...rest }) =>
1270
- rest)(fontUsage)
1271
- ),
1272
- })
1273
- ),
1287
+ fontInfo: buildFontInfoReport(htmlOrSvgAssetTextsWithProps),
1274
1288
  timings,
1275
1289
  };
1276
1290
  }
1277
1291
 
1278
1292
  module.exports = subsetFonts;
1279
- // Exported for testing
1280
- module.exports._escapeJsStringLiteral = escapeJsStringLiteral;
@@ -118,9 +118,11 @@ async function getSubsetsForFontUsage(
118
118
  formats,
119
119
  seenAxisValuesByFontUrlAndAxisName,
120
120
  cacheDir = null,
121
- console = null
121
+ console = null,
122
+ debug = false
122
123
  ) {
123
124
  const diskCache = cacheDir ? new SubsetDiskCache(cacheDir, console) : null;
125
+ const cacheStats = diskCache ? { hits: 0, misses: 0 } : null;
124
126
 
125
127
  // Collect one canonical fontUsage per font URL
126
128
  const canonicalFontUsageByUrl = new Map();
@@ -240,8 +242,10 @@ async function getSubsetsForFontUsage(
240
242
  const cachedResult = diskCache ? await diskCache.get(cacheKey) : null;
241
243
 
242
244
  if (cachedResult) {
245
+ if (cacheStats) cacheStats.hits++;
243
246
  subsetPromiseMap.set(promiseId, Promise.resolve(cachedResult));
244
247
  } else {
248
+ if (cacheStats) cacheStats.misses++;
245
249
  const subsetCall = subsetFontWithGlyphs(fontBuffer, text, {
246
250
  targetFormat,
247
251
  glyphIds: featureGlyphIds,
@@ -279,6 +283,14 @@ async function getSubsetsForFontUsage(
279
283
  )
280
284
  );
281
285
 
286
+ if (cacheStats && debug && console) {
287
+ const total = cacheStats.hits + cacheStats.misses;
288
+ const pct = total > 0 ? Math.round((cacheStats.hits * 100) / total) : 0;
289
+ console.log(
290
+ `[subfont timing] subset disk cache: ${cacheStats.hits} hit${cacheStats.hits === 1 ? '' : 's'}, ${cacheStats.misses} miss${cacheStats.misses === 1 ? '' : 'es'} (${pct}% hit rate)`
291
+ );
292
+ }
293
+
282
294
  // Assign subset results to canonical font usages
283
295
  for (const [, fontUsage] of canonicalFontUsageByUrl) {
284
296
  const info = subsetInfoByFontUrl.get(fontUsage.fontUrl);