lake-cimg 0.0.3 → 1.0.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
@@ -110,17 +110,17 @@ npx lake-cimg@latest picture <input> -O <outDir> [选项]
110
110
  - **格式**:对 JPEG/PNG 等提示可考虑 WebP/AVIF 或 `picture` 子命令
111
111
 
112
112
  ```text
113
- npx lake-cimg@latest scan-code <dir> [--no-recursive] [--limit <n>] [--issues-only] [--plain]
113
+ npx lake-cimg@latest scan-code [path] [--no-recursive] [--limit <n>] [--issues-only]
114
114
  ```
115
115
 
116
- | 选项 | 说明 |
116
+ | 参数 / 选项 | 说明 |
117
117
  | --- | --- |
118
- | `-r, --recursive` | 递归子目录(默认开启) |
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
- 默认会跳过 `node_modules`、`dist`、`.git` 等目录。动态 `src`、远程 URL、别名路径会进入报告但通常无法解析到磁盘文件。
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(rootDir, options)` — `lib/scanCodeReferences.js`,扫描源码引用并结合像素尺寸给出 CLS / 比例 / 格式类建议(供 CLI 或脚本使用)
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
@@ -217,10 +217,24 @@ 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
- .command("scan-code <dir>")
235
+ .command("scan-code [path]")
222
236
  .description(
223
- "扫描源码中的图片引用(html/vue/pug/js/ts/tsx/jsx),结合像素尺寸给出 CLS / 比例 / 格式建议(只读)"
237
+ "扫描源码图片引用并输出 JSON 建议(CLS/比例/格式)。path 可为目录或源码文件,省略时为当前目录"
224
238
  )
225
239
  .option("-r, --recursive", "递归扫描子目录", true)
226
240
  .option("--no-recursive", "不递归子目录")
@@ -231,31 +245,48 @@ program
231
245
  500
232
246
  )
233
247
  .option("--issues-only", "仅输出含 issues 的条目")
234
- .option("--plain", "纯文本输出(默认 stdout 为 JSON)")
235
- .action(async (dir, opts) => {
236
- if (!dir || dir.trim() === "") {
237
- program.outputHelp();
238
- process.exit(1);
239
- }
240
- const root = resolveUserPath(dir);
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
+ )
266
+ .action(async (pathArg, opts) => {
267
+ const raw =
268
+ pathArg != null && typeof pathArg === "string" ? pathArg.trim() : "";
269
+ const root = resolveUserPath(raw !== "" ? raw : ".");
241
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;
242
275
  try {
243
276
  const result = await scanCodeReferences(root, {
244
277
  recursive: opts.recursive !== false,
245
278
  cwd: process.cwd(),
246
279
  limit,
247
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
+ : {}),
248
288
  });
249
- if (!opts.plain) {
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
- }
289
+ console.log(JSON.stringify(result, null, 2));
259
290
  process.exit(0);
260
291
  } catch (err) {
261
292
  console.error("错误:", err.message);
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
- if (size != null && size > 0) {
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.jpeg({ quality, mozjpeg: true }).toBuffer();
154
+ return encodeJpegBuffer(pipeline, quality);
176
155
  case ".png":
177
156
  return pipeline.png({ compressionLevel: 9 }).toBuffer();
178
157
  case ".webp":
@@ -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, DEFAULT_EFFORT } from "./constants.js";
9
-
10
- /** AVIF effort 0–9 (higher = slower, often smaller) */
11
- const DEFAULT_AVIF_EFFORT = 4;
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
- .clone()
21
- .avif({ quality: q.avifQuality, effort: DEFAULT_AVIF_EFFORT })
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 = sharp(inputBuffer, { animated: false });
89
- if (size != null && size > 0) {
90
- pipeline = pipeline.resize({
91
- width: size,
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
@@ -52,6 +86,43 @@ function lineColAt(text, index) {
52
86
  return { line, column };
53
87
  }
54
88
 
89
+ /**
90
+ * Ranges [start, end) of `<picture>...</picture>` blocks that contain at least one
91
+ * AVIF/WebP `<source>` (MIME type or srcset extension). Non-nested `<picture>` typical case.
92
+ * @param {string} content
93
+ * @returns {Array<{ start: number, end: number }>}
94
+ */
95
+ function collectPictureModernIntervals(content) {
96
+ const re = /<picture\b[^>]*>([\s\S]*?)<\/picture>/gi;
97
+ const intervals = [];
98
+ let m;
99
+ while ((m = re.exec(content)) !== null) {
100
+ const full = m[0];
101
+ const start = m.index;
102
+ const end = start + full.length;
103
+ const hasModern =
104
+ /type\s*=\s*["']image\/(?:avif|webp)["']/i.test(full) ||
105
+ /\bsrcset\s*=\s*["'][^"']*\.(?:avif|webp)\b/i.test(full);
106
+ if (hasModern) {
107
+ intervals.push({ start, end });
108
+ }
109
+ }
110
+ return intervals;
111
+ }
112
+
113
+ /**
114
+ * @param {number} imgIndex
115
+ * @param {number} imgTagLen
116
+ * @param {Array<{ start: number, end: number }>} intervals
117
+ */
118
+ function imgInsidePictureModern(imgIndex, imgTagLen, intervals) {
119
+ const imgEnd = imgIndex + imgTagLen;
120
+ for (const { start, end } of intervals) {
121
+ if (imgIndex >= start && imgEnd <= end) return true;
122
+ }
123
+ return false;
124
+ }
125
+
55
126
  /**
56
127
  * @param {string} dir
57
128
  * @param {Set<string>} excludeDirs
@@ -276,11 +347,17 @@ function extractPugImgRefs(content) {
276
347
 
277
348
  /**
278
349
  * @param {string} ref
279
- * @param {string} hostDir
280
- * @returns {{ status: 'ok', path: string } | { status: 'skip', reason: string }}
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 }>}
281
357
  */
282
- function resolveLocalImageRef(ref, hostDir) {
283
- const trimmed = ref.trim();
358
+ async function resolveLocalImageRef(ref, hostDir, opts) {
359
+ const { projectRoot, aliases, publicDirs } = opts;
360
+ let trimmed = ref.trim();
284
361
  if (!trimmed) return { status: "skip", reason: "empty" };
285
362
  if (/^https?:\/\//i.test(trimmed)) {
286
363
  return { status: "skip", reason: "remote_url" };
@@ -288,14 +365,57 @@ function resolveLocalImageRef(ref, hostDir) {
288
365
  if (trimmed.startsWith("data:")) {
289
366
  return { status: "skip", reason: "data_uri" };
290
367
  }
291
- if (trimmed.startsWith("@/") || trimmed.startsWith("~/") || trimmed.startsWith("~@/")) {
292
- return { status: "skip", reason: "alias_path" };
368
+
369
+ if (trimmed.startsWith("~@/")) {
370
+ trimmed = `@/${trimmed.slice(3)}`;
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" };
293
408
  }
409
+
410
+ if (trimmed.startsWith("@") && trimmed.includes("/")) {
411
+ return { status: "skip", reason: "unknown_alias" };
412
+ }
413
+
294
414
  if (!IMG_EXT_RE.test(trimmed)) {
295
415
  return { status: "skip", reason: "not_image_extension" };
296
416
  }
297
- const path = resolve(hostDir, trimmed);
298
- return { status: "ok", path };
417
+ const pathAbs = resolve(hostDir, trimmed);
418
+ return { status: "ok", path: pathAbs };
299
419
  }
300
420
 
301
421
  /**
@@ -319,6 +439,8 @@ function extractFromContent(content, hostDir, sourceExt = "") {
319
439
  }
320
440
  }
321
441
 
442
+ const pictureModernIntervals = collectPictureModernIntervals(content);
443
+
322
444
  const imgRe = /<img\b[^>]*>/gis;
323
445
  let m;
324
446
  while ((m = imgRe.exec(content)) !== null) {
@@ -326,6 +448,11 @@ function extractFromContent(content, hostDir, sourceExt = "") {
326
448
  const index = m.index;
327
449
  const { line, column } = lineColAt(content, index);
328
450
  const parsed = parseImgTag(tag);
451
+ const pictureHasModernSources = imgInsidePictureModern(
452
+ index,
453
+ tag.length,
454
+ pictureModernIntervals
455
+ );
329
456
 
330
457
  if (parsed.srcKind === "dynamic") {
331
458
  refs.push({
@@ -335,7 +462,11 @@ function extractFromContent(content, hostDir, sourceExt = "") {
335
462
  rawRef: "",
336
463
  kind: "img",
337
464
  context: tag.slice(0, 200),
338
- parse: { ...parsed, dynamic: true },
465
+ parse: {
466
+ ...parsed,
467
+ dynamic: true,
468
+ ...(pictureHasModernSources ? { pictureHasModernSources: true } : {}),
469
+ },
339
470
  });
340
471
  continue;
341
472
  }
@@ -347,7 +478,11 @@ function extractFromContent(content, hostDir, sourceExt = "") {
347
478
  rawRef: "",
348
479
  kind: "img",
349
480
  context: tag.slice(0, 200),
350
- parse: { ...parsed, missingSrc: true },
481
+ parse: {
482
+ ...parsed,
483
+ missingSrc: true,
484
+ ...(pictureHasModernSources ? { pictureHasModernSources: true } : {}),
485
+ },
351
486
  });
352
487
  continue;
353
488
  }
@@ -359,7 +494,9 @@ function extractFromContent(content, hostDir, sourceExt = "") {
359
494
  rawRef: parsed.srcRaw,
360
495
  kind: "img",
361
496
  context: tag.slice(0, 200),
362
- parse: parsed,
497
+ parse: pictureHasModernSources
498
+ ? { ...parsed, pictureHasModernSources: true }
499
+ : parsed,
363
500
  });
364
501
  }
365
502
 
@@ -451,7 +588,7 @@ async function runPool(tasks, concurrency) {
451
588
  }
452
589
 
453
590
  /**
454
- * @param {string} rootDir
591
+ * @param {string} rootDir 扫描根路径:可为**目录**(递归枚举源码)或**单个源码文件**(仅扫描该文件)。传 `.` 表示当前工作目录。
455
592
  * @param {{
456
593
  * recursive?: boolean,
457
594
  * cwd?: string,
@@ -461,6 +598,9 @@ async function runPool(tasks, concurrency) {
461
598
  * issuesOnly?: boolean,
462
599
  * ratioTolerance?: number,
463
600
  * metadataConcurrency?: number,
601
+ * projectRoot?: string,
602
+ * aliases?: Record<string, string>,
603
+ * publicDirs?: string[],
464
604
  * }} [options]
465
605
  */
466
606
  export async function scanCodeReferences(rootDir, options = {}) {
@@ -471,8 +611,22 @@ export async function scanCodeReferences(rootDir, options = {}) {
471
611
  issuesOnly = false,
472
612
  ratioTolerance = DEFAULT_RATIO_TOLERANCE,
473
613
  metadataConcurrency = 8,
614
+ projectRoot: projectRootOpt,
615
+ aliases: aliasesOpt,
616
+ publicDirs: publicDirsOpt,
474
617
  } = options;
475
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
+
476
630
  const sourceExts =
477
631
  options.sourceExts != null
478
632
  ? new Set(
@@ -500,17 +654,26 @@ export async function scanCodeReferences(rootDir, options = {}) {
500
654
  } catch {
501
655
  throw new Error(`路径不存在: ${absoluteRoot}`);
502
656
  }
503
- if (!st.isDirectory()) {
504
- throw new Error(`不是目录: ${absoluteRoot}`);
657
+ /** @type {string[]} */
658
+ let sourcePaths;
659
+ if (st.isFile()) {
660
+ const ext = extname(absoluteRoot).toLowerCase();
661
+ if (!sourceExts.has(ext)) {
662
+ const supported = [...sourceExts].sort().join(", ");
663
+ throw new Error(`不支持的源码扩展名: ${ext || "(无)"}(支持: ${supported})`);
664
+ }
665
+ sourcePaths = [absoluteRoot];
666
+ } else if (st.isDirectory()) {
667
+ sourcePaths = await collectSourceFiles(
668
+ absoluteRoot,
669
+ excludeDirs,
670
+ sourceExts,
671
+ recursive
672
+ );
673
+ } else {
674
+ throw new Error(`不是文件或目录: ${absoluteRoot}`);
505
675
  }
506
676
 
507
- const sourcePaths = await collectSourceFiles(
508
- absoluteRoot,
509
- excludeDirs,
510
- sourceExts,
511
- recursive
512
- );
513
-
514
677
  /** @type {Array<{ file: string, absHost: string, ref: RawRef }>} */
515
678
  const staged = [];
516
679
 
@@ -539,7 +702,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
539
702
  for (const { absHost, ref } of staged) {
540
703
  if (ref.parse?.dynamic || ref.parse?.missingSrc) continue;
541
704
  if (!ref.rawRef) continue;
542
- const res = resolveLocalImageRef(ref.rawRef, absHost);
705
+ const res = await resolveLocalImageRef(ref.rawRef, absHost, resolveOpts);
543
706
  if (res.status === "ok") uniquePaths.add(res.path);
544
707
  }
545
708
 
@@ -628,7 +791,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
628
791
  continue;
629
792
  }
630
793
 
631
- const resolved = resolveLocalImageRef(r.rawRef, absHost);
794
+ const resolved = await resolveLocalImageRef(r.rawRef, absHost, resolveOpts);
632
795
  if (resolved.status === "skip") {
633
796
  const row = {
634
797
  file,
@@ -642,7 +805,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
642
805
  intrinsicHeight: null,
643
806
  intrinsicFormat: null,
644
807
  issues: ["cannot_resolve"],
645
- hints: [`跳过: ${resolved.reason}`],
808
+ hints: [`Skipped: ${resolved.reason}`],
646
809
  snippet: r.context,
647
810
  };
648
811
  if (!issuesOnly || row.issues.length) items.push(row);
@@ -701,7 +864,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
701
864
  if (!reservesSpace && iw > 0 && ih > 0) {
702
865
  issues.push("missing_dimensions");
703
866
  hints.push(
704
- "补充 widthheight,或 style 中的 aspect-ratio,以减少 CLS"
867
+ `missing_dimensions: set width/height or CSS aspect-ratio for CLS; intrinsicWidth=${iw} intrinsicHeight=${ih}`
705
868
  );
706
869
  }
707
870
 
@@ -724,6 +887,7 @@ export async function scanCodeReferences(rootDir, options = {}) {
724
887
  }
725
888
 
726
889
  if (
890
+ !(r.kind === "img" && r.parse?.pictureHasModernSources) &&
727
891
  (ext === ".jpg" ||
728
892
  ext === ".jpeg" ||
729
893
  ext === ".png" ||
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "lake-cimg",
3
- "version": "0.0.3",
3
+ "version": "1.0.1",
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",
@@ -9,50 +9,55 @@ description: >-
9
9
 
10
10
  # cimg image audit
11
11
 
12
- Use **`npx lake-cimg@latest`** from the project root (no global install).
12
+ Run **`npx lake-cimg@latest`** from the **project root** (no global install).
13
13
 
14
- ## Workflow
14
+ <a id="invoke-scan-code"></a>
15
15
 
16
- Copy into a task list and tick as you go:
16
+ ## Invoke `scan-code`
17
17
 
18
- - [ ] **1. Scan (read-only):** `npx lake-cimg@latest scan-code <dir>` — **stdout is JSON** (default); **`--plain`** for a short text summary. Prefer an **absolute** path for `<dir>`.
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
- ## Rules of thumb
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
- - **Aspect ratio:** For undistorted display, match **display ratio** to **intrinsic** (width ÷ height). For crop/letterbox, use **`object-fit`** with explicit size/aspect-ratio.
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.
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
- ## Limits
28
+ **Examples:**
32
29
 
33
- Dynamic **`src`** (e.g. `:src` without a static path) → **`needs_manual_review`**. Remote URLs, **`data:`**, and aliases (**`@/`**) → **`cannot_resolve`** until mapped to real paths. The scanner does not fetch network images.
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
- ## CLI
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
- | Command | Role |
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
- `npx lake-cimg@latest --help` global options. `npx lake-cimg@latest scan-code --help` — `--limit`, `--issues-only`, `--no-recursive`, `--plain`.
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` (default: **single WebP** — point `src` / `srcset` at `.webp` after exporting; use **`<picture>`** with AVIF/WebP + legacy fallback **only if the user explicitly asks** for multi-format markup or old-browser JPEG/PNG fallback), `needs_manual_review`, `missing_src`, `cannot_resolve`, `cannot_read_metadata`.
42
+ - [ ] **3. Fix then optimize:** Apply markup using [reference.md](reference.md) (`<img>` first; `<picture>` only when the user requires it). For **all** raster refs that need format or responsive delivery, not only one hero row. Compress / emit WebP: `npx lake-cimg@latest <path> [options]`. **Optional** full stack for `<picture>` when requested: `npx lake-cimg@latest picture <input> -O <outDir>` (details: package [README.md](../../README.md)).
44
43
 
