@mifort-solutions/qmetrix 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/LICENSE +21 -0
- package/README.md +75 -0
- package/package.json +51 -0
- package/src/audit-structure.mjs +103 -0
- package/src/bundle-codebase.mjs +626 -0
- package/src/check-images.mjs +125 -0
- package/src/coverage/clean.mjs +32 -0
- package/src/coverage/merge-istanbul.mjs +161 -0
- package/src/coverage/next-start-cov.mjs +38 -0
- package/src/coverage/report-global.mjs +90 -0
- package/src/coverage/report-suite.mjs +148 -0
- package/src/coverage/src-filter.mjs +50 -0
- package/src/dashboard/collectors/code.mjs +104 -0
- package/src/dashboard/collectors/composition-meta.mjs +295 -0
- package/src/dashboard/collectors/composition-transitions.mjs +0 -0
- package/src/dashboard/collectors/composition.mjs +360 -0
- package/src/dashboard/collectors/coverage.mjs +98 -0
- package/src/dashboard/collectors/deps.mjs +187 -0
- package/src/dashboard/collectors/entities.mjs +147 -0
- package/src/dashboard/collectors/graph.mjs +105 -0
- package/src/dashboard/collectors/lint.mjs +117 -0
- package/src/dashboard/collectors/routing.mjs +82 -0
- package/src/dashboard/collectors/security.mjs +182 -0
- package/src/dashboard/collectors/storybook.mjs +33 -0
- package/src/dashboard/config.mjs +15 -0
- package/src/dashboard/render/client.mjs +178 -0
- package/src/dashboard/render/components.mjs +247 -0
- package/src/dashboard/render/composition.mjs +192 -0
- package/src/dashboard/render/styles.mjs +217 -0
- package/src/dashboard/render/template.mjs +283 -0
- package/src/dashboard/utils/exec.mjs +29 -0
- package/src/dashboard/utils/format.mjs +32 -0
- package/src/dashboard/utils/fs.mjs +48 -0
- package/src/e2e-server-guard.mjs +283 -0
- package/src/optimize-images.mjs +231 -0
- package/src/quality-dashboard.mjs +291 -0
- package/src/security-scan.mjs +267 -0
- package/src/test-outline.mjs +98 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Guards the e2e suite against a stale `next start` server and a poisoned
|
|
4
|
+
* prerender cache on the Playwright port.
|
|
5
|
+
*
|
|
6
|
+
* The PW config reuses an existing server locally (reuseExistingServer), so a
|
|
7
|
+
* server started BEFORE the latest `next build` keeps serving HTML that
|
|
8
|
+
* references chunk files which no longer exist on disk — JS fails to load,
|
|
9
|
+
* React never hydrates, and every interaction-dependent test times out.
|
|
10
|
+
* Worse, while such a server runs, its ISR revalidations WRITE old-build HTML
|
|
11
|
+
* into the new build's prerender cache (.next/server/app), so even a freshly
|
|
12
|
+
* started server then serves broken pages for those routes (Next serves the
|
|
13
|
+
* stale cache entry first and only re-renders in the background).
|
|
14
|
+
*
|
|
15
|
+
* Two modes:
|
|
16
|
+
* - default: health-check a running server by fetching the home page and
|
|
17
|
+
* requesting the /_next/static assets it references (a fresh server serves
|
|
18
|
+
* its own assets; a stale one 404/500s them), then scan the prerender cache
|
|
19
|
+
* for entries referencing assets missing from .next/static and purge them.
|
|
20
|
+
* - --pre-build: a `next build` is about to run, which makes ANY currently
|
|
21
|
+
* running local server stale-after-build (and lets it poison the new
|
|
22
|
+
* build's cache via ISR). Kill it unconditionally.
|
|
23
|
+
*
|
|
24
|
+
* Outcomes (both modes): exit 0 when the port is safe for Playwright, exit 1
|
|
25
|
+
* with instructions when the port is held by something unrecognized. Runs from
|
|
26
|
+
* the npm scripts (test:e2e, coverage:e2e); invoking `playwright test`
|
|
27
|
+
* directly bypasses it. Skipped when PW_BASE_URL targets a remote site.
|
|
28
|
+
*/
|
|
29
|
+
import { execFileSync } from 'node:child_process';
|
|
30
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
|
|
33
|
+
const PORT = Number(process.env.PORT ?? 3000);
|
|
34
|
+
const BASE = `http://localhost:${PORT}`;
|
|
35
|
+
const MAX_ASSETS = 10;
|
|
36
|
+
const PRE_BUILD = process.argv.includes('--pre-build');
|
|
37
|
+
// Anchored on the consuming repo's cwd — every verb runs from the app root.
|
|
38
|
+
const rootDir = process.cwd();
|
|
39
|
+
const log = (msg) => console.log(`[e2e-server-guard] ${msg}`);
|
|
40
|
+
|
|
41
|
+
if (process.env.PW_BASE_URL) {
|
|
42
|
+
log(`PW_BASE_URL is set (${process.env.PW_BASE_URL}) - remote run, nothing to guard.`);
|
|
43
|
+
process.exit(0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const isConnRefused = (err) => err?.cause?.code === 'ECONNREFUSED' || err?.code === 'ECONNREFUSED';
|
|
47
|
+
|
|
48
|
+
async function probeHome() {
|
|
49
|
+
try {
|
|
50
|
+
const res = await fetch(`${BASE}/`, { signal: AbortSignal.timeout(8000) });
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
return { state: 'stale', reason: `GET / returned HTTP ${res.status}` };
|
|
53
|
+
}
|
|
54
|
+
return { state: 'up', html: await res.text() };
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (isConnRefused(err)) {
|
|
57
|
+
return { state: 'free' };
|
|
58
|
+
}
|
|
59
|
+
return { state: 'stale', reason: `GET / failed (${err.cause?.code ?? err.name})` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function findBrokenAssets(html) {
|
|
64
|
+
const assets = [...new Set(html.match(/\/_next\/static\/[^"'\\ )?#]+\.(?:js|css)/g) ?? [])];
|
|
65
|
+
if (assets.length === 0) {
|
|
66
|
+
return [`no /_next/static assets found in the served HTML - not this app's Next server?`];
|
|
67
|
+
}
|
|
68
|
+
const broken = [];
|
|
69
|
+
for (const asset of assets.slice(0, MAX_ASSETS)) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await fetch(BASE + asset, { signal: AbortSignal.timeout(8000) });
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
broken.push(`${asset} -> HTTP ${res.status}`);
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
broken.push(`${asset} -> ${err.cause?.code ?? err.name}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return broken;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findListeningPids() {
|
|
83
|
+
if (process.platform === 'win32') {
|
|
84
|
+
const out = execFileSync('netstat', ['-ano', '-p', 'tcp'], { encoding: 'utf8' });
|
|
85
|
+
const re = new RegExp(`TCP\\s+\\S*:${PORT}\\s+\\S+\\s+LISTENING\\s+(\\d+)`, 'g');
|
|
86
|
+
return [...new Set([...out.matchAll(re)].map((m) => Number(m[1])))];
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const out = execFileSync('lsof', ['-ti', `tcp:${PORT}`, '-sTCP:LISTEN'], { encoding: 'utf8' });
|
|
90
|
+
return [...new Set(out.split('\n').filter(Boolean).map(Number))];
|
|
91
|
+
} catch {
|
|
92
|
+
return []; // lsof exits non-zero when nothing matches
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function commandLineOf(pid) {
|
|
97
|
+
try {
|
|
98
|
+
if (process.platform === 'win32') {
|
|
99
|
+
return execFileSync(
|
|
100
|
+
'powershell',
|
|
101
|
+
[
|
|
102
|
+
'-NoProfile',
|
|
103
|
+
'-Command',
|
|
104
|
+
`(Get-CimInstance Win32_Process -Filter 'ProcessId=${pid}').CommandLine`,
|
|
105
|
+
],
|
|
106
|
+
{ encoding: 'utf8' },
|
|
107
|
+
).trim();
|
|
108
|
+
}
|
|
109
|
+
return execFileSync('ps', ['-o', 'command=', '-p', String(pid)], { encoding: 'utf8' }).trim();
|
|
110
|
+
} catch {
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function killPid(pid) {
|
|
116
|
+
if (process.platform === 'win32') {
|
|
117
|
+
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
118
|
+
} else {
|
|
119
|
+
process.kill(pid, 'SIGTERM');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function waitForPortFree() {
|
|
124
|
+
for (let i = 0; i < 20; i++) {
|
|
125
|
+
try {
|
|
126
|
+
await fetch(`${BASE}/`, { signal: AbortSignal.timeout(1000) });
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (isConnRefused(err)) {
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function killListeningServer(reason) {
|
|
138
|
+
log(`killing server on :${PORT}: ${reason}`);
|
|
139
|
+
const pids = findListeningPids();
|
|
140
|
+
if (pids.length === 0) {
|
|
141
|
+
log('could not find the owning process; free the port manually and retry.');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
for (const pid of pids) {
|
|
145
|
+
const cmd = commandLineOf(pid);
|
|
146
|
+
// Only kill processes that are recognizably this stack's dev/prod server.
|
|
147
|
+
if (!/next|node/i.test(cmd)) {
|
|
148
|
+
log(
|
|
149
|
+
`port :${PORT} is held by PID ${pid} (${cmd || 'unknown command'}) - not a Next/Node server, refusing to kill it.`,
|
|
150
|
+
);
|
|
151
|
+
log('stop it manually (or set PW_BASE_URL to target it intentionally) and retry.');
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
log(`killing PID ${pid} (${cmd})`);
|
|
155
|
+
killPid(pid);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- prerender-cache integrity ------------------------------------------------
|
|
160
|
+
// A stale server's ISR writes leave .next/server/app entries whose HTML/RSC
|
|
161
|
+
// reference assets from the previous build. Detect them by checking every
|
|
162
|
+
// /_next/static (or flight-payload "static/...") reference against the files
|
|
163
|
+
// actually present in .next/static, and purge the whole route entry
|
|
164
|
+
// (.html/.rsc/.meta/.segments) so the fresh server re-renders it on demand.
|
|
165
|
+
|
|
166
|
+
function* walkFiles(dir) {
|
|
167
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
168
|
+
const p = path.join(dir, entry.name);
|
|
169
|
+
if (entry.isDirectory()) {
|
|
170
|
+
yield* walkFiles(p);
|
|
171
|
+
} else {
|
|
172
|
+
yield p;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function staticRefsOf(text) {
|
|
178
|
+
const refs = new Set();
|
|
179
|
+
for (const m of text.match(/\/_next\/static\/[^"'\\ )?#]+/g) ?? []) {
|
|
180
|
+
refs.add(m.slice('/_next/static/'.length));
|
|
181
|
+
}
|
|
182
|
+
for (const m of text.match(/\bstatic\/(?:chunks|css|media)\/[^"'\\ )?#]+/g) ?? []) {
|
|
183
|
+
refs.add(m.slice('static/'.length));
|
|
184
|
+
}
|
|
185
|
+
return refs;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function purgePoisonedPrerenders() {
|
|
189
|
+
const appDir = path.join(rootDir, '.next', 'server', 'app');
|
|
190
|
+
const staticDir = path.join(rootDir, '.next', 'static');
|
|
191
|
+
const poisoned = new Set();
|
|
192
|
+
if (!existsSync(appDir) || !existsSync(staticDir)) {
|
|
193
|
+
return poisoned;
|
|
194
|
+
}
|
|
195
|
+
const segMarker = `.segments${path.sep}`;
|
|
196
|
+
for (const file of walkFiles(appDir)) {
|
|
197
|
+
if (!/\.(html|rsc)$/.test(file)) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// Files inside <route>.segments/ belong to that route; purge at route level.
|
|
201
|
+
const base = file.includes(segMarker)
|
|
202
|
+
? file.slice(0, file.indexOf(segMarker))
|
|
203
|
+
: file.replace(/\.(prefetch\.rsc|rsc|html)$/, '');
|
|
204
|
+
if (poisoned.has(base)) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
for (const ref of staticRefsOf(readFileSync(file, 'utf8'))) {
|
|
208
|
+
if (!existsSync(path.join(staticDir, ref))) {
|
|
209
|
+
poisoned.add(base);
|
|
210
|
+
log(`poisoned prerender: ${path.relative(rootDir, file)} references missing static/${ref}`);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
for (const base of poisoned) {
|
|
216
|
+
for (const suffix of ['.html', '.rsc', '.prefetch.rsc', '.meta']) {
|
|
217
|
+
rmSync(base + suffix, { force: true });
|
|
218
|
+
}
|
|
219
|
+
rmSync(base + '.segments', { recursive: true, force: true });
|
|
220
|
+
log(`purged stale prerender entry: ${path.relative(rootDir, base)}.* (re-rendered on demand)`);
|
|
221
|
+
}
|
|
222
|
+
if (poisoned.size === 0) {
|
|
223
|
+
log('prerender cache is consistent with .next/static.');
|
|
224
|
+
}
|
|
225
|
+
return poisoned;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// --- main ----------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
if (PRE_BUILD) {
|
|
231
|
+
// A rebuild is imminent: any running local server WILL be stale afterwards
|
|
232
|
+
// and would poison the new build's prerender cache via ISR writes.
|
|
233
|
+
if (findListeningPids().length === 0) {
|
|
234
|
+
log(`nothing listening on :${PORT} - safe to build.`);
|
|
235
|
+
} else {
|
|
236
|
+
killListeningServer('a new build is about to replace .next under it');
|
|
237
|
+
if (!(await waitForPortFree())) {
|
|
238
|
+
log(`port :${PORT} is still busy after kill; free it manually and retry.`);
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
log('server killed - safe to build.');
|
|
242
|
+
}
|
|
243
|
+
process.exit(0);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const probe = await probeHome();
|
|
247
|
+
let needsKill = null;
|
|
248
|
+
if (probe.state === 'stale') {
|
|
249
|
+
needsKill = probe.reason;
|
|
250
|
+
} else if (probe.state === 'up') {
|
|
251
|
+
const broken = await findBrokenAssets(probe.html);
|
|
252
|
+
if (broken.length > 0) {
|
|
253
|
+
needsKill = `serves assets that fail to load:\n ${broken.join('\n ')}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (needsKill) {
|
|
258
|
+
killListeningServer(needsKill);
|
|
259
|
+
if (!(await waitForPortFree())) {
|
|
260
|
+
log(`port :${PORT} is still busy after kill; free it manually and retry.`);
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
log('stale server killed - Playwright will start a fresh one.');
|
|
264
|
+
purgePoisonedPrerenders();
|
|
265
|
+
} else if (probe.state === 'free') {
|
|
266
|
+
log(`nothing listening on :${PORT} - Playwright will start a fresh server.`);
|
|
267
|
+
purgePoisonedPrerenders();
|
|
268
|
+
} else {
|
|
269
|
+
// Server looks healthy, but if the on-disk prerender cache held poisoned
|
|
270
|
+
// entries the server would still serve them (and may have them in memory) —
|
|
271
|
+
// in that case restart it on top of the purged cache.
|
|
272
|
+
const poisoned = purgePoisonedPrerenders();
|
|
273
|
+
if (poisoned.size > 0) {
|
|
274
|
+
killListeningServer('its in-memory cache may hold the purged poisoned entries');
|
|
275
|
+
if (!(await waitForPortFree())) {
|
|
276
|
+
log(`port :${PORT} is still busy after kill; free it manually and retry.`);
|
|
277
|
+
process.exit(1);
|
|
278
|
+
}
|
|
279
|
+
log('server restart forced - Playwright will start a fresh one on the purged cache.');
|
|
280
|
+
} else {
|
|
281
|
+
log(`server on :${PORT} serves its own assets - safe to reuse.`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Image optimizer — resize + recompress committed assets with sharp (bundled with Next 16).
|
|
4
|
+
*
|
|
5
|
+
* Downscales anything larger than --max-px (never upscales, keeps aspect ratio) and
|
|
6
|
+
* re-encodes at --quality. By default keeps each file's format and overwrites in place;
|
|
7
|
+
* with --format it writes a converted copy alongside the original (update references,
|
|
8
|
+
* then pass --replace to delete the source file).
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node dev/scripts/optimize-images.mjs [targets…] [options]
|
|
12
|
+
* npm run images:optimize -- public/images --format webp
|
|
13
|
+
*
|
|
14
|
+
* Targets: image files or directories, searched recursively (default: public/)
|
|
15
|
+
* Options:
|
|
16
|
+
* --max-px <n> longest side after resize (default 2560, no upscaling)
|
|
17
|
+
* --quality <n> encoder quality 1–100 (default 80)
|
|
18
|
+
* --format <f> keep | webp | avif | jpeg | png (default keep)
|
|
19
|
+
* --replace after a --format conversion, delete the original file
|
|
20
|
+
* --dry-run encode and report savings, but write nothing
|
|
21
|
+
*
|
|
22
|
+
* SVG and GIF are skipped (vector / animation — not sharp's job).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readdirSync, statSync, existsSync } from 'node:fs';
|
|
26
|
+
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import sharp from 'sharp';
|
|
29
|
+
|
|
30
|
+
// libvips' file cache holds input handles open, which breaks in-place overwrites
|
|
31
|
+
// on Windows (EBUSY/EPERM on write). We read inputs into buffers ourselves, but
|
|
32
|
+
// disable the cache too so no handle ever lingers.
|
|
33
|
+
sharp.cache(false);
|
|
34
|
+
|
|
35
|
+
// Anchored on the consuming repo's cwd — every verb runs from the app root.
|
|
36
|
+
const ROOT = process.cwd();
|
|
37
|
+
|
|
38
|
+
const RASTER_EXT = new Set(['.png', '.jpg', '.jpeg', '.webp', '.avif']);
|
|
39
|
+
const SKIPPED_EXT = new Set(['.svg', '.gif']);
|
|
40
|
+
const FORMATS = new Set(['keep', 'webp', 'avif', 'jpeg', 'png']);
|
|
41
|
+
|
|
42
|
+
/* ── CLI parsing ── */
|
|
43
|
+
const argv = process.argv.slice(2);
|
|
44
|
+
const targets = [];
|
|
45
|
+
let maxPx = 2560;
|
|
46
|
+
let quality = 80;
|
|
47
|
+
let format = 'keep';
|
|
48
|
+
let replace = false;
|
|
49
|
+
let dryRun = false;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < argv.length; i++) {
|
|
52
|
+
const a = argv[i];
|
|
53
|
+
if (a === '--max-px') {
|
|
54
|
+
maxPx = Number(argv[++i]);
|
|
55
|
+
} else if (a === '--quality') {
|
|
56
|
+
quality = Number(argv[++i]);
|
|
57
|
+
} else if (a === '--format') {
|
|
58
|
+
format = String(argv[++i]).toLowerCase();
|
|
59
|
+
} else if (a === '--replace') {
|
|
60
|
+
replace = true;
|
|
61
|
+
} else if (a === '--dry-run') {
|
|
62
|
+
dryRun = true;
|
|
63
|
+
} else if (a.startsWith('--')) {
|
|
64
|
+
console.error(`Unknown option: ${a} (see header of dev/scripts/optimize-images.mjs)`);
|
|
65
|
+
process.exit(2);
|
|
66
|
+
} else {
|
|
67
|
+
targets.push(a);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (!FORMATS.has(format) || !(maxPx > 0) || !(quality >= 1 && quality <= 100)) {
|
|
71
|
+
console.error('Invalid options — format keep|webp|avif|jpeg|png, max-px > 0, quality 1–100.');
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
if (targets.length === 0) {
|
|
75
|
+
targets.push('public');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* ── helpers ── */
|
|
79
|
+
const rel = (p) => path.relative(ROOT, p).split(path.sep).join('/');
|
|
80
|
+
const fmtKB = (bytes) =>
|
|
81
|
+
bytes >= 1024 * 1024
|
|
82
|
+
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
|
83
|
+
: bytes < 1024
|
|
84
|
+
? `${bytes} B`
|
|
85
|
+
: `${Math.round(bytes / 1024)} KB`;
|
|
86
|
+
|
|
87
|
+
// Same-format recompression is lossy; don't rewrite a file for a marginal win.
|
|
88
|
+
const MIN_GAIN = 0.05;
|
|
89
|
+
|
|
90
|
+
function* walk(dir) {
|
|
91
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
92
|
+
if (entry.isDirectory()) {
|
|
93
|
+
yield* walk(path.join(dir, entry.name));
|
|
94
|
+
} else {
|
|
95
|
+
yield path.join(dir, entry.name);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function collectFiles() {
|
|
101
|
+
const files = [];
|
|
102
|
+
for (const t of targets) {
|
|
103
|
+
const abs = path.isAbsolute(t) ? t : path.join(ROOT, t);
|
|
104
|
+
if (!existsSync(abs)) {
|
|
105
|
+
console.error(`✗ Not found: ${t}`);
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
if (statSync(abs).isDirectory()) {
|
|
109
|
+
files.push(...walk(abs));
|
|
110
|
+
} else {
|
|
111
|
+
files.push(abs);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return files.filter((f) => {
|
|
115
|
+
const ext = path.extname(f).toLowerCase();
|
|
116
|
+
if (SKIPPED_EXT.has(ext)) {
|
|
117
|
+
console.log(` ⊘ ${rel(f)} — ${ext.slice(1)} skipped (not handled by this tool)`);
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return RASTER_EXT.has(ext);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// PNG quality requires palette quantization; only engage it below lossless quality.
|
|
125
|
+
function encode(pipeline, fmt) {
|
|
126
|
+
if (fmt === 'jpeg') {
|
|
127
|
+
return pipeline.jpeg({ quality, mozjpeg: true });
|
|
128
|
+
}
|
|
129
|
+
if (fmt === 'webp') {
|
|
130
|
+
return pipeline.webp({ quality });
|
|
131
|
+
}
|
|
132
|
+
if (fmt === 'avif') {
|
|
133
|
+
return pipeline.avif({ quality });
|
|
134
|
+
}
|
|
135
|
+
if (fmt === 'png') {
|
|
136
|
+
return quality < 100
|
|
137
|
+
? pipeline.png({ compressionLevel: 9, adaptiveFiltering: true, palette: true, quality })
|
|
138
|
+
: pipeline.png({ compressionLevel: 9, adaptiveFiltering: true });
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`No encoder for format "${fmt}"`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const EXT_FOR = { jpeg: '.jpg', png: '.png', webp: '.webp', avif: '.avif' };
|
|
144
|
+
|
|
145
|
+
async function optimize(file) {
|
|
146
|
+
const input = await readFile(file); // buffer in, so the source file holds no open handle
|
|
147
|
+
const inBytes = input.length;
|
|
148
|
+
const meta = await sharp(input).metadata();
|
|
149
|
+
const srcFormat = meta.format === 'jpg' ? 'jpeg' : meta.format;
|
|
150
|
+
const outFormat = format === 'keep' ? srcFormat : format;
|
|
151
|
+
if (!EXT_FOR[outFormat]) {
|
|
152
|
+
console.log(` ⊘ ${rel(file)} — source format "${srcFormat}" not supported, skipped`);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const converting = outFormat !== srcFormat;
|
|
157
|
+
const willResize = Math.max(meta.width ?? 0, meta.height ?? 0) > maxPx;
|
|
158
|
+
|
|
159
|
+
// A palette PNG has already been through quantization (likely a previous run of
|
|
160
|
+
// this tool) — re-encoding would stack generation loss for pennies. Idempotency guard.
|
|
161
|
+
if (!converting && !willResize && srcFormat === 'png' && meta.isPalette) {
|
|
162
|
+
console.log(` = ${rel(file)} — already quantized (${fmtKB(inBytes)})`);
|
|
163
|
+
return { saved: 0 };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const pipeline = sharp(input)
|
|
167
|
+
.rotate() // bake EXIF orientation in before any resize
|
|
168
|
+
.resize({ width: maxPx, height: maxPx, fit: 'inside', withoutEnlargement: true });
|
|
169
|
+
const buf = await encode(pipeline, outFormat).toBuffer();
|
|
170
|
+
|
|
171
|
+
const outFile = converting
|
|
172
|
+
? path.join(path.dirname(file), path.basename(file, path.extname(file)) + EXT_FOR[outFormat])
|
|
173
|
+
: file;
|
|
174
|
+
|
|
175
|
+
// Same format and no resize needed: rewriting is a lossy generation — only
|
|
176
|
+
// worth it when the size win is real, not a rounding error.
|
|
177
|
+
if (!converting && !willResize && buf.length >= inBytes * (1 - MIN_GAIN)) {
|
|
178
|
+
console.log(` = ${rel(file)} — already optimal (${fmtKB(inBytes)})`);
|
|
179
|
+
return { saved: 0 };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!dryRun) {
|
|
183
|
+
await writeFile(outFile, buf);
|
|
184
|
+
if (converting && replace) {
|
|
185
|
+
await unlink(file);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const pct = Math.round((1 - buf.length / inBytes) * 100);
|
|
190
|
+
const dims = willResize ? ` ${meta.width}×${meta.height}→≤${maxPx}px` : '';
|
|
191
|
+
const arrow = converting ? ` → ${rel(outFile)}` : '';
|
|
192
|
+
console.log(
|
|
193
|
+
` ${dryRun ? '·' : '✓'} ${rel(file)}${arrow}${dims} ${fmtKB(inBytes)} → ${fmtKB(buf.length)} (−${pct}%)${dryRun ? ' [dry-run]' : ''}`,
|
|
194
|
+
);
|
|
195
|
+
return { saved: inBytes - buf.length };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ── main ── */
|
|
199
|
+
const files = collectFiles();
|
|
200
|
+
console.log(
|
|
201
|
+
`\n== Image optimizer == (${files.length} files · max ${maxPx}px · q${quality} · format=${format}${dryRun ? ' · DRY RUN' : ''})\n`,
|
|
202
|
+
);
|
|
203
|
+
if (files.length === 0) {
|
|
204
|
+
console.log('Nothing to do.\n');
|
|
205
|
+
process.exit(0);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let totalSaved = 0;
|
|
209
|
+
let failures = 0;
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
try {
|
|
212
|
+
const r = await optimize(file);
|
|
213
|
+
if (r) {
|
|
214
|
+
totalSaved += Math.max(0, r.saved);
|
|
215
|
+
}
|
|
216
|
+
} catch (err) {
|
|
217
|
+
failures++;
|
|
218
|
+
console.log(` ✗ ${rel(file)} — ${err.message}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
console.log(`\n${dryRun ? 'Would save' : 'Saved'} ${fmtKB(totalSaved)} total.`);
|
|
223
|
+
if (format !== 'keep') {
|
|
224
|
+
console.log(
|
|
225
|
+
replace
|
|
226
|
+
? 'Originals deleted (--replace) — update any code/markdown references to the new extension.'
|
|
227
|
+
: 'Converted copies written alongside originals — update references, then re-run with --replace.',
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
console.log('');
|
|
231
|
+
process.exit(failures ? 1 : 0);
|