lake-cimg 1.0.0 → 1.0.2
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 +1 -34
- package/bin/cimg.js +43 -0
- package/lib/compress.js +7 -28
- package/lib/pictureStack.js +14 -30
- package/lib/scanCodeReferences.js +110 -10
- package/lib/sharpHelpers.js +67 -0
- package/package.json +4 -7
- package/skills/README.md +0 -40
- package/skills/cimg-audit/SKILL.md +0 -63
- package/skills/cimg-audit/reference.md +0 -49
package/README.md
CHANGED
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
- 处理过程输出体积对比;失败时非零退出码
|
|
11
11
|
- **`picture` 子命令**:从单张 PNG/JPEG/WebP 源图一次生成 **AVIF + WebP + JPEG**,配合前端 `<picture>` 做渐进增强(不支持 GIF 动图源)
|
|
12
12
|
- **`scan-code` 子命令**:扫描源码中的图片引用并结合真实像素尺寸给出 CLS / 比例等建议(只读)
|
|
13
|
-
- **Agent Skill**:通过 [`npx skills add`](https://www.npmjs.com/package/skills) 安装 [`skills/cimg-audit`](skills/cimg-audit/SKILL.md)(技能名 **`cimg-audit`**,好记),在 Cursor 等环境里用自然语言驱动 `npx lake-cimg@latest …`
|
|
14
13
|
|
|
15
14
|
## 环境要求
|
|
16
15
|
|
|
@@ -156,38 +155,6 @@ npx lake-cimg@latest scan-code [path] [--no-recursive] [--limit <n>] [--issues-o
|
|
|
156
155
|
|
|
157
156
|
适合在构建脚本或 Node 服务中复用同一套逻辑。
|
|
158
157
|
|
|
159
|
-
## Agent Skill(Skills CLI / `npx skills add`)
|
|
160
|
-
|
|
161
|
-
本仓库按 [Skills CLI](https://www.npmjs.com/package/skills) 约定提供可安装 Skill,见目录 [`skills/`](skills/README.md)(与 [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills) 的 `skills/<name>/SKILL.md` 布局一致)。技能目录名为 **`cimg-audit`**(短、与包名一致,便于 `-s cimg-audit`)。
|
|
162
|
-
|
|
163
|
-
**何时需要 / 触发条件(选用本 Skill 的典型场景)**
|
|
164
|
-
|
|
165
|
-
- 对话或任务涉及:**图片体积与格式**(WebP/AVIF)、**`<picture>` / `srcset`**、**LCP / CLS / layout shift**、首屏大图
|
|
166
|
-
- 要先做**只读**引用审计再改代码:用 **`npx lake-cimg@latest scan-code`** 出 JSON,再按需改标签或压缩
|
|
167
|
-
- 希望 Agent **按固定流程**:先 `scan-code` → 再按需 `npx lake-cimg@latest` 压缩或 `picture` 多格式输出
|
|
168
|
-
|
|
169
|
-
更细的英文说明见 [`skills/README.md` 的「When to use」一节](skills/README.md#when-to-use)。
|
|
170
|
-
|
|
171
|
-
**从 GitHub 安装**(需已推送;将 `lake0090/lake-cimg` 换成你的 fork 若不同):
|
|
172
|
-
|
|
173
|
-
```bash
|
|
174
|
-
# 安装到用户级(-g),仅本 skill(-s),跳过确认(-y)
|
|
175
|
-
npx skills add lake0090/lake-cimg -s cimg-audit -g -y
|
|
176
|
-
|
|
177
|
-
# 仅安装到当前项目
|
|
178
|
-
npx skills add lake0090/lake-cimg -s cimg-audit -y
|
|
179
|
-
```
|
|
180
|
-
|
|
181
|
-
**本地克隆开发时**(在仓库根目录执行):
|
|
182
|
-
|
|
183
|
-
```bash
|
|
184
|
-
npx skills add . -s cimg-audit -y
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
可选:`--agent cursor` 等,见 `npx skills add --help`。Skill 正文在 [`skills/cimg-audit/SKILL.md`](skills/cimg-audit/SKILL.md),指导用 **`npx lake-cimg@latest scan-code`** 与压缩 / `picture` 子命令配合使用。
|
|
188
|
-
|
|
189
|
-
安装 Skill 后,在 Agent 对话里可直接让模型按 Skill 流程执行(例如:先 `scan-code` 再按需压缩);CLI 与 `npx` 均在本地执行,图片不会上传到云端。
|
|
190
|
-
|
|
191
158
|
## 常见问题
|
|
192
159
|
|
|
193
160
|
- **写权限**:`-o` 指向的目录需可创建/写入;否则 sharp 或 `fs` 会报错。
|
|
@@ -198,7 +165,7 @@ npx skills add . -s cimg-audit -y
|
|
|
198
165
|
|
|
199
166
|
- **Lighthouse 审计**:后续接入 [Lighthouse](https://developer.chrome.com/docs/lighthouse)(或 CI 中的 Lighthouse CI),对典型页面做性能 / 最佳实践等审计。
|
|
200
167
|
- **审计前后对比**:在引入 `scan-code`、压缩、`picture` 等优化前后各跑一轮,保存报告(JSON/HTML),对比 LCP、CLS、资源体积等指标,量化改动效果。
|
|
201
|
-
- **专项优化**:根据 Lighthouse 报告中的具体项(如 LCP 候选、未使用 CSS、图片尺寸等)做针对性迭代,与现有 CLI
|
|
168
|
+
- **专项优化**:根据 Lighthouse 报告中的具体项(如 LCP 候选、未使用 CSS、图片尺寸等)做针对性迭代,与现有 CLI 工作流互补。
|
|
202
169
|
|
|
203
170
|
## 发布到 npm(维护者)
|
|
204
171
|
|
package/bin/cimg.js
CHANGED
|
@@ -217,6 +217,20 @@ program
|
|
|
217
217
|
}
|
|
218
218
|
});
|
|
219
219
|
|
|
220
|
+
function collectString(val, memo) {
|
|
221
|
+
const arr = memo ?? [];
|
|
222
|
+
arr.push(val);
|
|
223
|
+
return arr;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function parseAliasPair(val) {
|
|
227
|
+
const i = val.indexOf("=");
|
|
228
|
+
if (i <= 0) {
|
|
229
|
+
throw new Error("格式须为 key=相对路径,例如 @=src 或 @assets=src/assets");
|
|
230
|
+
}
|
|
231
|
+
return [val.slice(0, i).trim(), val.slice(i + 1).trim()];
|
|
232
|
+
}
|
|
233
|
+
|
|
220
234
|
program
|
|
221
235
|
.command("scan-code [path]")
|
|
222
236
|
.description(
|
|
@@ -231,17 +245,46 @@ program
|
|
|
231
245
|
500
|
|
232
246
|
)
|
|
233
247
|
.option("--issues-only", "仅输出含 issues 的条目")
|
|
248
|
+
.option(
|
|
249
|
+
"--project-root <dir>",
|
|
250
|
+
"解析 /images/...、@/... 时的工程根目录(默认当前工作目录)"
|
|
251
|
+
)
|
|
252
|
+
.option(
|
|
253
|
+
"--public-dir <dir>",
|
|
254
|
+
"根路径下静态目录,可重复;用于解析以 / 开头的 URL 路径(默认 public、static)",
|
|
255
|
+
collectString
|
|
256
|
+
)
|
|
257
|
+
.option(
|
|
258
|
+
"--alias <pair>",
|
|
259
|
+
"路径别名 key=相对工程根的路径,可重复;默认 @=src。例: --alias @aaa=packages/aaa",
|
|
260
|
+
(v, prev) => {
|
|
261
|
+
const pair = parseAliasPair(v);
|
|
262
|
+
return [...(prev ?? []), pair];
|
|
263
|
+
},
|
|
264
|
+
[]
|
|
265
|
+
)
|
|
234
266
|
.action(async (pathArg, opts) => {
|
|
235
267
|
const raw =
|
|
236
268
|
pathArg != null && typeof pathArg === "string" ? pathArg.trim() : "";
|
|
237
269
|
const root = resolveUserPath(raw !== "" ? raw : ".");
|
|
238
270
|
const limit = Number.isNaN(opts.limit) ? 500 : opts.limit;
|
|
271
|
+
const aliasList = opts.alias ?? [];
|
|
272
|
+
const aliases =
|
|
273
|
+
aliasList.length > 0 ? Object.fromEntries(aliasList) : undefined;
|
|
274
|
+
const publicDirs = opts.publicDir;
|
|
239
275
|
try {
|
|
240
276
|
const result = await scanCodeReferences(root, {
|
|
241
277
|
recursive: opts.recursive !== false,
|
|
242
278
|
cwd: process.cwd(),
|
|
243
279
|
limit,
|
|
244
280
|
issuesOnly: !!opts.issuesOnly,
|
|
281
|
+
...(opts.projectRoot != null && String(opts.projectRoot).trim() !== ""
|
|
282
|
+
? { projectRoot: String(opts.projectRoot).trim() }
|
|
283
|
+
: {}),
|
|
284
|
+
...(aliases != null ? { aliases } : {}),
|
|
285
|
+
...(publicDirs != null && publicDirs.length > 0
|
|
286
|
+
? { publicDirs }
|
|
287
|
+
: {}),
|
|
245
288
|
});
|
|
246
289
|
console.log(JSON.stringify(result, null, 2));
|
|
247
290
|
process.exit(0);
|
package/lib/compress.js
CHANGED
|
@@ -9,9 +9,13 @@ import sharp from "sharp";
|
|
|
9
9
|
import {
|
|
10
10
|
SUPPORTED_EXT,
|
|
11
11
|
DEFAULT_QUALITY,
|
|
12
|
-
DEFAULT_EFFORT,
|
|
13
12
|
supportedFormatsLabel,
|
|
14
13
|
} from "./constants.js";
|
|
14
|
+
import {
|
|
15
|
+
applyResizeInside,
|
|
16
|
+
encodeWebpBuffer,
|
|
17
|
+
encodeJpegBuffer,
|
|
18
|
+
} from "./sharpHelpers.js";
|
|
15
19
|
|
|
16
20
|
/** Max concurrent tasks for processing images */
|
|
17
21
|
const MAX_CONCURRENCY = Math.max(1, cpus().length);
|
|
@@ -59,23 +63,6 @@ function isSupported(filePath) {
|
|
|
59
63
|
return SUPPORTED_EXT.includes(extname(filePath).toLowerCase());
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
/**
|
|
63
|
-
* Encode pipeline to WebP buffer with shared sharp options.
|
|
64
|
-
* @param {import("sharp").Sharp} pipeline
|
|
65
|
-
* @param {number} quality
|
|
66
|
-
* @param {{ lossless?: boolean }} [options]
|
|
67
|
-
*/
|
|
68
|
-
async function encodeWebpBuffer(pipeline, quality, { lossless = false } = {}) {
|
|
69
|
-
return pipeline
|
|
70
|
-
.webp({
|
|
71
|
-
quality,
|
|
72
|
-
effort: DEFAULT_EFFORT,
|
|
73
|
-
smartSubsample: true,
|
|
74
|
-
lossless,
|
|
75
|
-
})
|
|
76
|
-
.toBuffer();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
66
|
/**
|
|
80
67
|
* Recursively collect image paths under dir (or return [dir] if dir is a file).
|
|
81
68
|
* @param {string} inputPath - Absolute path to file or directory
|
|
@@ -135,15 +122,7 @@ export async function processOne(inputPath, options = {}) {
|
|
|
135
122
|
let pipeline = sharp(inputBuffer, { animated: true });
|
|
136
123
|
const inputMeta = await pipeline.metadata();
|
|
137
124
|
|
|
138
|
-
|
|
139
|
-
pipeline = pipeline.resize({
|
|
140
|
-
width: size,
|
|
141
|
-
height: size,
|
|
142
|
-
fit: "inside",
|
|
143
|
-
withoutEnlargement: true,
|
|
144
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
145
|
-
});
|
|
146
|
-
}
|
|
125
|
+
pipeline = applyResizeInside(pipeline, size);
|
|
147
126
|
|
|
148
127
|
const outputBuffer = toWebp
|
|
149
128
|
? await encodeWebpBuffer(pipeline, quality, { lossless: false })
|
|
@@ -172,7 +151,7 @@ async function toFormatBuffer(pipeline, ext, quality) {
|
|
|
172
151
|
switch (ext) {
|
|
173
152
|
case ".jpg":
|
|
174
153
|
case ".jpeg":
|
|
175
|
-
return pipeline
|
|
154
|
+
return encodeJpegBuffer(pipeline, quality);
|
|
176
155
|
case ".png":
|
|
177
156
|
return pipeline.png({ compressionLevel: 9 }).toBuffer();
|
|
178
157
|
case ".webp":
|
package/lib/pictureStack.js
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
import { readFile, writeFile, mkdir, stat } from "fs/promises";
|
|
6
6
|
import { dirname, join, resolve, basename, extname } from "path";
|
|
7
7
|
import sharp from "sharp";
|
|
8
|
-
import { DEFAULT_QUALITY
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
import { DEFAULT_QUALITY } from "./constants.js";
|
|
9
|
+
import {
|
|
10
|
+
applyResizeInside,
|
|
11
|
+
encodeAvifBuffer,
|
|
12
|
+
encodeWebpBuffer,
|
|
13
|
+
encodeJpegBuffer,
|
|
14
|
+
} from "./sharpHelpers.js";
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* @param {import("sharp").Sharp} pipeline - Configured pipeline (e.g. after resize)
|
|
@@ -16,22 +19,9 @@ const DEFAULT_AVIF_EFFORT = 4;
|
|
|
16
19
|
*/
|
|
17
20
|
async function encodeTriple(pipeline, q) {
|
|
18
21
|
const [avifBuf, webpBuf, jpegBuf] = await Promise.all([
|
|
19
|
-
pipeline
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.toBuffer(),
|
|
23
|
-
pipeline
|
|
24
|
-
.clone()
|
|
25
|
-
.webp({
|
|
26
|
-
quality: q.quality,
|
|
27
|
-
effort: DEFAULT_EFFORT,
|
|
28
|
-
smartSubsample: true,
|
|
29
|
-
})
|
|
30
|
-
.toBuffer(),
|
|
31
|
-
pipeline
|
|
32
|
-
.clone()
|
|
33
|
-
.jpeg({ quality: q.jpegQuality, mozjpeg: true })
|
|
34
|
-
.toBuffer(),
|
|
22
|
+
encodeAvifBuffer(pipeline.clone(), q.avifQuality),
|
|
23
|
+
encodeWebpBuffer(pipeline.clone(), q.quality),
|
|
24
|
+
encodeJpegBuffer(pipeline.clone(), q.jpegQuality),
|
|
35
25
|
]);
|
|
36
26
|
return { avifBuf, webpBuf, jpegBuf };
|
|
37
27
|
}
|
|
@@ -85,16 +75,10 @@ export async function processPictureStack(inputPath, options) {
|
|
|
85
75
|
const inputBuffer = await readFile(absoluteIn);
|
|
86
76
|
const sizeBefore = inputBuffer.length;
|
|
87
77
|
|
|
88
|
-
let pipeline =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
height: size,
|
|
93
|
-
fit: "inside",
|
|
94
|
-
withoutEnlargement: true,
|
|
95
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
96
|
-
});
|
|
97
|
-
}
|
|
78
|
+
let pipeline = applyResizeInside(
|
|
79
|
+
sharp(inputBuffer, { animated: false }),
|
|
80
|
+
size
|
|
81
|
+
);
|
|
98
82
|
|
|
99
83
|
const { avifBuf, webpBuf, jpegBuf } = await encodeTriple(pipeline, {
|
|
100
84
|
quality,
|
|
@@ -40,6 +40,40 @@ const DEFAULT_EXCLUDE_DIRS = new Set([
|
|
|
40
40
|
/** Relative ratio difference threshold (1% = 0.01) */
|
|
41
41
|
const DEFAULT_RATIO_TOLERANCE = 0.01;
|
|
42
42
|
|
|
43
|
+
/** Try these folders under projectRoot for URL paths like `/images/a.png` (public 静态资源) */
|
|
44
|
+
const DEFAULT_PUBLIC_DIRS = ["public", "static"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} ref
|
|
48
|
+
* @param {Record<string, string>} aliases
|
|
49
|
+
* @returns {object | null}
|
|
50
|
+
*/
|
|
51
|
+
function matchAliasRef(ref, aliases) {
|
|
52
|
+
const keys = Object.keys(aliases)
|
|
53
|
+
.filter((k) => k !== "~")
|
|
54
|
+
.sort((a, b) => b.length - a.length);
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const prefix = `${key}/`;
|
|
57
|
+
if (ref.startsWith(prefix)) {
|
|
58
|
+
return { key, rest: ref.slice(prefix.length) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} abs
|
|
66
|
+
* @returns {Promise<boolean>}
|
|
67
|
+
*/
|
|
68
|
+
async function isExistingFile(abs) {
|
|
69
|
+
try {
|
|
70
|
+
const s = await stat(abs);
|
|
71
|
+
return s.isFile();
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
43
77
|
/**
|
|
44
78
|
* @param {string} text
|
|
45
79
|
* @param {number} index
|
|
@@ -313,11 +347,17 @@ function extractPugImgRefs(content) {
|
|
|
313
347
|
|
|
314
348
|
/**
|
|
315
349
|
* @param {string} ref
|
|
316
|
-
* @param {string} hostDir
|
|
317
|
-
* @
|
|
350
|
+
* @param {string} hostDir - 源码文件所在目录(相对路径 ./ ../ 以此为基准)
|
|
351
|
+
* @param {{
|
|
352
|
+
* projectRoot: string,
|
|
353
|
+
* aliases: Record<string, string>,
|
|
354
|
+
* publicDirs: string[],
|
|
355
|
+
* }} opts
|
|
356
|
+
* @returns {Promise<{ status: 'ok', path: string } | { status: 'skip', reason: string }>}
|
|
318
357
|
*/
|
|
319
|
-
function resolveLocalImageRef(ref, hostDir) {
|
|
320
|
-
const
|
|
358
|
+
async function resolveLocalImageRef(ref, hostDir, opts) {
|
|
359
|
+
const { projectRoot, aliases, publicDirs } = opts;
|
|
360
|
+
let trimmed = ref.trim();
|
|
321
361
|
if (!trimmed) return { status: "skip", reason: "empty" };
|
|
322
362
|
if (/^https?:\/\//i.test(trimmed)) {
|
|
323
363
|
return { status: "skip", reason: "remote_url" };
|
|
@@ -325,14 +365,57 @@ function resolveLocalImageRef(ref, hostDir) {
|
|
|
325
365
|
if (trimmed.startsWith("data:")) {
|
|
326
366
|
return { status: "skip", reason: "data_uri" };
|
|
327
367
|
}
|
|
328
|
-
|
|
329
|
-
|
|
368
|
+
|
|
369
|
+
if (trimmed.startsWith("~@/")) {
|
|
370
|
+
trimmed = `@/${trimmed.slice(3)}`;
|
|
330
371
|
}
|
|
372
|
+
|
|
373
|
+
if (trimmed.startsWith("/") && !trimmed.startsWith("//")) {
|
|
374
|
+
const relFromWebRoot = trimmed.replace(/^\/+/, "");
|
|
375
|
+
for (const pd of publicDirs) {
|
|
376
|
+
const candidate = resolve(projectRoot, pd, relFromWebRoot);
|
|
377
|
+
if (await isExistingFile(candidate)) {
|
|
378
|
+
return { status: "ok", path: candidate };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { status: "skip", reason: "public_path_not_found" };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (trimmed.startsWith("~/")) {
|
|
385
|
+
const tildeBase = aliases["~"];
|
|
386
|
+
if (tildeBase == null || tildeBase === "") {
|
|
387
|
+
return { status: "skip", reason: "alias_path" };
|
|
388
|
+
}
|
|
389
|
+
const rest = trimmed.slice(2);
|
|
390
|
+
const candidate = resolve(projectRoot, tildeBase, rest);
|
|
391
|
+
if (await isExistingFile(candidate)) {
|
|
392
|
+
return { status: "ok", path: candidate };
|
|
393
|
+
}
|
|
394
|
+
return { status: "skip", reason: "alias_target_not_found" };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const aliasHit = matchAliasRef(trimmed, aliases);
|
|
398
|
+
if (aliasHit) {
|
|
399
|
+
const baseRel = aliases[aliasHit.key];
|
|
400
|
+
if (baseRel == null || baseRel === "") {
|
|
401
|
+
return { status: "skip", reason: "unknown_alias" };
|
|
402
|
+
}
|
|
403
|
+
const candidate = resolve(projectRoot, baseRel, aliasHit.rest);
|
|
404
|
+
if (await isExistingFile(candidate)) {
|
|
405
|
+
return { status: "ok", path: candidate };
|
|
406
|
+
}
|
|
407
|
+
return { status: "skip", reason: "alias_target_not_found" };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (trimmed.startsWith("@") && trimmed.includes("/")) {
|
|
411
|
+
return { status: "skip", reason: "unknown_alias" };
|
|
412
|
+
}
|
|
413
|
+
|
|
331
414
|
if (!IMG_EXT_RE.test(trimmed)) {
|
|
332
415
|
return { status: "skip", reason: "not_image_extension" };
|
|
333
416
|
}
|
|
334
|
-
const
|
|
335
|
-
return { status: "ok", path };
|
|
417
|
+
const pathAbs = resolve(hostDir, trimmed);
|
|
418
|
+
return { status: "ok", path: pathAbs };
|
|
336
419
|
}
|
|
337
420
|
|
|
338
421
|
/**
|
|
@@ -515,6 +598,9 @@ async function runPool(tasks, concurrency) {
|
|
|
515
598
|
* issuesOnly?: boolean,
|
|
516
599
|
* ratioTolerance?: number,
|
|
517
600
|
* metadataConcurrency?: number,
|
|
601
|
+
* projectRoot?: string,
|
|
602
|
+
* aliases?: Record<string, string>,
|
|
603
|
+
* publicDirs?: string[],
|
|
518
604
|
* }} [options]
|
|
519
605
|
*/
|
|
520
606
|
export async function scanCodeReferences(rootDir, options = {}) {
|
|
@@ -525,8 +611,22 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
525
611
|
issuesOnly = false,
|
|
526
612
|
ratioTolerance = DEFAULT_RATIO_TOLERANCE,
|
|
527
613
|
metadataConcurrency = 8,
|
|
614
|
+
projectRoot: projectRootOpt,
|
|
615
|
+
aliases: aliasesOpt,
|
|
616
|
+
publicDirs: publicDirsOpt,
|
|
528
617
|
} = options;
|
|
529
618
|
|
|
619
|
+
const projectRoot = resolve(cwd, projectRootOpt != null ? projectRootOpt : ".");
|
|
620
|
+
const aliases = {
|
|
621
|
+
"@": "src",
|
|
622
|
+
...(aliasesOpt && typeof aliasesOpt === "object" ? aliasesOpt : {}),
|
|
623
|
+
};
|
|
624
|
+
const publicDirs =
|
|
625
|
+
Array.isArray(publicDirsOpt) && publicDirsOpt.length > 0
|
|
626
|
+
? publicDirsOpt
|
|
627
|
+
: DEFAULT_PUBLIC_DIRS;
|
|
628
|
+
const resolveOpts = { projectRoot, aliases, publicDirs };
|
|
629
|
+
|
|
530
630
|
const sourceExts =
|
|
531
631
|
options.sourceExts != null
|
|
532
632
|
? new Set(
|
|
@@ -602,7 +702,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
602
702
|
for (const { absHost, ref } of staged) {
|
|
603
703
|
if (ref.parse?.dynamic || ref.parse?.missingSrc) continue;
|
|
604
704
|
if (!ref.rawRef) continue;
|
|
605
|
-
const res = resolveLocalImageRef(ref.rawRef, absHost);
|
|
705
|
+
const res = await resolveLocalImageRef(ref.rawRef, absHost, resolveOpts);
|
|
606
706
|
if (res.status === "ok") uniquePaths.add(res.path);
|
|
607
707
|
}
|
|
608
708
|
|
|
@@ -691,7 +791,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
691
791
|
continue;
|
|
692
792
|
}
|
|
693
793
|
|
|
694
|
-
const resolved = resolveLocalImageRef(r.rawRef, absHost);
|
|
794
|
+
const resolved = await resolveLocalImageRef(r.rawRef, absHost, resolveOpts);
|
|
695
795
|
if (resolved.status === "skip") {
|
|
696
796
|
const row = {
|
|
697
797
|
file,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Sharp pipeline helpers (resize + encode) for compress and picture stack.
|
|
3
|
+
*/
|
|
4
|
+
import { DEFAULT_EFFORT } from "./constants.js";
|
|
5
|
+
|
|
6
|
+
const RESIZE_BACKGROUND = { r: 0, g: 0, b: 0, alpha: 0 };
|
|
7
|
+
|
|
8
|
+
/** AVIF effort 0–9 (higher = slower, often smaller) */
|
|
9
|
+
export const DEFAULT_AVIF_EFFORT = 4;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Apply "fit inside" square resize when size > 0; otherwise return pipeline unchanged.
|
|
13
|
+
* @param {import("sharp").Sharp} pipeline
|
|
14
|
+
* @param {number | null | undefined} size
|
|
15
|
+
* @returns {import("sharp").Sharp}
|
|
16
|
+
*/
|
|
17
|
+
export function applyResizeInside(pipeline, size) {
|
|
18
|
+
if (size == null || size <= 0) return pipeline;
|
|
19
|
+
return pipeline.resize({
|
|
20
|
+
width: size,
|
|
21
|
+
height: size,
|
|
22
|
+
fit: "inside",
|
|
23
|
+
withoutEnlargement: true,
|
|
24
|
+
background: RESIZE_BACKGROUND,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {import("sharp").Sharp} pipeline
|
|
30
|
+
* @param {number} quality
|
|
31
|
+
* @param {{ lossless?: boolean }} [options]
|
|
32
|
+
*/
|
|
33
|
+
export async function encodeWebpBuffer(
|
|
34
|
+
pipeline,
|
|
35
|
+
quality,
|
|
36
|
+
{ lossless = false } = {}
|
|
37
|
+
) {
|
|
38
|
+
return pipeline
|
|
39
|
+
.webp({
|
|
40
|
+
quality,
|
|
41
|
+
effort: DEFAULT_EFFORT,
|
|
42
|
+
smartSubsample: true,
|
|
43
|
+
lossless,
|
|
44
|
+
})
|
|
45
|
+
.toBuffer();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import("sharp").Sharp} pipeline
|
|
50
|
+
* @param {number} quality
|
|
51
|
+
*/
|
|
52
|
+
export async function encodeJpegBuffer(pipeline, quality) {
|
|
53
|
+
return pipeline.jpeg({ quality, mozjpeg: true }).toBuffer();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {import("sharp").Sharp} pipeline
|
|
58
|
+
* @param {number} quality
|
|
59
|
+
* @param {number} [effort]
|
|
60
|
+
*/
|
|
61
|
+
export async function encodeAvifBuffer(
|
|
62
|
+
pipeline,
|
|
63
|
+
quality,
|
|
64
|
+
effort = DEFAULT_AVIF_EFFORT
|
|
65
|
+
) {
|
|
66
|
+
return pipeline.avif({ quality, effort }).toBuffer();
|
|
67
|
+
}
|
package/package.json
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lake-cimg",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Batch image compression to WebP — CLI
|
|
3
|
+
"version": "1.0.2",
|
|
4
|
+
"description": "Batch image compression to WebP — CLI with picture stack and scan-code for frontend image audit",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"image",
|
|
7
7
|
"compress",
|
|
8
8
|
"webp",
|
|
9
9
|
"sharp",
|
|
10
|
-
"
|
|
11
|
-
"skills",
|
|
12
|
-
"cursor"
|
|
10
|
+
"cli"
|
|
13
11
|
],
|
|
14
12
|
"repository": {
|
|
15
13
|
"type": "git",
|
|
@@ -23,8 +21,7 @@
|
|
|
23
21
|
"files": [
|
|
24
22
|
"bin",
|
|
25
23
|
"lib",
|
|
26
|
-
"README.md"
|
|
27
|
-
"skills"
|
|
24
|
+
"README.md"
|
|
28
25
|
],
|
|
29
26
|
"publishConfig": {
|
|
30
27
|
"access": "public"
|
package/skills/README.md
DELETED
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
# Skills (Skills CLI)
|
|
2
|
-
|
|
3
|
-
This directory follows the layout expected by **[Skills CLI](https://www.npmjs.com/package/skills)** (`npx skills add`), same idea as [vercel-labs/agent-skills](https://github.com/vercel-labs/agent-skills): each subfolder is one skill with a `SKILL.md`.
|
|
4
|
-
|
|
5
|
-
| Skill | Summary |
|
|
6
|
-
| --- | --- |
|
|
7
|
-
| [cimg-audit](./cimg-audit/SKILL.md) | Audit `<img>` / Pug `img()` / imports / `url()` for CLS, aspect ratio, and format hints via `npx lake-cimg@latest scan-code` |
|
|
8
|
-
|
|
9
|
-
### When to use
|
|
10
|
-
|
|
11
|
-
Skill id **`cimg-audit`** — use it when the conversation or task touches:
|
|
12
|
-
|
|
13
|
-
- **Performance / layout:** CLS, layout shift, LCP, hero images, `fetchPriority`
|
|
14
|
-
- **Markup / assets:** `<img>` dimensions, `aspect-ratio`, `<picture>`, `srcset` / `sizes`, WebP / AVIF
|
|
15
|
-
- **Workflow:** run **`scan-code`** first (read-only JSON), then fix markup and/or compress with **`npx lake-cimg@latest`** / **`picture`**
|
|
16
|
-
|
|
17
|
-
The skill name `cimg-audit` is short for discovery; the YAML `description` in `SKILL.md` is what agents match against.
|
|
18
|
-
|
|
19
|
-
Install from GitHub (after push):
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
npx skills add lake0090/lake-cimg -s cimg-audit -g -y
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Install only this repo’s skill into the **current project** (run from another repo):
|
|
26
|
-
|
|
27
|
-
```bash
|
|
28
|
-
npx skills add lake0090/lake-cimg -s cimg-audit -y
|
|
29
|
-
```
|
|
30
|
-
|
|
31
|
-
Develop locally from a clone of this repository:
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
cd /path/to/lake-cimg
|
|
35
|
-
npx skills add . -s cimg-audit -y
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
Use `-g` for a user-level install (`~/.cursor/skills/` etc., depending on agent). See `npx skills add --help` for `--agent` (e.g. `cursor`).
|
|
39
|
-
|
|
40
|
-
Browse the registry at [skills.sh](https://skills.sh/).
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: cimg-audit
|
|
3
|
-
description: >-
|
|
4
|
-
Audits HTML, Vue, Pug, JS, TS, TSX, and JSX image references against intrinsic
|
|
5
|
-
dimensions for CLS risk, aspect-ratio mismatches, and modern-format hints.
|
|
6
|
-
Use when optimizing images, fixing layout shift, LCP heroes, picture/srcset,
|
|
7
|
-
or running lake-cimg scan-code via npx lake-cimg@latest.
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
# cimg image audit
|
|
11
|
-
|
|
12
|
-
Run **`npx lake-cimg@latest`** from the **project root** (no global install).
|
|
13
|
-
|
|
14
|
-
<a id="invoke-scan-code"></a>
|
|
15
|
-
|
|
16
|
-
## Invoke `scan-code`
|
|
17
|
-
|
|
18
|
-
Use **one** command starting with `npx` — **not** `cd … && npx …` (PowerShell 5.1 on Windows does not support `&&`).
|
|
19
|
-
|
|
20
|
-
| `path` | Behavior |
|
|
21
|
-
| --- | --- |
|
|
22
|
-
| *(omitted)* | Scans **`.`** (current working directory). Run from repo root to cover the tree. |
|
|
23
|
-
| **Directory** | Recursively scans supported sources under that folder (honors `--no-recursive`). |
|
|
24
|
-
| **Single file** | Only that file. Extension must be `.html`, `.htm`, `.vue`, `.pug`, `.js`, `.mjs`, `.cjs`, `.ts`, `.tsx`, or `.jsx`. |
|
|
25
|
-
|
|
26
|
-
**Output:** stdout is **only** pretty-printed JSON: `items[]` with `issues`, `hints`, `snippet`, `intrinsicWidth` / `intrinsicHeight` when metadata was read, plus top-level `summary` and scan metadata. **`--issues-only`** drops rows with empty `issues`. For **`missing_dimensions`**, use **`intrinsicWidth` / `intrinsicHeight`** on the same item and/or the English hint that repeats them.
|
|
27
|
-
|
|
28
|
-
**Examples:**
|
|
29
|
-
|
|
30
|
-
```bash
|
|
31
|
-
npx lake-cimg@latest scan-code
|
|
32
|
-
npx lake-cimg@latest scan-code /absolute/path/to/src
|
|
33
|
-
npx lake-cimg@latest scan-code /absolute/path/to/about.pug
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
More flags: `npx lake-cimg@latest scan-code --help` (e.g. `--limit`, `--issues-only`, `--no-recursive`). Prefer an **absolute** `path` if the shell cwd may not be the repo root.
|
|
37
|
-
|
|
38
|
-
## Workflow
|
|
39
|
-
|
|
40
|
-
- [ ] **1. Scan (read-only):** `npx lake-cimg@latest scan-code [path]` — see **Invoke `scan-code`** above.
|
|
41
|
-
- [ ] **2. Triage:** Prefer **`hints`** for what to change; use **`issues`** codes to group or filter. Common codes: `missing_dimensions`, `aspect_ratio_mismatch`, `suggest_modern_format` (consider AVIF/WebP + `<picture>` — match product/browser support), `needs_manual_review`, `missing_src`, `cannot_resolve`, `cannot_read_metadata`.
|
|
42
|
-
- [ ] **3. Fix then optimize:** Apply markup fixes using [reference.md](reference.md) templates (`<img>`, `<picture>`). For **all** raster refs that need format or responsive delivery, not only one hero row. Then run compression / stacks: `npx lake-cimg@latest <path> [options]`; for AVIF + WebP + JPEG from one source: `npx lake-cimg@latest picture <input> -O <outDir>` (details: package [README.md](../../README.md)).
|
|
43
|
-
|
|
44
|
-
## Rules of thumb
|
|
45
|
-
|
|
46
|
-
- **Aspect ratio:** Match display ratio to intrinsic (w÷h), or use **`object-fit`** + explicit box / `aspect-ratio` for crop/letterbox.
|
|
47
|
-
- **CLS:** Add `width` and `height` to `<img>`, or use CSS `aspect-ratio`. For `missing_dimensions`, fill in the exact `intrinsicWidth` / `intrinsicHeight`.
|
|
48
|
-
- **Responsive:** `<picture>` and/or **`srcset` + `sizes`**; each `<source type="…">` must match the real file type; Generate width variants with `picture … -s <px>` or separate exports.
|
|
49
|
-
- **LCP:** At most one hero per view: **`fetchPriority="high"`**, **`loading="eager"`**; lazy-load the rest.
|
|
50
|
-
- **Alt:** Describe content and purpose; no keyword stuffing; **`alt=""`** only for decorative images.
|
|
51
|
-
|
|
52
|
-
## What the scanner cannot resolve
|
|
53
|
-
|
|
54
|
-
Dynamic **`src`** without a static path → **`needs_manual_review`**. **`http(s):`**, **`data:`**, and path aliases (**`@/`**, **`~/`**, etc.) → **`cannot_resolve`** (no alias map reads; no network fetch).
|
|
55
|
-
|
|
56
|
-
If **`hints`** mention alias skip: use **`rawRef`** and map the alias via Vite/webpack/tsconfig/Nuxt config. With a **real filesystem path**, run `npx lake-cimg@latest scan <resolved-path>` on assets or re-run **`scan-code`** on markup that uses resolvable relative paths. If unresolved, triage without intrinsic dimensions.
|
|
57
|
-
|
|
58
|
-
## Other CLI (after scan-code)
|
|
59
|
-
|
|
60
|
-
| Command | Role |
|
|
61
|
-
| --- | --- |
|
|
62
|
-
| `npx lake-cimg@latest <path>` | Compress / WebP — see `npx lake-cimg@latest --help` (`-o`, `-s`, `-q`, `-r`). |
|
|
63
|
-
| `npx lake-cimg@latest picture <input> -O <outDir>` | One raster → AVIF + WebP + JPEG for `<picture>`. |
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# Image markup templates
|
|
2
|
-
|
|
3
|
-
Supplement for [SKILL.md](SKILL.md). Copy-paste starting points; adjust paths, dimensions, and `sizes` to match your assets and layout.
|
|
4
|
-
|
|
5
|
-
## Basic `<img>`
|
|
6
|
-
|
|
7
|
-
**When:** Single raster URL; reserve space for CLS. **Check:** `width` / `height` match intrinsic pixels (or use `aspect-ratio` for fluid); meaningful `alt` or `alt=""` if decorative.
|
|
8
|
-
|
|
9
|
-
```html
|
|
10
|
-
<img
|
|
11
|
-
src="photo.jpg"
|
|
12
|
-
width="1200"
|
|
13
|
-
height="800"
|
|
14
|
-
alt="Describe subject and purpose on this page"
|
|
15
|
-
loading="lazy"
|
|
16
|
-
decoding="async">
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
## `<picture>` + `srcset` + `sizes`
|
|
20
|
-
|
|
21
|
-
**When:** AVIF/WebP stack with JPEG (or similar) fallback; responsive widths. **Check:** Each `<source type="…">` matches the real file type; `sizes` matches breakpoints; `<img>` is the final fallback with matching `srcset` / `sizes`.
|
|
22
|
-
|
|
23
|
-
```html
|
|
24
|
-
<picture>
|
|
25
|
-
<source
|
|
26
|
-
type="image/avif"
|
|
27
|
-
srcset="hero-400.avif 400w,
|
|
28
|
-
hero-800.avif 800w,
|
|
29
|
-
hero-1200.avif 1200w"
|
|
30
|
-
sizes="(max-width: 600px) 100vw, 50vw">
|
|
31
|
-
<source
|
|
32
|
-
type="image/webp"
|
|
33
|
-
srcset="hero-400.webp 400w,
|
|
34
|
-
hero-800.webp 800w,
|
|
35
|
-
hero-1200.webp 1200w"
|
|
36
|
-
sizes="(max-width: 600px) 100vw, 50vw">
|
|
37
|
-
<img
|
|
38
|
-
src="hero-800.jpg"
|
|
39
|
-
srcset="hero-400.jpg 400w,
|
|
40
|
-
hero-800.jpg 800w,
|
|
41
|
-
hero-1200.jpg 1200w"
|
|
42
|
-
sizes="(max-width: 600px) 100vw, 50vw"
|
|
43
|
-
width="1200"
|
|
44
|
-
height="600"
|
|
45
|
-
alt="Describe subject and purpose on this page"
|
|
46
|
-
loading="lazy"
|
|
47
|
-
decoding="async">
|
|
48
|
-
</picture>
|
|
49
|
-
```
|