@turntrout/subfont 1.10.1 → 1.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/lib/HeadlessBrowser.d.ts +2 -0
  2. package/lib/HeadlessBrowser.d.ts.map +1 -1
  3. package/lib/HeadlessBrowser.js +64 -58
  4. package/lib/HeadlessBrowser.js.map +1 -1
  5. package/lib/collectTextsByPage.d.ts.map +1 -1
  6. package/lib/collectTextsByPage.js +136 -132
  7. package/lib/collectTextsByPage.js.map +1 -1
  8. package/lib/concurrencyLimit.d.ts.map +1 -1
  9. package/lib/concurrencyLimit.js +6 -8
  10. package/lib/concurrencyLimit.js.map +1 -1
  11. package/lib/extractVisibleText.d.ts.map +1 -1
  12. package/lib/extractVisibleText.js +58 -30
  13. package/lib/extractVisibleText.js.map +1 -1
  14. package/lib/fontConverter.d.ts.map +1 -1
  15. package/lib/fontConverter.js +27 -23
  16. package/lib/fontConverter.js.map +1 -1
  17. package/lib/getCssRulesByProperty.d.ts.map +1 -1
  18. package/lib/getCssRulesByProperty.js +234 -207
  19. package/lib/getCssRulesByProperty.js.map +1 -1
  20. package/lib/injectSubsetDefinitions.d.ts.map +1 -1
  21. package/lib/injectSubsetDefinitions.js +24 -21
  22. package/lib/injectSubsetDefinitions.js.map +1 -1
  23. package/lib/parseCommandLineOptions.d.ts.map +1 -1
  24. package/lib/parseCommandLineOptions.js +29 -14
  25. package/lib/parseCommandLineOptions.js.map +1 -1
  26. package/lib/subfont.d.ts.map +1 -1
  27. package/lib/subfont.js +347 -311
  28. package/lib/subfont.js.map +1 -1
  29. package/lib/subsetFontWithGlyphs.d.ts.map +1 -1
  30. package/lib/subsetFontWithGlyphs.js +60 -48
  31. package/lib/subsetFontWithGlyphs.js.map +1 -1
  32. package/lib/subsetFonts.d.ts.map +1 -1
  33. package/lib/subsetFonts.js +338 -280
  34. package/lib/subsetFonts.js.map +1 -1
  35. package/lib/subsetGeneration.d.ts.map +1 -1
  36. package/lib/subsetGeneration.js +151 -127
  37. package/lib/subsetGeneration.js.map +1 -1
  38. package/lib/warnAboutMissingGlyphs.d.ts.map +1 -1
  39. package/lib/warnAboutMissingGlyphs.js +132 -112
  40. package/lib/warnAboutMissingGlyphs.js.map +1 -1
  41. package/package.json +1 -1
package/lib/subfont.js CHANGED
@@ -49,21 +49,7 @@ class UsageError extends Error {
49
49
  this.name = 'UsageError';
50
50
  }
51
51
  }
