oxlint-plugin-pngdeer 0.1.0 → 0.2.1
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 +32 -9
- package/bin/pngdeer.js +57 -14
- package/package.json +1 -1
- package/src/batch.js +7 -2
- package/src/cache.js +34 -4
- package/src/fill-worker.js +6 -1
- package/src/rule.js +94 -63
- package/src/scan.js +14 -7
- package/src/summary.js +163 -0
- package/src/text.js +26 -0
- package/src/worker.js +5 -2
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
A lint rule for [oxlint](https://oxc.rs/docs/guide/usage/linter) and ESLint. It runs [pngdeer](https://github.com/heydeer/pngdeer), a lossy PNG and JPEG compressor in the spirit of TinyPNG, locally on the images your code imports or points to. When an image could be much smaller at about the same visual quality, the rule warns on that line, with the saving and the command that shrinks the file.
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
⚠ pngdeer(compressible-image): hero.png could be 70% smaller (509 kB → 151 kB)
|
|
12
|
+
⚠ pngdeer(compressible-image): hero.png could be 70% smaller (509 kB → 151 kB) and look the same side by side (quality 87/100). Run: npx oxlint-plugin-pngdeer public/hero.png
|
|
13
13
|
╭─[src/App.jsx:10:16]
|
|
14
14
|
9 │ <main>
|
|
15
15
|
10 │ <img src="/hero.png" alt="An arcade stick" />
|
|
@@ -53,23 +53,46 @@ Run oxlint:
|
|
|
53
53
|
```console
|
|
54
54
|
$ npx oxlint
|
|
55
55
|
pngdeer: measuring 5 images not in the cache yet...
|
|
56
|
-
pngdeer: measured 5 images in 4.
|
|
57
|
-
|
|
56
|
+
pngdeer: measured 5 images in 4.6 s; later runs read the cache
|
|
57
|
+
|
|
58
|
+
⚠ pngdeer(compressible-image): hero.png could be 70% smaller (509 kB → 151 kB) and look the same side by side (quality 87/100). Run: npx oxlint-plugin-pngdeer public/hero.png
|
|
59
|
+
⚠ pngdeer(compressible-image): photo.jpg could be 64% smaller (235 kB → 85 kB) and look close, with differences you might spot side by side (quality 76/100). Run: npx oxlint-plugin-pngdeer public/photo.jpg
|
|
58
60
|
…
|
|
59
|
-
|
|
61
|
+
|
|
62
|
+
pngdeer: 4 flagged images could be 718 kB smaller in all (1.0 MB → 310 kB, 70% smaller):
|
|
63
|
+
image now after saves quality
|
|
64
|
+
public/hero.png 509 kB → 151 kB 359 kB 87
|
|
65
|
+
src/assets/diagram.png 248 kB → 63 kB 185 kB 90
|
|
66
|
+
public/photo.jpg 235 kB → 85 kB 150 kB 76
|
|
67
|
+
src/assets/crystal-ball.png 35 kB → 11 kB 24 kB 87
|
|
68
|
+
pngdeer: also 1 image that no linted JS or TS names could be 5.1 kB smaller:
|
|
69
|
+
public/unused-fire.png 13 kB → 7.5 kB 5.1 kB 90 mentioned in README.md
|
|
70
|
+
pngdeer: quality is SSIMULACRA2, out of 100: 90+ looks identical, 80+ the same side by side, 70+ close, with differences you might spot side by side
|
|
71
|
+
pngdeer: to shrink the 4 flagged images in place (lossy, so commit first), run: npx oxlint-plugin-pngdeer --flagged
|
|
72
|
+
pngdeer: to shrink it too, run: npx oxlint-plugin-pngdeer public/unused-fire.png
|
|
60
73
|
```
|
|
61
74
|
|
|
62
|
-
|
|
75
|
+
- **Each image warns once per file,** on the line that names it, and says how close to the original it would look.
|
|
76
|
+
- **The table at the end** counts each image once, biggest saving first.
|
|
77
|
+
- **Images no JS or TS names** get their own list, since no warning can point at them. Each row says where the image is used instead: `used in src/App.css`, `mentioned in README.md` (a Markdown file that names it), or `nothing names it`, which may mean the image is unused and could be deleted. These aren't in `--flagged`; the list ends with the command for them.
|
|
78
|
+
- **The table is left out** with `--quiet` and in formats other programs read (`json`, `checkstyle`, `junit`, `sarif`, `gitlab`).
|
|
79
|
+
|
|
80
|
+
Then shrink what it found. There's no autofix (lint fixes can only edit code), so run the command: `--flagged` takes every image the last lint in that folder warned about, or name images yourself.
|
|
63
81
|
|
|
64
82
|
```console
|
|
65
|
-
$ npx oxlint-plugin-pngdeer
|
|
66
|
-
|
|
67
|
-
public/
|
|
83
|
+
$ npx oxlint-plugin-pngdeer --flagged
|
|
84
|
+
pngdeer: shrinking 4 images in place (lossy; commit first so you can undo)
|
|
85
|
+
[1/4] public/hero.png: 509 kB → 151 kB (70% smaller, quality 87/100: looks the same side by side)
|
|
86
|
+
[2/4] src/assets/diagram.png: 248 kB → 63 kB (75% smaller, quality 90/100: looks identical)
|
|
87
|
+
[3/4] public/photo.jpg: 235 kB → 85 kB (64% smaller, quality 76/100: looks close, with differences you might spot side by side)
|
|
88
|
+
[4/4] src/assets/crystal-ball.png: 35 kB → 11 kB (68% smaller, quality 87/100: looks the same side by side)
|
|
89
|
+
pngdeer: 4 images, 1.0 MB → 310 kB (718 kB smaller). Lint again to check.
|
|
68
90
|
```
|
|
69
91
|
|
|
70
92
|
- **It's lossy, and it writes over the file.** Commit first so git is your undo, or write a copy to compare with `-o hero.smaller.png`.
|
|
93
|
+
- **A warning you turn off with a comment** stays out of the table and out of `--flagged`.
|
|
71
94
|
- **It takes a few seconds per image,** one at a time.
|
|
72
|
-
-
|
|
95
|
+
- **`--flagged` uses the `target` each image was measured at.** For images you name, pass it yourself (`--target 90`), as the command in each warning does. `--help` lists the options.
|
|
73
96
|
- **JPEGs keep their orientation and color profile;** other camera metadata (EXIF, GPS) is dropped.
|
|
74
97
|
- **It marks each file it writes** with a short text tag (`pngdeer <size>`, in a PNG `tEXt` chunk or a JPEG comment), so the rule doesn't flag it again, on any machine.
|
|
75
98
|
|
package/bin/pngdeer.js
CHANGED
|
@@ -7,14 +7,18 @@ import { basename, dirname, join } from "node:path";
|
|
|
7
7
|
import { compress } from "../src/engine.js";
|
|
8
8
|
import { unsupported } from "../src/images.js";
|
|
9
9
|
import { mark, marked } from "../src/marker.js";
|
|
10
|
-
import {
|
|
10
|
+
import { readFlagged } from "../src/summary.js";
|
|
11
|
+
import { count, looks, pathFrom, size, smaller } from "../src/text.js";
|
|
11
12
|
|
|
12
13
|
const HELP = `usage: oxlint-plugin-pngdeer IMAGE... [--target auto|T]
|
|
14
|
+
oxlint-plugin-pngdeer --flagged [--target auto|T]
|
|
13
15
|
oxlint-plugin-pngdeer IMAGE -o OUT [--target auto|T]
|
|
14
16
|
|
|
15
17
|
Makes PNGs and JPEGs smaller with pngdeer, in place, at about the same visual quality.
|
|
16
18
|
pngdeer is lossy: the picture looks the same, the bytes don't, so commit first.
|
|
17
19
|
|
|
20
|
+
--flagged the images the last lint run from this folder warned about, each at the
|
|
21
|
+
target it was measured at
|
|
18
22
|
-o OUT write to OUT instead of in place (one image only)
|
|
19
23
|
--target T the SSIMULACRA2 score to keep, 0-100 (90 looks identical at 1:1);
|
|
20
24
|
auto (the default) picks one per image, about TinyPNG's quality
|
|
@@ -27,6 +31,8 @@ function main(args) {
|
|
|
27
31
|
const inputs = [];
|
|
28
32
|
let output;
|
|
29
33
|
let target;
|
|
34
|
+
let targeted = false;
|
|
35
|
+
let fromLint = false;
|
|
30
36
|
for (let i = 0; i < args.length; i++) {
|
|
31
37
|
const arg = args[i];
|
|
32
38
|
if (arg === "-h" || arg === "--help") {
|
|
@@ -37,38 +43,74 @@ function main(args) {
|
|
|
37
43
|
console.log(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
|
|
38
44
|
return 0;
|
|
39
45
|
}
|
|
40
|
-
if (arg === "
|
|
46
|
+
if (arg === "--flagged") fromLint = true;
|
|
47
|
+
else if (arg === "-o" || arg === "--target") {
|
|
41
48
|
const value = args[++i];
|
|
42
49
|
if (value === undefined) return fail(`${arg} needs a value`);
|
|
43
50
|
if (arg === "-o") output = value;
|
|
44
|
-
else
|
|
45
|
-
|
|
46
|
-
|
|
51
|
+
else {
|
|
52
|
+
targeted = true;
|
|
53
|
+
target = value === "auto" ? undefined : Number(value);
|
|
54
|
+
if (target !== undefined && !(value.trim() !== "" && target >= 0 && target <= 100)) return fail(`--target must be auto or a number from 0 to 100, not ${value}`);
|
|
47
55
|
}
|
|
48
56
|
} else if (arg.startsWith("-") && arg !== "-") return fail(`unknown option ${arg}`);
|
|
49
57
|
else inputs.push(arg);
|
|
50
58
|
}
|
|
51
|
-
if (inputs.length
|
|
59
|
+
if (fromLint && (inputs.length || output !== undefined)) return fail("--flagged takes no images and no -o");
|
|
60
|
+
if (!fromLint && inputs.length === 0) return fail("no image given");
|
|
52
61
|
if (output !== undefined && inputs.length > 1) return fail("-o takes one image; without it, each image is written in place");
|
|
53
62
|
|
|
63
|
+
const flagged = fromLint ? readFlagged(process.cwd()) : null;
|
|
64
|
+
if (fromLint && !flagged) {
|
|
65
|
+
console.error("oxlint-plugin-pngdeer: no lint has run in this folder yet: run npx oxlint (or eslint) here first");
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
if (fromLint && flagged.length === 0) {
|
|
69
|
+
console.log("the last lint here flagged nothing");
|
|
70
|
+
return 0;
|
|
71
|
+
}
|
|
72
|
+
// each image, the path to show for it, and its target: a --target given here wins over the lint's
|
|
73
|
+
const jobs = fromLint
|
|
74
|
+
? flagged.map((image) => ({
|
|
75
|
+
input: pathFrom(process.cwd(), image.file),
|
|
76
|
+
target: targeted ? target : image.target,
|
|
77
|
+
}))
|
|
78
|
+
: inputs.map((input) => ({ input, target }));
|
|
79
|
+
|
|
80
|
+
// several images take a while, one after another: say what's coming, count, and add it all up
|
|
81
|
+
const many = jobs.length > 1;
|
|
82
|
+
if (many) console.error(`pngdeer: shrinking ${count(jobs.length, "image")} in place (lossy; commit first so you can undo)`);
|
|
54
83
|
let status = 0;
|
|
55
|
-
|
|
84
|
+
let original = 0;
|
|
85
|
+
let compressed = 0;
|
|
86
|
+
let shrunk = 0;
|
|
87
|
+
for (const [i, { input, target }] of jobs.entries()) {
|
|
88
|
+
const step = many ? `[${i + 1}/${jobs.length}] ` : "";
|
|
56
89
|
try {
|
|
57
|
-
|
|
90
|
+
const result = shrink(input, output ?? input, target);
|
|
91
|
+
console.log(`${step}${input}: ${result.text}`);
|
|
92
|
+
if (result.written) {
|
|
93
|
+
shrunk++;
|
|
94
|
+
original += result.original;
|
|
95
|
+
compressed += result.written;
|
|
96
|
+
}
|
|
58
97
|
} catch (error) {
|
|
59
|
-
console.error(`${input}: ${plain(error)}`);
|
|
98
|
+
console.error(`${step}${input}: ${plain(error)}`);
|
|
60
99
|
status = 1;
|
|
61
100
|
}
|
|
62
101
|
}
|
|
102
|
+
if (many && shrunk) {
|
|
103
|
+
console.error(`pngdeer: ${count(shrunk, "image")}, ${size(original)} → ${size(compressed)} (${size(original - compressed)} smaller). Lint again to check.`);
|
|
104
|
+
}
|
|
63
105
|
return status;
|
|
64
106
|
}
|
|
65
107
|
|
|
66
|
-
/** Writes `input` made smaller to `output
|
|
108
|
+
/** Writes `input` made smaller to `output`: { text } saying what happened, and the sizes if it wrote. */
|
|
67
109
|
function shrink(input, output, target) {
|
|
68
110
|
const bytes = readFileSync(input);
|
|
69
111
|
const unchanged = (note) => {
|
|
70
112
|
if (output !== input) save(output, bytes);
|
|
71
|
-
return `${note}; left as is
|
|
113
|
+
return { text: `${note}; left as is` };
|
|
72
114
|
};
|
|
73
115
|
if (marked(bytes)) return unchanged("already pngdeer's");
|
|
74
116
|
const why = unsupported(bytes);
|
|
@@ -77,14 +119,15 @@ function shrink(input, output, target) {
|
|
|
77
119
|
const { output: compressed, outcome, score } = compress(bytes, target);
|
|
78
120
|
if (outcome === "miss") {
|
|
79
121
|
const wanted = target === undefined ? "the automatic quality" : `--target ${target}`;
|
|
80
|
-
return unchanged(`no smaller file reaches ${wanted} (the best
|
|
122
|
+
return unchanged(`no smaller file reaches ${wanted} (the best reaches quality ${Math.round(score)}/100)`);
|
|
81
123
|
}
|
|
82
124
|
// the mark costs a few dozen bytes, more than pngdeer saves on some tiny files
|
|
83
125
|
const result = outcome === "original" ? null : mark(compressed);
|
|
84
126
|
if (!result || result.length >= bytes.length) return unchanged("pngdeer can't make it smaller");
|
|
85
127
|
save(output, result);
|
|
86
|
-
const
|
|
87
|
-
|
|
128
|
+
const quality = outcome === "hit" ? `quality ${Math.round(score)}/100: looks ${looks(score)}` : "lossless: looks identical";
|
|
129
|
+
const text = `${size(bytes.length)} → ${size(result.length)} (${smaller(bytes.length, result.length)}% smaller, ${quality})`;
|
|
130
|
+
return { text, original: bytes.length, written: result.length };
|
|
88
131
|
}
|
|
89
132
|
|
|
90
133
|
/** Writes a whole new file or nothing: the image is often written over itself. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oxlint-plugin-pngdeer",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Lint rule for oxlint and ESLint: flags PNGs and JPEGs your code uses that could be much smaller at about the same visual quality, with the command that shrinks them. Local WebAssembly, no upload.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"oxlint",
|
package/src/batch.js
CHANGED
|
@@ -14,8 +14,11 @@ const MAX_WORKERS = 8;
|
|
|
14
14
|
// replaced rather than kept.
|
|
15
15
|
const BIG = 300e6;
|
|
16
16
|
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Computes every file not cached yet; { computed, cached, skipped, failed, ms }. `progress(done,
|
|
19
|
+
* total)` hears about each image the engine finishes.
|
|
20
|
+
*/
|
|
21
|
+
export async function fill({ cwd, target, files }, progress = () => {}) {
|
|
19
22
|
const start = performance.now();
|
|
20
23
|
const dir = cacheDir(cwd);
|
|
21
24
|
// a cache that can't be written fails here, before any image is compressed for nothing
|
|
@@ -52,6 +55,7 @@ export async function fill({ cwd, target, files }) {
|
|
|
52
55
|
}
|
|
53
56
|
// biggest first, so the slowest image doesn't start last
|
|
54
57
|
todo.sort((a, b) => b.peak - a.peak);
|
|
58
|
+
const total = todo.length;
|
|
55
59
|
|
|
56
60
|
const record = (job, result) => {
|
|
57
61
|
// the file changed or went away after it was queued: cache nothing, the next lint measures it again
|
|
@@ -110,6 +114,7 @@ export async function fill({ cwd, target, files }) {
|
|
|
110
114
|
for (const l of lanes) l.worker?.terminate();
|
|
111
115
|
} finally {
|
|
112
116
|
self.busy = false;
|
|
117
|
+
progress(counts.computed + counts.failed, total);
|
|
113
118
|
wake();
|
|
114
119
|
}
|
|
115
120
|
}
|
package/src/cache.js
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
// saving only changes when its bytes, the engine or the target do. One small JSON file per entry,
|
|
3
3
|
// so parallel lint threads and batch workers never write the same file twice over.
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { marked } from "./marker.js";
|
|
8
|
+
import { smaller } from "./text.js";
|
|
9
|
+
|
|
10
|
+
const keys = new Map(); // image path -> { size, mtimeMs, target, key, marked }, so images aren't re-read on every keystroke
|
|
7
11
|
|
|
8
12
|
// The engine's identity: the first 16 hex digits of the wasm's SHA-256 (a few ms for 2 MB). A new
|
|
9
13
|
// engine invalidates every entry.
|
|
@@ -16,14 +20,18 @@ export function cacheDir(cwd) {
|
|
|
16
20
|
return join(cwd, "node_modules", ".cache", "pngdeer");
|
|
17
21
|
}
|
|
18
22
|
|
|
23
|
+
// What an entry holds: a new shape (2 added the score) measures everything again.
|
|
24
|
+
const FORMAT = 2;
|
|
25
|
+
|
|
19
26
|
/** The cache key of an image's bytes for a given target (undefined: automatic quality). */
|
|
20
27
|
export function key(bytes, target) {
|
|
21
|
-
return createHash("sha256").update(bytes).update(`\0${ENGINE}\0${target ?? "auto"}`).digest("hex");
|
|
28
|
+
return createHash("sha256").update(bytes).update(`\0${ENGINE}\0${target ?? "auto"}\0${FORMAT}`).digest("hex");
|
|
22
29
|
}
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
|
-
* { original, compressed, outcome, ms }
|
|
26
|
-
* { outcome: "output" } for bytes pngdeer
|
|
32
|
+
* { original, compressed, outcome, score, ms }, compressed counting the mark the command adds;
|
|
33
|
+
* { skipped } (with failed: true if pngdeer itself failed); { outcome: "output" } for bytes pngdeer
|
|
34
|
+
* wrote; or null if not cached yet.
|
|
27
35
|
*/
|
|
28
36
|
export function read(dir, k) {
|
|
29
37
|
try {
|
|
@@ -33,6 +41,28 @@ export function read(dir, k) {
|
|
|
33
41
|
}
|
|
34
42
|
}
|
|
35
43
|
|
|
44
|
+
/** Whether an entry is a saving the rule reports: smaller at the target, by both thresholds. */
|
|
45
|
+
export function reportable(entry, { minSavedBytes, minSavedPercent }) {
|
|
46
|
+
if (!entry || entry.skipped || !["hit", "exact", "lossless"].includes(entry.outcome)) return false;
|
|
47
|
+
const saved = entry.original - entry.compressed;
|
|
48
|
+
return saved >= minSavedBytes && smaller(entry.original, entry.compressed) >= minSavedPercent;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** An image's size, the cache key of its current bytes and whether pngdeer wrote it; null if it can't be read. */
|
|
52
|
+
export function fileKey(file, target) {
|
|
53
|
+
try {
|
|
54
|
+
const { size, mtimeMs } = statSync(file);
|
|
55
|
+
const memo = keys.get(file);
|
|
56
|
+
if (memo && memo.size === size && memo.mtimeMs === mtimeMs && memo.target === target) return memo;
|
|
57
|
+
const bytes = readFileSync(file);
|
|
58
|
+
const image = { size, mtimeMs, target, key: key(bytes, target), marked: marked(bytes) };
|
|
59
|
+
keys.set(file, image);
|
|
60
|
+
return image;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
36
66
|
export function write(dir, k, entry) {
|
|
37
67
|
mkdirSync(dir, { recursive: true });
|
|
38
68
|
// written whole and renamed, so a reader never sees half an entry
|
package/src/fill-worker.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
// Runs one batch (batch.js) off the lint thread; sync.js waits on `signal` when it needs the answer.
|
|
2
|
+
import { writeSync } from "node:fs";
|
|
3
|
+
import { isatty } from "node:tty";
|
|
2
4
|
import { workerData } from "node:worker_threads";
|
|
3
5
|
import { fill } from "./batch.js";
|
|
4
6
|
|
|
5
7
|
const { request, signal, port } = workerData;
|
|
8
|
+
// A count that rewrites its own line while the lint waits. The lint thread is blocked on `signal`,
|
|
9
|
+
// so it can't print; this writes to the terminal directly, and only to a terminal.
|
|
10
|
+
const progress = request.progress && isatty(2) ? (done, total) => writeSync(2, `\rpngdeer: measuring ${total} images... ${done}/${total}`) : undefined;
|
|
6
11
|
let reply;
|
|
7
12
|
try {
|
|
8
|
-
reply = { result: await fill(request) };
|
|
13
|
+
reply = { result: await fill(request, progress) };
|
|
9
14
|
} catch (error) {
|
|
10
15
|
reply = { error: String(error?.message ?? error) };
|
|
11
16
|
// a background batch has no one to tell but its exit code
|
package/src/rule.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
// pngdeer/compressible-image: on each string in the code that names a local PNG or JPEG (imports,
|
|
2
2
|
// require, new URL, JSX src and srcSet, plain strings), says how much smaller pngdeer would make it.
|
|
3
3
|
// oxlint never lints image files themselves, so the reference is where the warning goes.
|
|
4
|
-
import {
|
|
5
|
-
import { basename, dirname, join,
|
|
6
|
-
import { cacheDir,
|
|
7
|
-
import { marked } from "./marker.js";
|
|
4
|
+
import { statSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
+
import { cacheDir, fileKey, read, reportable } from "./cache.js";
|
|
8
7
|
import { projectImages } from "./scan.js";
|
|
8
|
+
import { flag, name, summarize, watch } from "./summary.js";
|
|
9
9
|
import { fillInBackground, fillSync } from "./sync.js";
|
|
10
|
-
import { count, quote, size } from "./text.js";
|
|
10
|
+
import { count, looks, pathFrom, quote, size, smaller } from "./text.js";
|
|
11
11
|
|
|
12
|
+
const RULE = "pngdeer/compressible-image";
|
|
12
13
|
const IMAGE = /\.(png|apng|jpe?g)$/i;
|
|
13
14
|
const REMOTE = /^[a-z][a-z0-9+.-]*:|^\/\//i;
|
|
14
15
|
const DEFAULTS = { minSavedBytes: 4096, minSavedPercent: 10, target: "auto", measure: "auto" };
|
|
@@ -24,12 +25,10 @@ const EDITOR =
|
|
|
24
25
|
process.env.ELECTRON_RUN_AS_NODE === "1";
|
|
25
26
|
|
|
26
27
|
// Per process (the language server's lasts the whole editor session):
|
|
27
|
-
const keys = new Map(); // image path -> { size, mtimeMs, target, key, marked }, so images aren't re-read on every keystroke
|
|
28
28
|
const roots = new Map(); // directory -> its project root
|
|
29
29
|
const scanned = new Set(); // `${root}\0${target}` whose images were all measured, or queued, once
|
|
30
30
|
const failed = new Set(); // outside editors: roots whose batch failed, not tried again this run
|
|
31
31
|
const told = new Set(); // outside editors: images already named on stderr
|
|
32
|
-
const savings = new Map(); // outside editors: image path -> { original, saved }, for the total at exit
|
|
33
32
|
|
|
34
33
|
export default {
|
|
35
34
|
meta: {
|
|
@@ -40,8 +39,8 @@ export default {
|
|
|
40
39
|
url: "https://github.com/lndgalante/oxlint-plugin-pngdeer/blob/main/docs/rules/compressible-image.md",
|
|
41
40
|
},
|
|
42
41
|
messages: {
|
|
43
|
-
compressible: "{{name}} could be {{percent}}% smaller ({{before}} → {{after}})
|
|
44
|
-
lossless: "{{name}} could be {{percent}}% smaller ({{before}} → {{after}}) losslessly. Run: {{command}}",
|
|
42
|
+
compressible: "{{name}} could be {{percent}}% smaller ({{before}} → {{after}}) and look {{looks}} (quality {{quality}}/100). Run: {{command}}",
|
|
43
|
+
lossless: "{{name}} could be {{percent}}% smaller ({{before}} → {{after}}), losslessly. Run: {{command}}",
|
|
45
44
|
},
|
|
46
45
|
schema: [
|
|
47
46
|
{
|
|
@@ -70,36 +69,55 @@ export default {
|
|
|
70
69
|
const dir = cacheDir(root);
|
|
71
70
|
const find = (path) => locate(path, from, root, publicDirs, aliases);
|
|
72
71
|
// a path as the person running the linter would type it
|
|
73
|
-
const shown = (file) =>
|
|
72
|
+
const shown = (file) => pathFrom(context.cwd, file);
|
|
74
73
|
const wait = measure === "always" || (measure === "auto" && !EDITOR);
|
|
75
74
|
const found = [];
|
|
76
75
|
|
|
76
|
+
const reported = new Set(); // images warned about in this file: once each, at their first mention
|
|
77
|
+
let disabled; // (line) => whether a comment turns this rule off there, read on first use
|
|
78
|
+
if (!EDITOR) watch(root, { minSavedBytes, minSavedPercent, target });
|
|
79
|
+
|
|
77
80
|
function check(node, text) {
|
|
78
|
-
for (const file of imagesIn(text, find)) {
|
|
81
|
+
for (const { file, part } of imagesIn(text, find)) {
|
|
79
82
|
const image = fileKey(file, target);
|
|
80
83
|
// a file smaller than minSavedBytes can't save that much: not worth measuring
|
|
81
|
-
if (
|
|
84
|
+
if (!EDITOR) name(file);
|
|
85
|
+
if (image && image.size >= minSavedBytes) found.push({ node, file, k: image.key, loc: part && within(node, part) });
|
|
82
86
|
}
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
if (
|
|
89
|
+
// Where `part` sits in a string's source, so a warning for one image of a srcSet underlines that
|
|
90
|
+
// image alone; null if the source spells it differently (escapes), and the whole string is used.
|
|
91
|
+
function within(node, part) {
|
|
92
|
+
const source = context.sourceCode;
|
|
93
|
+
const at = source.getText(node).indexOf(part);
|
|
94
|
+
if (at < 0) return null;
|
|
95
|
+
const start = node.range[0] + at;
|
|
96
|
+
return { start: source.getLocFromIndex(start), end: source.getLocFromIndex(start + part.length) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function report({ node, file, loc }, result) {
|
|
100
|
+
if (!reportable(result, { minSavedBytes, minSavedPercent }) || reported.has(file)) return;
|
|
101
|
+
// The linter hides a warning a comment turns off by itself, but the image mustn't count as
|
|
102
|
+
// flagged either, or --flagged would rewrite an image someone chose to keep.
|
|
103
|
+
disabled ??= disabledLines(context.sourceCode);
|
|
104
|
+
if (disabled((loc?.start ?? node.loc.start).line)) return;
|
|
105
|
+
reported.add(file);
|
|
106
|
+
if (!EDITOR) flag(file, { original: result.original, compressed: result.compressed, score: result.score, target });
|
|
91
107
|
// this package's own command (bin/pngdeer.js), at the target measured, so it writes exactly the
|
|
92
108
|
// file this promises; named by the package, so npx can't fetch someone else's
|
|
93
109
|
const command = `npx oxlint-plugin-pngdeer ${quote(shown(file))}${target === undefined ? "" : ` --target ${target}`}`;
|
|
94
110
|
context.report({
|
|
95
111
|
node,
|
|
112
|
+
loc: loc ?? undefined,
|
|
96
113
|
messageId: result.outcome === "hit" ? "compressible" : "lossless",
|
|
97
114
|
data: {
|
|
98
115
|
name: basename(file),
|
|
99
|
-
percent,
|
|
116
|
+
percent: smaller(result.original, result.compressed),
|
|
100
117
|
before: size(result.original),
|
|
101
118
|
after: size(result.compressed),
|
|
102
|
-
|
|
119
|
+
looks: looks(result.score),
|
|
120
|
+
quality: Math.round(result.score),
|
|
103
121
|
command,
|
|
104
122
|
},
|
|
105
123
|
});
|
|
@@ -119,15 +137,17 @@ export default {
|
|
|
119
137
|
TemplateLiteral(node) {
|
|
120
138
|
if (node.expressions.length === 0) check(node, node.quasis[0].value.cooked ?? "");
|
|
121
139
|
},
|
|
122
|
-
// What this file names is measured, then reported. The first
|
|
123
|
-
//
|
|
140
|
+
// What this file names is measured, then reported. The first file of a project in a run also
|
|
141
|
+
// takes every image in it, named or not, in the same parallel batch: the files linted next find
|
|
142
|
+
// theirs cached, and the summary can tell about images only CSS or HTML use.
|
|
124
143
|
"Program:exit"() {
|
|
125
|
-
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
const group = `${root}\0${target}`;
|
|
145
|
+
const first = measure !== "never" && !scanned.has(group);
|
|
146
|
+
if (found.length === 0 && !first) return;
|
|
147
|
+
scanned.add(group);
|
|
148
|
+
const missing = measure === "never" ? [] : found.filter(({ k }) => !read(dir, k)).map(({ file }) => file);
|
|
149
|
+
const files = [...new Set(first ? [...missing, ...projectImages(root, minSavedBytes)] : missing)];
|
|
150
|
+
if (files.length) {
|
|
131
151
|
if (!wait) fillInBackground({ cwd: root, target, files });
|
|
132
152
|
else if (!failed.has(root)) measureNow(files);
|
|
133
153
|
}
|
|
@@ -145,35 +165,32 @@ export default {
|
|
|
145
165
|
const image = fileKey(file, target);
|
|
146
166
|
return image && !image.marked && !read(dir, image.key);
|
|
147
167
|
}).length;
|
|
148
|
-
|
|
168
|
+
// everything cached (or pngdeer's own output, which needs no engine): nothing to wait for
|
|
169
|
+
if (!fresh) return;
|
|
170
|
+
// on a terminal the batch rewrites this line as a count (fill-worker.js), and it's cleared after
|
|
171
|
+
const tty = Boolean(process.stderr.isTTY);
|
|
172
|
+
process.stderr.write(`pngdeer: measuring ${count(fresh, "image")} not in the cache yet...${tty ? "" : "\n"}`);
|
|
149
173
|
try {
|
|
150
|
-
const { computed, ms } = fillSync({ cwd: root, target, files });
|
|
174
|
+
const { computed, ms } = fillSync({ cwd: root, target, files, progress: tty });
|
|
175
|
+
if (tty) process.stderr.write("\r\x1b[K");
|
|
151
176
|
if (computed) process.stderr.write(`pngdeer: measured ${count(computed, "image")} in ${(ms / 1000).toFixed(1)} s; later runs read the cache\n`);
|
|
152
177
|
} catch (error) {
|
|
153
178
|
// the images stay unmeasured (and unreported) rather than failing the lint, and the rest
|
|
154
179
|
// of this run doesn't try again
|
|
155
180
|
failed.add(root);
|
|
156
|
-
process.stderr.write(`${error.message}\n`);
|
|
181
|
+
process.stderr.write(`${tty ? "\n" : ""}${error.message}\n`);
|
|
157
182
|
}
|
|
158
183
|
}
|
|
159
184
|
},
|
|
160
185
|
};
|
|
161
186
|
|
|
162
|
-
// A run's last word, outside editors: what all the warnings add up to
|
|
163
|
-
if (!EDITOR)
|
|
164
|
-
process.once("exit", () => {
|
|
165
|
-
if (savings.size === 0) return;
|
|
166
|
-
let original = 0;
|
|
167
|
-
let saved = 0;
|
|
168
|
-
for (const image of savings.values()) {
|
|
169
|
-
original += image.original;
|
|
170
|
-
saved += image.saved;
|
|
171
|
-
}
|
|
172
|
-
process.stderr.write(`pngdeer: ${count(savings.size, "image")} could be ${size(saved)} smaller in all (${Math.round((100 * saved) / original)}%)\n`);
|
|
173
|
-
});
|
|
174
|
-
}
|
|
187
|
+
// A run's last word, outside editors: what all the warnings add up to (summary.js).
|
|
188
|
+
if (!EDITOR) process.once("exit", () => summarize());
|
|
175
189
|
|
|
176
|
-
/**
|
|
190
|
+
/**
|
|
191
|
+
* The image files a string in the code names, as [{ file, part }]: the string itself (part null), or
|
|
192
|
+
* else a srcSet's candidates, each with the text that names it.
|
|
193
|
+
*/
|
|
177
194
|
function imagesIn(text, find) {
|
|
178
195
|
// a query or hash ends a path, but a leading # is a subpath import ("#assets/x.png")
|
|
179
196
|
const strip = (path) => path.replace(/(?!^)[?#].*$/, "");
|
|
@@ -181,9 +198,38 @@ function imagesIn(text, find) {
|
|
|
181
198
|
// a whole path first, as file names can hold spaces and commas ("Screenshot … at 10.12.33.png")
|
|
182
199
|
const whole = strip(text.trim());
|
|
183
200
|
const single = usable(whole) && find(whole);
|
|
184
|
-
if (single) return [single];
|
|
185
|
-
const
|
|
186
|
-
return
|
|
201
|
+
if (single) return [{ file: single, part: null }];
|
|
202
|
+
const parts = text.split(",").map((part) => part.trim().split(/\s+/)[0]);
|
|
203
|
+
return parts
|
|
204
|
+
.filter((part) => usable(strip(part)))
|
|
205
|
+
.map((part) => ({ file: find(strip(part)), part }))
|
|
206
|
+
.filter(({ file }) => file);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Whether a comment turns this rule off at a line: `oxlint-` or `eslint-disable-next-line` above it,
|
|
211
|
+
* `-disable-line` on it, or a `-disable` block around it, for this rule or for all rules.
|
|
212
|
+
*/
|
|
213
|
+
function disabledLines(source) {
|
|
214
|
+
const lines = new Set();
|
|
215
|
+
const blocks = [];
|
|
216
|
+
let open = null;
|
|
217
|
+
for (const comment of source.getAllComments()) {
|
|
218
|
+
const directive = /^\s*(?:oxlint|eslint)-(disable-next-line|disable-line|disable|enable)\b(.*)$/s.exec(comment.value);
|
|
219
|
+
if (!directive) continue;
|
|
220
|
+
const [, kind, rest] = directive;
|
|
221
|
+
const rules = rest.split("--")[0].split(",").map((rule) => rule.trim()).filter(Boolean);
|
|
222
|
+
if (rules.length && !rules.includes(RULE)) continue;
|
|
223
|
+
if (kind === "disable-next-line") lines.add(comment.loc.end.line + 1);
|
|
224
|
+
else if (kind === "disable-line") lines.add(comment.loc.start.line);
|
|
225
|
+
else if (kind === "disable") open ??= comment.loc.start.line;
|
|
226
|
+
else if (open !== null) {
|
|
227
|
+
blocks.push([open, comment.loc.start.line]);
|
|
228
|
+
open = null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (open !== null) blocks.push([open, Infinity]);
|
|
232
|
+
return (line) => lines.has(line) || blocks.some(([from, to]) => line >= from && line <= to);
|
|
187
233
|
}
|
|
188
234
|
|
|
189
235
|
/** The file a path in `from` names, or null: relative to the file, or "/x" in a public dir or the root. */
|
|
@@ -208,21 +254,6 @@ function projectRoot(dir, fallback) {
|
|
|
208
254
|
return roots.get(dir);
|
|
209
255
|
}
|
|
210
256
|
|
|
211
|
-
/** An image's size, the cache key of its current bytes and whether pngdeer wrote it; null if it can't be read. */
|
|
212
|
-
function fileKey(file, target) {
|
|
213
|
-
try {
|
|
214
|
-
const { size, mtimeMs } = statSync(file);
|
|
215
|
-
const memo = keys.get(file);
|
|
216
|
-
if (memo && memo.size === size && memo.mtimeMs === mtimeMs && memo.target === target) return memo;
|
|
217
|
-
const bytes = readFileSync(file);
|
|
218
|
-
const image = { size, mtimeMs, target, key: key(bytes, target), marked: marked(bytes) };
|
|
219
|
-
keys.set(file, image);
|
|
220
|
-
return image;
|
|
221
|
-
} catch {
|
|
222
|
-
return null;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
257
|
function isFile(path) {
|
|
227
258
|
try {
|
|
228
259
|
return statSync(path).isFile();
|
package/src/scan.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
// Every PNG and JPEG in a project, to measure them all in one parallel batch
|
|
2
|
-
//
|
|
3
|
-
// by name, not by reading .gitignore: dot folders,
|
|
4
|
-
// as visual-regression snapshots, which are images
|
|
5
|
-
// package.json is another project, with its own cache,
|
|
1
|
+
// Every PNG and JPEG in a project, to measure them all in one parallel batch on a run's first file
|
|
2
|
+
// (later references then find them cached), and the other files the summary searches for images no
|
|
3
|
+
// linted code names. Folders are skipped by name, not by reading .gitignore: dot folders,
|
|
4
|
+
// dependencies, build output, and test output such as visual-regression snapshots, which are images
|
|
5
|
+
// too but never shipped. A folder with its own package.json is another project, with its own cache,
|
|
6
|
+
// and is left to it.
|
|
6
7
|
import { readdirSync, statSync } from "node:fs";
|
|
7
8
|
import { extname, join } from "node:path";
|
|
8
9
|
import { EXTENSIONS } from "./images.js";
|
|
@@ -15,6 +16,11 @@ const SKIP = new Set([
|
|
|
15
16
|
|
|
16
17
|
/** The project's images of at least `minSize` bytes. */
|
|
17
18
|
export function projectImages(root, minSize = 0) {
|
|
19
|
+
return projectFiles(root, EXTENSIONS, minSize);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The project's files with one of `extensions`, from `minSize` to `maxSize` bytes. */
|
|
23
|
+
export function projectFiles(root, extensions, minSize = 0, maxSize = Infinity) {
|
|
18
24
|
const found = [];
|
|
19
25
|
const walk = (dir) => {
|
|
20
26
|
let entries;
|
|
@@ -30,8 +36,9 @@ export function projectImages(root, minSize = 0) {
|
|
|
30
36
|
if (entry.isDirectory()) {
|
|
31
37
|
// Playwright keeps a test's snapshots in "<test file>-snapshots"
|
|
32
38
|
if (!SKIP.has(entry.name) && !entry.name.endsWith("-snapshots")) walk(path);
|
|
33
|
-
} else if (entry.isFile() &&
|
|
34
|
-
|
|
39
|
+
} else if (entry.isFile() && extensions.includes(extname(entry.name).toLowerCase())) {
|
|
40
|
+
const bytes = size(path);
|
|
41
|
+
if (bytes >= minSize && bytes <= maxSize) found.push(path);
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
};
|
package/src/summary.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// What a lint flagged, in all: a table at the end of the run, biggest saving first, and the list that
|
|
2
|
+
// `npx oxlint-plugin-pngdeer --flagged` shrinks, so nobody copies paths from warnings one by one.
|
|
3
|
+
// The table also lists images the run measured that no linted JS or TS names, which no warning can
|
|
4
|
+
// point at, each with where it is used instead (a stylesheet, a page, a template), if anywhere.
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { cacheDir, fileKey, read, reportable } from "./cache.js";
|
|
8
|
+
import { projectFiles, projectImages } from "./scan.js";
|
|
9
|
+
import { count, pathFrom, quote, size, smaller } from "./text.js";
|
|
10
|
+
|
|
11
|
+
// Rows shown per table; the rest are counted, and --flagged still takes them all.
|
|
12
|
+
const ROWS = 10;
|
|
13
|
+
const UNNAMED_ROWS = 5;
|
|
14
|
+
// Formats other programs read: the table on stderr would only get in the way.
|
|
15
|
+
const MACHINE = new Set(["json", "json-with-metadata", "checkstyle", "junit", "sarif", "gitlab"]);
|
|
16
|
+
// Files the linter doesn't read but that use images, most telling first: styles, pages and
|
|
17
|
+
// templates, then content and data. Markdown comes last, and only "mentions" an image: a README
|
|
18
|
+
// that names one may just be describing it.
|
|
19
|
+
const PAGES = [".css", ".scss", ".sass", ".less", ".styl", ".html", ".htm", ".vue", ".svelte", ".astro", ".mdx", ".json", ".yml", ".yaml"];
|
|
20
|
+
const DOCS = [".md"];
|
|
21
|
+
const PAGE_BYTES = 1e6; // bigger ones are build output or lockfiles, not where people name images
|
|
22
|
+
|
|
23
|
+
const flagged = new Map(); // image path -> { original, compressed, score, target }
|
|
24
|
+
const named = new Set(); // every image linted code names, flagged or not
|
|
25
|
+
const roots = new Map(); // project root -> the rule's options there
|
|
26
|
+
|
|
27
|
+
/** Notes an image a warning was reported for. */
|
|
28
|
+
export function flag(file, entry) {
|
|
29
|
+
flagged.set(file, entry);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Notes an image linted code names. */
|
|
33
|
+
export function name(file) {
|
|
34
|
+
named.add(file);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Notes a project the lint ran in, and the options it ran with, to find images nothing names. */
|
|
38
|
+
export function watch(root, options) {
|
|
39
|
+
roots.set(root, options);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The list a lint run from `cwd` leaves for --flagged. */
|
|
43
|
+
export function flaggedList(cwd) {
|
|
44
|
+
return join(cacheDir(cwd), "flagged.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The images the last lint run from `cwd` flagged, as [{ file, target }]; null if no lint left a list. */
|
|
48
|
+
export function readFlagged(cwd) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.parse(readFileSync(flaggedList(cwd), "utf8"));
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Prints the table on stderr and leaves the list in `cwd`; once, as the lint exits. */
|
|
57
|
+
export function summarize(cwd = process.cwd()) {
|
|
58
|
+
const list = flaggedList(cwd);
|
|
59
|
+
const images = [...flagged].sort(([, a], [, b]) => b.original - b.compressed - (a.original - a.compressed));
|
|
60
|
+
// a clean run replaces an older list, but doesn't create one
|
|
61
|
+
if (images.length || existsSync(list)) {
|
|
62
|
+
try {
|
|
63
|
+
mkdirSync(dirname(list), { recursive: true });
|
|
64
|
+
writeFileSync(list, JSON.stringify(images.map(([file, { target }]) => ({ file, target }))));
|
|
65
|
+
} catch {
|
|
66
|
+
// without the list, --flagged has nothing to read; the table still tells
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (process.argv.includes("--quiet") || MACHINE.has(format())) return;
|
|
70
|
+
|
|
71
|
+
const unnamed = unnamedImages();
|
|
72
|
+
if (images.length === 0 && unnamed.length === 0) return;
|
|
73
|
+
const shownUnnamed = unnamed.slice(0, UNNAMED_ROWS);
|
|
74
|
+
const row = ([file, { original, compressed, score }]) => [pathFrom(cwd, file), size(original), size(compressed), size(original - compressed), String(Math.round(score))];
|
|
75
|
+
const header = ["image", "now", "after", "saves", "quality"];
|
|
76
|
+
// the unnamed images say where they're named, as no warning can point there
|
|
77
|
+
const where = ([file, , root]) => {
|
|
78
|
+
const page = namedIn(root, file);
|
|
79
|
+
if (!page) return "nothing names it";
|
|
80
|
+
return `${page.endsWith(".md") ? "mentioned" : "used"} in ${pathFrom(cwd, page)}`;
|
|
81
|
+
};
|
|
82
|
+
const rows = [header, ...images.slice(0, ROWS).map(row), ...shownUnnamed.map((image) => [...row(image), where(image)])];
|
|
83
|
+
const width = (column) => Math.max(...rows.map((cells) => (cells[column] ?? "").length));
|
|
84
|
+
const line = ([path, before, after, saved, score, place], arrow = "→") =>
|
|
85
|
+
` ${path.padEnd(width(0))} ${before.padStart(width(1))} ${arrow} ${after.padStart(width(2))} ${saved.padStart(width(3))} ${score.padStart(width(4))}${place === undefined ? "" : ` ${place}`}`.trimEnd();
|
|
86
|
+
const total = (list) => {
|
|
87
|
+
const original = list.reduce((sum, [, image]) => sum + image.original, 0);
|
|
88
|
+
const compressed = list.reduce((sum, [, image]) => sum + image.compressed, 0);
|
|
89
|
+
return { saved: size(original - compressed), detail: `${size(original)} → ${size(compressed)}, ${smaller(original, compressed)}% smaller` };
|
|
90
|
+
};
|
|
91
|
+
const lines = [];
|
|
92
|
+
if (images.length) {
|
|
93
|
+
const { saved, detail } = total(images);
|
|
94
|
+
lines.push(`pngdeer: ${count(images.length, "flagged image")} could be ${saved} smaller in all (${detail}):`, line(header, " "));
|
|
95
|
+
lines.push(...rows.slice(1, 1 + Math.min(images.length, ROWS)).map((cells) => line(cells)));
|
|
96
|
+
if (images.length > ROWS) lines.push(` …and ${count(images.length - ROWS, "more image")}, ${total(images.slice(ROWS)).saved} in all`);
|
|
97
|
+
}
|
|
98
|
+
if (unnamed.length) {
|
|
99
|
+
// no warning can point at these: their only mention is in CSS, HTML and the like, or nowhere
|
|
100
|
+
const { saved } = total(unnamed);
|
|
101
|
+
const lead = images.length ? "also" : "no warnings, but";
|
|
102
|
+
lines.push(`pngdeer: ${lead} ${count(unnamed.length, "image")} that no linted JS or TS names could be ${saved} smaller:`);
|
|
103
|
+
if (!images.length) lines.push(line(header, " "));
|
|
104
|
+
lines.push(...rows.slice(1 + Math.min(images.length, ROWS)).map((cells) => line(cells)));
|
|
105
|
+
if (unnamed.length > UNNAMED_ROWS) lines.push(` …and ${count(unnamed.length - UNNAMED_ROWS, "more image")}`);
|
|
106
|
+
}
|
|
107
|
+
lines.push("pngdeer: quality is SSIMULACRA2, out of 100: 90+ looks identical, 80+ the same side by side, 70+ close, with differences you might spot side by side");
|
|
108
|
+
if (images.length) {
|
|
109
|
+
lines.push(`pngdeer: to shrink the ${images.length === 1 ? "flagged image" : `${images.length} flagged images`} in place (lossy, so commit first), run: npx oxlint-plugin-pngdeer --flagged`);
|
|
110
|
+
}
|
|
111
|
+
if (unnamed.length) {
|
|
112
|
+
const paths = shownUnnamed.map(([file]) => quote(pathFrom(cwd, file))).join(" ");
|
|
113
|
+
const which = unnamed.length === 1 ? "it" : "them";
|
|
114
|
+
lines.push(`pngdeer: to shrink ${images.length ? `${which} too` : `${which} in place (lossy, so commit first)`}, run: npx oxlint-plugin-pngdeer ${paths}`);
|
|
115
|
+
if (shownUnnamed.some((image) => !namedIn(image[2], image[0]))) {
|
|
116
|
+
lines.push("pngdeer: an image nothing names may be unused (or its path is built at runtime): deleting it saves it all");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
process.stderr.write(`${lines.join("\n")}\n`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Measured images in the linted projects that no linted code names, and that would clear the rule's
|
|
124
|
+
* thresholds: [[path, entry, root]], biggest saving first. Reads only results already cached.
|
|
125
|
+
*/
|
|
126
|
+
function unnamedImages() {
|
|
127
|
+
const found = [];
|
|
128
|
+
for (const [root, options] of roots) {
|
|
129
|
+
for (const file of projectImages(root, options.minSavedBytes)) {
|
|
130
|
+
if (named.has(file) || flagged.has(file)) continue;
|
|
131
|
+
const image = fileKey(file, options.target);
|
|
132
|
+
const entry = image && read(cacheDir(root), image.key);
|
|
133
|
+
if (reportable(entry, options)) found.push([file, entry, root]);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return found.sort(([, a], [, b]) => b.original - b.compressed - (a.original - a.compressed));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const pages = new Map(); // project root -> [{ path, text }] of its PAGES, then its DOCS, read once
|
|
140
|
+
|
|
141
|
+
/** The most telling file the linter doesn't read (a stylesheet, a template, a doc) that names `image`; or null. */
|
|
142
|
+
function namedIn(root, image) {
|
|
143
|
+
if (!pages.has(root)) {
|
|
144
|
+
const files = [...PAGES, ...DOCS].flatMap((extension) => projectFiles(root, [extension], 0, PAGE_BYTES));
|
|
145
|
+
pages.set(root, files.flatMap((path) => {
|
|
146
|
+
try {
|
|
147
|
+
return [{ path, text: readFileSync(path, "utf8") }];
|
|
148
|
+
} catch {
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
const name = basename(image);
|
|
154
|
+
return pages.get(root).find(({ text }) => text.includes(name))?.path ?? null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The linter's output format, from its command line: `-f x`, `--format x` or `--format=x`. */
|
|
158
|
+
function format() {
|
|
159
|
+
const args = process.argv;
|
|
160
|
+
const at = args.findIndex((arg) => arg === "-f" || arg === "--format");
|
|
161
|
+
if (at >= 0) return args[at + 1];
|
|
162
|
+
return args.find((arg) => arg.startsWith("--format="))?.slice("--format=".length);
|
|
163
|
+
}
|
package/src/text.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { relative, sep } from "node:path";
|
|
2
|
+
|
|
1
3
|
/** A file size as people read it: decimal kB and MB. */
|
|
2
4
|
export function size(bytes) {
|
|
3
5
|
if (bytes < 1000) return `${bytes} B`;
|
|
@@ -5,6 +7,30 @@ export function size(bytes) {
|
|
|
5
7
|
return `${(bytes / 1e6).toFixed(1)} MB`;
|
|
6
8
|
}
|
|
7
9
|
|
|
10
|
+
/** How much smaller `after` bytes are than `before`, in whole percent. */
|
|
11
|
+
export function smaller(before, after) {
|
|
12
|
+
return Math.round(100 - (100 * after) / before);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** `file` as typed from `cwd`, with forward slashes on every platform. */
|
|
16
|
+
export function pathFrom(cwd, file) {
|
|
17
|
+
return relative(cwd, file).split(sep).join("/");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How a file at this SSIMULACRA2 score looks next to the original, after "looks", in the metric's
|
|
22
|
+
* own terms: 90 can't be told apart even flipping between them, 80 not side by side, 70 shows
|
|
23
|
+
* artifacts that aren't annoying.
|
|
24
|
+
*/
|
|
25
|
+
export function looks(score) {
|
|
26
|
+
// the whole number people see (89.5 shows as 90), so the words match it
|
|
27
|
+
const shown = Math.round(score);
|
|
28
|
+
if (shown >= 90) return "identical";
|
|
29
|
+
if (shown >= 80) return "the same side by side";
|
|
30
|
+
if (shown >= 70) return "close, with differences you might spot side by side";
|
|
31
|
+
return "visibly different";
|
|
32
|
+
}
|
|
33
|
+
|
|
8
34
|
/** "1 image", "5 images". */
|
|
9
35
|
export function count(n, noun) {
|
|
10
36
|
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
package/src/worker.js
CHANGED
|
@@ -5,6 +5,7 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
import { parentPort, workerData } from "node:worker_threads";
|
|
6
6
|
import { key } from "./cache.js";
|
|
7
7
|
import { compress } from "./engine.js";
|
|
8
|
+
import { mark } from "./marker.js";
|
|
8
9
|
|
|
9
10
|
const why = (error) => String(error?.message ?? error);
|
|
10
11
|
|
|
@@ -19,9 +20,11 @@ parentPort.on("message", ({ file }) => {
|
|
|
19
20
|
}
|
|
20
21
|
const input = key(bytes, workerData.target);
|
|
21
22
|
try {
|
|
22
|
-
const { output, outcome } = compress(bytes, workerData.target);
|
|
23
|
+
const { output, outcome, score } = compress(bytes, workerData.target);
|
|
23
24
|
const ms = Math.round(performance.now() - start);
|
|
24
|
-
|
|
25
|
+
// the size the command will write, mark included, so the warning and the command agree
|
|
26
|
+
const compressed = outcome === "original" ? bytes.length : mark(output).length;
|
|
27
|
+
parentPort.postMessage({ input, original: bytes.length, compressed, outcome, score, ms, output: key(output, workerData.target) });
|
|
25
28
|
} catch (error) {
|
|
26
29
|
parentPort.postMessage({ input, error: why(error) });
|
|
27
30
|
}
|