45
- ## Examples
44
+ ## Rules of thumb
46
45
 
47
- **Read-only audit (stdout = JSON for parsing):**
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:** **`srcset` + `sizes`** on `<img>` (works with a single WebP). **`<picture>`** only when the user wants multiple formats in HTML; each `<source type="…">` must match the real file type. Width variants: separate files or `picture … -s <px>` when building a stack.
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
- ```bash
50
- npx lake-cimg@latest scan-code /absolute/path/to/project
51
- ```
52
+ ## What the scanner cannot resolve
52
53
 
53
- Use **`--plain`** when you want a short **text** summary in the terminal instead of JSON.
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
- ## Additional resources
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
- - **Alt text** (decorative `alt=""`, tone, anti-keyword-stuffing): [reference.md — Alt text](reference.md#alt-text)
58
- - Format choice, responsive `<picture>` patterns, and LCP markup examples: [reference.md](reference.md)
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 **when the user wants `<picture>` / multi-format**; not the default if single WebP is enough. |
@@ -1,95 +1,49 @@
1
- # Image optimization reference
1
+ # Image markup templates
2
2
 
3
- Supplement for [SKILL.md](SKILL.md). Browser share figures are **approximate** (global usage trends; check [Can I use](https://caniuse.com/) for current data).
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
- ## Format selection
5
+ ## Basic `<img>`
6
6
 
7
- | Format | Use case | Browser support (approx.) |
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
- Pair with **`lake-cimg`** `picture` subcommand for AVIF + WebP + JPEG from one source when you need stacks; use PNG/SVG where vector or lossless transparency matters.
15
-
16
- ## Alt text
17
-
18
- Write **`alt`** as: **图里是什么 / 与页面主题的关系** — what the image shows and why it belongs on this screen (one concise phrase or sentence).
19
-
20
- - **Do:** name the subject, setting, or action that matters; match **language** and **tone** of the page.
21
- - **Don’t:** repeat the page title, brand slogans, or comma‑separated “SEO” keywords; don’t start with “image of …” unless the medium itself matters (e.g. screenshot vs photo).
9
+ ```html
10
+ <img
11
+ srcset="
12
+ maine-coon-nap-320w.webp 320w,
13
+ maine-coon-nap-480w.webp 480w,
14
+ maine-coon-nap-800w.webp 800w
15
+ "
16
+ sizes="(max-width: 320px) 280px, (max-width: 480px) 440px, 800px"
17
+ src="maine-coon-nap-800w.webp"
18
+ alt="A watercolor illustration of a maine coon napping leisurely in front of a fireplace"
19
+ />
20
+ ```
22
21
 
23
- Decorative visuals: **`alt=""`** and ensure they’re not the only way to convey essential information.
22
+ ## `<picture>` + `srcset` + `sizes`
24
23
 
25
- ## Responsive images (`<picture>` + `srcset` + `sizes`)
24
+ **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
25
 
27
26
  ```html
28
27
  <picture>
29
- <!-- AVIF for modern browsers -->
30
28
  <source
31
29
  type="image/avif"
32
- srcset="hero-400.avif 400w,
33
- hero-800.avif 800w,
34
- hero-1200.avif 1200w"
35
- sizes="(max-width: 600px) 100vw, 50vw">
36
-
37
- <!-- WebP fallback -->
30
+ srcset="hero-400.avif 400w, hero-800.avif 800w, hero-1200.avif 1200w"
31
+ sizes="(max-width: 600px) 100vw, 50vw"
32
+ />
38
33
  <source
39
34
  type="image/webp"
40
- srcset="hero-400.webp 400w,
41
- hero-800.webp 800w,
42
- hero-1200.webp 1200w"
43
- sizes="(max-width: 600px) 100vw, 50vw">
44
-
45
- <!-- JPEG fallback -->
35
+ srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
36
+ sizes="(max-width: 600px) 100vw, 50vw"
37
+ />
46
38
  <img
47
39
  src="hero-800.jpg"
48
- srcset="hero-400.jpg 400w,
49
- hero-800.jpg 800w,
50
- hero-1200.jpg 1200w"
40
+ srcset="hero-400.jpg 400w, hero-800.jpg 800w, hero-1200.jpg 1200w"
51
41
  sizes="(max-width: 600px) 100vw, 50vw"
52
42
  width="1200"
53
43
  height="600"
54
- alt="Short: what’s shown + why it’s on this page (no keyword stuffing)"
44
+ alt="Describe subject and purpose on this page"
55
45
  loading="lazy"
56
- decoding="async">
46
+ decoding="async"
47
+ />
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.