@turntrout/subfont 1.0.4 → 1.1.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/README.md CHANGED
@@ -36,20 +36,26 @@ subfont path/to/index.html -i --cache
36
36
 
37
37
  ## Options
38
38
 
39
- | Flag | Default | Description |
40
- | ----------------: | :-----: | :----------------------------------------------------------- |
41
- | `-i, --in-place` | off | Modify files in-place |
42
- | `-o, --output` | | Output directory |
43
- | `-r, --recursive` | off | Crawl linked pages |
44
- | `--dynamic` | off | Trace with headless browser |
45
- | `--dry-run` | off | Preview without writing |
46
- | `--fallbacks` | on | Load the full original font for characters not in the subset |
47
- | `--font-display` | `swap` | `auto`/`block`/`swap`/`fallback`/`optional` |
48
- | `--text` | | Extra characters for every subset |
49
- | `--cache [dir]` | off | Cache subset results to disk between runs |
50
- | `--concurrency N` | | Max worker threads for parallel font tracing |
51
- | `--chrome-flags` | | Custom Chrome flags for `--dynamic` |
52
- | `--source-maps` | off | Preserve CSS source maps (slower) |
39
+ | Flag | Default | Description |
40
+ | -----------------: | :-----: | :----------------------------------------------------------- |
41
+ | `-i, --in-place` | off | Modify files in-place |
42
+ | `-o, --output` | | Output directory |
43
+ | `--root` | | Path to web root (deduced from input files if not specified) |
44
+ | `--canonical-root` | | URI root where the site will be deployed |
45
+ | `-r, --recursive` | off | Crawl linked pages |
46
+ | `--dynamic` | off | Trace with headless browser |
47
+ | `--dry-run` | off | Preview without writing |
48
+ | `--fallbacks` | on | Load the full original font for characters not in the subset |
49
+ | `--font-display` | `swap` | `auto`/`block`/`swap`/`fallback`/`optional` |
50
+ | `--text` | | Extra characters for every subset |
51
+ | `--cache [dir]` | off | Cache subset results to disk between runs |
52
+ | `--concurrency N` | | Max worker threads (capped by available memory, ~50 MB each) |
53
+ | `--chrome-flags` | | Custom Chrome flags for `--dynamic` |
54
+ | `--source-maps` | off | Preserve CSS source maps (slower) |
55
+ | `-s, --silent` | off | Suppress all console output |
56
+ | `-d, --debug` | off | Verbose timing and font glyph detection info |
57
+ | `--relative-urls` | off | Emit relative URLs instead of root-relative |
58
+ | `--inline-css` | off | Inline the subset @font-face CSS into HTML |
53
59
 
54
60
  Run `subfont --help` for the full list.
55
61
 
@@ -159,6 +159,17 @@ class FontTracerPool {
159
159
  }
160
160
  this._pendingTasks = [];
161
161
 
162
+ // Reject any in-flight tasks still assigned to workers.
163
+ // Clear _taskByWorker before terminate() so _onWorkerExit won't double-reject.
164
+ for (const [, taskId] of this._taskByWorker) {
165
+ const cb = this._taskCallbacks.get(taskId);
166
+ if (cb) {
167
+ this._taskCallbacks.delete(taskId);
168
+ cb.reject(new Error('Worker pool destroyed'));
169
+ }
170
+ }
171
+ this._taskByWorker.clear();
172
+
162
173
  // Terminate workers with a 5-second timeout to prevent hanging
163
174
  const TERMINATE_TIMEOUT_MS = 5000;
164
175
  await Promise.all(
@@ -180,7 +180,8 @@ class HeadlessBrowser {
180
180
  urlTools.resolveUrl(
181
181
  baseUrl,
182
182
  urlTools.buildRelativeUrl(assetGraph.root, htmlAsset.url)
183
- )
183
+ ),
184
+ { timeout: 30000 }
184
185
  );
185
186
 
