@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.
- package/CHANGELOG.md +35 -0
- package/CLAUDE.md +53 -0
- package/LICENSE.md +7 -0
- package/README.md +93 -0
- package/lib/FontTracerPool.js +158 -0
- package/lib/HeadlessBrowser.js +223 -0
- package/lib/cli.js +14 -0
- package/lib/collectFeatureGlyphIds.js +137 -0
- package/lib/collectTextsByPage.js +1017 -0
- package/lib/extractReferencedCustomPropertyNames.js +20 -0
- package/lib/extractVisibleText.js +64 -0
- package/lib/findCustomPropertyDefinitions.js +54 -0
- package/lib/fontFaceHelpers.js +292 -0
- package/lib/fontTracerWorker.js +76 -0
- package/lib/gatherStylesheetsWithPredicates.js +87 -0
- package/lib/getCssRulesByProperty.js +343 -0
- package/lib/getFontInfo.js +36 -0
- package/lib/initialValueByProp.js +18 -0
- package/lib/injectSubsetDefinitions.js +65 -0
- package/lib/normalizeFontPropertyValue.js +34 -0
- package/lib/parseCommandLineOptions.js +131 -0
- package/lib/parseFontVariationSettings.js +39 -0
- package/lib/sfntCache.js +29 -0
- package/lib/stripLocalTokens.js +23 -0
- package/lib/subfont.js +571 -0
- package/lib/subsetFontWithGlyphs.js +193 -0
- package/lib/subsetFonts.js +1218 -0
- package/lib/subsetGeneration.js +347 -0
- package/lib/unicodeRange.js +38 -0
- package/lib/unquote.js +23 -0
- package/lib/variationAxes.js +162 -0
- package/lib/warnAboutMissingGlyphs.js +145 -0
- package/lib/wasmQueue.js +11 -0
- package/package.json +113 -0
|
@@ -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;
|