@turntrout/subfont 1.10.1 → 1.10.3

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.
Files changed (41) hide show
  1. package/lib/HeadlessBrowser.d.ts +2 -0
  2. package/lib/HeadlessBrowser.d.ts.map +1 -1
  3. package/lib/HeadlessBrowser.js +64 -58
  4. package/lib/HeadlessBrowser.js.map +1 -1
  5. package/lib/collectTextsByPage.d.ts.map +1 -1
  6. package/lib/collectTextsByPage.js +136 -132
  7. package/lib/collectTextsByPage.js.map +1 -1
  8. package/lib/concurrencyLimit.d.ts.map +1 -1
  9. package/lib/concurrencyLimit.js +6 -8
  10. package/lib/concurrencyLimit.js.map +1 -1
  11. package/lib/extractVisibleText.d.ts.map +1 -1
  12. package/lib/extractVisibleText.js +58 -30
  13. package/lib/extractVisibleText.js.map +1 -1
  14. package/lib/fontConverter.d.ts.map +1 -1
  15. package/lib/fontConverter.js +27 -23
  16. package/lib/fontConverter.js.map +1 -1
  17. package/lib/getCssRulesByProperty.d.ts.map +1 -1
  18. package/lib/getCssRulesByProperty.js +234 -207
  19. package/lib/getCssRulesByProperty.js.map +1 -1
  20. package/lib/injectSubsetDefinitions.d.ts.map +1 -1
  21. package/lib/injectSubsetDefinitions.js +24 -21
  22. package/lib/injectSubsetDefinitions.js.map +1 -1
  23. package/lib/parseCommandLineOptions.d.ts.map +1 -1
  24. package/lib/parseCommandLineOptions.js +29 -14
  25. package/lib/parseCommandLineOptions.js.map +1 -1
  26. package/lib/subfont.d.ts.map +1 -1
  27. package/lib/subfont.js +347 -311
  28. package/lib/subfont.js.map +1 -1
  29. package/lib/subsetFontWithGlyphs.d.ts.map +1 -1
  30. package/lib/subsetFontWithGlyphs.js +60 -48
  31. package/lib/subsetFontWithGlyphs.js.map +1 -1
  32. package/lib/subsetFonts.d.ts.map +1 -1
  33. package/lib/subsetFonts.js +338 -280
  34. package/lib/subsetFonts.js.map +1 -1
  35. package/lib/subsetGeneration.d.ts.map +1 -1
  36. package/lib/subsetGeneration.js +151 -127
  37. package/lib/subsetGeneration.js.map +1 -1
  38. package/lib/warnAboutMissingGlyphs.d.ts.map +1 -1
  39. package/lib/warnAboutMissingGlyphs.js +132 -112
  40. package/lib/warnAboutMissingGlyphs.js.map +1 -1
  41. package/package.json +1 -1
@@ -455,47 +455,52 @@ async function computeCodepoints(assetGraph, htmlOrSvgAssetTextsWithProps, fontD
455
455
  }
456
456
  }
457
457
  }
