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 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) at about the same visual quality. Run: npx oxlint-plugin-pngdeer public/hero.png
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.5 s; later runs read the cache
57
- ⚠ pngdeer(compressible-image): hero.png could be 70% smaller (509 kB → 151 kB) at about the same visual quality. Run: npx oxlint-plugin-pngdeer public/hero.png
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
- pngdeer: 4 images could be 718 kB smaller in all (70%)
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
- Then shrink what it found. There's no autofix (lint fixes can only edit code), so pass the flagged images to the command, one or several:
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 public/hero.png public/photo.jpg
66
- public/hero.png: 509 kB → 151 kB (70% smaller, SSIMULACRA2 87.3)
67
- public/photo.jpg: 235 kB → 85 kB (64% smaller, SSIMULACRA2 75.5)
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
- - **It doesn't read your lint config.** If you set a `target`, pass it too (`--target 90`); the command in each warning already does. `--help` lists the options.
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 { size } from "../src/text.js";
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 === "-o" || arg === "--target") {
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 if (value !== "auto") {
45
- target = Number(value);
46
- if (!(value.trim() !== "" && target >= 0 && target <= 100)) return fail(`--target must be auto or a number from 0 to 100, not ${value}`);
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 === 0) return fail("no image given");
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
- for (const input of inputs) {
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
- console.log(`${input}: ${shrink(input, output ?? input, target)}`);
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`, and says what happened. */
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 scores ${score.toFixed(1)})`);
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 percent = Math.round(100 - (100 * result.length) / bytes.length);
87
- return `${size(bytes.length)} → ${size(result.length)} (${percent}% smaller, SSIMULACRA2 ${score.toFixed(1)})`;
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.0",
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
- /** Computes every file not cached yet; { computed, cached, skipped, failed, ms }. */
18
- export async function fill({ cwd, target, files }) {
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 }; { skipped } (with failed: true if pngdeer itself failed);
26
- * { outcome: "output" } for bytes pngdeer wrote; or null if not cached yet.
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
@@ -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 { readFileSync, statSync } from "node:fs";
5
- import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
- import { cacheDir, key, read } from "./cache.js";
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}}) at {{quality}}. Run: {{command}}",
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) => relative(context.cwd, file).split(sep).join("/");
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 (image && image.size >= minSavedBytes) found.push({ node, file, k: image.key });
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
- function report({ node, file }, result) {
86
- if (!result || result.skipped || !["hit", "exact", "lossless"].includes(result.outcome)) return;
87
- const saved = result.original - result.compressed;
88
- const percent = Math.round((100 * saved) / result.original);
89
- if (saved < minSavedBytes || percent < minSavedPercent) return;
90
- if (!EDITOR) savings.set(file, { original: result.original, saved });
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
- quality: target === undefined ? "about the same visual quality" : `SSIMULACRA2 ${target}`,
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 time, every image in the
123
- // project goes in the same parallel batch, so the files linted next find theirs cached.
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
- if (found.length === 0) return;
126
- const missing = measure === "never" ? [] : [...new Set(found.filter(({ k }) => !read(dir, k)).map(({ file }) => file))];
127
- if (missing.length) {
128
- const group = `${root}\0${target}`;
129
- const files = scanned.has(group) ? missing : [...new Set([...missing, ...projectImages(root, minSavedBytes)])];
130
- scanned.add(group);
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
- if (fresh) process.stderr.write(`pngdeer: measuring ${count(fresh, "image")} not in the cache yet...\n`);
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, each image counted once.
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
- /** The image files a string in the code names: itself, or else a srcSet's candidates. */
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 candidates = text.split(",").map((part) => strip(part.trim().split(/\s+/)[0]));
186
- return candidates.filter(usable).map(find).filter(Boolean);
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 the first time the
2
- // rule meets an image it hasn't seen (later references then find them cached). Folders are skipped
3
- // by name, not by reading .gitignore: dot folders, dependencies, build output, and test output such
4
- // as visual-regression snapshots, which are images too but never shipped. A folder with its own
5
- // package.json is another project, with its own cache, and is left to it.
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() && EXTENSIONS.includes(extname(entry.name).toLowerCase()) && size(path) >= minSize) {
34
- found.push(path);
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
- parentPort.postMessage({ input, original: bytes.length, compressed: output.length, outcome, ms, output: key(output, workerData.target) });
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
  }