@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,23 @@
1
+ const postcssValueParser = require('postcss-value-parser');
2
+
3
+ module.exports = function stripLocalTokens(cssValue) {
4
+ const rootNode = postcssValueParser(cssValue);
5
+ for (let i = 0; i < rootNode.nodes.length; i += 1) {
6
+ const node = rootNode.nodes[i];
7
+ if (node.type === 'function' && node.value.toLowerCase() === 'local') {
8
+ let numTokensToRemove = 1;
9
+ if (i + 1 < rootNode.nodes.length) {
10
+ const nextToken = rootNode.nodes[i + 1];
11
+ if (nextToken.type === 'div' && nextToken.value === ',') {
12
+ numTokensToRemove += 1;
13
+ if (i + 2 < rootNode.nodes.length) {
14
+ rootNode.nodes[i + 2].before = node.before;
15
+ }
16
+ }
17
+ }
18
+ rootNode.nodes.splice(i, numTokensToRemove);
19
+ i -= 1;
20
+ }
21
+ }
22
+ return postcssValueParser.stringify(rootNode);
23
+ };
package/lib/subfont.js ADDED
@@ -0,0 +1,571 @@
1
+ const fs = require('fs');
2
+ const pathModule = require('path');
3
+ const AssetGraph = require('assetgraph');
4
+ const prettyBytes = require('pretty-bytes');
5
+ const urlTools = require('urltools');
6
+ const util = require('util');
7
+ const subsetFonts = require('./subsetFonts');
8
+
9
+ module.exports = async function subfont(
10
+ {
11
+ root,
12
+ canonicalRoot,
13
+ output,
14
+ debug = false,
15
+ dryRun = false,
16
+ silent = false,
17
+ inlineCss = false,
18
+ fontDisplay = 'swap',
19
+ inPlace = false,
20
+ inputFiles = [],
21
+ recursive = false,
22
+ relativeUrls = false,
23
+ dynamic = false,
24
+ fallbacks = true,
25
+ text,
26
+ sourceMaps = false,
27
+ concurrency,
28
+ chromeFlags = [],
29
+ cache = false,
30
+ },
31
+ console
32
+ ) {
33
+ const formats = ['woff2'];
34
+
35
+ function logToConsole(severity, ...args) {
36
+ if (!silent && console) {
37
+ console[severity](...args);
38
+ }
39
+ }
40
+ function log(...args) {
41
+ logToConsole('log', ...args);
42
+ }
43
+ function warn(...args) {
44
+ logToConsole('warn', ...args);
45
+ }
46
+
47
+ let rootUrl = root && urlTools.urlOrFsPathToUrl(root, true);
48
+ // Validate --root path exists early to give a clear error message
49
+ if (root && rootUrl && rootUrl.startsWith('file:')) {
50
+ const rootPath = urlTools.fileUrlToFsPath(rootUrl);
51
+ if (!fs.existsSync(rootPath)) {
52
+ throw new SyntaxError(`The --root path does not exist: ${rootPath}`);
53
+ }
54
+ }
55
+ const outRoot = output && urlTools.urlOrFsPathToUrl(output, true);
56
+ let inputUrls;
57
+ if (inputFiles.length > 0) {
58
+ inputUrls = inputFiles.map((urlOrFsPath) =>
59
+ urlTools.urlOrFsPathToUrl(String(urlOrFsPath), false)
60
+ );
61
+ if (!rootUrl) {
62
+ rootUrl = urlTools.findCommonUrlPrefix(inputUrls);
63
+
64
+ if (rootUrl) {
65
+ if (rootUrl.startsWith('file:')) {
66
+ warn(`Guessing --root from input files: ${rootUrl}`);
67
+ } else {
68
+ rootUrl = urlTools.ensureTrailingSlash(rootUrl);
69
+ }
70
+ }
71
+ }
72
+ } else if (rootUrl && rootUrl.startsWith('file:')) {
73
+ inputUrls = [`${rootUrl}**/*.html`];
74
+ warn(`No input files specified, defaulting to ${inputUrls[0]}`);
75
+ } else {
76
+ throw new SyntaxError(
77
+ "No input files and no --root specified (or it isn't file:), cannot proceed.\n"
78
+ );
79
+ }
80
+
81
+ if (!inputUrls[0].startsWith('file:') && !outRoot && !dryRun) {
82
+ throw new SyntaxError(
83
+ '--output has to be specified when using non-file input urls'
84
+ );
85
+ }
86
+
87
+ if (!inPlace && !outRoot && !dryRun) {
88
+ throw new SyntaxError(
89
+ 'Either --output, --in-place, or --dry-run has to be specified'
90
+ );
91
+ }
92
+
93
+ const assetGraphConfig = {
94
+ root: rootUrl,
95
+ canonicalRoot,
96
+ };
97
+
98
+ if (!rootUrl.startsWith('file:')) {
99
+ assetGraphConfig.canonicalRoot = rootUrl.replace(/\/?$/, '/'); // Ensure trailing slash
100
+ }
101
+
102
+ // Subfont only needs to follow CSS-related relations during populate.
103
+ const cssRelatedTypes = [
104
+ 'HtmlStyle',
105
+ 'SvgStyle',
106
+ 'CssImport',
107
+ 'CssFontFaceSrc',
108
+ 'HttpRedirect',
109
+ 'HtmlMetaRefresh',
110
+ 'HtmlConditionalComment',
111
+ 'HtmlNoscript',
112
+ ];
113
+
114
+ let followRelationsQuery;
115
+ if (recursive) {
116
+ followRelationsQuery = {
117
+ $or: [
118
+ {
119
+ type: { $in: cssRelatedTypes },
120
+ },
121
+ {
122
+ type: { $in: [...cssRelatedTypes, 'HtmlAnchor', 'SvgAnchor'] },
123
+ crossorigin: false,
124
+ },
125
+ ],
126
+ };
127
+ } else {
128
+ followRelationsQuery = {
129
+ type: { $in: cssRelatedTypes },
130
+ };
131
+ }
132
+ const assetGraph = new AssetGraph(assetGraphConfig);
133
+
134
+ function isExtensionlessEnoent(err) {
135
+ return (
136
+ err &&
137
+ err.code === 'ENOENT' &&
138
+ typeof err.path === 'string' &&
139
+ !/\.[^/]+$/.test(err.path)
140
+ );
141
+ }
142
+
143
+ if (silent) {
144
+ assetGraph.on('warn', () => {});
145
+ } else {
146
+ const origEmit = assetGraph.emit;
147
+ assetGraph.emit = function (event, err, ...rest) {
148
+ if (event === 'warn' && isExtensionlessEnoent(err)) {
149
+ return false;
150
+ }
151
+ return origEmit.call(this, event, err, ...rest);
152
+ };
153
+ await assetGraph.logEvents({ console });
154
+ }
155
+
156
+ const outerTimings = {};
157
+
158
+ let phaseStart = Date.now();
159
+ await assetGraph.loadAssets(inputUrls);
160
+ outerTimings.loadAssets = Date.now() - phaseStart;
161
+ if (debug) log(`[subfont timing] loadAssets: ${outerTimings.loadAssets}ms`);
162
+
163
+ phaseStart = Date.now();
164
+ await assetGraph.populate({
165
+ followRelations: followRelationsQuery,
166
+ });
167
+ outerTimings['populate (initial)'] = Date.now() - phaseStart;
168
+ if (debug)
169
+ log(
170
+ `[subfont timing] populate (initial): ${outerTimings['populate (initial)']}ms`
171
+ );
172
+
173
+ const entrypointAssets = assetGraph.findAssets({ isInitial: true });
174
+ const redirectOrigins = new Set();
175
+ for (const relation of assetGraph
176
+ .findRelations({ type: 'HttpRedirect' })
177
+ .sort((a, b) => a.id - b.id)) {
178
+ if (relation.from.isInitial) {
179
+ assetGraph.info(
180
+ new Error(`${relation.from.url} redirected to ${relation.to.url}`)
181
+ );
182
+ relation.to.isInitial = true;
183
+ relation.from.isInitial = false;
184
+
185
+ redirectOrigins.add(relation.to.origin);
186
+ }
187
+ }
188
+ if (
189
+ entrypointAssets.length === redirectOrigins.size &&
190
+ redirectOrigins.size === 1
191
+ ) {
192
+ const newRoot = `${[...redirectOrigins][0]}/`;
193
+ if (newRoot !== assetGraph.root) {
194
+ assetGraph.info(
195
+ new Error(
196
+ `All entrypoints redirected, changing root from ${assetGraph.root} to ${newRoot}`
197
+ )
198
+ );
199
+ assetGraph.root = newRoot;
200
+ }
201
+ }
202
+
203
+ let sumSizesBefore = 0;
204
+ for (const asset of assetGraph.findAssets({
205
+ isInline: false,
206
+ isLoaded: true,
207
+ type: {
208
+ $in: ['Html', 'Svg', 'Css', 'JavaScript'],
209
+ },
210
+ })) {
211
+ sumSizesBefore += asset.rawSrc.length;
212
+ }
213
+
214
+ if (!sourceMaps) {
215
+ log(
216
+ 'Skipping CSS source map processing for faster execution. Use --source-maps to preserve them.'
217
+ );
218
+ }
219
+
220
+ phaseStart = Date.now();
221
+ const { fontInfo, timings: subsetTimings } = await subsetFonts(assetGraph, {
222
+ inlineCss,
223
+ fontDisplay,
224
+ formats,
225
+ omitFallbacks: !fallbacks,
226
+ hrefType: relativeUrls ? 'relative' : 'rootRelative',
227
+ text,
228
+ dynamic,
229
+ console,
230
+ sourceMaps,
231
+ debug,
232
+ concurrency,
233
+ chromeArgs: chromeFlags,
234
+ cacheDir: (() => {
235
+ if (cache && typeof cache === 'string' && cache.length > 0) return cache;
236
+ if (cache && rootUrl && rootUrl.startsWith('file:'))
237
+ return pathModule.join(
238
+ urlTools.fileUrlToFsPath(rootUrl),
239
+ '.subfont-cache'
240
+ );
241
+ if (cache)
242
+ warn(
243
+ '--cache ignored: caching requires a local --root or an explicit cache path'
244
+ );
245
+ return null;
246
+ })(),
247
+ });
248
+
249
+ const subsetFontsTotal = Date.now() - phaseStart;
250
+ if (debug) log(`[subfont timing] subsetFonts total: ${subsetFontsTotal}ms`);
251
+
252
+ phaseStart = Date.now();
253
+ let sumSizesAfter = 0;
254
+ for (const asset of assetGraph.findAssets({
255
+ isInline: false,
256
+ isLoaded: true,
257
+ type: {
258
+ $in: ['Html', 'Svg', 'Css', 'JavaScript'],
259
+ },
260
+ })) {
261
+ sumSizesAfter += asset.rawSrc.length;
262
+ }
263
+
264
+ // Omit function calls:
265
+ for (const relation of assetGraph.findRelations({
266
+ type: 'JavaScriptStaticUrl',
267
+ to: { isLoaded: true },
268
+ })) {
269
+ relation.omitFunctionCall();
270
+ }
271
+
272
+ for (const asset of assetGraph.findAssets({
273
+ isDirty: true,
274
+ isInline: false,
275
+ isLoaded: true,
276
+ type: 'Css',
277
+ })) {
278
+ if (!asset.url.startsWith(assetGraph.root)) {
279
+ assetGraph.info(
280
+ new Error(`Pulling down modified stylesheet ${asset.url}`)
281
+ );
282
+ asset.url = `${assetGraph.root}subfont/${
283
+ asset.baseName || 'index'
284
+ }-${asset.md5Hex.slice(0, 10)}${
285
+ asset.extension || asset.defaultExtension
286
+ }`;
287
+ }
288
+ }
289
+
290
+ if (!rootUrl.startsWith('file:')) {
291
+ for (const relation of assetGraph.findRelations()) {
292
+ if (
293
+ relation.hrefType === 'protocolRelative' ||
294
+ relation.hrefType === 'absolute'
295
+ ) {
296
+ relation.hrefType = 'rootRelative';
297
+ }
298
+ }
299
+
300
+ await assetGraph.moveAssets(
301
+ {
302
+ type: 'Html',
303
+ isLoaded: true,
304
+ isInline: false,
305
+ fileName: { $or: ['', undefined] },
306
+ },
307
+ (asset, assetGraph) =>
308
+ `${asset.url.replace(/\/?$/, '/')}index${asset.defaultExtension}`
309
+ );
310
+ }
311
+
312
+ outerTimings['post-subsetFonts processing'] = Date.now() - phaseStart;
313
+ if (debug)
314
+ log(
315
+ `[subfont timing] post-subsetFonts processing: ${outerTimings['post-subsetFonts processing']}ms`
316
+ );
317
+
318
+ phaseStart = Date.now();
319
+ if (!dryRun) {
320
+ await assetGraph.writeAssetsToDisc(
321
+ {
322
+ isLoaded: true,
323
+ isRedirect: { $ne: true },
324
+ url: (url) => url && url.startsWith(assetGraph.root),
325
+ },
326
+ outRoot,
327
+ assetGraph.root
328
+ );
329
+ }
330
+
331
+ outerTimings.writeAssetsToDisc = Date.now() - phaseStart;
332
+ if (debug)
333
+ log(
334
+ `[subfont timing] writeAssetsToDisc: ${outerTimings.writeAssetsToDisc}ms`
335
+ );
336
+
337
+ phaseStart = Date.now();
338
+ if (debug) {
339
+ const compactFontInfo = fontInfo.map(({ fontUsages, ...rest }) => ({
340
+ ...rest,
341
+ fontUsages: fontUsages.map(({ codepoints, texts, ...fu }) => ({
342
+ ...fu,
343
+ codepoints: codepoints
344
+ ? {
345
+ original: `[${codepoints.original.length} codepoints]`,
346
+ used: `[${codepoints.used.length} codepoints]`,
347
+ unused: `[${codepoints.unused.length} codepoints]`,
348
+ page: `[${codepoints.page.length} codepoints]`,
349
+ }
350
+ : undefined,
351
+ texts: texts ? `[${texts.length} entries]` : undefined,
352
+ })),
353
+ }));
354
+ log(util.inspect(compactFontInfo, false, 99));
355
+ }
356
+
357
+ let totalSavings = sumSizesBefore - sumSizesAfter;
358
+ for (const { assetFileName, fontUsages } of fontInfo) {
359
+ let sumSmallestSubsetSize = 0;
360
+ let sumSmallestOriginalSize = 0;
361
+ let maxUsedCodePoints = 0;
362
+ let maxOriginalCodePoints = 0;
363
+ for (const fontUsage of fontUsages) {
364
+ sumSmallestSubsetSize += fontUsage.smallestSubsetSize || 0;
365
+ sumSmallestOriginalSize += fontUsage.smallestOriginalSize;
366
+ maxUsedCodePoints = Math.max(
367
+ fontUsage.codepoints.used.length,
368
+ maxUsedCodePoints
369
+ );
370
+ maxOriginalCodePoints = Math.max(
371
+ fontUsage.codepoints.original.length,
372
+ maxOriginalCodePoints
373
+ );
374
+ }
375
+ const fontUsagesByFontFamily = {};
376
+ for (const fontUsage of fontUsages) {
377
+ const key = fontUsage.props['font-family'];
378
+ if (!fontUsagesByFontFamily[key]) fontUsagesByFontFamily[key] = [];
379
+ fontUsagesByFontFamily[key].push(fontUsage);
380
+ }
381
+ const numFonts = Object.keys(fontUsagesByFontFamily).length;
382
+ log(
383
+ `${assetFileName}: ${numFonts} font${numFonts === 1 ? '' : 's'} (${
384
+ fontUsages.length
385
+ } variant${fontUsages.length === 1 ? '' : 's'}) in use, ${prettyBytes(
386
+ sumSmallestOriginalSize
387
+ )} total. Created subsets: ${prettyBytes(sumSmallestSubsetSize)} total`
388
+ );
389
+ for (const fontFamily of Object.keys(fontUsagesByFontFamily).sort()) {
390
+ log(` ${fontFamily}:`);
391
+ for (const fontUsage of fontUsagesByFontFamily[fontFamily]) {
392
+ const variantShortName = `${fontUsage.props['font-weight']}${
393
+ fontUsage.props['font-style'] === 'italic' ? 'i' : ' '
394
+ }`;
395
+ let status = ` ${variantShortName}: ${String(
396
+ fontUsage.codepoints.used.length
397
+ ).padStart(String(maxUsedCodePoints).length)}/${String(
398
+ fontUsage.codepoints.original.length
399
+ ).padStart(String(maxOriginalCodePoints).length)} codepoints used`;
400
+ if (
401
+ fontUsage.codepoints.page.length !== fontUsage.codepoints.used.length
402
+ ) {
403
+ status += ` (${fontUsage.codepoints.page.length} on this page)`;
404
+ }
405
+ if (
406
+ fontUsage.smallestOriginalSize !== undefined &&
407
+ fontUsage.smallestSubsetSize !== undefined
408
+ ) {
409
+ if (fontUsage.fullyInstanced) {
410
+ status += ', fully instanced';
411
+ } else if (fontUsage.numAxesReduced > 0 || fontUsage.numAxesPinned) {
412
+ const instancingInfos = [];
413
+ if (fontUsage.numAxesPinned > 0) {
414
+ instancingInfos.push(
415
+ `${fontUsage.numAxesPinned} ${
416
+ fontUsage.numAxesPinned === 1 ? 'axis' : 'axes'
417
+ } pinned`
418
+ );
419
+ }
420
+ if (fontUsage.numAxesReduced) {
421
+ instancingInfos.push(
422
+ `${fontUsage.numAxesReduced}${
423
+ fontUsage.numAxesPinned > 0
424
+ ? ''
425
+ : fontUsage.numAxesReduced === 1
426
+ ? ' axis'
427
+ : ' axes'
428
+ } reduced`
429
+ );
430
+ }
431
+
432
+ status += `, partially instanced (${instancingInfos.join(', ')})`;
433
+ }
434
+ status += `, ${prettyBytes(fontUsage.smallestOriginalSize)} (${
435
+ fontUsage.smallestOriginalFormat
436
+ }) => ${prettyBytes(fontUsage.smallestSubsetSize)} (${
437
+ fontUsage.smallestSubsetFormat
438
+ })`;
439
+ totalSavings +=
440
+ fontUsage.smallestOriginalSize - fontUsage.smallestSubsetSize;
441
+ } else {
442
+ status += ', no subset font created';
443
+ }
444
+ log(status);
445
+ }
446
+ }
447
+ }
448
+ log(
449
+ `HTML/SVG/JS/CSS size increase: ${prettyBytes(
450
+ sumSizesAfter - sumSizesBefore
451
+ )}`
452
+ );
453
+ log(`Total savings: ${prettyBytes(totalSavings)}`);
454
+ outerTimings['output reporting'] = Date.now() - phaseStart;
455
+ if (debug)
456
+ log(
457
+ `[subfont timing] output reporting: ${outerTimings['output reporting']}ms`
458
+ );
459
+
460
+ const st = subsetTimings || {};
461
+ const details = st.collectTextsByPageDetails || {};
462
+ const totalElapsed =
463
+ (outerTimings.loadAssets || 0) +
464
+ (outerTimings['populate (initial)'] || 0) +
465
+ subsetFontsTotal +
466
+ (outerTimings['post-subsetFonts processing'] || 0) +
467
+ (outerTimings.writeAssetsToDisc || 0) +
468
+ (outerTimings['output reporting'] || 0);
469
+
470
+ const rows = [
471
+ ['loadAssets', outerTimings.loadAssets, 0],
472
+ ['populate (initial)', outerTimings['populate (initial)'], 0],
473
+ ['subsetFonts total', subsetFontsTotal, 0],
474
+ ['collectTextsByPage', st.collectTextsByPage, 1],
475
+ ['Stylesheet precompute', details['Stylesheet precompute'], 2],
476
+ ['Full tracing', details['Full tracing'], 2],
477
+ ['Fast-path extraction', details['Fast-path extraction'], 2],
478
+ ['Per-page loop', details['Per-page loop'], 2],
479
+ ['Post-processing', details['Post-processing total'], 2],
480
+ ['codepoint generation', st['codepoint generation'], 1],
481
+ ['getSubsetsForFontUsage', st.getSubsetsForFontUsage, 1],
482
+ ['insert subsets loop', st['insert subsets loop'], 1],
483
+ ['inject font-family', st['inject subset font-family'], 1],
484
+ ['post-subsetFonts', outerTimings['post-subsetFonts processing'], 0],
485
+ ['writeAssetsToDisc', outerTimings.writeAssetsToDisc, 0],
486
+ ['output reporting', outerTimings['output reporting'], 0],
487
+ ];
488
+
489
+ log('\n═══ Subfont Timing Summary ═══');
490
+ for (const [label, ms, indent] of rows) {
491
+ if (ms === undefined) continue;
492
+ const prefix = ' '.repeat(indent + 1);
493
+ const padded = (ms || 0).toLocaleString().padStart(8);
494
+ log(`${prefix}${label}: ${padded}ms`);
495
+ }
496
+ log(' ─────────────────────────────────');
497
+ log(` Total: ${totalElapsed.toLocaleString().padStart(8)}ms`);
498
+ log('═══════════════════════════════\n');
499
+
500
+ if (dryRun) {
501
+ log('\n═══ Dry Run Preview ═══');
502
+ const assetsToWrite = assetGraph.findAssets({
503
+ isLoaded: true,
504
+ isRedirect: { $ne: true },
505
+ url: (url) => url && url.startsWith(assetGraph.root),
506
+ });
507
+ const byType = {};
508
+ let totalOutputSize = 0;
509
+ for (const asset of assetsToWrite) {
510
+ const type = asset.type || 'Other';
511
+ if (!byType[type]) byType[type] = { count: 0, size: 0, files: [] };
512
+ const size = asset.rawSrc ? asset.rawSrc.length : 0;
513
+ byType[type].count += 1;
514
+ byType[type].size += size;
515
+ totalOutputSize += size;
516
+
517
+ if (asset.url && asset.url.includes('/subfont/')) {
518
+ byType[type].files.push(
519
+ ` ${asset.url.replace(assetGraph.root, '/')} (${prettyBytes(size)})`
520
+ );
521
+ }
522
+ }
523
+ for (const [type, info] of Object.entries(byType).sort(
524
+ ([, a], [, b]) => b.size - a.size
525
+ )) {
526
+ log(
527
+ ` ${type}: ${info.count} file${info.count === 1 ? '' : 's'}, ${prettyBytes(info.size)}`
528
+ );
529
+ for (const file of info.files) {
530
+ log(file);
531
+ }
532
+ }
533
+ log(` ─────────────────────────────────`);
534
+ log(` Total output: ${prettyBytes(totalOutputSize)}`);
535
+
536
+ const dirtyHtmlAssets = assetGraph.findAssets({
537
+ isDirty: true,
538
+ isLoaded: true,
539
+ type: { $in: ['Html', 'Svg'] },
540
+ });
541
+ if (dirtyHtmlAssets.length > 0) {
542
+ log(`\n Modified HTML/SVG files (${dirtyHtmlAssets.length}):`);
543
+ for (const asset of dirtyHtmlAssets) {
544
+ log(` ${asset.urlOrDescription}`);
545
+ }
546
+ }
547
+
548
+ const subsetCssAssets = assetGraph.findAssets({
549
+ type: 'Css',
550
+ isLoaded: true,
551
+ url: (url) => url && url.includes('/subfont/'),
552
+ });
553
+ if (subsetCssAssets.length > 0) {
554
+ log(
555
+ `\n Subset CSS files that would be created (${subsetCssAssets.length}):`
556
+ );
557
+ for (const css of subsetCssAssets) {
558
+ const fontFaceCount = (css.text.match(/@font-face/g) || []).length;
559
+ log(
560
+ ` ${css.url.replace(assetGraph.root, '/')} (${prettyBytes(css.rawSrc.length)}, ${fontFaceCount} @font-face rule${fontFaceCount === 1 ? '' : 's'})`
561
+ );
562
+ }
563
+ }
564
+
565
+ log('═══════════════════════════════\n');
566
+ log('Dry run complete — no files were written.');
567
+ } else {
568
+ log('Output written to', outRoot || assetGraph.root);
569
+ }
570
+ return assetGraph;
571
+ };