186
187
  await page.addScriptTag({
@@ -202,7 +203,13 @@ class HeadlessBrowser {
202
203
  const launchPromise = this._launchPromise;
203
204
  if (launchPromise) {
204
205
  this._launchPromise = undefined;
205
- const browser = await launchPromise;
206
+ let browser;
207
+ try {
208
+ browser = await launchPromise;
209
+ } catch {
210
+ // Launch failed — nothing to close
211
+ return;
212
+ }
206
213
  await browser.close();
207
214
  }
208
215
  }
@@ -0,0 +1,10 @@
1
+ const os = require('os');
2
+
3
+ // Approximate memory footprint of each worker thread (jsdom + font-tracer).
4
+ const WORKER_MEMORY_BYTES = 50 * 1024 * 1024; // 50 MB
5
+
6
+ function getMaxConcurrency() {
7
+ return Math.max(1, Math.floor(os.totalmem() / WORKER_MEMORY_BYTES));
8
+ }
9
+
10
+ module.exports = { WORKER_MEMORY_BYTES, getMaxConcurrency };
@@ -10,6 +10,7 @@ const INVISIBLE_ELEMENTS = new Set([
10
10
  'iframe',
11
11
  'object',
12
12
  'embed',
13
+ 'datalist',
13
14
  ]);
14
15
  const TEXT_ATTRIBUTES = new Set([
15
16
  'alt',
@@ -72,3 +73,4 @@ function extractVisibleText(html) {
72
73
  }
73
74
 
74
75
  module.exports = extractVisibleText;
76
+ module.exports.INVISIBLE_ELEMENTS = INVISIBLE_ELEMENTS;
@@ -28,11 +28,11 @@ parentPort.on('message', (msg) => {
28
28
  }
29
29
 
30
30
  if (msg.type === 'trace') {
31
+ const { taskId, htmlText, stylesheetsWithPredicates: serialized } = msg;
32
+ let dom;
31
33
  try {
32
- const { taskId, htmlText, stylesheetsWithPredicates: serialized } = msg;
33
-
34
34
  // Re-parse HTML with jsdom to get a DOM document
35
- const dom = new JSDOM(htmlText);
35
+ dom = new JSDOM(htmlText);
36
36
  const document = dom.window.document;
37
37
 
38
38
  // Re-parse CSS from serialized text — asset objects with PostCSS
@@ -50,9 +50,6 @@ parentPort.on('message', (msg) => {
50
50
  getCssRulesByProperty: memoizedGetCssRulesByProperty,
51
51
  });
52
52
 
53
- // Clean up jsdom to free memory
54
- dom.window.close();
55
-
56
53
  // Strip any non-serializable data from results
57
54
  const serializableResults = textByProps.map((entry) => ({
58
55
  text: entry.text,
@@ -71,6 +68,9 @@ parentPort.on('message', (msg) => {
71
68
  error: err.message,
72
69
  stack: err.stack,
73
70
  });
71
+ } finally {
72
+ // Clean up jsdom to free memory — must run even if fontTracer throws
73
+ if (dom) dom.window.close();
74
74
  }
75
75
  }
76
76
  });
@@ -1,8 +1,13 @@
1
1
  module.exports = function parseCommandLineOptions(argv) {
2
+ const os = require('os');
3
+ const { getMaxConcurrency } = require('./concurrencyLimit');
2
4
  let yargs = require('yargs');
3
5
  if (argv) {
4
6
  yargs = yargs(argv);
5
7
  }
8
+
9
+ const maxConcurrency = getMaxConcurrency();
10
+
6
11
  yargs
7
12
  .usage(
8
13
  'Create optimal font subsets from your actual font usage.\n$0 [options] <htmlFile(s) | url(s)>'
@@ -106,10 +111,22 @@ module.exports = function parseCommandLineOptions(argv) {
106
111
  },
107
112
  })
108
113
  .options('concurrency', {
109
- describe:
110
- 'Maximum number of worker threads for parallel font tracing. Defaults to the number of CPU cores (max 8)',
114
+ describe: `Maximum number of worker threads for parallel font tracing. Defaults to the number of CPU cores (max 8). Upper bound: ${maxConcurrency} (based on available memory)`,
111
115
  type: 'number',
112
116
  })
117
+ .check((argv) => {
118
+ if (argv.concurrency !== undefined) {
119
+ if (!Number.isInteger(argv.concurrency) || argv.concurrency < 1) {
120
+ throw new Error('--concurrency must be a positive integer');
121
+ }
122
+ if (argv.concurrency > maxConcurrency) {
123
+ throw new Error(
124
+ `--concurrency must not exceed ${maxConcurrency} (each worker uses ~50 MB; system has ${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB total)`
125
+ );
126
+ }
127
+ }
128
+ return true;
129
+ })
113
130
  .options('source-maps', {
114
131
  describe: 'Preserve CSS source maps through subfont processing',
115
132
  type: 'boolean',
package/lib/subfont.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const fsPromises = require('fs/promises');
2
+ const os = require('os');
2
3
  const pathModule = require('path');
4
+ const { getMaxConcurrency } = require('./concurrencyLimit');
3
5
  const AssetGraph = require('assetgraph');
4
6
  const prettyBytes = require('pretty-bytes');
5
7
  const urlTools = require('urltools');
@@ -43,6 +45,12 @@ module.exports = async function subfont(
43
45
  ) {
44
46
  throw new UsageError('--concurrency must be a positive integer');
45
47
  }
48
+ const maxConcurrency = getMaxConcurrency();
49
+ if (concurrency !== undefined && concurrency > maxConcurrency) {
50
+ throw new UsageError(
51
+ `--concurrency must not exceed ${maxConcurrency} (each worker uses ~50 MB; system has ${Math.round(os.totalmem() / (1024 * 1024 * 1024))} GB total)`
52
+ );
53
+ }
46
54
 
47
55
  const formats = ['woff2'];
48
56
 
@@ -216,14 +224,15 @@ module.exports = async function subfont(
216
224
  }
217
225
  }
218
226
 
219
- let sumSizesBefore = 0;
220
- for (const asset of assetGraph.findAssets({
227
+ const sizeableAssetQuery = {
221
228
  isInline: false,
222
229
  isLoaded: true,
223
230
  type: {
224
231
  $in: ['Html', 'Svg', 'Css', 'JavaScript'],
225
232
  },
226
- })) {
233
+ };
234
+ let sumSizesBefore = 0;
235
+ for (const asset of assetGraph.findAssets(sizeableAssetQuery)) {
227
236
  sumSizesBefore += asset.rawSrc.length;
228
237
  }
229
238
 
@@ -269,13 +278,7 @@ module.exports = async function subfont(
269
278
 
270
279
  phaseStart = Date.now();
271
280
  let sumSizesAfter = 0;
272
- for (const asset of assetGraph.findAssets({
273
- isInline: false,
274
- isLoaded: true,
275
- type: {
276
- $in: ['Html', 'Svg', 'Css', 'JavaScript'],
277
- },
278
- })) {
281
+ for (const asset of assetGraph.findAssets(sizeableAssetQuery)) {
279
282
  sumSizesAfter += asset.rawSrc.length;
280
283
  }
281
284
 
@@ -14,7 +14,7 @@ const getUnicodeRanges = (codePoints) => {
14
14
  const ranges = [];
15
15
  let start, end;
16
16
 
17
- codePoints.sort((a, b) => a - b);
17
+ codePoints = [...codePoints].sort((a, b) => a - b);
18
18
 
19
19
  for (let i = 0; i < codePoints.length; i++) {
20
20
  start = codePoints[i];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turntrout/subfont",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
4
4
  "description": "Automatically subset web fonts to only the characters used on your pages. Fork of Munter/subfont with modern defaults.",
5
5
  "engines": {
6
6
  "node": ">=18.0.0"