@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 -- Hard fork from [Munter/subfont](https://github.com/Munter/subfont)
|
|
4
|
+
|
|
5
|
+
Published as `@turntrout/subfont`. Based on Munter/subfont v7.2.3.
|
|
6
|
+
|
|
7
|
+
### Performance
|
|
8
|
+
|
|
9
|
+
On [TurnTrout.com](https://github.com/alexander-turner/TurnTrout.com) (382 pages), font subsetting dropped from 111 minutes to 28 minutes:
|
|
10
|
+
|
|
11
|
+
| | Version | Duration |
|
|
12
|
+
| ------------------------------------------------------------------------------------ | -------------- | -------- |
|
|
13
|
+
| [Before](https://github.com/alexander-turner/TurnTrout.com/actions/runs/23470135763) | Munter/subfont | 111 min |
|
|
14
|
+
| [After](https://github.com/alexander-turner/TurnTrout.com/actions/runs/23518006824) | This fork | 28 min |
|
|
15
|
+
|
|
16
|
+
### Breaking changes
|
|
17
|
+
|
|
18
|
+
- **woff2-only.** Removed `--browsers` and `--formats`. Every browser supports woff2.
|
|
19
|
+
- **Always-on variable font instancing.** Removed `--instance`. If you use weights 400 and 700 from a 100-900 variable font, the subset shrinks to just that range automatically.
|
|
20
|
+
- **Removed legacy flags:** `--skip-source-map-processing`, `--dryrun`/`--dry`/`--canonicalroot`/`--sourceMaps` aliases, and v5 flag validation.
|
|
21
|
+
|
|
22
|
+
### New features
|
|
23
|
+
|
|
24
|
+
- **`--cache [dir]`** -- Cache subset results to disk. Speeds up repeat builds.
|
|
25
|
+
- **`--chrome-flags`** -- Custom flags for headless Chrome with `--dynamic`.
|
|
26
|
+
- **`--concurrency N`** -- Control worker thread count for parallel font tracing.
|
|
27
|
+
- **Parallel font tracing** -- Worker pool (up to 8 threads). Pages sharing identical CSS are traced once.
|
|
28
|
+
- **`--root` validation** -- Fails early with a clear error.
|
|
29
|
+
- **Timing summary** -- Printed after every run.
|
|
30
|
+
- **Better `--dry-run`** -- Detailed preview of files, sizes, and CSS changes.
|
|
31
|
+
|
|
32
|
+
### Bug fixes
|
|
33
|
+
|
|
34
|
+
- Fixed crash on invalid/corrupt font files during instancing.
|
|
35
|
+
- Fixed incorrect axis range computation for variable fonts.
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# CLAUDE.md — subfont
|
|
2
|
+
|
|
3
|
+
## Project Overview
|
|
4
|
+
|
|
5
|
+
subfont is a CLI tool and Node.js library that speeds up initial page paint by automatically subsetting local or Google fonts and loading them optimally. It uses puppeteer to trace font usage across pages and generates optimized font subsets.
|
|
6
|
+
|
|
7
|
+
## Development Commands
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm install # Install dependencies
|
|
11
|
+
pnpm test # Run mocha tests + lint
|
|
12
|
+
pnpm run lint # ESLint + Prettier check
|
|
13
|
+
pnpm run coverage # Run tests with nyc coverage
|
|
14
|
+
pnpm run check-coverage # Verify coverage thresholds
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Code Style
|
|
18
|
+
|
|
19
|
+
- **Formatter**: Prettier with single quotes, trailing commas (es5)
|
|
20
|
+
- **Linter**: ESLint via neostandard + eslint-config-prettier
|
|
21
|
+
- **Rules**: `prefer-template`, `prefer-const` (destructuring: all)
|
|
22
|
+
- **Tests**: Mocha with `unexpected` assertion library (not chai/jest)
|
|
23
|
+
- No exclusive tests (`describe.only`, `it.only`) — enforced by eslint-plugin-mocha
|
|
24
|
+
|
|
25
|
+
## Project Structure
|
|
26
|
+
|
|
27
|
+
- `lib/` — Source code (entry: `lib/subfont.js`, CLI: `lib/cli.js`)
|
|
28
|
+
- `test/` — Mocha test files
|
|
29
|
+
- `testdata/` — HTML fixtures and font files for tests
|
|
30
|
+
- `cases/` — Additional test case data
|
|
31
|
+
|
|
32
|
+
## Key Architecture
|
|
33
|
+
|
|
34
|
+
- Built on **assetgraph** for HTML/CSS asset graph traversal
|
|
35
|
+
- Uses **puppeteer-core** for headless browser font tracing
|
|
36
|
+
- **font-tracer** traces which fonts are used on each page
|
|
37
|
+
- **subset-font** / **harfbuzzjs** for WOFF2 subsetting
|
|
38
|
+
- `lib/subsetFonts.js` — Main orchestration logic
|
|
39
|
+
- `lib/FontTracerPool.js` — Manages puppeteer browser pool for parallel tracing
|
|
40
|
+
|
|
41
|
+
## Testing Notes
|
|
42
|
+
|
|
43
|
+
- Tests have a 5-minute timeout (configured in `.mocharc.yml`)
|
|
44
|
+
- Tests use `httpception` for HTTP mocking and `unexpected` for assertions
|
|
45
|
+
- Some tests require puppeteer browser binaries (installed via `pnpm install`)
|
|
46
|
+
- Coverage thresholds are enforced via `nyc check-coverage`
|
|
47
|
+
|
|
48
|
+
## Conventions
|
|
49
|
+
|
|
50
|
+
- CommonJS modules (`require`/`module.exports`), not ESM
|
|
51
|
+
- Node.js >= 18 required
|
|
52
|
+
- Use `const` by default; `let` only when reassignment is needed
|
|
53
|
+
- Template literals preferred over string concatenation
|
package/LICENSE.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2017 Peter Brandt Müller
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# `@turntrout/subfont`
|
|
2
|
+
|
|
3
|
+
[](https://github.com/alexander-turner/subfont/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
A faster fork of [subfont](https://github.com/Munter/subfont) that subsets web fonts to only the characters used on your pages. Adds parallel tracing, disk caching, woff2-only output, and always-on variable font instancing.
|
|
6
|
+
|
|
7
|
+
## Performance
|
|
8
|
+
|
|
9
|
+
On [TurnTrout.com](https://github.com/alexander-turner/TurnTrout.com) (382 pages, 20+ font variants), switching to this fork cut font subsetting from **111 minutes to 28 minutes**:
|
|
10
|
+
|
|
11
|
+
| | Version | Duration |
|
|
12
|
+
| -----------------------------------------------------------------------------------: | :------------: | :------- |
|
|
13
|
+
| [Before](https://github.com/alexander-turner/TurnTrout.com/actions/runs/23470135763) | Munter/subfont | 111 min |
|
|
14
|
+
| [After](https://github.com/alexander-turner/TurnTrout.com/actions/runs/23518006824) | This fork | 28 min |
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
npm install -g @turntrout/subfont
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Node.js >= 18.
|
|
23
|
+
|
|
24
|
+
## Usage
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# Optimize build artifacts in-place (recommended)
|
|
28
|
+
subfont path/to/dist/index.html -i
|
|
29
|
+
|
|
30
|
+
# Preview without writing
|
|
31
|
+
subfont path/to/dist/index.html --dry-run
|
|
32
|
+
|
|
33
|
+
# Output to a separate directory
|
|
34
|
+
subfont path/to/index.html -o path/to/output
|
|
35
|
+
|
|
36
|
+
# Crawl all linked pages
|
|
37
|
+
subfont path/to/index.html -i --recursive
|
|
38
|
+
|
|
39
|
+
# Trace JS-rendered content in headless Chrome
|
|
40
|
+
subfont path/to/index.html -i --dynamic
|
|
41
|
+
|
|
42
|
+
# Cache subset results between runs
|
|
43
|
+
subfont path/to/index.html -i --cache
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
| Flag | Default | Description |
|
|
49
|
+
| ----------------: | :-----: | :----------------------------------------------------------- |
|
|
50
|
+
| `-i, --in-place` | off | Modify files in-place |
|
|
51
|
+
| `-o, --output` | | Output directory |
|
|
52
|
+
| `-r, --recursive` | off | Crawl linked pages |
|
|
53
|
+
| `--dynamic` | off | Trace with headless browser |
|
|
54
|
+
| `--dry-run` | off | Preview without writing |
|
|
55
|
+
| `--fallbacks` | on | Load the full original font for characters not in the subset |
|
|
56
|
+
| `--font-display` | `swap` | `auto`/`block`/`swap`/`fallback`/`optional` |
|
|
57
|
+
| `--text` | | Extra characters for every subset |
|
|
58
|
+
| `--cache [dir]` | off | Cache subset results to disk between runs |
|
|
59
|
+
| `--concurrency N` | | Max worker threads for parallel font tracing |
|
|
60
|
+
| `--chrome-flags` | | Custom Chrome flags for `--dynamic` |
|
|
61
|
+
| `--source-maps` | off | Preserve CSS source maps (slower) |
|
|
62
|
+
|
|
63
|
+
Run `subfont --help` for the full list.
|
|
64
|
+
|
|
65
|
+
To include extra characters in a specific font's subset, add `-subfont-text` to its `@font-face`:
|
|
66
|
+
|
|
67
|
+
```css
|
|
68
|
+
@font-face {
|
|
69
|
+
font-family: Roboto;
|
|
70
|
+
src: url(roboto.woff2) format('woff2');
|
|
71
|
+
-subfont-text: '0123456789';
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Programmatic API
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
const subfont = require('@turntrout/subfont');
|
|
79
|
+
|
|
80
|
+
const assetGraph = await subfont(
|
|
81
|
+
{
|
|
82
|
+
inputFiles: ['path/to/index.html'],
|
|
83
|
+
inPlace: true,
|
|
84
|
+
},
|
|
85
|
+
console
|
|
86
|
+
);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Returns the [Assetgraph](https://github.com/assetgraph/assetgraph) instance.
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT -- Original work by [Peter Muller (Munter)](https://github.com/Munter/subfont)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
const pathModule = require('path');
|
|
2
|
+
const { Worker } = require('worker_threads');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Worker pool for running fontTracer in parallel across pages.
|
|
6
|
+
* Each worker re-parses HTML with jsdom and runs fontTracer independently.
|
|
7
|
+
*/
|
|
8
|
+
class FontTracerPool {
|
|
9
|
+
constructor(numWorkers) {
|
|
10
|
+
this._workerPath = pathModule.join(__dirname, 'fontTracerWorker.js');
|
|
11
|
+
this._numWorkers = numWorkers;
|
|
12
|
+
this._workers = [];
|
|
13
|
+
this._idle = [];
|
|
14
|
+
this._pendingTasks = [];
|
|
15
|
+
this._taskCallbacks = new Map();
|
|
16
|
+
this._taskByWorker = new Map(); // track which taskId each worker is processing
|
|
17
|
+
this._nextTaskId = 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async init() {
|
|
21
|
+
const initPromises = [];
|
|
22
|
+
for (let i = 0; i < this._numWorkers; i++) {
|
|
23
|
+
const worker = new Worker(this._workerPath);
|
|
24
|
+
this._workers.push(worker);
|
|
25
|
+
|
|
26
|
+
const initPromise = new Promise((resolve, reject) => {
|
|
27
|
+
const onError = reject;
|
|
28
|
+
const onMessage = (msg) => {
|
|
29
|
+
if (msg.type === 'ready') {
|
|
30
|
+
worker.off('message', onMessage);
|
|
31
|
+
worker.off('error', onError);
|
|
32
|
+
worker.on('message', (msg) => this._onWorkerMessage(worker, msg));
|
|
33
|
+
worker.on('exit', (code) => this._onWorkerExit(worker, code));
|
|
34
|
+
this._idle.push(worker);
|
|
35
|
+
resolve();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
worker.on('message', onMessage);
|
|
39
|
+
worker.on('error', onError);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
worker.postMessage({ type: 'init' });
|
|
43
|
+
|
|
44
|
+
initPromises.push(initPromise);
|
|
45
|
+
}
|
|
46
|
+
await Promise.all(initPromises);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_onWorkerMessage(worker, msg) {
|
|
50
|
+
this._taskByWorker.delete(worker);
|
|
51
|
+
const cb = this._taskCallbacks.get(msg.taskId);
|
|
52
|
+
if (cb) {
|
|
53
|
+
this._taskCallbacks.delete(msg.taskId);
|
|
54
|
+
if (msg.type === 'result') {
|
|
55
|
+
cb.resolve(msg.textByProps);
|
|
56
|
+
} else if (msg.type === 'error') {
|
|
57
|
+
cb.reject(new Error(`Worker error: ${msg.error}\n${msg.stack}`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Worker is now idle, check for pending tasks
|
|
61
|
+
this._idle.push(worker);
|
|
62
|
+
this._dispatchPending();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
_onWorkerExit(worker, code) {
|
|
66
|
+
// Remove crashed worker from tracking
|
|
67
|
+
const workerIdx = this._workers.indexOf(worker);
|
|
68
|
+
if (workerIdx !== -1) {
|
|
69
|
+
this._workers.splice(workerIdx, 1);
|
|
70
|
+
}
|
|
71
|
+
const idleIdx = this._idle.indexOf(worker);
|
|
72
|
+
if (idleIdx !== -1) {
|
|
73
|
+
this._idle.splice(idleIdx, 1);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (code !== 0) {
|
|
77
|
+
// Reject the task that was in-flight on this worker
|
|
78
|
+
const taskId = this._taskByWorker.get(worker);
|
|
79
|
+
this._taskByWorker.delete(worker);
|
|
80
|
+
if (taskId !== undefined) {
|
|
81
|
+
const cb = this._taskCallbacks.get(taskId);
|
|
82
|
+
if (cb) {
|
|
83
|
+
this._taskCallbacks.delete(taskId);
|
|
84
|
+
cb.reject(new Error(`Worker exited with code ${code}`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// If no workers remain, reject all pending tasks
|
|
89
|
+
if (this._workers.length === 0) {
|
|
90
|
+
for (const task of this._pendingTasks) {
|
|
91
|
+
const cb = this._taskCallbacks.get(task.message.taskId);
|
|
92
|
+
if (cb) {
|
|
93
|
+
this._taskCallbacks.delete(task.message.taskId);
|
|
94
|
+
cb.reject(
|
|
95
|
+
new Error('All workers have crashed, no workers available')
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
this._pendingTasks = [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_dispatchPending() {
|
|
105
|
+
while (this._idle.length > 0 && this._pendingTasks.length > 0) {
|
|
106
|
+
const worker = this._idle.pop();
|
|
107
|
+
const task = this._pendingTasks.shift();
|
|
108
|
+
this._taskByWorker.set(worker, task.message.taskId);
|
|
109
|
+
try {
|
|
110
|
+
worker.postMessage(task.message);
|
|
111
|
+
} catch (err) {
|
|
112
|
+
// postMessage can fail synchronously (e.g. structured clone error).
|
|
113
|
+
// Return the worker to the idle pool and reject the task.
|
|
114
|
+
this._taskByWorker.delete(worker);
|
|
115
|
+
this._idle.push(worker);
|
|
116
|
+
const cb = this._taskCallbacks.get(task.message.taskId);
|
|
117
|
+
if (cb) {
|
|
118
|
+
this._taskCallbacks.delete(task.message.taskId);
|
|
119
|
+
cb.reject(err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Run fontTracer on the given HTML text + stylesheets in a worker.
|
|
127
|
+
* Returns a promise that resolves to textByProps.
|
|
128
|
+
*/
|
|
129
|
+
trace(htmlText, stylesheetsWithPredicates) {
|
|
130
|
+
const taskId = this._nextTaskId++;
|
|
131
|
+
// Serialize stylesheets to plain data — asset objects contain DOM/PostCSS
|
|
132
|
+
// trees that cannot be transferred via structured clone.
|
|
133
|
+
const serializedStylesheets = stylesheetsWithPredicates.map((entry) => ({
|
|
134
|
+
text: entry.text || (entry.asset && entry.asset.text) || '',
|
|
135
|
+
predicates: entry.predicates || {},
|
|
136
|
+
}));
|
|
137
|
+
const message = {
|
|
138
|
+
type: 'trace',
|
|
139
|
+
taskId,
|
|
140
|
+
htmlText,
|
|
141
|
+
stylesheetsWithPredicates: serializedStylesheets,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
return new Promise((resolve, reject) => {
|
|
145
|
+
this._taskCallbacks.set(taskId, { resolve, reject });
|
|
146
|
+
this._pendingTasks.push({ message });
|
|
147
|
+
this._dispatchPending();
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async destroy() {
|
|
152
|
+
await Promise.all(this._workers.map((w) => w.terminate()));
|
|
153
|
+
this._workers = [];
|
|
154
|
+
this._idle = [];
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = FontTracerPool;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const urlTools = require('urltools');
|
|
2
|
+
const puppeteer = require('puppeteer-core');
|
|
3
|
+
const pathModule = require('path');
|
|
4
|
+
const {
|
|
5
|
+
install,
|
|
6
|
+
uninstall,
|
|
7
|
+
Browser,
|
|
8
|
+
detectBrowserPlatform,
|
|
9
|
+
Cache,
|
|
10
|
+
} = require('@puppeteer/browsers');
|
|
11
|
+
|
|
12
|
+
async function transferResults(jsHandle) {
|
|
13
|
+
const results = await jsHandle.jsonValue();
|
|
14
|
+
for (const [i, result] of results.entries()) {
|
|
15
|
+
const resultHandle = await jsHandle.getProperty(String(i));
|
|
16
|
+
const elementHandle = await resultHandle.getProperty('node');
|
|
17
|
+
result.node = elementHandle;
|
|
18
|
+
}
|
|
19
|
+
return results;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function downloadOrLocatePreferredBrowserRevision(extraArgs = []) {
|
|
23
|
+
if (process.env.PUPPETEER_EXECUTABLE_PATH) {
|
|
24
|
+
return puppeteer.launch({
|
|
25
|
+
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
|
|
26
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', ...extraArgs],
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const cacheDir = pathModule.resolve(__dirname, '..', 'puppeteer-browsers');
|
|
30
|
+
const platform = detectBrowserPlatform();
|
|
31
|
+
const cache = new Cache(cacheDir);
|
|
32
|
+
const installed = cache.getInstalledBrowsers();
|
|
33
|
+
let executablePath;
|
|
34
|
+
const chromeEntry = installed.find((b) => b.browser === Browser.CHROME);
|
|
35
|
+
if (chromeEntry) {
|
|
36
|
+
executablePath = chromeEntry.executablePath;
|
|
37
|
+
} else {
|
|
38
|
+
// Check the default puppeteer cache (~/.cache/puppeteer) before downloading
|
|
39
|
+
const defaultCacheDir = pathModule.join(
|
|
40
|
+
require('os').homedir(),
|
|
41
|
+
'.cache',
|
|
42
|
+
'puppeteer'
|
|
43
|
+
);
|
|
44
|
+
const defaultCache = new Cache(defaultCacheDir);
|
|
45
|
+
const defaultInstalled = defaultCache.getInstalledBrowsers();
|
|
46
|
+
const defaultChromeEntry = defaultInstalled.find(
|
|
47
|
+
(b) => b.browser === Browser.CHROME
|
|
48
|
+
);
|
|
49
|
+
if (defaultChromeEntry) {
|
|
50
|
+
executablePath = defaultChromeEntry.executablePath;
|
|
51
|
+
} else {
|
|
52
|
+
console.log('Downloading Chrome');
|
|
53
|
+
const result = await install({
|
|
54
|
+
browser: Browser.CHROME,
|
|
55
|
+
buildId: 'stable',
|
|
56
|
+
cacheDir,
|
|
57
|
+
platform,
|
|
58
|
+
});
|
|
59
|
+
executablePath = result.executablePath;
|
|
60
|
+
|
|
61
|
+
// Clean up older Chrome versions that may have accumulated from
|
|
62
|
+
// previous runs with different stable buildIds.
|
|
63
|
+
const allInstalled = cache.getInstalledBrowsers();
|
|
64
|
+
for (const entry of allInstalled) {
|
|
65
|
+
if (
|
|
66
|
+
entry.browser === Browser.CHROME &&
|
|
67
|
+
entry.executablePath !== executablePath
|
|
68
|
+
) {
|
|
69
|
+
try {
|
|
70
|
+
await uninstall({
|
|
71
|
+
browser: entry.browser,
|
|
72
|
+
buildId: entry.buildId,
|
|
73
|
+
cacheDir,
|
|
74
|
+
});
|
|
75
|
+
console.log(`Removed old Chrome ${entry.buildId}`);
|
|
76
|
+
} catch {
|
|
77
|
+
// Ignore cleanup errors — the old version may be in use or locked
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return puppeteer.launch({
|
|
84
|
+
executablePath,
|
|
85
|
+
args: ['--no-sandbox', '--disable-setuid-sandbox', ...extraArgs],
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class HeadlessBrowser {
|
|
90
|
+
constructor({ console, chromeArgs = [] }) {
|
|
91
|
+
this.console = console;
|
|
92
|
+
this._chromeArgs = chromeArgs;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_ensureBrowserDownloaded() {}
|
|
96
|
+
|
|
97
|
+
_launchBrowserMemoized() {
|
|
98
|
+
// Make sure we only download and launch one browser per HeadlessBrowser instance
|
|
99
|
+
return (this._launchPromise =
|
|
100
|
+
this._launchPromise ||
|
|
101
|
+
downloadOrLocatePreferredBrowserRevision(this._chromeArgs));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async tracePage(htmlAsset) {
|
|
105
|
+
const assetGraph = htmlAsset.assetGraph;
|
|
106
|
+
const browser = await this._launchBrowserMemoized();
|
|
107
|
+
const page = await browser.newPage();
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
// Make up a base url to map to the assetgraph root.
|
|
111
|
+
// Use the canonical root if available, so that it'll be
|
|
112
|
+
// easier to handle absolute and protocol-relative urls pointing
|
|
113
|
+
// at it, as well as fall through to the actual domain if some
|
|
114
|
+
// assets aren't found in the graph.
|
|
115
|
+
const baseUrl = assetGraph.canonicalRoot
|
|
116
|
+
? assetGraph.canonicalRoot.replace(/\/?$/, '/')
|
|
117
|
+
: 'https://example.com/';
|
|
118
|
+
|
|
119
|
+
// Intercept all requests made by the headless browser, and
|
|
120
|
+
// fake a response from the assetgraph instance if the corresponding
|
|
121
|
+
// asset is found there:
|
|
122
|
+
await page.setRequestInterception(true);
|
|
123
|
+
page.on('request', (request) => {
|
|
124
|
+
const url = request.url();
|
|
125
|
+
if (url.startsWith(baseUrl)) {
|
|
126
|
+
let agUrl = url.replace(baseUrl, assetGraph.root);
|
|
127
|
+
if (/\/$/.test(agUrl)) {
|
|
128
|
+
agUrl += 'index.html';
|
|
129
|
+
}
|
|
130
|
+
const asset = assetGraph.findAssets({
|
|
131
|
+
isLoaded: true,
|
|
132
|
+
url: agUrl,
|
|
133
|
+
})[0];
|
|
134
|
+
if (asset) {
|
|
135
|
+
request.respond({
|
|
136
|
+
status: 200,
|
|
137
|
+
contentType: asset.contentType,
|
|
138
|
+
body: asset.rawSrc,
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
// Asset not in graph — return 404 instead of letting the
|
|
142
|
+
// request hit the network (baseUrl is synthetic).
|
|
143
|
+
request.respond({ status: 404, body: '' });
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (url.startsWith('file:')) {
|
|
148
|
+
request.continue();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// External request — abort to avoid hanging on DNS/network.
|
|
152
|
+
// The requestfailed handler will log it.
|
|
153
|
+
request.abort('failed');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
page.on('requestfailed', (request) => {
|
|
157
|
+
const response = request.response();
|
|
158
|
+
if (response && response.status() > 400) {
|
|
159
|
+
this.console.error(
|
|
160
|
+
`${request.method()} ${request.url()} returned ${response.status()}`
|
|
161
|
+
);
|
|
162
|
+
} else {
|
|
163
|
+
this.console.error(
|
|
164
|
+
`${request.method()} ${request.url()} failed: ${
|
|
165
|
+
request.failure().errorText
|
|
166
|
+
}`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
page.on('pageerror', (err) => {
|
|
172
|
+
// Puppeteer v24+ passes Error objects; format stack to match v19 style
|
|
173
|
+
if (err instanceof Error && err.stack) {
|
|
174
|
+
// Normalize "at <anonymous> (url:line:col)" to "at url:line:col"
|
|
175
|
+
const normalized = err.stack.replace(
|
|
176
|
+
/at <anonymous> \((.+)\)/g,
|
|
177
|
+
'at $1'
|
|
178
|
+
);
|
|
179
|
+
this.console.error(normalized);
|
|
180
|
+
} else if (err instanceof Error) {
|
|
181
|
+
this.console.error(`${err.name}: ${err.message}`);
|
|
182
|
+
} else {
|
|
183
|
+
this.console.error(err);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
page.on('error', this.console.error);
|
|
187
|
+
|
|
188
|
+
// Prevent the CSP of the page from rejecting our injection of font-tracer
|
|
189
|
+
await page.setBypassCSP(true);
|
|
190
|
+
|
|
191
|
+
await page.goto(
|
|
192
|
+
urlTools.resolveUrl(
|
|
193
|
+
baseUrl,
|
|
194
|
+
urlTools.buildRelativeUrl(assetGraph.root, htmlAsset.url)
|
|
195
|
+
)
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await page.addScriptTag({
|
|
199
|
+
path: require.resolve('font-tracer/dist/fontTracer.browser.js'),
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const jsHandle = await page.evaluateHandle(
|
|
203
|
+
/* global fontTracer */
|
|
204
|
+
/* istanbul ignore next */
|
|
205
|
+
() => fontTracer(document)
|
|
206
|
+
);
|
|
207
|
+
return await transferResults(jsHandle);
|
|
208
|
+
} finally {
|
|
209
|
+
await page.close();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async close() {
|
|
214
|
+
const launchPromise = this._launchPromise;
|
|
215
|
+
if (launchPromise) {
|
|
216
|
+
this._launchPromise = undefined;
|
|
217
|
+
const browser = await launchPromise;
|
|
218
|
+
await browser.close();
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = HeadlessBrowser;
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { yargs, help, ...options } = require('./parseCommandLineOptions')();
|
|
4
|
+
|
|
5
|
+
require('@gustavnikolaj/async-main-wrap')(require('./subfont'), {
|
|
6
|
+
processError(err) {
|
|
7
|
+
yargs.showHelp();
|
|
8
|
+
if (err.constructor === SyntaxError) {
|
|
9
|
+
// Avoid rendering a stack trace for the wrong usage errors
|
|
10
|
+
err.customOutput = err.message;
|
|
11
|
+
}
|
|
12
|
+
return err;
|
|
13
|
+
},
|
|
14
|
+
})(options, console);
|