oxlint-plugin-pngdeer 0.2.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
@@ -65,14 +65,16 @@ pngdeer: 4 flagged images could be 718 kB smaller in all (1.0 MB → 310 kB, 70%
65
65
  src/assets/diagram.png 248 kB → 63 kB 185 kB 90
66
66
  public/photo.jpg 235 kB → 85 kB 150 kB 76
67
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
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
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
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
72
73
  ```
73
74
 
74
75
  - **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 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.
76
78
  - **The table is left out** with `--quiet` and in formats other programs read (`json`, `checkstyle`, `junit`, `sarif`, `gitlab`).
77
79
 
78
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oxlint-plugin-pngdeer",
3
- "version": "0.2.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/cache.js CHANGED
@@ -2,10 +2,13 @@
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";
7
8
  import { smaller } from "./text.js";
8
9
 
10
+ const keys = new Map(); // image path -> { size, mtimeMs, target, key, marked }, so images aren't re-read on every keystroke
11
+
9
12
  // The engine's identity: the first 16 hex digits of the wasm's SHA-256 (a few ms for 2 MB). A new
10
13
  // engine invalidates every entry.
11
14
  export const ENGINE = createHash("sha256")
@@ -45,6 +48,21 @@ export function reportable(entry, { minSavedBytes, minSavedPercent }) {
45
48
  return saved >= minSavedBytes && smaller(entry.original, entry.compressed) >= minSavedPercent;
46
49
  }
47
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
+
48
66
  export function write(dir, k, entry) {
49
67
  mkdirSync(dir, { recursive: true });
50
68
  // written whole and renamed, so a reader never sees half an entry
package/src/rule.js CHANGED
@@ -1,10 +1,9 @@
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";
4
+ import { statSync } from "node:fs";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
- import { cacheDir, key, read, reportable } from "./cache.js";
7
- import { marked } from "./marker.js";
6
+ import { cacheDir, fileKey, read, reportable } from "./cache.js";
8
7
  import { projectImages } from "./scan.js";
9
8
  import { flag, name, summarize, watch } from "./summary.js";
10
9
  import { fillInBackground, fillSync } from "./sync.js";
@@ -26,7 +25,6 @@ const EDITOR =
26
25
  process.env.ELECTRON_RUN_AS_NODE === "1";
27
26
 
28
27
  // Per process (the language server's lasts the whole editor session):
29
- const keys = new Map(); // image path -> { size, mtimeMs, target, key, marked }, so images aren't re-read on every keystroke
30
28
  const roots = new Map(); // directory -> its project root
31
29
  const scanned = new Set(); // `${root}\0${target}` whose images were all measured, or queued, once
32
30
  const failed = new Set(); // outside editors: roots whose batch failed, not tried again this run
@@ -139,15 +137,17 @@ export default {
139
137
  TemplateLiteral(node) {
140
138
  if (node.expressions.length === 0) check(node, node.quasis[0].value.cooked ?? "");
141
139
  },
142
- // What this file names is measured, then reported. The first time, every image in the
143
- // 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.
144
143
  "Program:exit"() {
145
- if (found.length === 0) return;
146
- const missing = measure === "never" ? [] : [...new Set(found.filter(({ k }) => !read(dir, k)).map(({ file }) => file))];
147
- if (missing.length) {
148
- const group = `${root}\0${target}`;
149
- const files = scanned.has(group) ? missing : [...new Set([...missing, ...projectImages(root, minSavedBytes)])];
150
- 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) {
151
151
  if (!wait) fillInBackground({ cwd: root, target, files });
152
152
  else if (!failed.has(root)) measureNow(files);
153
153
  }
@@ -165,9 +165,11 @@ export default {
165
165
  const image = fileKey(file, target);
166
166
  return image && !image.marked && !read(dir, image.key);
167
167
  }).length;
168
+ // everything cached (or pngdeer's own output, which needs no engine): nothing to wait for
169
+ if (!fresh) return;
168
170
  // 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"}`);
