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 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 / Skill 工作流互补。
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
- 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
@@ -313,11 +347,17 @@ function extractPugImgRefs(content) {
313
347
 
314
348
  /**
315
349
  * @param {string} ref
316
- * @param {string} hostDir
317
- * @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 }>}
318
357
  */
319
- function resolveLocalImageRef(ref, hostDir) {
320
- const trimmed = ref.trim();
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
- if (trimmed.startsWith("@/") || trimmed.startsWith("~/") || trimmed.startsWith("~@/")) {
329
- return { status: "skip", reason: "alias_path" };
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 path = resolve(hostDir, trimmed);
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.0",
4
- "description": "Batch image compression to WebP — CLI plus Agent Skills for frontend image audit (scan-code, picture stack)",
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
- "agent-skills",
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
- ```