52
- const subfont = async function subfont({ root, canonicalRoot, output, debug = false, dryRun = false, silent = false, inlineCss = false, fontDisplay = 'swap', inPlace = false, inputFiles = [], recursive = false, relativeUrls = false, dynamic = false, fallbacks = true, text, sourceMaps = false, concurrency, chromeFlags = [], cache = false, strict = false, }, console) {
53
- if (concurrency !== undefined &&
54
- (!Number.isInteger(concurrency) || concurrency < 1)) {
55
- throw new UsageError('--concurrency must be a positive integer');
56
- }
57
- // Prevent postcss plugins (colormin, convert-values, etc.) invoked by
58
- // cssnano from walking the filesystem for a "browserslist" config.
59
- // Under pnpm, `node_modules/.bin/browserslist` is a shell shim that
60
- // browserslist mis-parses as browser queries, throwing
61
- // BrowserslistError and silently aborting CSS minification.
62
- // Setting BROWSERSLIST short-circuits the walk entirely.
63
- if (!process.env.BROWSERSLIST && !process.env.BROWSERSLIST_CONFIG) {
64
- process.env.BROWSERSLIST = 'defaults';
65
- }
66
- const formats = ['woff2'];
52
+ function makeLogger(silent, console) {
67
53
  // Variadic console-style helpers: console.log / .warn accept any argument.
68
54
  /* eslint-disable no-restricted-syntax */
69
55
  function logToConsole(severity, ...args) {
@@ -71,20 +57,15 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
71
57
  console[severity](...args);
72
58
  }
73
59
  }
74
- function log(...args) {
75
- logToConsole('log', ...args);
76
- }
77
- function warn(...args) {
78
- logToConsole('warn', ...args);
79
- }
60
+ return {
61
+ log: (...args) => logToConsole('log', ...args),
62
+ warn: (...args) => logToConsole('warn', ...args),
63
+ };
80
64
  /* eslint-enable no-restricted-syntax */
81
- const maxConcurrency = (0, concurrencyLimit_1.getMaxConcurrency)();
82
- if (concurrency !== undefined && concurrency > maxConcurrency) {
83
- warn(`--concurrency ${concurrency} exceeds estimated safe limit of ${maxConcurrency} (each worker uses ~50 MB; ${Math.round(os.freemem() / (1024 * 1024 * 1024))} GB free, ${os.cpus().length} CPUs). Proceeding anyway — reduce if you hit OOM.`);
84
- }
85
- let rootUrl = root && urlTools.urlOrFsPathToUrl(root, true);
65
+ }
66
+ async function resolveInputUrls(inputFiles, rootUrl, warn) {
86
67
  // Validate --root path exists early to give a clear error message
87
- if (root && rootUrl && rootUrl.startsWith('file:')) {
68
+ if (rootUrl && rootUrl.startsWith('file:')) {
88
69
  const rootPath = urlTools.fileUrlToFsPath(rootUrl);
89
70
  try {
90
71
  await fsPromises.access(rootPath);
@@ -93,7 +74,6 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
93
74
  throw new UsageError(`The --root path does not exist: ${rootPath}`);
94
75
  }
95
76
  }
96
- const outRoot = output && urlTools.urlOrFsPathToUrl(output, true);
97
77
  let inputUrls;
98
78
  if (inputFiles.length > 0) {
99
79
  inputUrls = inputFiles.map((urlOrFsPath) => urlTools.urlOrFsPathToUrl(String(urlOrFsPath), false));
@@ -116,61 +96,45 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
116
96
  else {
117
97
  throw new UsageError("No input files and no --root specified (or it isn't file:), cannot proceed.\n");
118
98
  }
119
- if (!inputUrls[0].startsWith('file:') && !outRoot && !dryRun) {
120
- throw new UsageError('--output has to be specified when using non-file input urls');
121
- }
122
- if (!inPlace && !outRoot && !dryRun) {
123
- throw new UsageError('Either --output, --in-place, or --dry-run has to be specified');
124
- }
125
- const assetGraphConfig = {
126
- root: rootUrl,
127
- canonicalRoot,
99
+ return { inputUrls, rootUrl };
100
+ }
101
+ // Subfont only needs to follow CSS-related relations during populate.
102
+ const cssRelatedTypes = [
103
+ 'HtmlStyle',
104
+ 'SvgStyle',
105
+ 'CssImport',
106
+ 'CssFontFaceSrc',
107
+ 'HttpRedirect',
108
+ 'HtmlMetaRefresh',
109
+ 'HtmlConditionalComment',
110
+ 'HtmlNoscript',
111
+ ];
112
+ function buildFollowRelationsQuery(recursive) {
113
+ if (!recursive) {
114
+ return { type: { $in: cssRelatedTypes } };
115
+ }
116
+ return {
117
+ $or: [
118
+ { type: { $in: cssRelatedTypes } },
119
+ {
120
+ type: { $in: [...cssRelatedTypes, 'HtmlAnchor', 'SvgAnchor'] },
121
+ crossorigin: false,
122
+ },
123
+ ],
128
124
  };
129
- if (rootUrl && !rootUrl.startsWith('file:')) {
130
- assetGraphConfig.canonicalRoot = rootUrl.replace(/\/?$/, '/'); // Ensure trailing slash
131
- }
132
- // Subfont only needs to follow CSS-related relations during populate.
133
- const cssRelatedTypes = [
134
- 'HtmlStyle',
135
- 'SvgStyle',
136
- 'CssImport',
137
- 'CssFontFaceSrc',
138
- 'HttpRedirect',
139
- 'HtmlMetaRefresh',
140
- 'HtmlConditionalComment',
141
- 'HtmlNoscript',
142
- ];
143
- let followRelationsQuery;
144
- if (recursive) {
145
- followRelationsQuery = {
146
- $or: [
147
- {
148
- type: { $in: cssRelatedTypes },
149
- },
150
- {
151
- type: { $in: [...cssRelatedTypes, 'HtmlAnchor', 'SvgAnchor'] },
152
- crossorigin: false,
153
- },
154
- ],
155
- };
156
- }
157
- else {
158
- followRelationsQuery = {
159
- type: { $in: cssRelatedTypes },
160
- };
161
- }
162
- const assetGraph = new AssetGraph(assetGraphConfig);
163
- // Catch-clause idiom: error values are `unknown` until narrowed.
125
+ }
126
+ // Catch-clause idiom: error values are `unknown` until narrowed.
127
+ // eslint-disable-next-line no-restricted-syntax
128
+ function isExtensionlessEnoent(err) {
129
+ if (typeof err !== 'object' || err === null)
130
+ return false;
164
131
  // eslint-disable-next-line no-restricted-syntax
165
- function isExtensionlessEnoent(err) {
166
- if (typeof err !== 'object' || err === null)
167
- return false;
168
- // eslint-disable-next-line no-restricted-syntax
169
- const e = err;
170
- return (e.code === 'ENOENT' &&
171
- typeof e.path === 'string' &&
172
- !/\.[^/]+$/.test(e.path));
173
- }
132
+ const e = err;
133
+ return (e.code === 'ENOENT' &&
134
+ typeof e.path === 'string' &&
135
+ !/\.[^/]+$/.test(e.path));
136
+ }
137
+ async function installWarningHandlers(assetGraph, silent, strict, console) {
174
138
  let sawWarning = false;
175
139
  const origEmit = assetGraph.emit;
176
140
  // EventEmitter.emit forwards arbitrary varargs.
@@ -189,28 +153,18 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
189
153
  else {
190
154
  await assetGraph.logEvents({ console, stopOnWarning: strict });
191
155
  }
192
- const outerTimings = {};
193
- // The tracker writes with console.log (duck-typed). Route it through
194
- // the silent-aware log wrapper so --silent suppresses phase markers
195
- // the same way it suppresses other subfont output.
196
- const trackPhase = (0, progress_1.makePhaseTracker)({ log }, debug);
197
- const loadAssetsPhase = trackPhase('loadAssets');
198
- await assetGraph.loadAssets(inputUrls);
199
- outerTimings.loadAssets = loadAssetsPhase.end();
200
- const populatePhase = trackPhase('populate (initial)');
201
- await assetGraph.populate({
202
- followRelations: followRelationsQuery,
203
- });
204
- outerTimings['populate (initial)'] = populatePhase.end();
156
+ return () => sawWarning;
157
+ }
158
+ function handleInitialRedirects(assetGraph) {
205
159
  const entrypointAssets = assetGraph.findAssets({ isInitial: true });
206
160
  const redirectOrigins = new Set();
207
161
  for (const relation of assetGraph.findRelations({ type: 'HttpRedirect' }).sort((a, b) => a.id - b.id)) {
208
- if (relation.from.isInitial) {
209
- assetGraph.info(new Error(`${relation.from.url} redirected to ${relation.to.url}`));
210
- relation.to.isInitial = true;
211
- relation.from.isInitial = false;
212
- redirectOrigins.add(relation.to.origin);
213
- }
162
+ if (!relation.from.isInitial)
163
+ continue;
164
+ assetGraph.info(new Error(`${relation.from.url} redirected to ${relation.to.url}`));
165
+ relation.to.isInitial = true;
166
+ relation.from.isInitial = false;
167
+ redirectOrigins.add(relation.to.origin);
214
168
  }
215
169
  if (entrypointAssets.length === redirectOrigins.size &&
216
170
  redirectOrigins.size === 1) {
@@ -220,53 +174,21 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
220
174
  assetGraph.root = newRoot;
221
175
  }
222
176
  }
223
- const sizeableAssetQuery = {
224
- isInline: false,
225
- isLoaded: true,
226
- type: {
227
- $in: ['Html', 'Svg', 'Css', 'JavaScript'],
228
- },
229
- };
230
- let sumSizesBefore = 0;
231
- for (const asset of assetGraph.findAssets(sizeableAssetQuery)) {
232
- sumSizesBefore += asset.rawSrc.length;
233
- }
234
- if (!sourceMaps) {
235
- log('Skipping CSS source map processing for faster execution. Use --source-maps to preserve them.');
236
- }
237
- let cacheDir = null;
177
+ }
178
+ function resolveCacheDir(cache, rootUrl, warn) {
238
179
  if (cache && typeof cache === 'string' && cache.length > 0) {
239
- cacheDir = cache;
180
+ return cache;
240
181
  }
241
- else if (cache && rootUrl && rootUrl.startsWith('file:')) {
242
- cacheDir = pathModule.join(urlTools.fileUrlToFsPath(rootUrl), '.subfont-cache');
182
+ if (cache && rootUrl && rootUrl.startsWith('file:')) {
183
+ return pathModule.join(urlTools.fileUrlToFsPath(rootUrl), '.subfont-cache');
243
184
  }
244
- else if (cache) {
185
+ if (cache) {
245
186
  warn('--cache ignored: caching requires a local --root or an explicit cache path');
246
187
  }
247
- const subsetPhase = trackPhase('subsetFonts total');
248
- const { fontInfo, timings: subsetTimings } = await subsetFonts(assetGraph, {
249
- inlineCss,
250
- fontDisplay,
251
- formats,
252
- omitFallbacks: !fallbacks,
253
- hrefType: relativeUrls ? 'relative' : 'rootRelative',
254
- text,
255
- dynamic,
256
- console,
257
- sourceMaps,
258
- debug,
259
- concurrency,
260
- chromeArgs: chromeFlags,
261
- cacheDir,
262
- });
263
- const subsetFontsTotal = subsetPhase.end();
264
- const postProcessingPhase = trackPhase('post-subsetFonts processing');
265
- let sumSizesAfter = 0;
266
- for (const asset of assetGraph.findAssets(sizeableAssetQuery)) {
267
- sumSizesAfter += asset.rawSrc.length;
268
- }
269
- // Omit function calls:
188
+ return null;
189
+ }
190
+ async function runPostProcessing(assetGraph, rootUrl) {
191
+ // Omit function calls
270
192
  for (const relation of assetGraph.findRelations({
271
193
  type: 'JavaScriptStaticUrl',
272
194
  to: { isLoaded: true },
@@ -279,11 +201,11 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
279
201
  isLoaded: true,
280
202
  type: 'Css',
281
203
  })) {
282
- if (!asset.url.startsWith(assetGraph.root)) {
283
- assetGraph.info(new Error(`Pulling down modified stylesheet ${asset.url}`));
284
- const safeName = sanitizeFilename(asset.baseName || '', { replacement: '_' }) || 'index';
285
- asset.url = `${assetGraph.root}subfont/${safeName}-${asset.md5Hex.slice(0, 10)}${asset.extension || asset.defaultExtension}`;
286
- }
204
+ if (asset.url.startsWith(assetGraph.root))
205
+ continue;
206
+ assetGraph.info(new Error(`Pulling down modified stylesheet ${asset.url}`));
207
+ const safeName = sanitizeFilename(asset.baseName || '', { replacement: '_' }) || 'index';
208
+ asset.url = `${assetGraph.root}subfont/${safeName}-${asset.md5Hex.slice(0, 10)}${asset.extension || asset.defaultExtension}`;
287
209
  }
288
210
  if (rootUrl && !rootUrl.startsWith('file:')) {
289
211
  for (const relation of assetGraph.findRelations({
@@ -298,78 +220,98 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
298
220
  fileName: { $or: ['', undefined] },
299
221
  }, (asset) => `${asset.url.replace(/\/?$/, '/')}index${asset.defaultExtension}`);
300
222
  }
301
- outerTimings['post-subsetFonts processing'] = postProcessingPhase.end();
302
- if (strict && sawWarning) {
303
- // In non-silent mode, assetgraph's logEvents normally exits earlier via
304
- // stopOnWarning. This guard covers silent mode and warnings that slipped
305
- // past a transform boundary.
306
- throw new Error('subfont: --strict was set and one or more warnings were emitted; refusing to write output.');
307
- }
308
- const writePhase = trackPhase('writeAssetsToDisc');
309
- if (!dryRun) {
310
- await assetGraph.writeAssetsToDisc({
311
- isLoaded: true,
312
- isRedirect: { $ne: true },
313
- url: (url) => url && url.startsWith(assetGraph.root),
314
- }, outRoot, assetGraph.root);
315
- }
316
- outerTimings.writeAssetsToDisc = writePhase.end();
317
- const reportingPhase = trackPhase('output reporting');
318
- if (debug) {
319
- // One entry per unique (fontUrl, props) variant. A variable-font URL can
320
- // back multiple variants, so fontUrl alone is too coarse. Codepoint unions
321
- // and subset sizes are per-font, so the remaining per-page variation
322
- // worth surfacing is just which pages reference the variant.
323
- const SAMPLE_PAGES = 5;
324
- const byVariant = new Map();
325
- for (const { assetFileName, fontUsages } of fontInfo) {
326
- for (const fu of fontUsages) {
327
- const p = fu.props || {};
328
- const key = [
329
- fu.fontUrl || '[inline]',
330
- p['font-family'],
331
- p['font-weight'],
332
- p['font-style'],
333
- p['font-stretch'],
334
- ].join('\0');
335
- let entry = byVariant.get(key);
336
- if (!entry) {
337
- entry = {
338
- fontUrl: fu.fontUrl,
339
- props: fu.props,
340
- preload: fu.preload,
341
- fullyInstanced: fu.fullyInstanced,
342
- numAxesPinned: fu.numAxesPinned,
343
- numAxesReduced: fu.numAxesReduced,
344
- smallestOriginalFormat: fu.smallestOriginalFormat,
345
- smallestSubsetFormat: fu.smallestSubsetFormat,
346
- smallestOriginalSize: fu.smallestOriginalSize,
347
- smallestSubsetSize: fu.smallestSubsetSize,
348
- codepoints: {
349
- original: fu.codepoints.original.length,
350
- used: fu.codepoints.used.length,
351
- unused: fu.codepoints.unused.length,
352
- },
353
- pageCount: 0,
354
- samplePages: [],
355
- };
356
- byVariant.set(key, entry);
357
- }
358
- entry.pageCount += 1;
359
- if (entry.samplePages.length < SAMPLE_PAGES) {
360
- entry.samplePages.push(assetFileName);
361
- }
223
+ }
224
+ // One entry per unique (fontUrl, props) variant. A variable-font URL can
225
+ // back multiple variants, so fontUrl alone is too coarse. Codepoint unions
226
+ // and subset sizes are per-font, so the remaining per-page variation worth
227
+ // surfacing is just which pages reference the variant.
228
+ function printVariantSummary(fontInfo, log) {
229
+ const SAMPLE_PAGES = 5;
230
+ const byVariant = new Map();
231
+ for (const { assetFileName, fontUsages } of fontInfo) {
232
+ for (const fu of fontUsages) {
233
+ const p = fu.props || {};
234
+ const key = [
235
+ fu.fontUrl || '[inline]',
236
+ p['font-family'],
237
+ p['font-weight'],
238
+ p['font-style'],
239
+ p['font-stretch'],
240
+ ].join('\0');
241
+ let entry = byVariant.get(key);
242
+ if (!entry) {
243
+ entry = {
244
+ fontUrl: fu.fontUrl,
245
+ props: fu.props,
246
+ preload: fu.preload,
247
+ fullyInstanced: fu.fullyInstanced,
248
+ numAxesPinned: fu.numAxesPinned,
249
+ numAxesReduced: fu.numAxesReduced,
250
+ smallestOriginalFormat: fu.smallestOriginalFormat,
251
+ smallestSubsetFormat: fu.smallestSubsetFormat,
252
+ smallestOriginalSize: fu.smallestOriginalSize,
253
+ smallestSubsetSize: fu.smallestSubsetSize,
254
+ codepoints: {
255
+ original: fu.codepoints.original.length,
256
+ used: fu.codepoints.used.length,
257
+ unused: fu.codepoints.unused.length,
258
+ },
259
+ pageCount: 0,
260
+ samplePages: [],
261
+ };
262
+ byVariant.set(key, entry);
362
263
  }
363
- }
364
- for (const entry of byVariant.values()) {
365
- const remaining = entry.pageCount - entry.samplePages.length;
366
- if (remaining > 0) {
367
- entry.samplePages.push(`...and ${remaining} more`);
264
+ entry.pageCount += 1;
265
+ if (entry.samplePages.length < SAMPLE_PAGES) {
266
+ entry.samplePages.push(assetFileName);
368
267
  }
369
268
  }
370
- log(`Font variants (aggregated across ${fontInfo.length} page${fontInfo.length === 1 ? '' : 's'}):`);
371
- log(util.inspect([...byVariant.values()], false, 99));
372
269
  }
270
+ for (const entry of byVariant.values()) {
271
+ const remaining = entry.pageCount - entry.samplePages.length;
272
+ if (remaining > 0) {
273
+ entry.samplePages.push(`...and ${remaining} more`);
274
+ }
275
+ }
276
+ log(`Font variants (aggregated across ${fontInfo.length} page${fontInfo.length === 1 ? '' : 's'}):`);
277
+ log(util.inspect([...byVariant.values()], false, 99));
278
+ }
279
+ function buildInstancingSuffix(fontUsage) {
280
+ const numAxesReduced = fontUsage.numAxesReduced ?? 0;
281
+ const numAxesPinned = fontUsage.numAxesPinned ?? 0;
282
+ if (fontUsage.fullyInstanced) {
283
+ return ', fully instanced';
284
+ }
285
+ if (numAxesReduced === 0 && numAxesPinned === 0) {
286
+ return '';
287
+ }
288
+ const instancingInfos = [];
289
+ if (numAxesPinned > 0) {
290
+ instancingInfos.push(`${numAxesPinned} ${numAxesPinned === 1 ? 'axis' : 'axes'} pinned`);
291
+ }
292
+ if (numAxesReduced) {
293
+ instancingInfos.push(`${numAxesReduced}${numAxesPinned > 0 ? '' : numAxesReduced === 1 ? ' axis' : ' axes'} reduced`);
294
+ }
295
+ return `, partially instanced (${instancingInfos.join(', ')})`;
296
+ }
297
+ function describeFontUsageStatus(fontUsage, usedPad, originalPad) {
298
+ const variantShortName = `${fontUsage.props['font-weight']}${fontUsage.props['font-style'] === 'italic' ? 'i' : ' '}`;
299
+ let status = ` ${variantShortName}: ${String(fontUsage.codepoints.used.length).padStart(usedPad)}/${String(fontUsage.codepoints.original.length).padStart(originalPad)} codepoints used`;
300
+ if (fontUsage.codepoints.page.length !== fontUsage.codepoints.used.length) {
301
+ status += ` (${fontUsage.codepoints.page.length} on this page)`;
302
+ }
303
+ let savings = 0;
304
+ if (fontUsage.smallestSubsetSize !== undefined) {
305
+ status += buildInstancingSuffix(fontUsage);
306
+ status += `, ${prettyBytes(fontUsage.smallestOriginalSize)} (${fontUsage.smallestOriginalFormat}) => ${prettyBytes(fontUsage.smallestSubsetSize)} (${fontUsage.smallestSubsetFormat})`;
307
+ savings = fontUsage.smallestOriginalSize - fontUsage.smallestSubsetSize;
308
+ }
309
+ else {
310
+ status += ', no subset font created';
311
+ }
312
+ return { status, savings };
313
+ }
314
+ function printPerAssetFontReport(fontInfo, sumSizesBefore, sumSizesAfter, log) {
373
315
  let totalSavings = sumSizesBefore - sumSizesAfter;
374
316
  for (const { assetFileName, fontUsages } of fontInfo) {
375
317
  let sumSmallestSubsetSize = 0;
@@ -396,45 +338,15 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
396
338
  for (const fontFamily of Object.keys(fontUsagesByFontFamily).sort()) {
397
339
  log(` ${fontFamily}:`);
398
340
  for (const fontUsage of fontUsagesByFontFamily[fontFamily]) {
399
- const variantShortName = `${fontUsage.props['font-weight']}${fontUsage.props['font-style'] === 'italic' ? 'i' : ' '}`;
400
- let status = ` ${variantShortName}: ${String(fontUsage.codepoints.used.length).padStart(usedPad)}/${String(fontUsage.codepoints.original.length).padStart(originalPad)} codepoints used`;
401
- if (fontUsage.codepoints.page.length !== fontUsage.codepoints.used.length) {
402
- status += ` (${fontUsage.codepoints.page.length} on this page)`;
403
- }
404
- if (fontUsage.smallestSubsetSize !== undefined) {
405
- const numAxesReduced = fontUsage.numAxesReduced ?? 0;
406
- const numAxesPinned = fontUsage.numAxesPinned ?? 0;
407
- if (fontUsage.fullyInstanced) {
408
- status += ', fully instanced';
409
- }
410
- else if (numAxesReduced > 0 || numAxesPinned) {
411
- const instancingInfos = [];
412
- if (numAxesPinned > 0) {
413
- instancingInfos.push(`${numAxesPinned} ${numAxesPinned === 1 ? 'axis' : 'axes'} pinned`);
414
- }
415
- if (numAxesReduced) {
416
- instancingInfos.push(`${numAxesReduced}${numAxesPinned > 0
417
- ? ''
418
- : numAxesReduced === 1
419
- ? ' axis'
420
- : ' axes'} reduced`);
421
- }
422
- status += `, partially instanced (${instancingInfos.join(', ')})`;
423
- }
424
- status += `, ${prettyBytes(fontUsage.smallestOriginalSize)} (${fontUsage.smallestOriginalFormat}) => ${prettyBytes(fontUsage.smallestSubsetSize)} (${fontUsage.smallestSubsetFormat})`;
425
- totalSavings +=
426
- fontUsage.smallestOriginalSize - fontUsage.smallestSubsetSize;
427
- }
428
- else {
429
- status += ', no subset font created';
430
- }
341
+ const { status, savings } = describeFontUsageStatus(fontUsage, usedPad, originalPad);
342
+ totalSavings += savings;
431
343
  log(status);
432
344
  }
433
345
  }
434
346
  }
435
- log(`HTML/SVG/JS/CSS size increase: ${prettyBytes(sumSizesAfter - sumSizesBefore)}`);
436
- log(`Total savings: ${prettyBytes(totalSavings)}`);
437
- outerTimings['output reporting'] = reportingPhase.end();
347
+ return totalSavings;
348
+ }
349
+ function printTimingSummary(outerTimings, subsetFontsTotal, subsetTimings, log) {
438
350
  const st = subsetTimings ?? {};
439
351
  const detailsRaw = st.collectTextsByPageDetails;
440
352
  const details = detailsRaw && typeof detailsRaw === 'object' ? detailsRaw : {};
@@ -466,73 +378,197 @@ const subfont = async function subfont({ root, canonicalRoot, output, debug = fa
466
378
  ['writeAssetsToDisc', outerTimings.writeAssetsToDisc, 0],
467
379
  ['output reporting', outerTimings['output reporting'], 0],
468
380
  ];
469
- if (debug) {
470
- log('\n═══ Subfont Timing Summary ═══');
471
- for (const [label, ms, indent] of rows) {
472
- if (ms === undefined)
473
- continue;
474
- const prefix = ' '.repeat(indent + 1);
475
- const padded = (ms || 0).toLocaleString().padStart(8);
476
- log(`${prefix}${label}: ${padded}ms`);
381
+ log('\n═══ Subfont Timing Summary ═══');
382
+ for (const [label, ms, indent] of rows) {
383
+ if (ms === undefined)
384
+ continue;
385
+ const prefix = ' '.repeat(indent + 1);
386
+ const padded = (ms || 0).toLocaleString().padStart(8);
387
+ log(`${prefix}${label}: ${padded}ms`);
388
+ }
389
+ log(' ─────────────────────────────────');
390
+ log(` Total: ${totalElapsed.toLocaleString().padStart(8)}ms`);
391
+ log('═══════════════════════════════\n');
392
+ }
393
+ function printDryRunPreview(assetGraph, log) {
394
+ log('\n═══ Dry Run Preview ═══');
395
+ const assetsToWrite = assetGraph.findAssets({
396
+ isLoaded: true,
397
+ isRedirect: { $ne: true },
398
+ url: (url) => url && url.startsWith(assetGraph.root),
399
+ });
400
+ const byType = {};
401
+ let totalOutputSize = 0;
402
+ for (const asset of assetsToWrite) {
403
+ const type = asset.type || 'Other';
404
+ if (!byType[type])
405
+ byType[type] = { count: 0, size: 0, files: [] };
406
+ const size = asset.rawSrc ? asset.rawSrc.length : 0;
407
+ byType[type].count += 1;
408
+ byType[type].size += size;
409
+ totalOutputSize += size;
410
+ if (asset.url && asset.url.includes('/subfont/')) {
411
+ byType[type].files.push(` ${asset.url.replace(assetGraph.root, '/')} (${prettyBytes(size)})`);
477
412
  }
478
- log(' ─────────────────────────────────');
479
- log(` Total: ${totalElapsed.toLocaleString().padStart(8)}ms`);
480
- log('═══════════════════════════════\n');
481
413
  }
482
- if (dryRun) {
483
- log('\n═══ Dry Run Preview ═══');
484
- const assetsToWrite = assetGraph.findAssets({
485
- isLoaded: true,
486
- isRedirect: { $ne: true },
487
- url: (url) => url && url.startsWith(assetGraph.root),
488
- });
489
- const byType = {};
490
- let totalOutputSize = 0;
491
- for (const asset of assetsToWrite) {
492
- const type = asset.type || 'Other';
493
- if (!byType[type])
494
- byType[type] = { count: 0, size: 0, files: [] };
495
- const size = asset.rawSrc ? asset.rawSrc.length : 0;
496
- byType[type].count += 1;
497
- byType[type].size += size;
498
- totalOutputSize += size;
499
- if (asset.url && asset.url.includes('/subfont/')) {
500
- byType[type].files.push(` ${asset.url.replace(assetGraph.root, '/')} (${prettyBytes(size)})`);
501
- }
414
+ for (const [type, info] of Object.entries(byType).sort(([, a], [, b]) => b.size - a.size)) {
415
+ log(` ${type}: ${info.count} file${info.count === 1 ? '' : 's'}, ${prettyBytes(info.size)}`);
416
+ for (const file of info.files) {
417
+ log(file);
502
418
  }
503
- for (const [type, info] of Object.entries(byType).sort(([, a], [, b]) => b.size - a.size)) {
504
- log(` ${type}: ${info.count} file${info.count === 1 ? '' : 's'}, ${prettyBytes(info.size)}`);
505
- for (const file of info.files) {
506
- log(file);
507
- }
419
+ }
420
+ log(` ─────────────────────────────────`);
421
+ log(` Total output: ${prettyBytes(totalOutputSize)}`);
422
+ const dirtyHtmlAssets = assetGraph.findAssets({
423
+ isDirty: true,
424
+ isLoaded: true,
425
+ type: { $in: ['Html', 'Svg'] },
426
+ });
427
+ if (dirtyHtmlAssets.length > 0) {
428
+ log(`\n Modified HTML/SVG files (${dirtyHtmlAssets.length}):`);
429
+ for (const asset of dirtyHtmlAssets) {
430
+ log(` ${asset.urlOrDescription}`);
508
431
  }
509
- log(` ─────────────────────────────────`);
510
- log(` Total output: ${prettyBytes(totalOutputSize)}`);
511
- const dirtyHtmlAssets = assetGraph.findAssets({
512
- isDirty: true,
513
- isLoaded: true,
514
- type: { $in: ['Html', 'Svg'] },
515
- });
516
- if (dirtyHtmlAssets.length > 0) {
517
- log(`\n Modified HTML/SVG files (${dirtyHtmlAssets.length}):`);
518
- for (const asset of dirtyHtmlAssets) {
519
- log(` ${asset.urlOrDescription}`);
520
- }
432
+ }
433
+ const subsetCssAssets = assetGraph.findAssets({
434
+ type: 'Css',
435
+ isLoaded: true,
436
+ url: (url) => url && url.includes('/subfont/'),
437
+ });
438
+ if (subsetCssAssets.length > 0) {
439
+ log(`\n Subset CSS files that would be created (${subsetCssAssets.length}):`);
440
+ for (const css of subsetCssAssets) {
441
+ const fontFaceCount = (css.text.match(/@font-face/g) || []).length;
442
+ log(` ${css.url.replace(assetGraph.root, '/')} (${prettyBytes(css.rawSrc.length)}, ${fontFaceCount} @font-face rule${fontFaceCount === 1 ? '' : 's'})`);
521
443
  }
522
- const subsetCssAssets = assetGraph.findAssets({
523
- type: 'Css',
444
+ }
445
+ log('═══════════════════════════════\n');
446
+ log('Dry run complete — no files were written.');
447
+ }
448
+ async function resolveRootsAndInputs(root, output, inputFiles, inPlace, dryRun, warn) {
449
+ let rootUrl = root && urlTools.urlOrFsPathToUrl(root, true);
450
+ const outRoot = output && urlTools.urlOrFsPathToUrl(output, true);
451
+ const resolved = await resolveInputUrls(inputFiles, rootUrl, warn);
452
+ rootUrl = resolved.rootUrl;
453
+ const inputUrls = resolved.inputUrls;
454
+ if (!inputUrls[0].startsWith('file:') && !outRoot && !dryRun) {
455
+ throw new UsageError('--output has to be specified when using non-file input urls');
456
+ }
457
+ if (!inPlace && !outRoot && !dryRun) {
458
+ throw new UsageError('Either --output, --in-place, or --dry-run has to be specified');
459
+ }
460
+ return { rootUrl, outRoot, inputUrls };
461
+ }
462
+ function printRunReport(fontInfo, sumSizesBefore, sumSizesAfter, debug, log) {
463
+ if (debug)
464
+ printVariantSummary(fontInfo, log);
465
+ const totalSavings = printPerAssetFontReport(fontInfo, sumSizesBefore, sumSizesAfter, log);
466
+ log(`HTML/SVG/JS/CSS size increase: ${prettyBytes(sumSizesAfter - sumSizesBefore)}`);
467
+ log(`Total savings: ${prettyBytes(totalSavings)}`);
468
+ }
469
+ const subfont = async function subfont({ root, canonicalRoot, output, debug = false, dryRun = false, silent = false, inlineCss = false, fontDisplay = 'swap', inPlace = false, inputFiles = [], recursive = false, relativeUrls = false, dynamic = false, fallbacks = true, text, sourceMaps = false, concurrency, chromeFlags = [], cache = false, strict = false, }, console) {
470
+ if (concurrency !== undefined &&
471
+ (!Number.isInteger(concurrency) || concurrency < 1)) {
472
+ throw new UsageError('--concurrency must be a positive integer');
473
+ }
474
+ // Prevent postcss plugins (colormin, convert-values, etc.) invoked by
475
+ // cssnano from walking the filesystem for a "browserslist" config.
476
+ // Under pnpm, `node_modules/.bin/browserslist` is a shell shim that
477
+ // browserslist mis-parses as browser queries, throwing
478
+ // BrowserslistError and silently aborting CSS minification.
479
+ // Setting BROWSERSLIST short-circuits the walk entirely.
480
+ if (!process.env.BROWSERSLIST && !process.env.BROWSERSLIST_CONFIG) {
481
+ process.env.BROWSERSLIST = 'defaults';
482
+ }
483
+ const { log, warn } = makeLogger(silent, console);
484
+ const maxConcurrency = (0, concurrencyLimit_1.getMaxConcurrency)();
485
+ if (concurrency !== undefined && concurrency > maxConcurrency) {
486
+ warn(`--concurrency ${concurrency} exceeds estimated safe limit of ${maxConcurrency} (each worker uses ~50 MB; ${Math.round(os.freemem() / (1024 * 1024 * 1024))} GB free, ${os.cpus().length} CPUs). Proceeding anyway — reduce if you hit OOM.`);
487
+ }
488
+ const { rootUrl, outRoot, inputUrls } = await resolveRootsAndInputs(root, output, inputFiles, inPlace, dryRun, warn);
489
+ const assetGraph = new AssetGraph({
490
+ root: rootUrl,
491
+ // Non-file roots get an explicit trailing-slash canonicalRoot so
492
+ // relative-URL resolution lines up with how the deployed site reads.
493
+ canonicalRoot: rootUrl && !rootUrl.startsWith('file:')
494
+ ? rootUrl.replace(/\/?$/, '/')
495
+ : canonicalRoot,
496
+ });
497
+ const getSawWarning = await installWarningHandlers(assetGraph, silent, strict, console);
498
+ const outerTimings = {};
499
+ // The tracker writes with console.log (duck-typed). Route it through
500
+ // the silent-aware log wrapper so --silent suppresses phase markers
501
+ // the same way it suppresses other subfont output.
502
+ const trackPhase = (0, progress_1.makePhaseTracker)({ log }, debug);
503
+ const loadAssetsPhase = trackPhase('loadAssets');
504
+ await assetGraph.loadAssets(inputUrls);
505
+ outerTimings.loadAssets = loadAssetsPhase.end();
506
+ const populatePhase = trackPhase('populate (initial)');
507
+ await assetGraph.populate({
508
+ followRelations: buildFollowRelationsQuery(recursive),
509
+ });
510
+ outerTimings['populate (initial)'] = populatePhase.end();
511
+ handleInitialRedirects(assetGraph);
512
+ const sizeableAssetQuery = {
513
+ isInline: false,
514
+ isLoaded: true,
515
+ type: { $in: ['Html', 'Svg', 'Css', 'JavaScript'] },
516
+ };
517
+ let sumSizesBefore = 0;
518
+ for (const asset of assetGraph.findAssets(sizeableAssetQuery)) {
519
+ sumSizesBefore += asset.rawSrc.length;
520
+ }
521
+ if (!sourceMaps) {
522
+ log('Skipping CSS source map processing for faster execution. Use --source-maps to preserve them.');
523
+ }
524
+ const cacheDir = resolveCacheDir(cache, rootUrl, warn);
525
+ const subsetPhase = trackPhase('subsetFonts total');
526
+ const { fontInfo, timings: subsetTimings } = await subsetFonts(assetGraph, {
527
+ inlineCss,
528
+ fontDisplay,
529
+ formats: ['woff2'],
530
+ omitFallbacks: !fallbacks,
531
+ hrefType: relativeUrls ? 'relative' : 'rootRelative',
532
+ text,
533
+ dynamic,
534
+ console,
535
+ sourceMaps,
536
+ debug,
537
+ concurrency,
538
+ chromeArgs: chromeFlags,
539
+ cacheDir,
540
+ });
541
+ const subsetFontsTotal = subsetPhase.end();
542
+ const postProcessingPhase = trackPhase('post-subsetFonts processing');
543
+ let sumSizesAfter = 0;
544
+ for (const asset of assetGraph.findAssets(sizeableAssetQuery)) {
545
+ sumSizesAfter += asset.rawSrc.length;
546
+ }
547
+ await runPostProcessing(assetGraph, rootUrl);
548
+ outerTimings['post-subsetFonts processing'] = postProcessingPhase.end();
549
+ if (strict && getSawWarning()) {
550
+ // In non-silent mode, assetgraph's logEvents normally exits earlier via
551
+ // stopOnWarning. This guard covers silent mode and warnings that slipped
552
+ // past a transform boundary.
553
+ throw new Error('subfont: --strict was set and one or more warnings were emitted; refusing to write output.');
554
+ }
555
+ const writePhase = trackPhase('writeAssetsToDisc');
556
+ if (!dryRun) {
557
+ await assetGraph.writeAssetsToDisc({
524
558
  isLoaded: true,
525
- url: (url) => url && url.includes('/subfont/'),
526
- });
527
- if (subsetCssAssets.length > 0) {
528
- log(`\n Subset CSS files that would be created (${subsetCssAssets.length}):`);
529
- for (const css of subsetCssAssets) {
530
- const fontFaceCount = (css.text.match(/@font-face/g) || []).length;
531
- log(` ${css.url.replace(assetGraph.root, '/')} (${prettyBytes(css.rawSrc.length)}, ${fontFaceCount} @font-face rule${fontFaceCount === 1 ? '' : 's'})`);
532
- }
533
- }
534
- log('═══════════════════════════════\n');
535
- log('Dry run complete — no files were written.');
559
+ isRedirect: { $ne: true },
560
+ url: (url) => url && url.startsWith(assetGraph.root),
561
+ }, outRoot, assetGraph.root);
562
+ }
563
+ outerTimings.writeAssetsToDisc = writePhase.end();
564
+ const reportingPhase = trackPhase('output reporting');
565
+ printRunReport(fontInfo, sumSizesBefore, sumSizesAfter, debug, log);
566
+ outerTimings['output reporting'] = reportingPhase.end();
567
+ if (debug) {
568
+ printTimingSummary(outerTimings, subsetFontsTotal, subsetTimings, log);
569
+ }
570
+ if (dryRun) {
571
+ printDryRunPreview(assetGraph, log);
536
572
  }
537
573
  else {
538
574
  log('Output written to', outRoot || assetGraph.root);