171
+ const tty = Boolean(process.stderr.isTTY);
172
+ process.stderr.write(`pngdeer: measuring ${count(fresh, "image")} not in the cache yet...${tty ? "" : "\n"}`);
171
173
  try {
172
174
  const { computed, ms } = fillSync({ cwd: root, target, files, progress: tty });
173
175
  if (tty) process.stderr.write("\r\x1b[K");
@@ -252,21 +254,6 @@ function projectRoot(dir, fallback) {
252
254
  return roots.get(dir);
253
255
  }
254
256
 
255
- /** An image's size, the cache key of its current bytes and whether pngdeer wrote it; null if it can't be read. */
256
- function fileKey(file, target) {
257
- try {
258
- const { size, mtimeMs } = statSync(file);
259
- const memo = keys.get(file);
260
- if (memo && memo.size === size && memo.mtimeMs === mtimeMs && memo.target === target) return memo;
261
- const bytes = readFileSync(file);
262
- const image = { size, mtimeMs, target, key: key(bytes, target), marked: marked(bytes) };
263
- keys.set(file, image);
264
- return image;
265
- } catch {
266
- return null;
267
- }
268
- }
269
-
270
257
  function isFile(path) {
271
258
  try {
272
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 CHANGED
@@ -1,18 +1,24 @@
1
1
  // What a lint flagged, in all: a table at the end of the run, biggest saving first, and the list that
2
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.
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
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";
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
10
 
11
11
  // Rows shown per table; the rest are counted, and --flagged still takes them all.
12
12
  const ROWS = 10;
13
13
  const UNNAMED_ROWS = 5;
14
14
  // Formats other programs read: the table on stderr would only get in the way.
15
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
16
22
 
17
23
  const flagged = new Map(); // image path -> { original, compressed, score, target }
18
24
  const named = new Set(); // every image linted code names, flagged or not
@@ -64,11 +70,19 @@ export function summarize(cwd = process.cwd()) {
64
70
 
65
71
  const unnamed = unnamedImages();
66
72
  if (images.length === 0 && unnamed.length === 0) return;
73
+ const shownUnnamed = unnamed.slice(0, UNNAMED_ROWS);
67
74
  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))}`;
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();
72
86
  const total = (list) => {
73
87
  const original = list.reduce((sum, [, image]) => sum + image.original, 0);
74
88
  const compressed = list.reduce((sum, [, image]) => sum + image.compressed, 0);
@@ -77,14 +91,16 @@ export function summarize(cwd = process.cwd()) {
77
91
  const lines = [];
78
92
  if (images.length) {
79
93
  const { saved, detail } = total(images);
80
- lines.push(`pngdeer: ${count(images.length, "flagged image")} could be ${saved} smaller in all (${detail}):`, line(rows[0], " "));
94
+ lines.push(`pngdeer: ${count(images.length, "flagged image")} could be ${saved} smaller in all (${detail}):`, line(header, " "));
81
95
  lines.push(...rows.slice(1, 1 + Math.min(images.length, ROWS)).map((cells) => line(cells)));
82
96
  if (images.length > ROWS) lines.push(` …and ${count(images.length - ROWS, "more image")}, ${total(images.slice(ROWS)).saved} in all`);
83
97
  }
84
98
  if (unnamed.length) {
99
+ // no warning can point at these: their only mention is in CSS, HTML and the like, or nowhere
85
100
  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], " "));
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, " "));
88
104
  lines.push(...rows.slice(1 + Math.min(images.length, ROWS)).map((cells) => line(cells)));
89
105
  if (unnamed.length > UNNAMED_ROWS) lines.push(` …and ${count(unnamed.length - UNNAMED_ROWS, "more image")}`);
90
106
  }
@@ -92,28 +108,50 @@ export function summarize(cwd = process.cwd()) {
92
108
  if (images.length) {
93
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`);
94
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
+ }
95
119
  process.stderr.write(`${lines.join("\n")}\n`);
96
120
  }
97
121
 
98
122
  /**
99
123
  * 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.
124
+ * thresholds: [[path, entry, root]], biggest saving first. Reads only results already cached.
101
125
  */
102
126
  function unnamedImages() {
103
127
  const found = [];
104
128
  for (const [root, options] of roots) {
105
129
  for (const file of projectImages(root, options.minSavedBytes)) {
106
130
  if (named.has(file) || flagged.has(file)) continue;
107
- let entry;
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) => {
108
146
  try {
109
- entry = read(cacheDir(root), key(readFileSync(file), options.target));
147
+ return [{ path, text: readFileSync(path, "utf8") }];
110
148
  } catch {
111
- continue;
149
+ return [];
112
150
  }
113
- if (reportable(entry, options)) found.push([file, entry]);
114
- }
151
+ }));
115
152
  }
116
- return found.sort(([, a], [, b]) => b.original - b.compressed - (a.original - a.compressed));
153
+ const name = basename(image);
154
+ return pages.get(root).find(({ text }) => text.includes(name))?.path ?? null;
117
155
  }
118
156
 
119
157
  /** The linter's output format, from its command line: `-f x`, `--format x` or `--format=x`. */