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 +5 -3
- package/package.json +1 -1
- package/src/cache.js +19 -1
- package/src/rule.js +16 -29
- package/src/scan.js +14 -7
- package/src/summary.js +58 -20
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
|
|
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.
|
|
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.
|
|
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 {
|
|
4
|
+
import { statSync } from "node:fs";
|
|
5
5
|
import { basename, dirname, join, resolve } from "node:path";
|
|
6
|
-
import { cacheDir,
|
|
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
|
|
143
|
-
//
|
|
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
|
-
|
|
146
|
-
const
|
|
147
|
-
if (
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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(
|
|
170
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
// by name, not by reading .gitignore: dot folders,
|
|
4
|
-
// as visual-regression snapshots, which are images
|
|
5
|
-
// package.json is another project, with its own cache,
|
|
1
|
+
// Every PNG and JPEG in a project, to measure them all in one parallel batch on a run's first file
|
|
2
|
+
// (later references then find them cached), and the other files the summary searches for images no
|
|
3
|
+
// linted code names. Folders are skipped by name, not by reading .gitignore: dot folders,
|
|
4
|
+
// dependencies, build output, and test output such as visual-regression snapshots, which are images
|
|
5
|
+
// too but never shipped. A folder with its own package.json is another project, with its own cache,
|
|
6
|
+
// and is left to it.
|
|
6
7
|
import { readdirSync, statSync } from "node:fs";
|
|
7
8
|
import { extname, join } from "node:path";
|
|
8
9
|
import { EXTENSIONS } from "./images.js";
|
|
@@ -15,6 +16,11 @@ const SKIP = new Set([
|
|
|
15
16
|
|
|
16
17
|
/** The project's images of at least `minSize` bytes. */
|
|
17
18
|
export function projectImages(root, minSize = 0) {
|
|
19
|
+
return projectFiles(root, EXTENSIONS, minSize);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The project's files with one of `extensions`, from `minSize` to `maxSize` bytes. */
|
|
23
|
+
export function projectFiles(root, extensions, minSize = 0, maxSize = Infinity) {
|
|
18
24
|
const found = [];
|
|
19
25
|
const walk = (dir) => {
|
|
20
26
|
let entries;
|
|
@@ -30,8 +36,9 @@ export function projectImages(root, minSize = 0) {
|
|
|
30
36
|
if (entry.isDirectory()) {
|
|
31
37
|
// Playwright keeps a test's snapshots in "<test file>-snapshots"
|
|
32
38
|
if (!SKIP.has(entry.name) && !entry.name.endsWith("-snapshots")) walk(path);
|
|
33
|
-
} else if (entry.isFile() &&
|
|
34
|
-
|
|
39
|
+
} else if (entry.isFile() && extensions.includes(extname(entry.name).toLowerCase())) {
|
|
40
|
+
const bytes = size(path);
|
|
41
|
+
if (bytes >= minSize && bytes <= maxSize) found.push(path);
|
|
35
42
|
}
|
|
36
43
|
}
|
|
37
44
|
};
|
package/src/summary.js
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
|
|
4
|
-
//
|
|
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,
|
|
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
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
|
|
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(
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
+
return [{ path, text: readFileSync(path, "utf8") }];
|
|
110
148
|
} catch {
|
|
111
|
-
|
|
149
|
+
return [];
|
|
112
150
|
}
|
|
113
|
-
|
|
114
|
-
}
|
|
151
|
+
}));
|
|
115
152
|
}
|
|
116
|
-
|
|
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`. */
|