@turntrout/subfont 1.10.2 → 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.
- package/lib/HeadlessBrowser.d.ts +2 -0
- package/lib/HeadlessBrowser.d.ts.map +1 -1
- package/lib/HeadlessBrowser.js +64 -58
- package/lib/HeadlessBrowser.js.map +1 -1
- package/lib/collectTextsByPage.d.ts.map +1 -1
- package/lib/collectTextsByPage.js +136 -132
- package/lib/collectTextsByPage.js.map +1 -1
- package/lib/getCssRulesByProperty.d.ts.map +1 -1
- package/lib/getCssRulesByProperty.js +234 -207
- package/lib/getCssRulesByProperty.js.map +1 -1
- package/lib/parseCommandLineOptions.d.ts.map +1 -1
- package/lib/parseCommandLineOptions.js +29 -14
- package/lib/parseCommandLineOptions.js.map +1 -1
- package/lib/subfont.d.ts.map +1 -1
- package/lib/subfont.js +347 -311
- package/lib/subfont.js.map +1 -1
- package/lib/subsetFontWithGlyphs.d.ts.map +1 -1
- package/lib/subsetFontWithGlyphs.js +60 -48
- package/lib/subsetFontWithGlyphs.js.map +1 -1
- package/lib/subsetFonts.d.ts.map +1 -1
- package/lib/subsetFonts.js +338 -280
- package/lib/subsetFonts.js.map +1 -1
- package/lib/subsetGeneration.d.ts.map +1 -1
- package/lib/subsetGeneration.js +151 -127
- package/lib/subsetGeneration.js.map +1 -1
- package/lib/warnAboutMissingGlyphs.d.ts.map +1 -1
- package/lib/warnAboutMissingGlyphs.js +132 -112
- package/lib/warnAboutMissingGlyphs.js.map +1 -1
- package/package.json +1 -1
package/lib/subsetFonts.js
CHANGED
|
@@ -455,47 +455,52 @@ async function computeCodepoints(assetGraph, htmlOrSvgAssetTextsWithProps, fontD
|
|
|
455
455
|
}
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
946
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1039
|
-
timings['source maps + orphan cleanup'] = orphanCleanupPhase.end();
|
|
1097
|
+
await runPostSubsetCleanup(ctx);
|
|
1040
1098
|
return {
|
|
1041
1099
|
fontInfo: buildFontInfoReport(pages),
|
|
1042
1100
|
timings,
|