oxlint-plugin-pngdeer 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,44 @@ 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: 1 more image could be 5.1 kB smaller, but no linted code names it (used from CSS or HTML, or not at all):
69
+ public/unused-fire.png 13 kB → 7.5 kB 5.1 kB 90
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
60
72
  ```
61
73
 
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:
74
+ - **Each image warns once per file,** on the line that names it, and says how close to the original it would look.
75
+ - **The table at the end** counts each image once, biggest saving first. It also lists images the run measured that no linted code names, like a CSS background, which no warning can point at.
76
+ - **The table is left out** with `--quiet` and in formats other programs read (`json`, `checkstyle`, `junit`, `sarif`, `gitlab`).
77
+
78
+ 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
79
 
64
80
  ```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)
81
+ $ npx oxlint-plugin-pngdeer --flagged
82
+ pngdeer: shrinking 4 images in place (lossy; commit first so you can undo)
83
+ [1/4] public/hero.png: 509 kB → 151 kB (70% smaller, quality 87/100: looks the same side by side)
84
+ [2/4] src/assets/diagram.png: 248 kB → 63 kB (75% smaller, quality 90/100: looks identical)
85
+ [3/4] public/photo.jpg: 235 kB → 85 kB (64% smaller, quality 76/100: looks close, with differences you might spot side by side)
86
+ [4/4] src/assets/crystal-ball.png: 35 kB → 11 kB (68% smaller, quality 87/100: looks the same side by side)
87
+ pngdeer: 4 images, 1.0 MB → 310 kB (718 kB smaller). Lint again to check.
68
88
  ```
69
89
 
70
90
  - **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`.
91
+ - **A warning you turn off with a comment** stays out of the table and out of `--flagged`.
71
92
  - **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.
