@turntrout/subfont 1.0.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.
@@ -0,0 +1,1218 @@
1
+ const urltools = require('urltools');
2
+
3
+ const fontverter = require('fontverter');
4
+
5
+ const compileQuery = require('assetgraph/lib/compileQuery');
6
+
7
+ const findCustomPropertyDefinitions = require('./findCustomPropertyDefinitions');
8
+ const extractReferencedCustomPropertyNames = require('./extractReferencedCustomPropertyNames');
9
+ const injectSubsetDefinitions = require('./injectSubsetDefinitions');
10
+ const cssFontParser = require('css-font-parser');
11
+ const cssListHelpers = require('css-list-helpers');
12
+ const unquote = require('./unquote');
13
+ const normalizeFontPropertyValue = require('./normalizeFontPropertyValue');
14
+ const unicodeRange = require('./unicodeRange');
15
+ const getFontInfo = require('./getFontInfo');
16
+ const collectTextsByPage = require('./collectTextsByPage');
17
+
18
+ const {
19
+ maybeCssQuote,
20
+ getFontFaceDeclarationText,
21
+ getUnusedVariantsStylesheet,
22
+ getFontUsageStylesheet,
23
+ getCodepoints,
24
+ cssAssetIsEmpty,
25
+ parseFontWeightRange,
26
+ parseFontStretchRange,
27
+ hashHexPrefix,
28
+ } = require('./fontFaceHelpers');
29
+ const { getVariationAxisUsage } = require('./variationAxes');
30
+ const { getSubsetsForFontUsage } = require('./subsetGeneration');
31
+
32
+ const googleFontsCssUrlRegex = /^(?:https?:)?\/\/fonts\.googleapis\.com\/css/;
33
+
34
+ function getParents(asset, assetQuery) {
35
+ const assetMatcher = compileQuery(assetQuery);
36
+ const seenAssets = new Set();
37
+ const parents = [];
38
+ (function visit(asset) {
39
+ if (seenAssets.has(asset)) {
40
+ return;
41
+ }
42
+ seenAssets.add(asset);
43
+
44
+ for (const incomingRelation of asset.incomingRelations) {
45
+ if (assetMatcher(incomingRelation.from)) {
46
+ parents.push(incomingRelation.from);
47
+ } else {
48
+ visit(incomingRelation.from);
49
+ }
50
+ }
51
+ })(asset);
52
+
53
+ return parents;
54
+ }
55
+
56
+ // Escape a value for safe inclusion in any JS string context (single-quoted,
57
+ // double-quoted, or template literal). Uses JSON.stringify for robust escaping
58
+ // of backslashes, quotes, newlines, U+2028, U+2029, etc.
59
+ // The < escape prevents </script> from closing an inline script tag.
60
+ function escapeJsStringLiteral(str) {
61
+ return JSON.stringify(str)
62
+ .slice(1, -1)
63
+ .replace(/'/g, "\\'")
64
+ .replace(/`/g, '\\x60')
65
+ .replace(/</g, '\\x3c');
66
+ }
67
+
68
+ function asyncLoadStyleRelationWithFallback(
69
+ htmlOrSvgAsset,
70
+ originalRelation,
71
+ hrefType
72
+ ) {
73
+ // Async load google font stylesheet
74
+ // Insert async CSS loading <script>
75
+ const href = escapeJsStringLiteral(
76
+ htmlOrSvgAsset.assetGraph.buildHref(
77
+ originalRelation.to.url,
78
+ htmlOrSvgAsset.url,
79
+ { hrefType }
80
+ )
81
+ );
82
+ const mediaAssignment = originalRelation.media
83
+ ? `el.media = '${escapeJsStringLiteral(originalRelation.media)}';`
84
+ : '';
85
+ const asyncCssLoadingRelation = htmlOrSvgAsset.addRelation(
86
+ {
87
+ type: 'HtmlScript',
88
+ hrefType: 'inline',
89
+ to: {
90
+ type: 'JavaScript',
91
+ text: `
92
+ (function () {
93
+ var el = document.createElement('link');
94
+ el.href = '${href}'.toString('url');
95
+ el.rel = 'stylesheet';
96
+ ${mediaAssignment}
97
+ document.body.appendChild(el);
98
+ }())
99
+ `,
100
+ },
101
+ },
102
+ 'lastInBody'
103
+ );
104
+
105
+ // Insert <noscript> fallback sync CSS loading
106
+ const noScriptFallbackRelation = htmlOrSvgAsset.addRelation(
107
+ {
108
+ type: 'HtmlNoscript',
109
+ to: {
110
+ type: 'Html',
111
+ text: '',
112
+ },
113
+ },
114
+ 'lastInBody'
115
+ );
116
+
117
+ noScriptFallbackRelation.to.addRelation(
118
+ {
119
+ type: 'HtmlStyle',
120
+ media: originalRelation.media,
121
+ to: originalRelation.to,
122
+ hrefType,
123
+ },
124
+ 'last'
125
+ );
126
+
127
+ noScriptFallbackRelation.inline();
128
+ asyncCssLoadingRelation.to.minify();
129
+ htmlOrSvgAsset.markDirty();
130
+ }
131
+
132
+ const extensionByFormat = {
133
+ truetype: '.ttf',
134
+ woff: '.woff',
135
+ woff2: '.woff2',
136
+ };
137
+
138
+ async function createSelfHostedGoogleFontsCssAsset(
139
+ assetGraph,
140
+ googleFontsCssAsset,
141
+ formats,
142
+ hrefType,
143
+ subsetUrl
144
+ ) {
145
+ const lines = [];
146
+ for (const cssFontFaceSrc of assetGraph.findRelations({
147
+ from: googleFontsCssAsset,
148
+ type: 'CssFontFaceSrc',
149
+ })) {
150
+ lines.push(`@font-face {`);
151
+ const fontFaceDeclaration = cssFontFaceSrc.node;
152
+ fontFaceDeclaration.walkDecls((declaration) => {
153
+ const propName = declaration.prop.toLowerCase();
154
+ if (propName !== 'src') {
155
+ lines.push(` ${propName}: ${declaration.value};`);
156
+ }
157
+ });
158
+ // Convert to all formats in parallel
159
+ const convertedFonts = await Promise.all(
160
+ formats.map((format) =>
161
+ fontverter.convert(cssFontFaceSrc.to.rawSrc, format)
162
+ )
163
+ );
164
+ const srcFragments = [];
165
+ for (let fi = 0; fi < formats.length; fi++) {
166
+ const format = formats[fi];
167
+ const rawSrc = convertedFonts[fi];
168
+ const url = assetGraph.resolveUrl(
169
+ subsetUrl,
170
+ `${cssFontFaceSrc.to.baseName}-${hashHexPrefix(rawSrc)}${
171
+ extensionByFormat[format]
172
+ }`
173
+ );
174
+ const fontAsset =
175
+ assetGraph.findAssets({ url })[0] ||
176
+ (await assetGraph.addAsset({
177
+ url,
178
+ rawSrc,
179
+ }));
180
+ srcFragments.push(
181
+ `url(${assetGraph.buildHref(fontAsset.url, subsetUrl, {
182
+ hrefType,
183
+ })}) format('${format}')`
184
+ );
185
+ }
186
+ lines.push(` src: ${srcFragments.join(', ')};`);
187
+ lines.push(
188
+ ` unicode-range: ${unicodeRange(
189
+ (await getFontInfo(cssFontFaceSrc.to.rawSrc)).characterSet
190
+ )};`
191
+ );
192
+ lines.push('}');
193
+ }
194
+ const text = lines.join('\n');
195
+ const fallbackAsset = assetGraph.addAsset({
196
+ type: 'Css',
197
+ url: assetGraph.resolveUrl(
198
+ subsetUrl,
199
+ `fallback-${hashHexPrefix(text)}.css`
200
+ ),
201
+ text,
202
+ });
203
+ return fallbackAsset;
204
+ }
205
+
206
+ const validFontDisplayValues = [
207
+ 'auto',
208
+ 'block',
209
+ 'swap',
210
+ 'fallback',
211
+ 'optional',
212
+ ];
213
+
214
+ const warnAboutMissingGlyphs = require('./warnAboutMissingGlyphs');
215
+
216
+ async function subsetFonts(
217
+ assetGraph,
218
+ {
219
+ formats = ['woff2'],
220
+ subsetPath = 'subfont/',
221
+ omitFallbacks = false,
222
+ inlineCss,
223
+ fontDisplay,
224
+ hrefType = 'rootRelative',
225
+ onlyInfo,
226
+ dynamic,
227
+ console = global.console,
228
+ text,
229
+ sourceMaps = false,
230
+ debug = false,
231
+ concurrency,
232
+ chromeArgs = [],
233
+ cacheDir = null,
234
+ } = {}
235
+ ) {
236
+ if (!validFontDisplayValues.includes(fontDisplay)) {
237
+ fontDisplay = undefined;
238
+ }
239
+
240
+ const subsetUrl = urltools.ensureTrailingSlash(assetGraph.root + subsetPath);
241
+
242
+ const timings = {};
243
+
244
+ let phaseStart = Date.now();
245
+ if (sourceMaps) {
246
+ await assetGraph.applySourceMaps({ type: 'Css' });
247
+ }
248
+ timings.applySourceMaps = Date.now() - phaseStart;
249
+ if (debug && console)
250
+ console.log(
251
+ `[subfont timing] applySourceMaps: ${timings.applySourceMaps}ms`
252
+ );
253
+
254
+ phaseStart = Date.now();
255
+ // Only run Google Fonts populate if there are actually Google Fonts
256
+ // references in the graph. This avoids ~30s of wasted work on sites
257
+ // that use only self-hosted fonts.
258
+ const hasGoogleFonts =
259
+ assetGraph.findRelations({
260
+ to: { url: { $regex: googleFontsCssUrlRegex } },
261
+ }).length > 0;
262
+
263
+ if (hasGoogleFonts) {
264
+ await assetGraph.populate({
265
+ followRelations: {
266
+ $or: [
267
+ {
268
+ to: {
269
+ url: { $regex: googleFontsCssUrlRegex },
270
+ },
271
+ },
272
+ {
273
+ type: 'CssFontFaceSrc',
274
+ from: {
275
+ url: { $regex: googleFontsCssUrlRegex },
276
+ },
277
+ },
278
+ ],
279
+ },
280
+ });
281
+ }
282
+ timings['populate (google fonts)'] = Date.now() - phaseStart;
283
+ if (debug && console)
284
+ console.log(
285
+ `[subfont timing] populate (google fonts): ${timings['populate (google fonts)']}ms${hasGoogleFonts ? '' : ' (skipped, no Google Fonts found)'}`
286
+ );
287
+
288
+ const htmlOrSvgAssets = assetGraph.findAssets({
289
+ $or: [
290
+ {
291
+ type: 'Html',
292
+ isInline: false,
293
+ },
294
+ {
295
+ type: 'Svg',
296
+ },
297
+ ],
298
+ });
299
+
300
+ if (debug && console)
301
+ console.log(
302
+ `[subfont timing] Starting collectTextsByPage for ${htmlOrSvgAssets.length} pages`
303
+ );
304
+ const collectStart = Date.now();
305
+ const {
306
+ htmlOrSvgAssetTextsWithProps,
307
+ fontFaceDeclarationsByHtmlOrSvgAsset,
308
+ subTimings,
309
+ } = await collectTextsByPage(assetGraph, htmlOrSvgAssets, {
310
+ text,
311
+ console,
312
+ dynamic,
313
+ debug,
314
+ concurrency,
315
+ chromeArgs,
316
+ });
317
+ timings.collectTextsByPage = Date.now() - collectStart;
318
+ timings.collectTextsByPageDetails = subTimings;
319
+ if (debug && console)
320
+ console.log(
321
+ `[subfont timing] collectTextsByPage finished in ${timings.collectTextsByPage}ms`
322
+ );
323
+
324
+ phaseStart = Date.now();
325
+
326
+ const potentiallyOrphanedAssets = new Set();
327
+ if (omitFallbacks) {
328
+ for (const htmlOrSvgAsset of htmlOrSvgAssets) {
329
+ const accumulatedFontFaceDeclarations =
330
+ fontFaceDeclarationsByHtmlOrSvgAsset.get(htmlOrSvgAsset);
331
+ // Remove the original @font-face rules:
332
+ for (const { relations } of accumulatedFontFaceDeclarations) {
333
+ for (const relation of relations) {
334
+ potentiallyOrphanedAssets.add(relation.to);
335
+ if (relation.node.parentNode) {
336
+ relation.node.parentNode.removeChild(relation.node);
337
+ }
338
+ relation.remove();
339
+ }
340
+ }
341
+ htmlOrSvgAsset.markDirty();
342
+ }
343
+ }
344
+
345
+ timings['omitFallbacks processing'] = Date.now() - phaseStart;
346
+ if (debug && console)
347
+ console.log(
348
+ `[subfont timing] omitFallbacks processing: ${timings['omitFallbacks processing']}ms`
349
+ );
350
+ phaseStart = Date.now();
351
+
352
+ if (fontDisplay) {
353
+ for (const htmlOrSvgAssetTextWithProps of htmlOrSvgAssetTextsWithProps) {
354
+ for (const fontUsage of htmlOrSvgAssetTextWithProps.fontUsages) {
355
+ fontUsage.props['font-display'] = fontDisplay;
356
+ }
357
+ }
358
+ }
359
+
360
+ // Pre-compute the global codepoints (original, used, unused) once per fontUrl
361
+ // since fontUsage.text is the same global union on every page.
362
+ // Pre-index all loaded assets by URL for O(1) lookups instead of O(n) scans.
363
+ const loadedAssetsByUrl = new Map();
364
+ for (const asset of assetGraph.findAssets({ isLoaded: true })) {
365
+ if (asset.url) loadedAssetsByUrl.set(asset.url, asset);
366
+ }
367
+ const codepointFontAssetByUrl = new Map();
368
+ for (const htmlOrSvgAssetTextWithProps of htmlOrSvgAssetTextsWithProps) {
369
+ for (const fontUsage of htmlOrSvgAssetTextWithProps.fontUsages) {
370
+ if (
371
+ fontUsage.fontUrl &&
372
+ !codepointFontAssetByUrl.has(fontUsage.fontUrl)
373
+ ) {
374
+ const originalFont = loadedAssetsByUrl.get(fontUsage.fontUrl);
375
+ if (originalFont) {
376
+ codepointFontAssetByUrl.set(fontUsage.fontUrl, originalFont);
377
+ }
378
+ }
379
+ }
380
+ }
381
+
382
+ // getFontInfo internally serializes harfbuzzjs WASM calls (which are
383
+ // not concurrency-safe), so Promise.all here just queues them up
384
+ // and avoids awaiting each individually in the loop below.
385
+ const fontInfoPromises = new Map();
386
+ for (const [fontUrl, fontAsset] of codepointFontAssetByUrl) {
387
+ if (fontAsset.isLoaded) {
388
+ fontInfoPromises.set(
389
+ fontUrl,
390
+ getFontInfo(fontAsset.rawSrc).catch((err) => {
391
+ err.asset = err.asset || fontAsset;
392
+ assetGraph.warn(err);
393
+ return null;
394
+ })
395
+ );
396
+ }
397
+ }
398
+ const fontInfoResults = new Map();
399
+ const fontInfoKeys = [...fontInfoPromises.keys()];
400
+ const fontInfoValues = await Promise.all(fontInfoPromises.values());
401
+ for (let i = 0; i < fontInfoKeys.length; i++) {
402
+ fontInfoResults.set(fontInfoKeys[i], fontInfoValues[i]);
403
+ }
404
+
405
+ // Build global codepoints synchronously from pre-fetched results
406
+ const globalCodepointsByFontUrl = new Map();
407
+ const codepointsCache = new Map();
408
+ for (const htmlOrSvgAssetTextWithProps of htmlOrSvgAssetTextsWithProps) {
409
+ for (const fontUsage of htmlOrSvgAssetTextWithProps.fontUsages) {
410
+ let cached = globalCodepointsByFontUrl.get(fontUsage.fontUrl);
411
+ if (!cached) {
412
+ cached = { originalCodepoints: null };
413
+ const fontInfo = fontInfoResults.get(fontUsage.fontUrl);
414
+ if (fontInfo) {
415
+ cached.originalCodepoints = fontInfo.characterSet;
416
+ cached.usedCodepoints = getCodepoints(fontUsage.text);
417
+ const usedCodepointsSet = new Set(cached.usedCodepoints);
418
+ cached.unusedCodepoints = cached.originalCodepoints.filter(
419
+ (n) => !usedCodepointsSet.has(n)
420
+ );
421
+ }
422
+ globalCodepointsByFontUrl.set(fontUsage.fontUrl, cached);
423
+ }
424
+
425
+ if (cached.originalCodepoints) {
426
+ // Cache getCodepoints result by pageText string to avoid
427
+ // recomputing for pages with identical text per font
428
+ let pageCodepoints = codepointsCache.get(fontUsage.pageText);
429
+ if (!pageCodepoints) {
430
+ pageCodepoints = getCodepoints(fontUsage.pageText);
431
+ codepointsCache.set(fontUsage.pageText, pageCodepoints);
432
+ }
433
+ fontUsage.codepoints = {
434
+ original: cached.originalCodepoints,
435
+ used: cached.usedCodepoints,
436
+ unused: cached.unusedCodepoints,
437
+ page: pageCodepoints,
438
+ };
439
+ } else {
440
+ fontUsage.codepoints = {
441
+ original: [],
442
+ used: [],
443
+ unused: [],
444
+ page: [],
445
+ };
446
+ }
447
+ }
448
+ }
449
+
450
+ timings['codepoint generation'] = Date.now() - phaseStart;
451
+ if (debug && console)
452
+ console.log(
453
+ `[subfont timing] codepoint generation: ${timings['codepoint generation']}ms`
454
+ );
455
+ phaseStart = Date.now();
456
+
457
+ if (onlyInfo) {
458
+ return {
459
+ fontInfo: htmlOrSvgAssetTextsWithProps.map(
460
+ ({ fontUsages, htmlOrSvgAsset }) => ({
461
+ assetFileName: htmlOrSvgAsset.nonInlineAncestor.urlOrDescription,
462
+ fontUsages: fontUsages.map((fontUsage) =>
463
+ (({ hasFontFeatureSettings, ...rest }) => rest)(fontUsage)
464
+ ),
465
+ })
466
+ ),
467
+ timings,
468
+ };
469
+ }
470
+
471
+ const { seenAxisValuesByFontUrlAndAxisName } = getVariationAxisUsage(
472
+ htmlOrSvgAssetTextsWithProps,
473
+ parseFontWeightRange,
474
+ parseFontStretchRange
475
+ );
476
+
477
+ timings['variation axis usage'] = Date.now() - phaseStart;
478
+ if (debug && console)
479
+ console.log(
480
+ `[subfont timing] variation axis usage: ${timings['variation axis usage']}ms`
481
+ );
482
+ phaseStart = Date.now();
483
+
484
+ // Generate subsets:
485
+ await getSubsetsForFontUsage(
486
+ assetGraph,
487
+ htmlOrSvgAssetTextsWithProps,
488
+ formats,
489
+ seenAxisValuesByFontUrlAndAxisName,
490
+ cacheDir
491
+ );
492
+
493
+ timings.getSubsetsForFontUsage = Date.now() - phaseStart;
494
+ if (debug && console)
495
+ console.log(
496
+ `[subfont timing] getSubsetsForFontUsage: ${timings.getSubsetsForFontUsage}ms`
497
+ );
498
+ phaseStart = Date.now();
499
+
500
+ await warnAboutMissingGlyphs(htmlOrSvgAssetTextsWithProps, assetGraph);
501
+ timings.warnAboutMissingGlyphs = Date.now() - phaseStart;
502
+ if (debug && console)
503
+ console.log(
504
+ `[subfont timing] warnAboutMissingGlyphs: ${timings.warnAboutMissingGlyphs}ms`
505
+ );
506
+ phaseStart = Date.now();
507
+
508
+ // Insert subsets:
509
+
510
+ // Pre-compute which fontUrls are used (with text) on every page,
511
+ // so we can avoid O(pages × fontUsages) checks inside the font loop.
512
+ const fontUrlsUsedOnEveryPage = new Set();
513
+ if (htmlOrSvgAssetTextsWithProps.length > 0) {
514
+ // Start with all fontUrls from the first page
515
+ const firstPageFontUrls = new Set();
516
+ for (const fu of htmlOrSvgAssetTextsWithProps[0].fontUsages) {
517
+ if (fu.pageText) firstPageFontUrls.add(fu.fontUrl);
518
+ }
519
+ for (const fontUrl of firstPageFontUrls) {
520
+ if (
521
+ htmlOrSvgAssetTextsWithProps.every(({ fontUsages }) =>
522
+ fontUsages.some((fu) => fu.pageText && fu.fontUrl === fontUrl)
523
+ )
524
+ ) {
525
+ fontUrlsUsedOnEveryPage.add(fontUrl);
526
+ }
527
+ }
528
+ }
529
+
530
+ // Cache subset CSS assets by their source text to avoid redundant
531
+ // addAsset/minify/removeAsset cycles for pages sharing identical CSS.
532
+ const subsetCssAssetCache = new Map();
533
+
534
+ // Pre-index relations by source asset to avoid O(allRelations) scans
535
+ // in the per-page injection loop below. Build indices once, then use
536
+ // O(1) lookups per page instead of repeated assetGraph.findRelations.
537
+ const styleRelsByAsset = new Map();
538
+ const noscriptRelsByAsset = new Map();
539
+ const preloadRelsByAsset = new Map();
540
+ for (const relation of assetGraph.findRelations({
541
+ type: {
542
+ $in: [
543
+ 'HtmlStyle',
544
+ 'SvgStyle',
545
+ 'HtmlNoscript',
546
+ 'HtmlPrefetchLink',
547
+ 'HtmlPreloadLink',
548
+ ],
549
+ },
550
+ })) {
551
+ const from = relation.from;
552
+ if (relation.type === 'HtmlStyle' || relation.type === 'SvgStyle') {
553
+ if (!styleRelsByAsset.has(from)) styleRelsByAsset.set(from, []);
554
+ styleRelsByAsset.get(from).push(relation);
555
+ } else if (relation.type === 'HtmlNoscript') {
556
+ if (!noscriptRelsByAsset.has(from)) noscriptRelsByAsset.set(from, []);
557
+ noscriptRelsByAsset.get(from).push(relation);
558
+ } else {
559
+ if (!preloadRelsByAsset.has(from)) preloadRelsByAsset.set(from, []);
560
+ preloadRelsByAsset.get(from).push(relation);
561
+ }
562
+ }
563
+
564
+ let numFontUsagesWithSubset = 0;
565
+ for (const {
566
+ htmlOrSvgAsset,
567
+ fontUsages,
568
+ accumulatedFontFaceDeclarations,
569
+ } of htmlOrSvgAssetTextsWithProps) {
570
+ const styleRels = styleRelsByAsset.get(htmlOrSvgAsset) || [];
571
+ let insertionPoint = styleRels[0];
572
+
573
+ // Fall back to inserting before a <noscript> that contains a stylesheet
574
+ // when no direct stylesheet relation exists (assetgraph#1251)
575
+ if (!insertionPoint && htmlOrSvgAsset.type === 'Html') {
576
+ for (const htmlNoScript of noscriptRelsByAsset.get(htmlOrSvgAsset) ||
577
+ []) {
578
+ const noscriptStyleRels = styleRelsByAsset.get(htmlNoScript.to) || [];
579
+ if (noscriptStyleRels.length > 0) {
580
+ insertionPoint = htmlNoScript;
581
+ break;
582
+ }
583
+ }
584
+ }
585
+ const subsetFontUsages = fontUsages.filter(
586
+ (fontUsage) => fontUsage.subsets
587
+ );
588
+ const subsetFontUsagesSet = new Set(subsetFontUsages);
589
+ const unsubsettedFontUsages = fontUsages.filter(
590
+ (fontUsage) => !subsetFontUsagesSet.has(fontUsage)
591
+ );
592
+
593
+ // Remove all existing preload hints to fonts that might have new subsets
594
+ const fontUrls = new Set(fontUsages.map((fu) => fu.fontUrl));
595
+ for (const relation of preloadRelsByAsset.get(htmlOrSvgAsset) || []) {
596
+ if (relation.to && fontUrls.has(relation.to.url)) {
597
+ if (relation.type === 'HtmlPrefetchLink') {
598
+ const err = new Error(
599
+ `Detached ${relation.node.outerHTML}. Will be replaced with preload with JS fallback.\nIf you feel this is wrong, open an issue at https://github.com/Munter/subfont/issues`
600
+ );
601
+ err.asset = relation.from;
602
+ err.relation = relation;
603
+
604
+ assetGraph.info(err);
605
+ }
606
+
607
+ relation.detach();
608
+ }
609
+ }
610
+
611
+ const unsubsettedFontUsagesToPreload = unsubsettedFontUsages.filter(
612
+ (fontUsage) => fontUsage.preload
613
+ );
614
+
615
+ if (unsubsettedFontUsagesToPreload.length > 0) {
616
+ // Insert <link rel="preload">
617
+ for (const fontUsage of unsubsettedFontUsagesToPreload) {
618
+ // Always preload unsubsetted font files, they might be any format, so can't be clever here
619
+ const preloadRelation = htmlOrSvgAsset.addRelation(
620
+ {
621
+ type: 'HtmlPreloadLink',
622
+ hrefType,
623
+ to: fontUsage.fontUrl,
624
+ as: 'font',
625
+ },
626
+ insertionPoint ? 'before' : 'firstInHead',
627
+ insertionPoint
628
+ );
629
+ insertionPoint = insertionPoint || preloadRelation;
630
+ }
631
+ }
632
+
633
+ if (subsetFontUsages.length === 0) {
634
+ continue;
635
+ }
636
+ numFontUsagesWithSubset += subsetFontUsages.length;
637
+
638
+ let subsetCssText = getFontUsageStylesheet(subsetFontUsages);
639
+ const unusedVariantsCss = getUnusedVariantsStylesheet(
640
+ fontUsages,
641
+ accumulatedFontFaceDeclarations
642
+ );
643
+ if (!inlineCss && !omitFallbacks) {
644
+ // This can go into the same stylesheet because we won't reload all __subset suffixed families in the JS preload fallback
645
+ subsetCssText += unusedVariantsCss;
646
+ }
647
+
648
+ let cssAsset = subsetCssAssetCache.get(subsetCssText);
649
+ if (!cssAsset) {
650
+ cssAsset = assetGraph.addAsset({
651
+ type: 'Css',
652
+ url: `${subsetUrl}subfontTemp.css`,
653
+ text: subsetCssText,
654
+ });
655
+
656
+ await cssAsset.minify();
657
+
658
+ for (const [i, fontRelation] of cssAsset.outgoingRelations.entries()) {
659
+ const fontAsset = fontRelation.to;
660
+ if (!fontAsset.isLoaded) {
661
+ // An unused variant that does not exist, don't try to hash
662
+ fontRelation.hrefType = hrefType;
663
+ continue;
664
+ }
665
+
666
+ const fontUsage = subsetFontUsages[i];
667
+ if (
668
+ formats.length === 1 &&
669
+ fontUsage &&
670
+ (!inlineCss || htmlOrSvgAssetTextsWithProps.length === 1) &&
671
+ fontUrlsUsedOnEveryPage.has(fontUsage.fontUrl)
672
+ ) {
673
+ // We're only outputting one font format, we're not inlining the subfont CSS (or there's only one page), and this font is used on every page -- keep it inline in the subfont CSS
674
+ continue;
675
+ }
676
+
677
+ const extension = fontAsset.contentType.split('/').pop();
678
+
679
+ const nameProps = ['font-family', 'font-weight', 'font-style']
680
+ .map((prop) =>
681
+ fontRelation.node.nodes.find((decl) => decl.prop === prop)
682
+ )
683
+ .map((decl) => decl.value);
684
+
685
+ const fontWeightRangeStr = nameProps[1]
686
+ .split(/\s+/)
687
+ .map((token) => normalizeFontPropertyValue('font-weight', token))
688
+ .join('_');
689
+ const fileNamePrefix = `${unquote(
690
+ cssFontParser.parseFontFamily(nameProps[0])[0]
691
+ )
692
+ .replace(/__subset$/, '')
693
+ .replace(/[^a-z0-9_-]/gi, '_')}-${fontWeightRangeStr}${
694
+ nameProps[2] === 'italic' ? 'i' : ''
695
+ }`;
696
+
697
+ const fontFileName = `${fileNamePrefix}-${fontAsset.md5Hex.slice(
698
+ 0,
699
+ 10
700
+ )}.${extension}`;
701
+
702
+ // If it's not inline, it's one of the unused variants that gets a mirrored declaration added
703
+ // for the __subset @font-face. Do not move it to /subfont/
704
+ if (fontAsset.isInline) {
705
+ const fontAssetUrl = subsetUrl + fontFileName;
706
+ const existingFontAsset = assetGraph.findAssets({
707
+ url: fontAssetUrl,
708
+ })[0];
709
+ if (existingFontAsset) {
710
+ fontRelation.to = existingFontAsset;
711
+ assetGraph.removeAsset(fontAsset);
712
+ } else {
713
+ fontAsset.url = subsetUrl + fontFileName;
714
+ }
715
+ }
716
+
717
+ fontRelation.hrefType = hrefType;
718
+ }
719
+
720
+ const cssAssetUrl = `${subsetUrl}fonts-${cssAsset.md5Hex.slice(
721
+ 0,
722
+ 10
723
+ )}.css`;
724
+ const existingCssAsset = assetGraph.findAssets({ url: cssAssetUrl })[0];
725
+ if (existingCssAsset) {
726
+ assetGraph.removeAsset(cssAsset);
727
+ cssAsset = existingCssAsset;
728
+ } else {
729
+ cssAsset.url = cssAssetUrl;
730
+ }
731
+ subsetCssAssetCache.set(subsetCssText, cssAsset);
732
+ }
733
+
734
+ for (const fontRelation of cssAsset.outgoingRelations) {
735
+ if (fontRelation.hrefType === 'inline') {
736
+ continue;
737
+ }
738
+ const fontAsset = fontRelation.to;
739
+
740
+ if (
741
+ fontAsset.contentType === 'font/woff2' &&
742
+ fontRelation.to.url.startsWith(subsetUrl)
743
+ ) {
744
+ const fontFaceDeclaration = fontRelation.node;
745
+ const originalFontFamily = unquote(
746
+ fontFaceDeclaration.nodes.find((node) => node.prop === 'font-family')
747
+ .value
748
+ ).replace(/__subset$/, '');
749
+ if (
750
+ !fontUsages.some(
751
+ (fontUsage) =>
752
+ fontUsage.fontFamilies.has(originalFontFamily) &&
753
+ fontUsage.preload
754
+ )
755
+ ) {
756
+ continue;
757
+ }
758
+
759
+ // Only <link rel="preload"> for woff2 files
760
+ // Preload support is a subset of woff2 support:
761
+ // - https://caniuse.com/#search=woff2
762
+ // - https://caniuse.com/#search=preload
763
+
764
+ if (htmlOrSvgAsset.type === 'Html') {
765
+ const htmlPreloadLink = htmlOrSvgAsset.addRelation(
766
+ {
767
+ type: 'HtmlPreloadLink',
768
+ hrefType,
769
+ to: fontAsset,
770
+ as: 'font',
771
+ },
772
+ insertionPoint ? 'before' : 'firstInHead',
773
+ insertionPoint
774
+ );
775
+ insertionPoint = insertionPoint || htmlPreloadLink;
776
+ }
777
+ }
778
+ }
779
+ const cssRelation = htmlOrSvgAsset.addRelation(
780
+ {
781
+ type: `${htmlOrSvgAsset.type}Style`,
782
+ hrefType:
783
+ inlineCss || htmlOrSvgAsset.type === 'Svg' ? 'inline' : hrefType,
784
+ to: cssAsset,
785
+ },
786
+ insertionPoint ? 'before' : 'firstInHead',
787
+ insertionPoint
788
+ );
789
+ insertionPoint = insertionPoint || cssRelation;
790
+
791
+ if (!omitFallbacks && inlineCss && unusedVariantsCss) {
792
+ // The fallback CSS for unused variants needs to go into its own stylesheet after the crude version of the JS-based preload "polyfill"
793
+ const cssAsset = htmlOrSvgAsset.addRelation(
794
+ {
795
+ type: 'HtmlStyle',
796
+ to: {
797
+ type: 'Css',
798
+ text: unusedVariantsCss,
799
+ },
800
+ },
801
+ 'after',
802
+ cssRelation
803
+ ).to;
804
+ for (const relation of cssAsset.outgoingRelations) {
805
+ relation.hrefType = hrefType;
806
+ }
807
+ }
808
+ }
809
+
810
+ timings['insert subsets loop'] = Date.now() - phaseStart;
811
+ if (debug && console)
812
+ console.log(
813
+ `[subfont timing] insert subsets loop: ${timings['insert subsets loop']}ms`
814
+ );
815
+ phaseStart = Date.now();
816
+
817
+ if (numFontUsagesWithSubset === 0) {
818
+ return { fontInfo: [], timings };
819
+ }
820
+
821
+ const relationsToRemove = new Set();
822
+
823
+ // Lazy load the original @font-face declarations of self-hosted fonts (unless omitFallbacks)
824
+ const originalRelations = new Set();
825
+ const fallbackCssAssetCache = new Map();
826
+ for (const htmlOrSvgAsset of htmlOrSvgAssets) {
827
+ const accumulatedFontFaceDeclarations =
828
+ fontFaceDeclarationsByHtmlOrSvgAsset.get(htmlOrSvgAsset);
829
+ // TODO: Maybe group by media?
830
+ const containedRelationsByFontFaceRule = new Map();
831
+ for (const { relations } of accumulatedFontFaceDeclarations) {
832
+ for (const relation of relations) {
833
+ if (
834
+ relation.from.hostname === 'fonts.googleapis.com' || // Google Web Fonts handled separately below
835
+ containedRelationsByFontFaceRule.has(relation.node)
836
+ ) {
837
+ continue;
838
+ }
839
+ originalRelations.add(relation);
840
+ containedRelationsByFontFaceRule.set(
841
+ relation.node,
842
+ relation.from.outgoingRelations.filter(
843
+ (otherRelation) => otherRelation.node === relation.node
844
+ )
845
+ );
846
+ }
847
+ }
848
+
849
+ if (containedRelationsByFontFaceRule.size > 0 && !omitFallbacks) {
850
+ const fallbackCssText = [...containedRelationsByFontFaceRule.keys()]
851
+ .map((rule) =>
852
+ getFontFaceDeclarationText(
853
+ rule,
854
+ containedRelationsByFontFaceRule.get(rule)
855
+ )
856
+ )
857
+ .join('');
858
+
859
+ let cssAsset = fallbackCssAssetCache.get(fallbackCssText);
860
+ if (!cssAsset) {
861
+ cssAsset = assetGraph.addAsset({
862
+ type: 'Css',
863
+ text: fallbackCssText,
864
+ });
865
+ for (const relation of cssAsset.outgoingRelations) {
866
+ relation.hrefType = hrefType;
867
+ }
868
+ await cssAsset.minify();
869
+ cssAsset.url = `${subsetUrl}fallback-${cssAsset.md5Hex.slice(
870
+ 0,
871
+ 10
872
+ )}.css`;
873
+ fallbackCssAssetCache.set(fallbackCssText, cssAsset);
874
+ }
875
+
876
+ if (htmlOrSvgAsset.type === 'Html') {
877
+ // Create a <link rel="stylesheet"> that asyncLoadStyleRelationWithFallback can convert to async with noscript fallback:
878
+ const fallbackHtmlStyle = htmlOrSvgAsset.addRelation({
879
+ type: 'HtmlStyle',
880
+ to: cssAsset,
881
+ });
882
+
883
+ asyncLoadStyleRelationWithFallback(
884
+ htmlOrSvgAsset,
885
+ fallbackHtmlStyle,
886
+ hrefType
887
+ );
888
+ relationsToRemove.add(fallbackHtmlStyle);
889
+ }
890
+ }
891
+ }
892
+
893
+ timings['lazy load fallback CSS'] = Date.now() - phaseStart;
894
+ if (debug && console)
895
+ console.log(
896
+ `[subfont timing] lazy load fallback CSS: ${timings['lazy load fallback CSS']}ms`
897
+ );
898
+ phaseStart = Date.now();
899
+
900
+ // Remove the original @font-face blocks, and don't leave behind empty stylesheets:
901
+ const maybeEmptyCssAssets = new Set();
902
+ for (const relation of originalRelations) {
903
+ const cssAsset = relation.from;
904
+ if (relation.node.parent) {
905
+ relation.node.parent.removeChild(relation.node);
906
+ }
907
+ relation.remove();
908
+ cssAsset.markDirty();
909
+ maybeEmptyCssAssets.add(cssAsset);
910
+ }
911
+
912
+ for (const cssAsset of maybeEmptyCssAssets) {
913
+ if (cssAssetIsEmpty(cssAsset)) {
914
+ for (const incomingRelation of cssAsset.incomingRelations) {
915
+ incomingRelation.detach();
916
+ }
917
+ assetGraph.removeAsset(cssAsset);
918
+ }
919
+ }
920
+
921
+ timings['remove original @font-face'] = Date.now() - phaseStart;
922
+ if (debug && console)
923
+ console.log(
924
+ `[subfont timing] remove original @font-face: ${timings['remove original @font-face']}ms`
925
+ );
926
+ phaseStart = Date.now();
927
+
928
+ // Async load Google Web Fonts CSS
929
+ const googleFontStylesheets = assetGraph.findAssets({
930
+ type: 'Css',
931
+ url: { $regex: googleFontsCssUrlRegex },
932
+ });
933
+ const selfHostedGoogleCssByUrl = new Map();
934
+ for (const googleFontStylesheet of googleFontStylesheets) {
935
+ const seenPages = new Set(); // Only do the work once for each font on each page
936
+ for (const googleFontStylesheetRelation of googleFontStylesheet.incomingRelations) {
937
+ let htmlParents;
938
+
939
+ if (googleFontStylesheetRelation.type === 'CssImport') {
940
+ // Gather Html parents. Relevant if we are dealing with CSS @import relations
941
+ htmlParents = getParents(googleFontStylesheetRelation.to, {
942
+ type: { $in: ['Html', 'Svg'] },
943
+ isInline: false,
944
+ isLoaded: true,
945
+ });
946
+ } else if (
947
+ ['Html', 'Svg'].includes(googleFontStylesheetRelation.from.type)
948
+ ) {
949
+ htmlParents = [googleFontStylesheetRelation.from];
950
+ } else {
951
+ htmlParents = [];
952
+ }
953
+ for (const htmlParent of htmlParents) {
954
+ if (seenPages.has(htmlParent)) {
955
+ continue;
956
+ }
957
+ seenPages.add(htmlParent);
958
+
959
+ if (!omitFallbacks) {
960
+ let selfHostedGoogleFontsCssAsset = selfHostedGoogleCssByUrl.get(
961
+ googleFontStylesheetRelation.to.url
962
+ );
963
+ if (!selfHostedGoogleFontsCssAsset) {
964
+ selfHostedGoogleFontsCssAsset =
965
+ await createSelfHostedGoogleFontsCssAsset(
966
+ assetGraph,
967
+ googleFontStylesheetRelation.to,
968
+ formats,
969
+ hrefType,
970
+ subsetUrl
971
+ );
972
+ await selfHostedGoogleFontsCssAsset.minify();
973
+ selfHostedGoogleCssByUrl.set(
974
+ googleFontStylesheetRelation.to.url,
975
+ selfHostedGoogleFontsCssAsset
976
+ );
977
+ }
978
+ const selfHostedFallbackRelation = htmlParent.addRelation(
979
+ {
980
+ type: `${htmlParent.type}Style`,
981
+ to: selfHostedGoogleFontsCssAsset,
982
+ hrefType,
983
+ },
984
+ 'lastInBody'
985
+ );
986
+ relationsToRemove.add(selfHostedFallbackRelation);
987
+ if (htmlParent.type === 'Html') {
988
+ asyncLoadStyleRelationWithFallback(
989
+ htmlParent,
990
+ selfHostedFallbackRelation,
991
+ hrefType
992
+ );
993
+ }
994
+ }
995
+ relationsToRemove.add(googleFontStylesheetRelation);
996
+ }
997
+ }
998
+ googleFontStylesheet.unload();
999
+ }
1000
+
1001
+ // Clean up, making sure not to detach the same relation twice, eg. when multiple pages use the same stylesheet that imports a font
1002
+ for (const relation of relationsToRemove) {
1003
+ relation.detach();
1004
+ }
1005
+
1006
+ timings['Google Fonts + cleanup'] = Date.now() - phaseStart;
1007
+ if (debug && console)
1008
+ console.log(
1009
+ `[subfont timing] Google Fonts + cleanup: ${timings['Google Fonts + cleanup']}ms`
1010
+ );
1011
+ phaseStart = Date.now();
1012
+
1013
+ // Use subsets in font-family:
1014
+
1015
+ const webfontNameMap = {};
1016
+
1017
+ for (const { fontUsages } of htmlOrSvgAssetTextsWithProps) {
1018
+ for (const { subsets, fontFamilies, props } of fontUsages) {
1019
+ if (subsets) {
1020
+ for (const fontFamily of fontFamilies) {
1021
+ webfontNameMap[fontFamily.toLowerCase()] =
1022
+ `${props['font-family']}__subset`;
1023
+ }
1024
+ }
1025
+ }
1026
+ }
1027
+
1028
+ let customPropertyDefinitions; // Avoid computing this unless necessary
1029
+ // Inject subset font name before original webfont in SVG font-family attributes
1030
+ const svgAssets = assetGraph.findAssets({ type: 'Svg' });
1031
+ for (const svgAsset of svgAssets) {
1032
+ if (!svgAsset.isLoaded) continue;
1033
+ let changesMade = false;
1034
+ for (const element of Array.from(
1035
+ svgAsset.parseTree.querySelectorAll('[font-family]')
1036
+ )) {
1037
+ const fontFamilies = cssListHelpers.splitByCommas(
1038
+ element.getAttribute('font-family')
1039
+ );
1040
+ for (let i = 0; i < fontFamilies.length; i += 1) {
1041
+ const subsetFontFamily =
1042
+ webfontNameMap[
1043
+ cssFontParser.parseFontFamily(fontFamilies[i])[0].toLowerCase()
1044
+ ];
1045
+ if (subsetFontFamily && !fontFamilies.includes(subsetFontFamily)) {
1046
+ fontFamilies.splice(
1047
+ i,
1048
+ omitFallbacks ? 1 : 0,
1049
+ maybeCssQuote(subsetFontFamily)
1050
+ );
1051
+ i += 1;
1052
+ element.setAttribute('font-family', fontFamilies.join(', '));
1053
+ changesMade = true;
1054
+ }
1055
+ }
1056
+ }
1057
+ if (changesMade) {
1058
+ svgAsset.markDirty();
1059
+ }
1060
+ }
1061
+
1062
+ // Inject subset font name before original webfont in CSS
1063
+ const cssAssets = assetGraph.findAssets({
1064
+ type: 'Css',
1065
+ isLoaded: true,
1066
+ });
1067
+ let changesMadeToCustomPropertyDefinitions = false;
1068
+ for (const cssAsset of cssAssets) {
1069
+ let changesMade = false;
1070
+ cssAsset.eachRuleInParseTree((cssRule) => {
1071
+ if (cssRule.parent.type === 'rule' && cssRule.type === 'decl') {
1072
+ const propName = cssRule.prop.toLowerCase();
1073
+ if (
1074
+ (propName === 'font' || propName === 'font-family') &&
1075
+ cssRule.value.includes('var(')
1076
+ ) {
1077
+ if (!customPropertyDefinitions) {
1078
+ customPropertyDefinitions =
1079
+ findCustomPropertyDefinitions(cssAssets);
1080
+ }
1081
+ for (const customPropertyName of extractReferencedCustomPropertyNames(
1082
+ cssRule.value
1083
+ )) {
1084
+ for (const relatedCssRule of [
1085
+ cssRule,
1086
+ ...(customPropertyDefinitions[customPropertyName] || []),
1087
+ ]) {
1088
+ const modifiedValue = injectSubsetDefinitions(
1089
+ relatedCssRule.value,
1090
+ webfontNameMap,
1091
+ omitFallbacks // replaceOriginal
1092
+ );
1093
+ if (modifiedValue !== relatedCssRule.value) {
1094
+ relatedCssRule.value = modifiedValue;
1095
+ changesMadeToCustomPropertyDefinitions = true;
1096
+ }
1097
+ }
1098
+ }
1099
+ } else if (propName === 'font-family') {
1100
+ const fontFamilies = cssListHelpers.splitByCommas(cssRule.value);
1101
+ for (let i = 0; i < fontFamilies.length; i += 1) {
1102
+ const subsetFontFamily =
1103
+ webfontNameMap[
1104
+ cssFontParser.parseFontFamily(fontFamilies[i])[0].toLowerCase()
1105
+ ];
1106
+ if (subsetFontFamily && !fontFamilies.includes(subsetFontFamily)) {
1107
+ fontFamilies.splice(
1108
+ i,
1109
+ omitFallbacks ? 1 : 0,
1110
+ maybeCssQuote(subsetFontFamily)
1111
+ );
1112
+ i += 1;
1113
+ cssRule.value = fontFamilies.join(', ');
1114
+ changesMade = true;
1115
+ }
1116
+ }
1117
+ } else if (propName === 'font') {
1118
+ const fontProperties = cssFontParser.parseFont(cssRule.value);
1119
+ const fontFamilies =
1120
+ fontProperties && fontProperties['font-family'].map(unquote);
1121
+ if (fontFamilies) {
1122
+ const subsetFontFamily =
1123
+ webfontNameMap[fontFamilies[0].toLowerCase()];
1124
+ if (subsetFontFamily && !fontFamilies.includes(subsetFontFamily)) {
1125
+ // Rebuild the font shorthand with the subset family prepended
1126
+ if (omitFallbacks) {
1127
+ fontFamilies.shift();
1128
+ }
1129
+ fontFamilies.unshift(subsetFontFamily);
1130
+ const stylePrefix = fontProperties['font-style']
1131
+ ? `${fontProperties['font-style']} `
1132
+ : '';
1133
+ const weightPrefix = fontProperties['font-weight']
1134
+ ? `${fontProperties['font-weight']} `
1135
+ : '';
1136
+ const lineHeightSuffix = fontProperties['line-height']
1137
+ ? `/${fontProperties['line-height']}`
1138
+ : '';
1139
+ cssRule.value = `${stylePrefix}${weightPrefix}${
1140
+ fontProperties['font-size']
1141
+ }${lineHeightSuffix} ${fontFamilies
1142
+ .map(maybeCssQuote)
1143
+ .join(', ')}`;
1144
+ changesMade = true;
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+ });
1150
+ if (changesMade) {
1151
+ cssAsset.markDirty();
1152
+ }
1153
+ }
1154
+
1155
+ // This is a bit crude, could be more efficient if we tracked the containing asset in findCustomPropertyDefinitions
1156
+ if (changesMadeToCustomPropertyDefinitions) {
1157
+ for (const cssAsset of cssAssets) {
1158
+ cssAsset.markDirty();
1159
+ }
1160
+ }
1161
+
1162
+ timings['inject subset font-family'] = Date.now() - phaseStart;
1163
+ if (debug && console)
1164
+ console.log(
1165
+ `[subfont timing] inject subset font-family into CSS/SVG: ${timings['inject subset font-family']}ms`
1166
+ );
1167
+ phaseStart = Date.now();
1168
+
1169
+ if (sourceMaps) {
1170
+ await assetGraph.serializeSourceMaps(undefined, {
1171
+ type: 'Css',
1172
+ outgoingRelations: {
1173
+ $where: (relations) =>
1174
+ relations.some((relation) => relation.type === 'CssSourceMappingUrl'),
1175
+ },
1176
+ });
1177
+ for (const relation of assetGraph.findRelations({
1178
+ type: 'SourceMapSource',
1179
+ })) {
1180
+ relation.hrefType = hrefType;
1181
+ }
1182
+ for (const relation of assetGraph.findRelations({
1183
+ type: 'CssSourceMappingUrl',
1184
+ hrefType: { $in: ['relative', 'inline'] },
1185
+ })) {
1186
+ relation.hrefType = hrefType;
1187
+ }
1188
+ }
1189
+
1190
+ for (const asset of potentiallyOrphanedAssets) {
1191
+ if (asset.incomingRelations.length === 0) {
1192
+ assetGraph.removeAsset(asset);
1193
+ }
1194
+ }
1195
+
1196
+ timings['source maps + orphan cleanup'] = Date.now() - phaseStart;
1197
+ if (debug && console)
1198
+ console.log(
1199
+ `[subfont timing] source maps + orphan cleanup: ${timings['source maps + orphan cleanup']}ms`
1200
+ );
1201
+
1202
+ // Hand out some useful info about the detected subsets:
1203
+ return {
1204
+ fontInfo: htmlOrSvgAssetTextsWithProps.map(
1205
+ ({ fontUsages, htmlOrSvgAsset }) => ({
1206
+ assetFileName: htmlOrSvgAsset.nonInlineAncestor.urlOrDescription,
1207
+ fontUsages: fontUsages.map((fontUsage) =>
1208
+ (({ subsets, hasFontFeatureSettings, ...rest }) => rest)(fontUsage)
1209
+ ),
1210
+ })
1211
+ ),
1212
+ timings,
1213
+ };
1214
+ }
1215
+
1216
+ module.exports = subsetFonts;
1217
+ // Exported for testing
1218
+ module.exports._escapeJsStringLiteral = escapeJsStringLiteral;