lake-cimg 0.0.2 → 1.0.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 +8 -10
- package/bin/cimg.js +7 -19
- package/lib/scanCodeReferences.js +220 -19
- package/package.json +3 -2
- package/skills/README.md +1 -1
- package/skills/cimg-audit/SKILL.md +37 -31
- package/skills/cimg-audit/reference.md +16 -62
package/README.md
CHANGED
|
@@ -103,24 +103,24 @@ npx lake-cimg@latest picture <input> -O <outDir> [选项]
|
|
|
103
103
|
|
|
104
104
|
### 子命令:`scan-code`(源码中的图片引用)
|
|
105
105
|
|
|
106
|
-
在 **html / htm / vue / js / mjs / cjs / ts / tsx / jsx** 中查找图片引用(`<img
|
|
106
|
+
在 **html / htm / vue / pug / js / mjs / cjs / ts / tsx / jsx** 中查找图片引用(`<img>`、Pug `img(src=…)`、`import … from '…png'`、`new URL('…', import.meta.url)`、`url(…)` 等),将**可解析的本地路径**与 **sharp 读出的真实宽高**对比,只读输出 JSON(默认),用于:
|
|
107
107
|
|
|
108
108
|
- **CLS**:缺少 `width`/`height` 且无 `aspect-ratio` 等占位
|
|
109
109
|
- **比例**:声明的宽高比与素材像素比不一致(未使用 `object-fit: cover|contain|fill` 等时标为问题)
|
|
110
110
|
- **格式**:对 JPEG/PNG 等提示可考虑 WebP/AVIF 或 `picture` 子命令
|
|
111
111
|
|
|
112
112
|
```text
|
|
113
|
-
npx lake-cimg@latest scan-code
|
|
113
|
+
npx lake-cimg@latest scan-code [path] [--no-recursive] [--limit <n>] [--issues-only]
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
| 选项 | 说明 |
|
|
116
|
+
| 参数 / 选项 | 说明 |
|
|
117
117
|
| --- | --- |
|
|
118
|
-
|
|
|
118
|
+
| `path` | 可选。省略时等价于 `.`(当前工作目录,一般在项目根执行)。可为**目录**(递归扫描其下源码)或**单个源码文件**(如某 `.pug` / `.vue`,只扫该文件) |
|
|
119
|
+
| `-r, --recursive` | 递归子目录(默认开启;仅对**目录**扫描有效) |
|
|
119
120
|
| `--limit <n>` | 最多输出多少条引用点(默认 `500`) |
|
|
120
|
-
| `--issues-only` | 仅输出 `issues`
|
|
121
|
-
| `--plain` | 简要文本而非 JSON(默认 stdout 为 JSON) |
|
|
121
|
+
| `--issues-only` | 仅输出 `issues` 非空的条目(全绿引用不出现在 `items` 里,JSON 更小、省 token) |
|
|
122
122
|
|
|
123
|
-
|
|
123
|
+
目录扫描时默认会跳过 `node_modules`、`dist`、`.git` 等子目录。动态 `src`、远程 URL、别名路径会进入报告但通常无法解析到磁盘文件。
|
|
124
124
|
|
|
125
125
|
### 前端最佳实践(React / `<picture>`)
|
|
126
126
|
|
|
@@ -130,8 +130,6 @@ npx lake-cimg@latest scan-code <dir> [--no-recursive] [--limit <n>] [--issues-on
|
|
|
130
130
|
4. **响应式**:若同一图有多套宽度,应为每条 `source`/`img` 提供 **`sizes`** 与多宽度 `srcSet`(需配合构建或 `npx lake-cimg@latest picture` 多次导出不同 `size`);当前 CLI 一次导出的是单套 URL。
|
|
131
131
|
5. **路径**:把生成文件放到 **`public/`**(如 Next.js)或 CDN,片段里的路径与部署前缀一致。
|
|
132
132
|
|
|
133
|
-
`--snippet` 输出的是可直接改的模板;生产环境请按设计稿补上准确的 `width`/`height`/`alt`/`sizes`。
|
|
134
|
-
|
|
135
133
|
## 支持格式
|
|
136
134
|
|
|
137
135
|
输入:**jpg / jpeg、png、webp、gif**(gif 含动图,处理时由 sharp 按能力读写)。
|
|
@@ -153,7 +151,7 @@ npx lake-cimg@latest scan-code <dir> [--no-recursive] [--limit <n>] [--issues-on
|
|
|
153
151
|
- `processOne(inputPath, options)` — 单文件
|
|
154
152
|
- `collectFiles(inputPath, recursive)` — 枚举待处理路径
|
|
155
153
|
- `scanAssets(dir, options)` — `lib/scanAssets.js`,按文件体积筛选偏大图片并生成建议(供脚本或自建工具使用)
|
|
156
|
-
- `scanCodeReferences(
|
|
154
|
+
- `scanCodeReferences(path, options)` — `lib/scanCodeReferences.js`,`path` 为目录或单个支持的源码文件(或 `.`),扫描源码引用并结合像素尺寸给出 CLS / 比例 / 格式类建议(供 CLI 或脚本使用)
|
|
157
155
|
- `processPictureStack(inputPath, options)` — `lib/pictureStack.js`,一次写出 AVIF / WebP / JPEG
|
|
158
156
|
|
|
159
157
|
适合在构建脚本或 Node 服务中复用同一套逻辑。
|
package/bin/cimg.js
CHANGED
|
@@ -218,9 +218,9 @@ program
|
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
program
|
|
221
|
-
.command("scan-code
|
|
221
|
+
.command("scan-code [path]")
|
|
222
222
|
.description(
|
|
223
|
-
"
|
|
223
|
+
"扫描源码图片引用并输出 JSON 建议(CLS/比例/格式)。path 可为目录或源码文件,省略时为当前目录"
|
|
224
224
|
)
|
|
225
225
|
.option("-r, --recursive", "递归扫描子目录", true)
|
|
226
226
|
.option("--no-recursive", "不递归子目录")
|
|
@@ -231,13 +231,10 @@ program
|
|
|
231
231
|
500
|
|
232
232
|
)
|
|
233
233
|
.option("--issues-only", "仅输出含 issues 的条目")
|
|
234
|
-
.
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
process.exit(1);
|
|
239
|
-
}
|
|
240
|
-
const root = resolveUserPath(dir);
|
|
234
|
+
.action(async (pathArg, opts) => {
|
|
235
|
+
const raw =
|
|
236
|
+
pathArg != null && typeof pathArg === "string" ? pathArg.trim() : "";
|
|
237
|
+
const root = resolveUserPath(raw !== "" ? raw : ".");
|
|
241
238
|
const limit = Number.isNaN(opts.limit) ? 500 : opts.limit;
|
|
242
239
|
try {
|
|
243
240
|
const result = await scanCodeReferences(root, {
|
|
@@ -246,16 +243,7 @@ program
|
|
|
246
243
|
limit,
|
|
247
244
|
issuesOnly: !!opts.issuesOnly,
|
|
248
245
|
});
|
|
249
|
-
|
|
250
|
-
console.log(JSON.stringify(result, null, 2));
|
|
251
|
-
} else {
|
|
252
|
-
console.log(result.summary);
|
|
253
|
-
for (const it of result.items) {
|
|
254
|
-
console.log(
|
|
255
|
-
`${it.file}:${it.line} ${it.issues?.join(",") || ""} ${it.rawRef || ""} -> ${it.resolvedPath || "-"}`
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
246
|
+
console.log(JSON.stringify(result, null, 2));
|
|
259
247
|
process.exit(0);
|
|
260
248
|
} catch (err) {
|
|
261
249
|
console.error("错误:", err.message);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Scan JS/HTML/Vue/TS/TSX/JSX for local image references, join sharp metadata,
|
|
2
|
+
* Scan JS/HTML/Vue/Pug/TS/TSX/JSX for local image references, join sharp metadata,
|
|
3
3
|
* and flag CLS / aspect ratio / modern-format hints (read-only).
|
|
4
4
|
*/
|
|
5
5
|
import { readdir, readFile, stat } from "fs/promises";
|
|
@@ -20,6 +20,7 @@ const DEFAULT_SOURCE_EXTS = new Set([
|
|
|
20
20
|
".ts",
|
|
21
21
|
".tsx",
|
|
22
22
|
".jsx",
|
|
23
|
+
".pug",
|
|
23
24
|
]);
|
|
24
25
|
|
|
25
26
|
const DEFAULT_EXCLUDE_DIRS = new Set([
|
|
@@ -51,6 +52,43 @@ function lineColAt(text, index) {
|
|
|
51
52
|
return { line, column };
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Ranges [start, end) of `<picture>...</picture>` blocks that contain at least one
|
|
57
|
+
* AVIF/WebP `<source>` (MIME type or srcset extension). Non-nested `<picture>` typical case.
|
|
58
|
+
* @param {string} content
|
|
59
|
+
* @returns {Array<{ start: number, end: number }>}
|
|
60
|
+
*/
|
|
61
|
+
function collectPictureModernIntervals(content) {
|
|
62
|
+
const re = /<picture\b[^>]*>([\s\S]*?)<\/picture>/gi;
|
|
63
|
+
const intervals = [];
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = re.exec(content)) !== null) {
|
|
66
|
+
const full = m[0];
|
|
67
|
+
const start = m.index;
|
|
68
|
+
const end = start + full.length;
|
|
69
|
+
const hasModern =
|
|
70
|
+
/type\s*=\s*["']image\/(?:avif|webp)["']/i.test(full) ||
|
|
71
|
+
/\bsrcset\s*=\s*["'][^"']*\.(?:avif|webp)\b/i.test(full);
|
|
72
|
+
if (hasModern) {
|
|
73
|
+
intervals.push({ start, end });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return intervals;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {number} imgIndex
|
|
81
|
+
* @param {number} imgTagLen
|
|
82
|
+
* @param {Array<{ start: number, end: number }>} intervals
|
|
83
|
+
*/
|
|
84
|
+
function imgInsidePictureModern(imgIndex, imgTagLen, intervals) {
|
|
85
|
+
const imgEnd = imgIndex + imgTagLen;
|
|
86
|
+
for (const { start, end } of intervals) {
|
|
87
|
+
if (imgIndex >= start && imgEnd <= end) return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
54
92
|
/**
|
|
55
93
|
* @param {string} dir
|
|
56
94
|
* @param {Set<string>} excludeDirs
|
|
@@ -146,6 +184,133 @@ function parseImgTag(tag) {
|
|
|
146
184
|
return { srcRaw, srcKind, width, height, style };
|
|
147
185
|
}
|
|
148
186
|
|
|
187
|
+
/**
|
|
188
|
+
* Parse Pug `img(src=… width=… height=…)` (one call block).
|
|
189
|
+
* @param {string} block - e.g. `img(src='a.png' width=300 height=100)`
|
|
190
|
+
*/
|
|
191
|
+
function parsePugImgBlock(block) {
|
|
192
|
+
let srcRaw = null;
|
|
193
|
+
let srcKind = null;
|
|
194
|
+
const srcQuoted = block.match(/\bsrc\s*=\s*["']([^"']*)["']/i);
|
|
195
|
+
const srcUnquoted = block.match(
|
|
196
|
+
/\bsrc\s*=\s*([\w./$-]+\.(?:jpg|jpeg|png|webp|gif))\b/i
|
|
197
|
+
);
|
|
198
|
+
if (srcQuoted) {
|
|
199
|
+
const val = srcQuoted[1].trim();
|
|
200
|
+
if (
|
|
201
|
+
IMG_EXT_RE.test(val) ||
|
|
202
|
+
val.startsWith("./") ||
|
|
203
|
+
val.startsWith("../") ||
|
|
204
|
+
val.startsWith("/")
|
|
205
|
+
) {
|
|
206
|
+
srcRaw = val.split("?")[0];
|
|
207
|
+
srcKind = "static";
|
|
208
|
+
} else {
|
|
209
|
+
srcKind = "dynamic";
|
|
210
|
+
}
|
|
211
|
+
} else if (srcUnquoted) {
|
|
212
|
+
srcRaw = srcUnquoted[1].split("?")[0];
|
|
213
|
+
srcKind = "static";
|
|
214
|
+
} else if (/\bsrc\s*=/i.test(block)) {
|
|
215
|
+
srcKind = "dynamic";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const w1 = block.match(/\bwidth\s*=\s*["']?(\d+)["']?/i);
|
|
219
|
+
const h1 = block.match(/\bheight\s*=\s*["']?(\d+)["']?/i);
|
|
220
|
+
const width = w1 ? parseInt(w1[1], 10) : null;
|
|
221
|
+
const height = h1 ? parseInt(h1[1], 10) : null;
|
|
222
|
+
const styleMatch = block.match(/\bstyle\s*=\s*["']([^"']*)["']/i);
|
|
223
|
+
const style = styleMatch ? styleMatch[1] : null;
|
|
224
|
+
|
|
225
|
+
return { srcRaw, srcKind, width, height, style };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* @param {string} content
|
|
230
|
+
* @returns {Array<{ index: number, line: number, column: number, rawRef: string, kind: string, context: string, parse: object }>}
|
|
231
|
+
*/
|
|
232
|
+
function extractPugImgRefs(content) {
|
|
233
|
+
/** @type {Array<{ index: number, line: number, column: number, rawRef: string, kind: string, context: string, parse: object }>} */
|
|
234
|
+
const refs = [];
|
|
235
|
+
const re = /\bimg\s*\(/g;
|
|
236
|
+
let m;
|
|
237
|
+
while ((m = re.exec(content)) !== null) {
|
|
238
|
+
const startParen = m.index + m[0].length - 1;
|
|
239
|
+
let depth = 1;
|
|
240
|
+
let i = startParen + 1;
|
|
241
|
+
while (i < content.length && depth > 0) {
|
|
242
|
+
const c = content[i];
|
|
243
|
+
if (c === "(") {
|
|
244
|
+
depth++;
|
|
245
|
+
i++;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (c === ")") {
|
|
249
|
+
depth--;
|
|
250
|
+
i++;
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
254
|
+
const q = c;
|
|
255
|
+
i++;
|
|
256
|
+
while (i < content.length) {
|
|
257
|
+
if (content[i] === "\\") {
|
|
258
|
+
i += 2;
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
if (content[i] === q) {
|
|
262
|
+
i++;
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
i++;
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
i++;
|
|
270
|
+
}
|
|
271
|
+
const block = content.slice(m.index, i);
|
|
272
|
+
const index = m.index;
|
|
273
|
+
const { line, column } = lineColAt(content, index);
|
|
274
|
+
const parsed = parsePugImgBlock(block);
|
|
275
|
+
|
|
276
|
+
if (parsed.srcKind === "dynamic") {
|
|
277
|
+
refs.push({
|
|
278
|
+
index,
|
|
279
|
+
line,
|
|
280
|
+
column,
|
|
281
|
+
rawRef: "",
|
|
282
|
+
kind: "img",
|
|
283
|
+
context: block.slice(0, 200),
|
|
284
|
+
parse: { ...parsed, dynamic: true },
|
|
285
|
+
});
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (!parsed.srcRaw) {
|
|
289
|
+
refs.push({
|
|
290
|
+
index,
|
|
291
|
+
line,
|
|
292
|
+
column,
|
|
293
|
+
rawRef: "",
|
|
294
|
+
kind: "img",
|
|
295
|
+
context: block.slice(0, 200),
|
|
296
|
+
parse: { ...parsed, missingSrc: true },
|
|
297
|
+
});
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
refs.push({
|
|
302
|
+
index,
|
|
303
|
+
line,
|
|
304
|
+
column,
|
|
305
|
+
rawRef: parsed.srcRaw,
|
|
306
|
+
kind: "img",
|
|
307
|
+
context: block.slice(0, 200),
|
|
308
|
+
parse: parsed,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return refs;
|
|
312
|
+
}
|
|
313
|
+
|
|
149
314
|
/**
|
|
150
315
|
* @param {string} ref
|
|
151
316
|
* @param {string} hostDir
|
|
@@ -177,12 +342,22 @@ function resolveLocalImageRef(ref, hostDir) {
|
|
|
177
342
|
/**
|
|
178
343
|
* @param {string} content
|
|
179
344
|
* @param {string} hostDir
|
|
345
|
+
* @param {string} [sourceExt] - lowercase extension e.g. `.pug` to run Pug `img()` extraction
|
|
180
346
|
* @returns {RawRef[]}
|
|
181
347
|
*/
|
|
182
|
-
function extractFromContent(content, hostDir) {
|
|
348
|
+
function extractFromContent(content, hostDir, sourceExt = "") {
|
|
183
349
|
/** @type {RawRef[]} */
|
|
184
350
|
const refs = [];
|
|
185
351
|
|
|
352
|
+
if (sourceExt === ".pug") {
|
|
353
|
+
const pugRefs = extractPugImgRefs(content);
|
|
354
|
+
for (const r of pugRefs) {
|
|
355
|
+
refs.push(r);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const pictureModernIntervals = collectPictureModernIntervals(content);
|
|
360
|
+
|
|
186
361
|
const imgRe = /<img\b[^>]*>/gis;
|
|
187
362
|
let m;
|
|
188
363
|
while ((m = imgRe.exec(content)) !== null) {
|
|
@@ -190,6 +365,11 @@ function extractFromContent(content, hostDir) {
|
|
|
190
365
|
const index = m.index;
|
|
191
366
|
const { line, column } = lineColAt(content, index);
|
|
192
367
|
const parsed = parseImgTag(tag);
|
|
368
|
+
const pictureHasModernSources = imgInsidePictureModern(
|
|
369
|
+
index,
|
|
370
|
+
tag.length,
|
|
371
|
+
pictureModernIntervals
|
|
372
|
+
);
|
|
193
373
|
|
|
194
374
|
if (parsed.srcKind === "dynamic") {
|
|
195
375
|
refs.push({
|
|
@@ -199,7 +379,11 @@ function extractFromContent(content, hostDir) {
|
|
|
199
379
|
rawRef: "",
|
|
200
380
|
kind: "img",
|
|
201
381
|
context: tag.slice(0, 200),
|
|
202
|
-
parse: {
|
|
382
|
+
parse: {
|
|
383
|
+
...parsed,
|
|
384
|
+
dynamic: true,
|
|
385
|
+
...(pictureHasModernSources ? { pictureHasModernSources: true } : {}),
|
|
386
|
+
},
|
|
203
387
|
});
|
|
204
388
|
continue;
|
|
205
389
|
}
|
|
@@ -211,7 +395,11 @@ function extractFromContent(content, hostDir) {
|
|
|
211
395
|
rawRef: "",
|
|
212
396
|
kind: "img",
|
|
213
397
|
context: tag.slice(0, 200),
|
|
214
|
-
parse: {
|
|
398
|
+
parse: {
|
|
399
|
+
...parsed,
|
|
400
|
+
missingSrc: true,
|
|
401
|
+
...(pictureHasModernSources ? { pictureHasModernSources: true } : {}),
|
|
402
|
+
},
|
|
215
403
|
});
|
|
216
404
|
continue;
|
|
217
405
|
}
|
|
@@ -223,7 +411,9 @@ function extractFromContent(content, hostDir) {
|
|
|
223
411
|
rawRef: parsed.srcRaw,
|
|
224
412
|
kind: "img",
|
|
225
413
|
context: tag.slice(0, 200),
|
|
226
|
-
parse:
|
|
414
|
+
parse: pictureHasModernSources
|
|
415
|
+
? { ...parsed, pictureHasModernSources: true }
|
|
416
|
+
: parsed,
|
|
227
417
|
});
|
|
228
418
|
}
|
|
229
419
|
|
|
@@ -315,7 +505,7 @@ async function runPool(tasks, concurrency) {
|
|
|
315
505
|
}
|
|
316
506
|
|
|
317
507
|
/**
|
|
318
|
-
* @param {string} rootDir
|
|
508
|
+
* @param {string} rootDir 扫描根路径:可为**目录**(递归枚举源码)或**单个源码文件**(仅扫描该文件)。传 `.` 表示当前工作目录。
|
|
319
509
|
* @param {{
|
|
320
510
|
* recursive?: boolean,
|
|
321
511
|
* cwd?: string,
|
|
@@ -364,17 +554,26 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
364
554
|
} catch {
|
|
365
555
|
throw new Error(`路径不存在: ${absoluteRoot}`);
|
|
366
556
|
}
|
|
367
|
-
|
|
368
|
-
|
|
557
|
+
/** @type {string[]} */
|
|
558
|
+
let sourcePaths;
|
|
559
|
+
if (st.isFile()) {
|
|
560
|
+
const ext = extname(absoluteRoot).toLowerCase();
|
|
561
|
+
if (!sourceExts.has(ext)) {
|
|
562
|
+
const supported = [...sourceExts].sort().join(", ");
|
|
563
|
+
throw new Error(`不支持的源码扩展名: ${ext || "(无)"}(支持: ${supported})`);
|
|
564
|
+
}
|
|
565
|
+
sourcePaths = [absoluteRoot];
|
|
566
|
+
} else if (st.isDirectory()) {
|
|
567
|
+
sourcePaths = await collectSourceFiles(
|
|
568
|
+
absoluteRoot,
|
|
569
|
+
excludeDirs,
|
|
570
|
+
sourceExts,
|
|
571
|
+
recursive
|
|
572
|
+
);
|
|
573
|
+
} else {
|
|
574
|
+
throw new Error(`不是文件或目录: ${absoluteRoot}`);
|
|
369
575
|
}
|
|
370
576
|
|
|
371
|
-
const sourcePaths = await collectSourceFiles(
|
|
372
|
-
absoluteRoot,
|
|
373
|
-
excludeDirs,
|
|
374
|
-
sourceExts,
|
|
375
|
-
recursive
|
|
376
|
-
);
|
|
377
|
-
|
|
378
577
|
/** @type {Array<{ file: string, absHost: string, ref: RawRef }>} */
|
|
379
578
|
const staged = [];
|
|
380
579
|
|
|
@@ -389,7 +588,8 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
389
588
|
const relFile = relative(cwd, absPath);
|
|
390
589
|
const displayFile =
|
|
391
590
|
relFile && !relFile.startsWith("..") ? relFile : absPath;
|
|
392
|
-
const
|
|
591
|
+
const sourceExt = extname(absPath).toLowerCase();
|
|
592
|
+
const rawRefs = extractFromContent(content, hostDir, sourceExt);
|
|
393
593
|
for (const ref of rawRefs) {
|
|
394
594
|
staged.push({ file: displayFile, absHost: hostDir, ref });
|
|
395
595
|
}
|
|
@@ -451,7 +651,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
451
651
|
|
|
452
652
|
if (r.parse?.dynamic) {
|
|
453
653
|
issues.push("needs_manual_review");
|
|
454
|
-
hints.push("Vue/React 动态
|
|
654
|
+
hints.push("Vue/React/Pug 动态 src 或绑定,需人工确认资源与尺寸");
|
|
455
655
|
items.push({
|
|
456
656
|
file,
|
|
457
657
|
line: r.line,
|
|
@@ -505,7 +705,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
505
705
|
intrinsicHeight: null,
|
|
506
706
|
intrinsicFormat: null,
|
|
507
707
|
issues: ["cannot_resolve"],
|
|
508
|
-
hints: [
|
|
708
|
+
hints: [`Skipped: ${resolved.reason}`],
|
|
509
709
|
snippet: r.context,
|
|
510
710
|
};
|
|
511
711
|
if (!issuesOnly || row.issues.length) items.push(row);
|
|
@@ -564,7 +764,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
564
764
|
if (!reservesSpace && iw > 0 && ih > 0) {
|
|
565
765
|
issues.push("missing_dimensions");
|
|
566
766
|
hints.push(
|
|
567
|
-
|
|
767
|
+
`missing_dimensions: set width/height or CSS aspect-ratio for CLS; intrinsicWidth=${iw} intrinsicHeight=${ih}`
|
|
568
768
|
);
|
|
569
769
|
}
|
|
570
770
|
|
|
@@ -587,6 +787,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
|
|
|
587
787
|
}
|
|
588
788
|
|
|
589
789
|
if (
|
|
790
|
+
!(r.kind === "img" && r.parse?.pictureHasModernSources) &&
|
|
590
791
|
(ext === ".jpg" ||
|
|
591
792
|
ext === ".jpeg" ||
|
|
592
793
|
ext === ".png" ||
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lake-cimg",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Batch image compression to WebP — CLI plus Agent Skills for frontend image audit (scan-code, picture stack)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"image",
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
36
|
"compress": "node bin/cimg.js",
|
|
37
|
-
"start": "node bin/cimg.js"
|
|
37
|
+
"start": "node bin/cimg.js",
|
|
38
|
+
"test": "node --test test/*.test.mjs"
|
|
38
39
|
},
|
|
39
40
|
"dependencies": {
|
|
40
41
|
"commander": "^12.0.0",
|
package/skills/README.md
CHANGED
|
@@ -4,7 +4,7 @@ This directory follows the layout expected by **[Skills CLI](https://www.npmjs.c
|
|
|
4
4
|
|
|
5
5
|
| Skill | Summary |
|
|
6
6
|
| --- | --- |
|
|
7
|
-
| [cimg-audit](./cimg-audit/SKILL.md) | Audit `<img>` / imports / `url()` for CLS, aspect ratio, and format hints via `npx lake-cimg@latest scan-code` |
|
|
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
8
|
|
|
9
9
|
### When to use
|
|
10
10
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cimg-audit
|
|
3
3
|
description: >-
|
|
4
|
-
Audits HTML, Vue, JS, TS, TSX, and JSX image references against intrinsic
|
|
4
|
+
Audits HTML, Vue, Pug, JS, TS, TSX, and JSX image references against intrinsic
|
|
5
5
|
dimensions for CLS risk, aspect-ratio mismatches, and modern-format hints.
|
|
6
6
|
Use when optimizing images, fixing layout shift, LCP heroes, picture/srcset,
|
|
7
7
|
or running lake-cimg scan-code via npx lake-cimg@latest.
|
|
@@ -9,49 +9,55 @@ description: >-
|
|
|
9
9
|
|
|
10
10
|
# cimg image audit
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Run **`npx lake-cimg@latest`** from the **project root** (no global install).
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
<a id="invoke-scan-code"></a>
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
## Invoke `scan-code`
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
- [ ] **2. Triage `issues`:** Each JSON row has `issues` (string codes) and **`hints`** (human-readable, often Chinese)—use **`hints` first** when choosing a fix; codes are for filtering and grouping. Possible codes: `missing_dimensions`, `aspect_ratio_mismatch`, `suggest_modern_format`, `needs_manual_review`, `missing_src`, `cannot_resolve`, `cannot_read_metadata`.
|
|
20
|
-
- [ ] **3. Fix markup:** add `width`/`height`, CSS `aspect-ratio`, or intentional `object-fit: cover|contain` when the box ratio must differ from the asset.
|
|
21
|
-
- [ ] **4. Optimize assets last:** `npx lake-cimg@latest <path> [options]`; for AVIF+WebP+JPEG stacks: `npx lake-cimg@latest picture <input> -O <outDir>` (see package README).
|
|
18
|
+
Use **one** command starting with `npx` — **not** `cd … && npx …` (PowerShell 5.1 on Windows does not support `&&`).
|
|
22
19
|
|
|
23
|
-
|
|
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`. |
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
- **CLS:** Prefer **`width` and `height`** on `<img>` (or **`aspect-ratio`** when fluid) so space is reserved before paint.
|
|
27
|
-
- **Responsive:** `<picture>` / **`srcset` + `sizes`**; align `type` with AVIF/WebP/JPEG; React uses **`srcSet`**. Use `picture` (and `-s`) to emit multiple width variants if needed.
|
|
28
|
-
- **LCP:** At most one hero: **`fetchPriority="high"`**, **`loading="eager"`**; lazy-load the rest.
|
|
29
|
-
- **Alt:** Describe **what’s in the image** and **how it supports the page** (subject + role in context). **Do not** stack keywords for SEO; empty **`alt=""`** only when the image is decorative. More: [reference.md](reference.md#alt-text).
|
|
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.
|
|
30
27
|
|
|
31
|
-
|
|
28
|
+
**Examples:**
|
|
32
29
|
|
|
33
|
-
|
|
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
|
+
```
|
|
34
35
|
|
|
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.
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
| --- | --- |
|
|
39
|
-
| `npx lake-cimg@latest scan-code <dir>` | Reference audit (JSON default; `--plain` for text) |
|
|
40
|
-
| `npx lake-cimg@latest <path>` | Compress / WebP (`--help` for `-o`, `-s`, `-q`, `-r`) |
|
|
41
|
-
| `npx lake-cimg@latest picture <input> -O <outDir>` | One source → AVIF + WebP + JPEG for `<picture>` |
|
|
38
|
+
## Workflow
|
|
42
39
|
|
|
43
|
-
|
|
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)).
|
|
44
43
|
|
|
45
|
-
##
|
|
44
|
+
## Rules of thumb
|
|
46
45
|
|
|
47
|
-
|
|
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.
|
|
48
51
|
|
|
49
|
-
|
|
50
|
-
npx lake-cimg@latest scan-code /absolute/path/to/project
|
|
51
|
-
```
|
|
52
|
+
## What the scanner cannot resolve
|
|
52
53
|
|
|
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).
|
|
54
55
|
|
|
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.
|
|
56
57
|
|
|
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,48 +1,39 @@
|
|
|
1
|
-
# Image
|
|
1
|
+
# Image markup templates
|
|
2
2
|
|
|
3
|
-
Supplement for [SKILL.md](SKILL.md).
|
|
3
|
+
Supplement for [SKILL.md](SKILL.md). Copy-paste starting points; adjust paths, dimensions, and `sizes` to match your assets and layout.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Basic `<img>`
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
| --- | --- | --- |
|
|
9
|
-
| **AVIF** | Photos, best compression | 92%+ |
|
|
10
|
-
| **WebP** | Photos, good fallback | 97%+ |
|
|
11
|
-
| **PNG** | Graphics with transparency | Universal |
|
|
12
|
-
| **SVG** | Icons, logos, illustrations | Universal |
|
|
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.
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
```
|
|
22
18
|
|
|
23
|
-
|
|
19
|
+
## `<picture>` + `srcset` + `sizes`
|
|
24
20
|
|
|
25
|
-
|
|
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`.
|
|
26
22
|
|
|
27
23
|
```html
|
|
28
24
|
<picture>
|
|
29
|
-
<!-- AVIF for modern browsers -->
|
|
30
25
|
<source
|
|
31
26
|
type="image/avif"
|
|
32
27
|
srcset="hero-400.avif 400w,
|
|
33
28
|
hero-800.avif 800w,
|
|
34
29
|
hero-1200.avif 1200w"
|
|
35
30
|
sizes="(max-width: 600px) 100vw, 50vw">
|
|
36
|
-
|
|
37
|
-
<!-- WebP fallback -->
|
|
38
31
|
<source
|
|
39
32
|
type="image/webp"
|
|
40
33
|
srcset="hero-400.webp 400w,
|
|
41
34
|
hero-800.webp 800w,
|
|
42
35
|
hero-1200.webp 1200w"
|
|
43
36
|
sizes="(max-width: 600px) 100vw, 50vw">
|
|
44
|
-
|
|
45
|
-
<!-- JPEG fallback -->
|
|
46
37
|
<img
|
|
47
38
|
src="hero-800.jpg"
|
|
48
39
|
srcset="hero-400.jpg 400w,
|
|
@@ -51,45 +42,8 @@ Decorative visuals: **`alt=""`** and ensure they’re not the only way to convey
|
|
|
51
42
|
sizes="(max-width: 600px) 100vw, 50vw"
|
|
52
43
|
width="1200"
|
|
53
44
|
height="600"
|
|
54
|
-
alt="
|
|
45
|
+
alt="Describe subject and purpose on this page"
|
|
55
46
|
loading="lazy"
|
|
56
47
|
decoding="async">
|
|
57
48
|
</picture>
|
|
58
49
|
```
|
|
59
|
-
|
|
60
|
-
- Match **`sizes`** to real layout breakpoints; generate multiple widths with **`npx lake-cimg@latest picture … -s <px>`** runs or separate exports per width.
|
|
61
|
-
- In **React**, use **`srcSet`** and **`fetchPriority`** (camelCase) on `<img>`.
|
|
62
|
-
|
|
63
|
-
## LCP image priority
|
|
64
|
-
|
|
65
|
-
**Above-the-fold LCP candidate** — eager load and high fetch priority:
|
|
66
|
-
|
|
67
|
-
```html
|
|
68
|
-
<img
|
|
69
|
-
src="hero.webp"
|
|
70
|
-
fetchpriority="high"
|
|
71
|
-
loading="eager"
|
|
72
|
-
decoding="sync"
|
|
73
|
-
alt="Hero: subject + role on this page (not SEO keywords)">
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
**Below-the-fold** — defer work off the critical path:
|
|
77
|
-
|
|
78
|
-
```html
|
|
79
|
-
<img
|
|
80
|
-
src="product.webp"
|
|
81
|
-
loading="lazy"
|
|
82
|
-
decoding="async"
|
|
83
|
-
alt="Product: what’s visible + relevance (no keyword list)">
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Use **at most one** `fetchpriority="high"` (or `fetchPriority="high"` in React) per route/view for the true LCP image; lazy-load the rest.
|
|
87
|
-
|
|
88
|
-
## CLI tie-in
|
|
89
|
-
|
|
90
|
-
| Need | Command |
|
|
91
|
-
| --- | --- |
|
|
92
|
-
| Audit references + intrinsic size vs markup | `npx lake-cimg@latest scan-code <dir>` |
|
|
93
|
-
| One file → AVIF + WebP + JPEG | `npx lake-cimg@latest picture <input> -O <outDir>` |
|
|
94
|
-
|
|
95
|
-
Full options: repository **README.md** at package root.
|