93
+ - **`--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
94
  - **JPEGs keep their orientation and color profile;** other camera metadata (EXIF, GPS) is dropped.
74
95
  - **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
96
 
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.0",
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
@@ -4,6 +4,7 @@
4
4
  import { createHash } from "node:crypto";
5
5
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
+ import { smaller } from "./text.js";
7
8
 
8
9
  // The engine's identity: the first 16 hex digits of the wasm's SHA-256 (a few ms for 2 MB). A new
9
10
  // engine invalidates every entry.
@@ -16,14 +17,18 @@ export function cacheDir(cwd) {
16
17
  return join(cwd, "node_modules", ".cache", "pngdeer");
17
18
  }
18
19
 
20
+ // What an entry holds: a new shape (2 added the score) measures everything again.
21
+ const FORMAT = 2;
22
+
19
23
  /** The cache key of an image's bytes for a given target (undefined: automatic quality). */
20
24
  export function key(bytes, target) {
21
- return createHash("sha256").update(bytes).update(`\0${ENGINE}\0${target ?? "auto"}`).digest("hex");
25
+ return createHash("sha256").update(bytes).update(`\0${ENGINE}\0${target ?? "auto"}\0${FORMAT}`).digest("hex");
22
26
  }
23
27
 
24
28
  /**
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.
29
+ * { original, compressed, outcome, score, ms }, compressed counting the mark the command adds;
30
+ * { skipped } (with failed: true if pngdeer itself failed); { outcome: "output" } for bytes pngdeer
31
+ * wrote; or null if not cached yet.
27
32
  */
28
33
  export function read(dir, k) {
29
34
  try {
@@ -33,6 +38,13 @@ export function read(dir, k) {
33
38
  }
34
39
  }
35
40
 
41
+ /** Whether an entry is a saving the rule reports: smaller at the target, by both thresholds. */
42
+ export function reportable(entry, { minSavedBytes, minSavedPercent }) {
43
+ if (!entry || entry.skipped || !["hit", "exact", "lossless"].includes(entry.outcome)) return false;
44
+ const saved = entry.original - entry.compressed;
45
+ return saved >= minSavedBytes && smaller(entry.original, entry.compressed) >= minSavedPercent;
46
+ }
47
+
36
48
  export function write(dir, k, entry) {
37
49
  mkdirSync(dir, { recursive: true });
38
50
  // 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
@@ -2,13 +2,15 @@
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
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";
5
+ import { basename, dirname, join, resolve } from "node:path";
6
+ import { cacheDir, key, read, reportable } from "./cache.js";
7
7
  import { marked } from "./marker.js";
8
8
  import { projectImages } from "./scan.js";
9
+ import { flag, name, summarize, watch } from "./summary.js";
9
10
  import { fillInBackground, fillSync } from "./sync.js";
10
- import { count, quote, size } from "./text.js";
11
+ import { count, looks, pathFrom, quote, size, smaller } from "./text.js";
11
12
 
13
+ const RULE = "pngdeer/compressible-image";
12
14
  const IMAGE = /\.(png|apng|jpe?g)$/i;
13
15
  const REMOTE = /^[a-z][a-z0-9+.-]*:|^\/\//i;
14
16
  const DEFAULTS = { minSavedBytes: 4096, minSavedPercent: 10, target: "auto", measure: "auto" };
@@ -29,7 +31,6 @@ const roots = new Map(); // directory -> its project root
29
31
  const scanned = new Set(); // `${root}\0${target}` whose images were all measured, or queued, once
30
32
  const failed = new Set(); // outside editors: roots whose batch failed, not tried again this run
31
33
  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
34
 
34
35
  export default {
35
36
  meta: {
@@ -40,8 +41,8 @@ export default {
40
41
  url: "https://github.com/lndgalante/oxlint-plugin-pngdeer/blob/main/docs/rules/compressible-image.md",
41
42
  },
42
43
  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}}",
44
+ compressible: "{{name}} could be {{percent}}% smaller ({{before}} → {{after}}) and look {{looks}} (quality {{quality}}/100). Run: {{command}}",
45
+ lossless: "{{name}} could be {{percent}}% smaller ({{before}} → {{after}}), losslessly. Run: {{command}}",
45
46
  },
46
47
  schema: [
47
48
  {
@@ -70,36 +71,55 @@ export default {
70
71
  const dir = cacheDir(root);
71
72
  const find = (path) => locate(path, from, root, publicDirs, aliases);
72
73
  // a path as the person running the linter would type it
73
- const shown = (file) => relative(context.cwd, file).split(sep).join("/");
74
+ const shown = (file) => pathFrom(context.cwd, file);
74
75
  const wait = measure === "always" || (measure === "auto" && !EDITOR);
75
76
  const found = [];
76
77
 
78
+ const reported = new Set(); // images warned about in this file: once each, at their first mention
79
+ let disabled; // (line) => whether a comment turns this rule off there, read on first use
80
+ if (!EDITOR) watch(root, { minSavedBytes, minSavedPercent, target });
81
+
77
82
  function check(node, text) {
78
- for (const file of imagesIn(text, find)) {
83
+ for (const { file, part } of imagesIn(text, find)) {
79
84
  const image = fileKey(file, target);
80
85
  // 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 });
86
+ if (!EDITOR) name(file);
87
+ if (image && image.size >= minSavedBytes) found.push({ node, file, k: image.key, loc: part && within(node, part) });
82
88
  }
83
89
  }
84
90
 
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 });
91
+ // Where `part` sits in a string's source, so a warning for one image of a srcSet underlines that
92
+ // image alone; null if the source spells it differently (escapes), and the whole string is used.
93
+ function within(node, part) {
94
+ const source = context.sourceCode;
95
+ const at = source.getText(node).indexOf(part);
96
+ if (at < 0) return null;
97
+ const start = node.range[0] + at;
98
+ return { start: source.getLocFromIndex(start), end: source.getLocFromIndex(start + part.length) };
99
+ }
100
+
101
+ function report({ node, file, loc }, result) {
102
+ if (!reportable(result, { minSavedBytes, minSavedPercent }) || reported.has(file)) return;
103
+ // The linter hides a warning a comment turns off by itself, but the image mustn't count as
104
+ // flagged either, or --flagged would rewrite an image someone chose to keep.
105
+ disabled ??= disabledLines(context.sourceCode);
106
+ if (disabled((loc?.start ?? node.loc.start).line)) return;
107
+ reported.add(file);
108
+ if (!EDITOR) flag(file, { original: result.original, compressed: result.compressed, score: result.score, target });
91
109
  // this package's own command (bin/pngdeer.js), at the target measured, so it writes exactly the
92
110
  // file this promises; named by the package, so npx can't fetch someone else's
93
111
  const command = `npx oxlint-plugin-pngdeer ${quote(shown(file))}${target === undefined ? "" : ` --target ${target}`}`;
94
112
  context.report({
95
113
  node,
114
+ loc: loc ?? undefined,
96
115
  messageId: result.outcome === "hit" ? "compressible" : "lossless",
97
116
  data: {
98
117
  name: basename(file),
99
- percent,
118
+ percent: smaller(result.original, result.compressed),
100
119
  before: size(result.original),
101
120
  after: size(result.compressed),
102
- quality: target === undefined ? "about the same visual quality" : `SSIMULACRA2 ${target}`,
121
+ looks: looks(result.score),
122
+ quality: Math.round(result.score),
103
123
  command,
104
124
  },
105
125
  });
@@ -145,35 +165,30 @@ 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
+ // on a terminal the batch rewrites this line as a count (fill-worker.js), and it's cleared after
169
+ const tty = Boolean(fresh && process.stderr.isTTY);
170
+ if (fresh) process.stderr.write(`pngdeer: measuring ${count(fresh, "image")} not in the cache yet...${tty ? "" : "\n"}`);
149
171
  try {
150
- const { computed, ms } = fillSync({ cwd: root, target, files });
172
+ const { computed, ms } = fillSync({ cwd: root, target, files, progress: tty });
173
+ if (tty) process.stderr.write("\r\x1b[K");
151
174
  if (computed) process.stderr.write(`pngdeer: measured ${count(computed, "image")} in ${(ms / 1000).toFixed(1)} s; later runs read the cache\n`);
152
175
  } catch (error) {
153
176
  // the images stay unmeasured (and unreported) rather than failing the lint, and the rest
154
177
  // of this run doesn't try again
155
178
  failed.add(root);
156
- process.stderr.write(`${error.message}\n`);
179
+ process.stderr.write(`${tty ? "\n" : ""}${error.message}\n`);
157
180
  }
158
181
  }
159
182
  },
160
183
  };
161
184
 
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
- }
185
+ // A run's last word, outside editors: what all the warnings add up to (summary.js).
186
+ if (!EDITOR) process.once("exit", () => summarize());
175
187
 
176
- /** The image files a string in the code names: itself, or else a srcSet's candidates. */
188
+ /**
189
+ * The image files a string in the code names, as [{ file, part }]: the string itself (part null), or
190
+ * else a srcSet's candidates, each with the text that names it.
191
+ */
177
192
  function imagesIn(text, find) {
178
193
  // a query or hash ends a path, but a leading # is a subpath import ("#assets/x.png")
179
194
  const strip = (path) => path.replace(/(?!^)[?#].*$/, "");
@@ -181,9 +196,38 @@ function imagesIn(text, find) {
181
196
  // a whole path first, as file names can hold spaces and commas ("Screenshot … at 10.12.33.png")
182
197
  const whole = strip(text.trim());
183
198
  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);
199
+ if (single) return [{ file: single, part: null }];
200
+ const parts = text.split(",").map((part) => part.trim().split(/\s+/)[0]);
201
+ return parts
202
+ .filter((part) => usable(strip(part)))
203
+ .map((part) => ({ file: find(strip(part)), part }))
204
+ .filter(({ file }) => file);
205
+ }
206
+
207
+ /**
208
+ * Whether a comment turns this rule off at a line: `oxlint-` or `eslint-disable-next-line` above it,
209
+ * `-disable-line` on it, or a `-disable` block around it, for this rule or for all rules.
210
+ */
211
+ function disabledLines(source) {
212
+ const lines = new Set();
213
+ const blocks = [];
214
+ let open = null;
215
+ for (const comment of source.getAllComments()) {
216
+ const directive = /^\s*(?:oxlint|eslint)-(disable-next-line|disable-line|disable|enable)\b(.*)$/s.exec(comment.value);
217
+ if (!directive) continue;
218
+ const [, kind, rest] = directive;
219
+ const rules = rest.split("--")[0].split(",").map((rule) => rule.trim()).filter(Boolean);
220
+ if (rules.length && !rules.includes(RULE)) continue;
221
+ if (kind === "disable-next-line") lines.add(comment.loc.end.line + 1);
222
+ else if (kind === "disable-line") lines.add(comment.loc.start.line);
223
+ else if (kind === "disable") open ??= comment.loc.start.line;
224
+ else if (open !== null) {
225
+ blocks.push([open, comment.loc.start.line]);
226
+ open = null;
227
+ }
228
+ }
229
+ if (open !== null) blocks.push([open, Infinity]);
230
+ return (line) => lines.has(line) || blocks.some(([from, to]) => line >= from && line <= to);
187
231
  }
188
232
 
189
233
  /** The file a path in `from` names, or null: relative to the file, or "/x" in a public dir or the root. */
package/src/summary.js ADDED
@@ -0,0 +1,125 @@
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 names images the run measured but no linted code names (used from CSS or HTML, or
4
+ // not at all), which no warning can point at.
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { cacheDir, key, read, reportable } from "./cache.js";
8
+ import { projectImages } from "./scan.js";
9
+ import { count, pathFrom, 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
+
17
+ const flagged = new Map(); // image path -> { original, compressed, score, target }
18
+ const named = new Set(); // every image linted code names, flagged or not
19
+ const roots = new Map(); // project root -> the rule's options there
20
+
21
+ /** Notes an image a warning was reported for. */
22
+ export function flag(file, entry) {
23
+ flagged.set(file, entry);
24
+ }
25
+
26
+ /** Notes an image linted code names. */
27
+ export function name(file) {
28
+ named.add(file);
29
+ }
30
+
31
+ /** Notes a project the lint ran in, and the options it ran with, to find images nothing names. */
32
+ export function watch(root, options) {
33
+ roots.set(root, options);
34
+ }
35
+
36
+ /** The list a lint run from `cwd` leaves for --flagged. */
37
+ export function flaggedList(cwd) {
38
+ return join(cacheDir(cwd), "flagged.json");
39
+ }
40
+
41
+ /** The images the last lint run from `cwd` flagged, as [{ file, target }]; null if no lint left a list. */
42
+ export function readFlagged(cwd) {
43
+ try {
44
+ return JSON.parse(readFileSync(flaggedList(cwd), "utf8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /** Prints the table on stderr and leaves the list in `cwd`; once, as the lint exits. */
51
+ export function summarize(cwd = process.cwd()) {
52
+ const list = flaggedList(cwd);
53
+ const images = [...flagged].sort(([, a], [, b]) => b.original - b.compressed - (a.original - a.compressed));
54
+ // a clean run replaces an older list, but doesn't create one
55
+ if (images.length || existsSync(list)) {
56
+ try {
57
+ mkdirSync(dirname(list), { recursive: true });
58
+ writeFileSync(list, JSON.stringify(images.map(([file, { target }]) => ({ file, target }))));
59
+ } catch {
60
+ // without the list, --flagged has nothing to read; the table still tells
61
+ }
62
+ }
63
+ if (process.argv.includes("--quiet") || MACHINE.has(format())) return;
64
+
65
+ const unnamed = unnamedImages();
66
+ if (images.length === 0 && unnamed.length === 0) return;
67
+ const row = ([file, { original, compressed, score }]) => [pathFrom(cwd, file), size(original), size(compressed), size(original - compressed), String(Math.round(score))];
68
+ const rows = [["image", "now", "after", "saves", "quality"], ...images.slice(0, ROWS).map(row), ...unnamed.slice(0, UNNAMED_ROWS).map(row)];
69
+ const width = (column) => Math.max(...rows.map((cells) => cells[column].length));
70
+ const line = ([path, before, after, saved, score], arrow = "→") =>
71
+ ` ${path.padEnd(width(0))} ${before.padStart(width(1))} ${arrow} ${after.padStart(width(2))} ${saved.padStart(width(3))} ${score.padStart(width(4))}`;
72
+ const total = (list) => {
73
+ const original = list.reduce((sum, [, image]) => sum + image.original, 0);
74
+ const compressed = list.reduce((sum, [, image]) => sum + image.compressed, 0);
75
+ return { saved: size(original - compressed), detail: `${size(original)} → ${size(compressed)}, ${smaller(original, compressed)}% smaller` };
76
+ };
77
+ const lines = [];
78
+ if (images.length) {
79
+ const { saved, detail } = total(images);
80
+ lines.push(`pngdeer: ${count(images.length, "flagged image")} could be ${saved} smaller in all (${detail}):`, line(rows[0], " "));
81
+ lines.push(...rows.slice(1, 1 + Math.min(images.length, ROWS)).map((cells) => line(cells)));
82
+ if (images.length > ROWS) lines.push(` …and ${count(images.length - ROWS, "more image")}, ${total(images.slice(ROWS)).saved} in all`);
83
+ }
84
+ if (unnamed.length) {
85
+ const { saved } = total(unnamed);
86
+ lines.push(`pngdeer: ${count(unnamed.length, "more image")} could be ${saved} smaller, but no linted code names ${unnamed.length === 1 ? "it" : "them"} (used from CSS or HTML, or not at all):`);
87
+ if (!images.length) lines.push(line(rows[0], " "));
88
+ lines.push(...rows.slice(1 + Math.min(images.length, ROWS)).map((cells) => line(cells)));
89
+ if (unnamed.length > UNNAMED_ROWS) lines.push(` …and ${count(unnamed.length - UNNAMED_ROWS, "more image")}`);
90
+ }
91
+ 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");
92
+ if (images.length) {
93
+ 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`);
94
+ }
95
+ process.stderr.write(`${lines.join("\n")}\n`);
96
+ }
97
+
98
+ /**
99
+ * Measured images in the linted projects that no linted code names, and that would clear the rule's
100
+ * thresholds: [[path, entry]], biggest saving first. Reads only results already cached.
101
+ */
102
+ function unnamedImages() {
103
+ const found = [];
104
+ for (const [root, options] of roots) {
105
+ for (const file of projectImages(root, options.minSavedBytes)) {
106
+ if (named.has(file) || flagged.has(file)) continue;
107
+ let entry;
108
+ try {
109
+ entry = read(cacheDir(root), key(readFileSync(file), options.target));
110
+ } catch {
111
+ continue;
112
+ }
113
+ if (reportable(entry, options)) found.push([file, entry]);
114
+ }
115
+ }
116
+ return found.sort(([, a], [, b]) => b.original - b.compressed - (a.original - a.compressed));
117
+ }
118
+
119
+ /** The linter's output format, from its command line: `-f x`, `--format x` or `--format=x`. */
120
+ function format() {
121
+ const args = process.argv;
122
+ const at = args.findIndex((arg) => arg === "-f" || arg === "--format");
123
+ if (at >= 0) return args[at + 1];
124
+ return args.find((arg) => arg.startsWith("--format="))?.slice("--format=".length);
125
+ }
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
  }