458
- // Inject __subset font-family names into CSS declarations and SVG attributes
459
- // so the browser picks up the subset fonts instead of the originals.
460
- function injectSubsetFontFamilies(assetGraph, htmlOrSvgAssetTextsWithProps, omitFallbacks) {
458
+ function buildWebfontNameMap(htmlOrSvgAssetTextsWithProps) {
461
459
  const webfontNameMap = Object.create(null);
462
460
  for (const { fontUsages } of htmlOrSvgAssetTextsWithProps) {
463
461
  for (const { subsets, fontFamilies, props } of fontUsages) {
464
- if (subsets) {
465
- for (const fontFamily of fontFamilies) {
466
- webfontNameMap[fontFamily.toLowerCase()] =
467
- `${props['font-family']}__subset`;
468
- }
462
+ if (!subsets)
463
+ continue;
464
+ for (const fontFamily of fontFamilies) {
465
+ webfontNameMap[fontFamily.toLowerCase()] =
466
+ `${props['font-family']}__subset`;
469
467
  }
470
468
  }
471
469
  }
472
- let customPropertyDefinitions;
473
- const cssAssetsDirtiedByCustomProps = new Set();
470
+ return webfontNameMap;
471
+ }
472
+ // Rewrites a comma-separated font-family list to prepend the matching
473
+ // __subset family. Returns null if no change was needed.
474
+ function rewriteFontFamilyList(value, webfontNameMap, omitFallbacks) {
475
+ const fontFamilies = cssListHelpers.splitByCommas(value);
476
+ const updatedFamilies = [];
477
+ let modified = false;
478
+ for (const family of fontFamilies) {
479
+ const parsed = cssFontParser.parseFontFamily(family)[0];
480
+ const subsetFontFamily = parsed
481
+ ? webfontNameMap[parsed.toLowerCase()]
482
+ : undefined;
483
+ if (subsetFontFamily && !fontFamilies.includes(subsetFontFamily)) {
484
+ updatedFamilies.push((0, fontFaceHelpers_1.maybeCssQuote)(subsetFontFamily));
485
+ if (!omitFallbacks)
486
+ updatedFamilies.push(family);
487
+ modified = true;
488
+ }
489
+ else {
490
+ updatedFamilies.push(family);
491
+ }
492
+ }
493
+ return modified ? updatedFamilies.join(', ') : null;
494
+ }
495
+ function injectSubsetIntoSvgAssets(assetGraph, webfontNameMap, omitFallbacks) {
474
496
  for (const svgAsset of assetGraph.findAssets({ type: 'Svg' })) {
475
497
  if (!svgAsset.isLoaded)
476
498
  continue;
477
499
  let changesMade = false;
478
500
  for (const element of Array.from(svgAsset.parseTree.querySelectorAll('[font-family]'))) {
479
- const fontFamilies = cssListHelpers.splitByCommas(element.getAttribute('font-family'));
480
- const updatedFamilies = [];
481
- let modified = false;
482
- for (const family of fontFamilies) {
483
- const parsed = cssFontParser.parseFontFamily(family)[0];
484
- const subsetFontFamily = parsed
485
- ? webfontNameMap[parsed.toLowerCase()]
486
- : undefined;
487
- if (subsetFontFamily && !fontFamilies.includes(subsetFontFamily)) {
488
- updatedFamilies.push((0, fontFaceHelpers_1.maybeCssQuote)(subsetFontFamily));
489
- if (!omitFallbacks)
490
- updatedFamilies.push(family);
491
- modified = true;
492
- }
493
- else {
494
- updatedFamilies.push(family);
495
- }
496
- }
497
- if (modified) {
498
- element.setAttribute('font-family', updatedFamilies.join(', '));
501
+ const updated = rewriteFontFamilyList(element.getAttribute('font-family'), webfontNameMap, omitFallbacks);
502
+ if (updated !== null) {
503
+ element.setAttribute('font-family', updated);
499
504
  changesMade = true;
500
505
  }
501
506
  }
@@ -503,11 +508,61 @@ function injectSubsetFontFamilies(assetGraph, htmlOrSvgAssetTextsWithProps, omit
503
508
  svgAsset.markDirty();
504
509
  }
505
510
  }
511
+ }
512
+ function rewriteFontShorthand(value, webfontNameMap, omitFallbacks) {
513
+ const fontProperties = cssFontParser.parseFont(value);
514
+ const fontFamilies = fontProperties && fontProperties['font-family'].map(unquote);
515
+ if (!fontFamilies || fontFamilies.length === 0)
516
+ return null;
517
+ const subsetFontFamily = webfontNameMap[fontFamilies[0].toLowerCase()];
518
+ if (!subsetFontFamily || fontFamilies.includes(subsetFontFamily)) {
519
+ return null;
520
+ }
521
+ if (omitFallbacks) {
522
+ fontFamilies.shift();
523
+ }
524
+ fontFamilies.unshift(subsetFontFamily);
525
+ const stylePrefix = fontProperties['font-style']
526
+ ? `${fontProperties['font-style']} `
527
+ : '';
528
+ const weightPrefix = fontProperties['font-weight']
529
+ ? `${fontProperties['font-weight']} `
530
+ : '';
531
+ const lineHeightSuffix = fontProperties['line-height']
532
+ ? `/${fontProperties['line-height']}`
533
+ : '';
534
+ return `${stylePrefix}${weightPrefix}${fontProperties['font-size']}${lineHeightSuffix} ${fontFamilies.map(fontFaceHelpers_1.maybeCssQuote).join(', ')}`;
535
+ }
536
+ function injectSubsetIntoCssAssets(assetGraph, webfontNameMap, omitFallbacks) {
506
537
  const cssAssets = assetGraph.findAssets({ type: 'Css', isLoaded: true });
507
538
  const parseTreeToAsset = new Map();
508
539
  for (const cssAsset of cssAssets) {
509
540
  parseTreeToAsset.set(cssAsset.parseTree, cssAsset);
510
541
  }
542
+ const cssAssetsDirtiedByCustomProps = new Set();
543
+ // Lazy: findCustomPropertyDefinitions walks every CSS asset's parse tree.
544
+ // Skip the work entirely when no rule references var(); pay it once on the
545
+ // first hit.
546
+ let customPropertyDefinitions;
547
+ const injectVarRule = (cssRule) => {
548
+ if (!customPropertyDefinitions) {
549
+ customPropertyDefinitions = findCustomPropertyDefinitions(cssAssets);
550
+ }
551
+ for (const customPropertyName of extractReferencedCustomPropertyNames(cssRule.value)) {
552
+ for (const relatedCssRule of [
553
+ cssRule,
554
+ ...(customPropertyDefinitions[customPropertyName] || []),
555
+ ]) {
556
+ const modifiedValue = injectSubsetDefinitions(relatedCssRule.value, webfontNameMap, omitFallbacks);
557
+ if (modifiedValue === relatedCssRule.value)
558
+ continue;
559
+ relatedCssRule.value = modifiedValue;
560
+ const ownerAsset = parseTreeToAsset.get(relatedCssRule.root());
561
+ if (ownerAsset)
562
+ cssAssetsDirtiedByCustomProps.add(ownerAsset);
563
+ }
564
+ }
565
+ };
511
566
  for (const cssAsset of cssAssets) {
512
567
  let changesMade = false;
513
568
  cssAsset.eachRuleInParseTree((cssRule) => {
@@ -516,82 +571,37 @@ function injectSubsetFontFamilies(assetGraph, htmlOrSvgAssetTextsWithProps, omit
516
571
  const propName = cssRule.prop.toLowerCase();
517
572
  if ((propName === 'font' || propName === 'font-family') &&
518
573
  cssRule.value.includes('var(')) {
519
- if (!customPropertyDefinitions) {
520
- customPropertyDefinitions = findCustomPropertyDefinitions(cssAssets);
521
- }
522
- for (const customPropertyName of extractReferencedCustomPropertyNames(cssRule.value)) {
523
- for (const relatedCssRule of [
524
- cssRule,
525
- ...(customPropertyDefinitions[customPropertyName] || []),
526
- ]) {
527
- const modifiedValue = injectSubsetDefinitions(relatedCssRule.value, webfontNameMap, omitFallbacks);
528
- if (modifiedValue !== relatedCssRule.value) {
529
- relatedCssRule.value = modifiedValue;
530
- const ownerAsset = parseTreeToAsset.get(relatedCssRule.root());
531
- if (ownerAsset) {
532
- cssAssetsDirtiedByCustomProps.add(ownerAsset);
533
- }
534
- }
535
- }
536
- }
574
+ injectVarRule(cssRule);
537
575
  }
538
576
  else if (propName === 'font-family') {
539
- const fontFamilies = cssListHelpers.splitByCommas(cssRule.value);
540
- const updatedFamilies = [];
541
- let familyModified = false;
542
- for (const family of fontFamilies) {
543
- const parsed = cssFontParser.parseFontFamily(family)[0];
544
- const subsetFontFamily = parsed
545
- ? webfontNameMap[parsed.toLowerCase()]
546
- : undefined;
547
- if (subsetFontFamily && !fontFamilies.includes(subsetFontFamily)) {
548
- updatedFamilies.push((0, fontFaceHelpers_1.maybeCssQuote)(subsetFontFamily));
549
- if (!omitFallbacks)
550
- updatedFamilies.push(family);
551
- familyModified = true;
552
- }
553
- else {
554
- updatedFamilies.push(family);
555
- }
556
- }
557
- if (familyModified) {
558
- cssRule.value = updatedFamilies.join(', ');
577
+ const updated = rewriteFontFamilyList(cssRule.value, webfontNameMap, omitFallbacks);
578
+ if (updated !== null) {
579
+ cssRule.value = updated;
559
580
  changesMade = true;
560
581
  }
561
582
  }
562
583
  else if (propName === 'font') {
563
- const fontProperties = cssFontParser.parseFont(cssRule.value);
564
- const fontFamilies = fontProperties && fontProperties['font-family'].map(unquote);
565
- if (!fontFamilies || fontFamilies.length === 0)
566
- return;
567
- const subsetFontFamily = webfontNameMap[fontFamilies[0].toLowerCase()];
568
- if (!subsetFontFamily || fontFamilies.includes(subsetFontFamily))
569
- return;
570
- if (omitFallbacks) {
571
- fontFamilies.shift();
584
+ const updated = rewriteFontShorthand(cssRule.value, webfontNameMap, omitFallbacks);
585
+ if (updated !== null) {
586
+ cssRule.value = updated;
587
+ changesMade = true;
572
588
  }
573
- fontFamilies.unshift(subsetFontFamily);
574
- const stylePrefix = fontProperties['font-style']
575
- ? `${fontProperties['font-style']} `
576
- : '';
577
- const weightPrefix = fontProperties['font-weight']
578
- ? `${fontProperties['font-weight']} `
579
- : '';
580
- const lineHeightSuffix = fontProperties['line-height']
581
- ? `/${fontProperties['line-height']}`
582
- : '';
583
- cssRule.value = `${stylePrefix}${weightPrefix}${fontProperties['font-size']}${lineHeightSuffix} ${fontFamilies.map(fontFaceHelpers_1.maybeCssQuote).join(', ')}`;
584
- changesMade = true;
585
589
  }
586
590
  });
587
- if (changesMade) {
591
+ if (changesMade)
588
592
  cssAsset.markDirty();
589
- }
590
593
  }
591
594
  for (const dirtiedAsset of cssAssetsDirtiedByCustomProps) {
592
595
  dirtiedAsset.markDirty();
593
596
  }
594
597
  }
598
+ // Inject __subset font-family names into CSS declarations and SVG attributes
599
+ // so the browser picks up the subset fonts instead of the originals.
600
+ function injectSubsetFontFamilies(assetGraph, htmlOrSvgAssetTextsWithProps, omitFallbacks) {
601
+ const webfontNameMap = buildWebfontNameMap(htmlOrSvgAssetTextsWithProps);
602
+ injectSubsetIntoSvgAssets(assetGraph, webfontNameMap, omitFallbacks);
603
+ injectSubsetIntoCssAssets(assetGraph, webfontNameMap, omitFallbacks);
604
+ }
595
605
  // Build subset CSS assets and inject them (plus preload hints) into each
596
606
  // page. All cache state is local; nothing leaks back to the caller.
597
607
  async function insertSubsets({ assetGraph, pages, formats, subsetUrl, hrefType, inlineCss, omitFallbacks, }) {
@@ -754,176 +764,82 @@ async function insertSubsets({ assetGraph, pages, formats, subsetUrl, hrefType,
754
764
  }
755
765
  return { numFontUsagesWithSubset };
756
766
  }
757
- async function subsetFonts(assetGraph, { formats = ['woff2'], subsetPath = 'subfont/', omitFallbacks = false, inlineCss = false, fontDisplay, hrefType = 'rootRelative', onlyInfo, dynamic, console = global.console, text, sourceMaps = false, debug = false, concurrency, chromeArgs = [], cacheDir = null, } = {}) {
758
- if (fontDisplay && !validFontDisplayValues.includes(fontDisplay)) {
759
- fontDisplay = undefined;
760
- }
761
- // Pre-warm the WASM pool: start compiling harfbuzz WASM while
762
- // collectTextsByPage traces fonts. Compilation (~50-200ms) overlaps
763
- // with tracing work rather than appearing on the critical path.
764
- subsetFontWithGlyphs.warmup().catch((err) => {
765
- if (debug) {
766
- console.warn('WASM warmup failed (will retry on first subset call):', err);
767
+ // Walk up the postcss ancestor chain to find the @media query enclosing
768
+ // a rule (if any). Returns the empty string for rules outside any @media.
769
+ function findEnclosingMediaQuery(rule) {
770
+ let ancestor = rule.parent;
771
+ while (ancestor) {
772
+ if (ancestor.type === 'atrule' &&
773
+ ancestor.name?.toLowerCase() === 'media') {
774
+ return ancestor.params ?? '';
767
775
  }
768
- });
769
- const subsetUrl = urltools.ensureTrailingSlash(assetGraph.root + subsetPath);
770
- const timings = {};
771
- const trackPhase = (0, progress_1.makePhaseTracker)(console, debug);
772
- const applySourceMapsPhase = trackPhase('applySourceMaps');
773
- if (sourceMaps) {
774
- await assetGraph.applySourceMaps({ type: 'Css' });
776
+ ancestor = ancestor.parent;
775
777
  }
776
- timings.applySourceMaps = applySourceMapsPhase.end();
777
- const googlePopulatePhase = trackPhase('populate (google fonts)');
778
- const hasGoogleFonts = await populateGoogleFontsIfPresent(assetGraph);
779
- timings['populate (google fonts)'] = googlePopulatePhase.end(hasGoogleFonts ? null : 'skipped, no Google Fonts found');
780
- const htmlOrSvgAssets = assetGraph.findAssets({
781
- $or: [
782
- {
783
- type: 'Html',
784
- isInline: false,
785
- },
786
- {
787
- type: 'Svg',
788
- },
789
- ],
790
- });
791
- const collectPhase = trackPhase(`collectTextsByPage (${htmlOrSvgAssets.length} pages)`);
792
- const { htmlOrSvgAssetTextsWithProps, fontFaceDeclarationsByHtmlOrSvgAsset, subTimings, } = await collectTextsByPage(assetGraph, htmlOrSvgAssets, {
793
- text,
794
- console,
795
- dynamic,
796
- debug,
797
- concurrency,
798
- chromeArgs,
799
- });
800
- timings.collectTextsByPage = collectPhase.end();
801
- timings.collectTextsByPageDetails = subTimings;
802
- // textByProps is consumed inside collectTextsByPage (see buildPerPageFontUsages)
803
- // and never read again by anything in the subsetFonts pipeline; the raw
804
- // font-tracer text strings inside scale with #pages and are the largest
805
- // per-page artefact at the 1800-page scale. Release them before
806
- // computeCodepoints / subsetting / injection so they don't pin heap.
807
- for (const entry of htmlOrSvgAssetTextsWithProps) {
808
- entry.textByProps = [];
809
- }
810
- const omitFallbacksPhase = trackPhase('omitFallbacks processing');
811
- const potentiallyOrphanedAssets = new Set();
812
- if (omitFallbacks) {
813
- removeOriginalFontFaceRules(htmlOrSvgAssets, fontFaceDeclarationsByHtmlOrSvgAsset, potentiallyOrphanedAssets);
814
- }
815
- timings['omitFallbacks processing'] = omitFallbacksPhase.end();
816
- // Stage 1 → 2 placeholder: SubsettedFontUsage only adds optional fields
817
- // on top of TracedFontUsage, so this upcast is structurally valid even
818
- // before getSubsetsForFontUsage runs.
819
- const pages = htmlOrSvgAssetTextsWithProps;
820
- const codepointPhase = trackPhase('codepoint generation');
821
- await computeCodepoints(assetGraph, pages, fontDisplay);
822
- timings['codepoint generation'] = codepointPhase.end();
823
- if (onlyInfo) {
824
- // Stage 2 hasn't run, but buildFontInfoReport's input only requires
825
- // `codepoints` (stage 3) — already attached above. Subset fields are
826
- // optional and simply absent here, matching how the report describes
827
- // a "no subset created" entry.
828
- return {
829
- fontInfo: buildFontInfoReport(pages),
830
- timings,
831
- };
778
+ return '';
779
+ }
780
+ // Group @font-face rules by their enclosing @media context so the fallback
781
+ // CSS preserves the original media-conditional loading.
782
+ function buildFallbackCssText(containedRelationsByFontFaceRule) {
783
+ const rulesByMedia = new Map();
784
+ for (const rule of containedRelationsByFontFaceRule.keys()) {
785
+ const mediaKey = findEnclosingMediaQuery(rule);
786
+ if (!rulesByMedia.has(mediaKey))
787
+ rulesByMedia.set(mediaKey, []);
788
+ rulesByMedia
789
+ .get(mediaKey)
790
+ .push((0, fontFaceHelpers_1.getFontFaceDeclarationText)(rule, containedRelationsByFontFaceRule.get(rule) ?? []));
832
791
  }
833
- const variationPhase = trackPhase('variation axis usage');
834
- const { seenAxisValuesByFontUrlAndAxisName } = (0, variationAxes_1.getVariationAxisUsage)(pages, fontFaceHelpers_1.parseFontWeightRange, fontFaceHelpers_1.parseFontStretchRange);
835
- timings['variation axis usage'] = variationPhase.end();
836
- // Generate subsets:
837
- if (console) {
838
- const uniqueFontUrls = countUniqueFontUrls(pages);
839
- if (uniqueFontUrls > 0) {
840
- console.log(` Subsetting ${uniqueFontUrls} unique font file${uniqueFontUrls === 1 ? '' : 's'}...`);
792
+ let fallbackCssText = '';
793
+ for (const [media, texts] of rulesByMedia) {
794
+ if (media) {
795
+ fallbackCssText += `@media ${media}{${texts.join('')}}`;
796
+ }
797
+ else {
798
+ fallbackCssText += texts.join('');
841
799
  }
842
800
  }
843
- const subsetPhase = trackPhase('getSubsetsForFontUsage');
844
- await (0, subsetGeneration_1.getSubsetsForFontUsage)(assetGraph, pages, formats, seenAxisValuesByFontUrlAndAxisName, cacheDir, console, debug);
845
- timings.getSubsetsForFontUsage = subsetPhase.end();
846
- const warnGlyphsPhase = trackPhase('warnAboutMissingGlyphs');
847
- await warnAboutMissingGlyphs(pages, assetGraph);
848
- timings.warnAboutMissingGlyphs = warnGlyphsPhase.end();
849
- // Insert subsets:
850
- const insertPhase = trackPhase(`insert subsets loop (${pages.length} pages)`);
851
- const { numFontUsagesWithSubset } = await insertSubsets({
852
- assetGraph,
853
- pages,
854
- formats,
855
- subsetUrl,
856
- hrefType,
857
- inlineCss,
858
- omitFallbacks,
859
- });
860
- timings['insert subsets loop'] = insertPhase.end();
861
- if (numFontUsagesWithSubset === 0) {
862
- return { fontInfo: [], timings };
801
+ return fallbackCssText;
802
+ }
803
+ // Returns the map of @font-face rule node → sibling relations sharing that
804
+ // rule. As a side effect, adds every retained relation to originalRelations
805
+ // so the caller can later remove the original CSS in one pass.
806
+ function collectFontFaceRelations(accumulatedFontFaceDeclarations, originalRelations) {
807
+ const containedRelationsByFontFaceRule = new Map();
808
+ for (const { relations } of accumulatedFontFaceDeclarations) {
809
+ for (const relation of relations) {
810
+ if (
811
+ // Google Web Fonts handled separately in handleGoogleFontStylesheets
812
+ relation.from.hostname ===
813
+ 'fonts.googleapis.com' ||
814
+ containedRelationsByFontFaceRule.has(relation.node)) {
815
+ continue;
816
+ }
817
+ originalRelations.add(relation);
818
+ containedRelationsByFontFaceRule.set(relation.node, relation.from.outgoingRelations.filter((otherRelation) => otherRelation.node === relation.node));
819
+ }
863
820
  }
864
- const lazyFallbackPhase = trackPhase('lazy load fallback CSS');
865
- const relationsToRemove = new Set();
866
- // Lazy load the original @font-face declarations of self-hosted fonts (unless omitFallbacks)
867
- const originalRelations = new Set();
821
+ return containedRelationsByFontFaceRule;
822
+ }
823
+ // Lazy load the original @font-face declarations of self-hosted fonts (unless
824
+ // omitFallbacks), and collect references into originalRelations so subsetFonts
825
+ // can remove them after the lazy fallback is in place.
826
+ async function emitLazyFallbackCss(ctx, relationsToRemove, originalRelations) {
827
+ const { assetGraph, htmlOrSvgAssets, fontFaceDeclarationsByHtmlOrSvgAsset, omitFallbacks, hrefType, subsetUrl, } = ctx;
868
828
  const fallbackCssAssetCache = new Map();
869
829
  for (const htmlOrSvgAsset of htmlOrSvgAssets) {
870
830
  const accumulatedFontFaceDeclarations = fontFaceDeclarationsByHtmlOrSvgAsset.get(htmlOrSvgAsset);
871
831
  if (!accumulatedFontFaceDeclarations)
872
832
  continue;
873
- const containedRelationsByFontFaceRule = new Map();
874
- for (const { relations } of accumulatedFontFaceDeclarations) {
875
- for (const relation of relations) {
876
- if (relation.from.hostname ===
877
- 'fonts.googleapis.com' || // Google Web Fonts handled separately below
878
- containedRelationsByFontFaceRule.has(relation.node)) {
879
- continue;
880
- }
881
- originalRelations.add(relation);
882
- containedRelationsByFontFaceRule.set(relation.node, relation.from.outgoingRelations.filter((otherRelation) => otherRelation.node === relation.node));
883
- }
884
- }
833
+ const containedRelationsByFontFaceRule = collectFontFaceRelations(accumulatedFontFaceDeclarations, originalRelations);
885
834
  if (containedRelationsByFontFaceRule.size === 0 ||
886
835
  omitFallbacks ||
887
836
  htmlOrSvgAsset.type !== 'Html') {
888
837
  continue;
889
838
  }
890
- // Group @font-face rules by their enclosing @media context so the
891
- // fallback CSS preserves the original media-conditional loading.
892
- // Walk up the ancestor chain in case the rule is nested (e.g.
893
- // inside @supports inside @media).
894
- const rulesByMedia = new Map();
895
- for (const rule of containedRelationsByFontFaceRule.keys()) {
896
- let mediaKey = '';
897
- let ancestor = rule.parent;
898
- while (ancestor) {
899
- if (ancestor.type === 'atrule' &&
900
- ancestor.name?.toLowerCase() === 'media') {
901
- mediaKey = ancestor.params ?? '';
902
- break;
903
- }
904
- ancestor = ancestor.parent;
905
- }
906
- if (!rulesByMedia.has(mediaKey))
907
- rulesByMedia.set(mediaKey, []);
908
- rulesByMedia
909
- .get(mediaKey)
910
- .push((0, fontFaceHelpers_1.getFontFaceDeclarationText)(rule, containedRelationsByFontFaceRule.get(rule) ?? []));
911
- }
912
- let fallbackCssText = '';
913
- for (const [media, texts] of rulesByMedia) {
914
- if (media) {
915
- fallbackCssText += `@media ${media}{${texts.join('')}}`;
916
- }
917
- else {
918
- fallbackCssText += texts.join('');
919
- }
920
- }
839
+ const fallbackCssText = buildFallbackCssText(containedRelationsByFontFaceRule);
921
840
  let cssAsset = fallbackCssAssetCache.get(fallbackCssText);
922
841
  if (!cssAsset) {
923
- cssAsset = assetGraph.addAsset({
924
- type: 'Css',
925
- text: fallbackCssText,
926
- });
842
+ cssAsset = assetGraph.addAsset({ type: 'Css', text: fallbackCssText });
927
843
  for (const relation of cssAsset.outgoingRelations) {
928
844
  relation.hrefType = hrefType;
929
845
  }
@@ -931,7 +847,8 @@ async function subsetFonts(assetGraph, { formats = ['woff2'], subsetPath = 'subf
931
847
  cssAsset.url = `${subsetUrl}fallback-${cssAsset.md5Hex.slice(0, 10)}.css`;
932
848
  fallbackCssAssetCache.set(fallbackCssText, cssAsset);
933
849
  }
934
- // Create a <link rel="stylesheet"> that asyncLoadStyleRelationWithFallback can convert to async with noscript fallback:
850
+ // Create a <link rel="stylesheet"> that asyncLoadStyleRelationWithFallback
851
+ // can convert to async with noscript fallback.
935
852
  const fallbackHtmlStyle = htmlOrSvgAsset.addRelation({
936
853
  type: 'HtmlStyle',
937
854
  to: cssAsset,
@@ -939,11 +856,12 @@ async function subsetFonts(assetGraph, { formats = ['woff2'], subsetPath = 'subf
939
856
  asyncLoadStyleRelationWithFallback(htmlOrSvgAsset, fallbackHtmlStyle, hrefType);
940
857
  relationsToRemove.add(fallbackHtmlStyle);
941
858
  }
942
- timings['lazy load fallback CSS'] = lazyFallbackPhase.end();
943
859
  // Same reasoning as subsetCssAssetCache: keys are full CSS text.
944
860
  fallbackCssAssetCache.clear();
945
- const removeFontFacePhase = trackPhase('remove original @font-face');
946
- // Remove the original @font-face blocks, and don't leave behind empty stylesheets:
861
+ }
862
+ // Remove the original @font-face blocks, and don't leave behind empty
863
+ // stylesheets.
864
+ function removeOriginalFontFaceRelations(assetGraph, originalRelations) {
947
865
  const maybeEmptyCssAssets = new Set();
948
866
  for (const relation of originalRelations) {
949
867
  const cssAsset = relation.from;
@@ -962,12 +880,24 @@ async function subsetFonts(assetGraph, { formats = ['woff2'], subsetPath = 'subf
962
880
  assetGraph.removeAsset(cssAsset);
963
881
  }
964
882
  }
965
- timings['remove original @font-face'] = removeFontFacePhase.end();
966
- const googleCleanupPhase = trackPhase('Google Fonts + cleanup');
967
- // Async load Google Web Fonts CSS. Skip the regex findAssets scan and
968
- // the surrounding loop entirely when no Google Fonts were detected up
969
- // front the final detach loop below still runs because other phases
970
- // (lazy fallback CSS) populate relationsToRemove.
883
+ }
884
+ function getHtmlParentsForGoogleFontsRelation(googleFontStylesheetRelation) {
885
+ if (googleFontStylesheetRelation.type === 'CssImport') {
886
+ return getParents(googleFontStylesheetRelation.to, {
887
+ type: { $in: ['Html', 'Svg'] },
888
+ isInline: false,
889
+ isLoaded: true,
890
+ });
891
+ }
892
+ if (['Html', 'Svg'].includes(googleFontStylesheetRelation.from.type ?? '')) {
893
+ return [googleFontStylesheetRelation.from];
894
+ }
895
+ return [];
896
+ }
897
+ // Async load Google Web Fonts CSS. Skip the regex findAssets scan and the
898
+ // surrounding loop entirely when no Google Fonts were detected up front.
899
+ async function handleGoogleFontStylesheets(ctx, relationsToRemove) {
900
+ const { assetGraph, hasGoogleFonts, omitFallbacks, formats, hrefType, subsetUrl, } = ctx;
971
901
  const googleFontStylesheets = hasGoogleFonts
972
902
  ? assetGraph.findAssets({
973
903
  type: 'Css',
@@ -976,23 +906,10 @@ async function subsetFonts(assetGraph, { formats = ['woff2'], subsetPath = 'subf
976
906
  : [];
977
907
  const selfHostedGoogleCssByUrl = new Map();
978
908
  for (const googleFontStylesheet of googleFontStylesheets) {
979
- const seenPages = new Set(); // Only do the work once for each font on each page
909
+ // Only do the work once for each font on each page
910
+ const seenPages = new Set();
980
911
  for (const googleFontStylesheetRelation of googleFontStylesheet.incomingRelations) {
981
- let htmlParents;
982
- if (googleFontStylesheetRelation.type === 'CssImport') {
983
- // Gather Html parents. Relevant if we are dealing with CSS @import relations
984
- htmlParents = getParents(googleFontStylesheetRelation.to, {
985
- type: { $in: ['Html', 'Svg'] },
986
- isInline: false,
987
- isLoaded: true,
988
- });
989
- }
990
- else if (['Html', 'Svg'].includes(googleFontStylesheetRelation.from.type ?? '')) {
991
- htmlParents = [googleFontStylesheetRelation.from];
992
- }
993
- else {
994
- htmlParents = [];
995
- }
912
+ const htmlParents = getHtmlParentsForGoogleFontsRelation(googleFontStylesheetRelation);
996
913
  for (const htmlParent of htmlParents) {
997
914
  if (seenPages.has(htmlParent))
998
915
  continue;
@@ -1020,23 +937,164 @@ async function subsetFonts(assetGraph, { formats = ['woff2'], subsetPath = 'subf
1020
937
  }
1021
938
  googleFontStylesheet.unload();
1022
939
  }
1023
- // Cache served its purpose; injectSubsetFontFamilies and the rest of the
1024
- // function don't need it. Free the URL keys before injection runs.
940
+ // Cache served its purpose. Free the URL keys before injection runs.
1025
941
  selfHostedGoogleCssByUrl.clear();
1026
- // Clean up, making sure not to detach the same relation twice, eg. when multiple pages use the same stylesheet that imports a font
942
+ }
943
+ async function runCollectAndPrepPagesPhase(ctx) {
944
+ const collectPhase = ctx.trackPhase(`collectTextsByPage (${ctx.htmlOrSvgAssets.length} pages)`);
945
+ const { htmlOrSvgAssetTextsWithProps, fontFaceDeclarationsByHtmlOrSvgAsset, subTimings, } = await collectTextsByPage(ctx.assetGraph, ctx.htmlOrSvgAssets, {
946
+ text: ctx.text,
947
+ console: ctx.console,
948
+ dynamic: ctx.dynamic,
949
+ debug: ctx.debug,
950
+ concurrency: ctx.concurrency,
951
+ chromeArgs: ctx.chromeArgs,
952
+ });
953
+ ctx.timings.collectTextsByPage = collectPhase.end();
954
+ ctx.timings.collectTextsByPageDetails = subTimings;
955
+ // textByProps is consumed inside collectTextsByPage (see buildPerPageFont-
956
+ // Usages) and never read again by anything in the subsetFonts pipeline;
957
+ // the raw font-tracer text strings inside scale with #pages and are the
958
+ // largest per-page artefact at the 1800-page scale. Release them before
959
+ // computeCodepoints / subsetting / injection so they don't pin heap.
960
+ for (const entry of htmlOrSvgAssetTextsWithProps) {
961
+ entry.textByProps = [];
962
+ }
963
+ const omitFallbacksPhase = ctx.trackPhase('omitFallbacks processing');
964
+ if (ctx.omitFallbacks) {
965
+ removeOriginalFontFaceRules(ctx.htmlOrSvgAssets, fontFaceDeclarationsByHtmlOrSvgAsset, ctx.potentiallyOrphanedAssets);
966
+ }
967
+ ctx.timings['omitFallbacks processing'] = omitFallbacksPhase.end();
968
+ // Stage 1 → 2 placeholder: SubsettedFontUsage only adds optional fields
969
+ // on top of TracedFontUsage; this upcast happens implicitly in the
970
+ // returned AssetTextWithProps shape.
971
+ return {
972
+ pages: htmlOrSvgAssetTextsWithProps,
973
+ fontFaceDeclarationsByHtmlOrSvgAsset,
974
+ };
975
+ }
976
+ async function runSubsetPhase(ctx) {
977
+ const variationPhase = ctx.trackPhase('variation axis usage');
978
+ const { seenAxisValuesByFontUrlAndAxisName } = (0, variationAxes_1.getVariationAxisUsage)(ctx.pages, fontFaceHelpers_1.parseFontWeightRange, fontFaceHelpers_1.parseFontStretchRange);
979
+ ctx.timings['variation axis usage'] = variationPhase.end();
980
+ if (ctx.console) {
981
+ const uniqueFontUrls = countUniqueFontUrls(ctx.pages);
982
+ if (uniqueFontUrls > 0) {
983
+ ctx.console.log(` Subsetting ${uniqueFontUrls} unique font file${uniqueFontUrls === 1 ? '' : 's'}...`);
984
+ }
985
+ }
986
+ const subsetPhase = ctx.trackPhase('getSubsetsForFontUsage');
987
+ await (0, subsetGeneration_1.getSubsetsForFontUsage)(ctx.assetGraph, ctx.pages, ctx.formats, seenAxisValuesByFontUrlAndAxisName, ctx.cacheDir, ctx.console, ctx.debug);
988
+ ctx.timings.getSubsetsForFontUsage = subsetPhase.end();
989
+ const warnGlyphsPhase = ctx.trackPhase('warnAboutMissingGlyphs');
990
+ await warnAboutMissingGlyphs(ctx.pages, ctx.assetGraph);
991
+ ctx.timings.warnAboutMissingGlyphs = warnGlyphsPhase.end();
992
+ }
993
+ async function runPostSubsetCleanup(ctx) {
994
+ const relationsToRemove = new Set();
995
+ const originalRelations = new Set();
996
+ const lazyFallbackPhase = ctx.trackPhase('lazy load fallback CSS');
997
+ await emitLazyFallbackCss(ctx, relationsToRemove, originalRelations);
998
+ ctx.timings['lazy load fallback CSS'] = lazyFallbackPhase.end();
999
+ const removeFontFacePhase = ctx.trackPhase('remove original @font-face');
1000
+ removeOriginalFontFaceRelations(ctx.assetGraph, originalRelations);
1001
+ ctx.timings['remove original @font-face'] = removeFontFacePhase.end();
1002
+ const googleCleanupPhase = ctx.trackPhase('Google Fonts + cleanup');
1003
+ await handleGoogleFontStylesheets(ctx, relationsToRemove);
1004
+ // Clean up, making sure not to detach the same relation twice, eg. when
1005
+ // multiple pages use the same stylesheet that imports a font.
1027
1006
  for (const relation of relationsToRemove) {
1028
1007
  relation.detach();
1029
1008
  }
1030
- timings['Google Fonts + cleanup'] = googleCleanupPhase.end();
1031
- const injectPhase = trackPhase('inject subset font-family into CSS/SVG');
1032
- injectSubsetFontFamilies(assetGraph, pages, omitFallbacks);
1033
- timings['inject subset font-family'] = injectPhase.end();
1034
- const orphanCleanupPhase = trackPhase('source maps + orphan cleanup');
1009
+ ctx.timings['Google Fonts + cleanup'] = googleCleanupPhase.end();
1010
+ const injectPhase = ctx.trackPhase('inject subset font-family into CSS/SVG');
1011
+ injectSubsetFontFamilies(ctx.assetGraph, ctx.pages, ctx.omitFallbacks);
1012
+ ctx.timings['inject subset font-family'] = injectPhase.end();
1013
+ const orphanCleanupPhase = ctx.trackPhase('source maps + orphan cleanup');
1014
+ if (ctx.sourceMaps) {
1015
+ await rewriteCssSourceMaps(ctx.assetGraph, ctx.hrefType);
1016
+ }
1017
+ removeOrphanedAssets(ctx.assetGraph, ctx.potentiallyOrphanedAssets);
1018
+ ctx.timings['source maps + orphan cleanup'] = orphanCleanupPhase.end();
1019
+ }
1020
+ async function subsetFonts(assetGraph, { formats = ['woff2'], subsetPath = 'subfont/', omitFallbacks = false, inlineCss = false, fontDisplay, hrefType = 'rootRelative', onlyInfo, dynamic, console = global.console, text, sourceMaps = false, debug = false, concurrency, chromeArgs = [], cacheDir = null, } = {}) {
1021
+ if (fontDisplay && !validFontDisplayValues.includes(fontDisplay)) {
1022
+ fontDisplay = undefined;
1023
+ }
1024
+ // Pre-warm the WASM pool: start compiling harfbuzz WASM while
1025
+ // collectTextsByPage traces fonts. Compilation (~50-200ms) overlaps
1026
+ // with tracing work rather than appearing on the critical path.
1027
+ subsetFontWithGlyphs.warmup().catch((err) => {
1028
+ if (debug) {
1029
+ console.warn('WASM warmup failed (will retry on first subset call):', err);
1030
+ }
1031
+ });
1032
+ const subsetUrl = urltools.ensureTrailingSlash(assetGraph.root + subsetPath);
1033
+ const timings = {};
1034
+ const trackPhase = (0, progress_1.makePhaseTracker)(console, debug);
1035
+ const applySourceMapsPhase = trackPhase('applySourceMaps');
1035
1036
  if (sourceMaps) {
1036
- await rewriteCssSourceMaps(assetGraph, hrefType);
1037
+ await assetGraph.applySourceMaps({ type: 'Css' });
1038
+ }
1039
+ timings.applySourceMaps = applySourceMapsPhase.end();
1040
+ const googlePopulatePhase = trackPhase('populate (google fonts)');
1041
+ const hasGoogleFonts = await populateGoogleFontsIfPresent(assetGraph);
1042
+ timings['populate (google fonts)'] = googlePopulatePhase.end(hasGoogleFonts ? null : 'skipped, no Google Fonts found');
1043
+ const preCtx = {
1044
+ assetGraph,
1045
+ htmlOrSvgAssets: assetGraph.findAssets({
1046
+ $or: [{ type: 'Html', isInline: false }, { type: 'Svg' }],
1047
+ }),
1048
+ console,
1049
+ debug,
1050
+ text,
1051
+ dynamic,
1052
+ concurrency,
1053
+ chromeArgs,
1054
+ formats,
1055
+ hrefType,
1056
+ subsetUrl,
1057
+ omitFallbacks,
1058
+ sourceMaps,
1059
+ cacheDir,
1060
+ hasGoogleFonts,
1061
+ potentiallyOrphanedAssets: new Set(),
1062
+ trackPhase,
1063
+ timings,
1064
+ };
1065
+ const { pages, fontFaceDeclarationsByHtmlOrSvgAsset } = await runCollectAndPrepPagesPhase(preCtx);
1066
+ const ctx = {
1067
+ ...preCtx,
1068
+ pages,
1069
+ fontFaceDeclarationsByHtmlOrSvgAsset,
1070
+ };
1071
+ const codepointPhase = trackPhase('codepoint generation');
1072
+ await computeCodepoints(assetGraph, pages, fontDisplay);
1073
+ timings['codepoint generation'] = codepointPhase.end();
1074
+ if (onlyInfo) {
1075
+ // Stage 2 hasn't run, but buildFontInfoReport's input only requires
1076
+ // `codepoints` (stage 3) — already attached above.
1077
+ return {
1078
+ fontInfo: buildFontInfoReport(pages),
1079
+ timings,
1080
+ };
1081
+ }
1082
+ await runSubsetPhase(ctx);
1083
+ const insertPhase = trackPhase(`insert subsets loop (${pages.length} pages)`);
1084
+ const { numFontUsagesWithSubset } = await insertSubsets({
1085
+ assetGraph,
1086
+ pages,
1087
+ formats,
1088
+ subsetUrl,
1089
+ hrefType,
1090
+ inlineCss,
1091
+ omitFallbacks,
1092
+ });
1093
+ timings['insert subsets loop'] = insertPhase.end();
1094
+ if (numFontUsagesWithSubset === 0) {
1095
+ return { fontInfo: [], timings };
1037
1096
  }
1038
- removeOrphanedAssets(assetGraph, potentiallyOrphanedAssets);
1039
- timings['source maps + orphan cleanup'] = orphanCleanupPhase.end();
1097
+ await runPostSubsetCleanup(ctx);
1040
1098
  return {
1041
1099
  fontInfo: buildFontInfoReport(pages),
1042
1100
  timings,