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 +7 -9
- package/bin/cimg.js +50 -19
- package/lib/compress.js +7 -28
- package/lib/pictureStack.js +14 -30
- package/lib/scanCodeReferences.js +189 -25
- package/lib/sharpHelpers.js +67 -0
- package/package.json +3 -2
- package/skills/cimg-audit/SKILL.md +36 -31
- package/skills/cimg-audit/reference.md +28 -74
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
|
|
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
|
@@ -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
|
|
235
|
+
.command("scan-code [path]")
|
|
222
236
|
.description(
|
|
223
|
-
"
|
|
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(
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
pipeline = pipeline.resize({
|
|
140
|
-
width: size,
|
|
141
|
-
height: size,
|
|
142
|
-
fit: "inside",
|
|
143
|
-
withoutEnlargement: true,
|
|
144
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
145
|
-
});
|
|
146
|
-
}
|
|
125
|
+
pipeline = applyResizeInside(pipeline, size);
|
|
147
126
|
|
|
148
127
|
const outputBuffer = toWebp
|
|
149
128
|
? await encodeWebpBuffer(pipeline, quality, { lossless: false })
|
|
@@ -172,7 +151,7 @@ async function toFormatBuffer(pipeline, ext, quality) {
|
|
|
172
151
|
switch (ext) {
|
|
173
152
|
case ".jpg":
|
|
174
153
|
case ".jpeg":
|
|
175
|
-
return pipeline
|
|
154
|
+
return encodeJpegBuffer(pipeline, quality);
|
|
176
155
|
case ".png":
|
|
177
156
|
return pipeline.png({ compressionLevel: 9 }).toBuffer();
|
|
178
157
|
case ".webp":
|
package/lib/pictureStack.js
CHANGED
|
@@ -5,10 +5,13 @@
|
|
|
5
5
|
import { readFile, writeFile, mkdir, stat } from "fs/promises";
|
|
6
6
|
import { dirname, join, resolve, basename, extname } from "path";
|
|
7
7
|
import sharp from "sharp";
|
|
8
|
-
import { DEFAULT_QUALITY
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
import { DEFAULT_QUALITY } from "./constants.js";
|
|
9
|
+
import {
|
|
10
|
+
applyResizeInside,
|
|
11
|
+
encodeAvifBuffer,
|
|
12
|
+
encodeWebpBuffer,
|
|
13
|
+
encodeJpegBuffer,
|
|
14
|
+
} from "./sharpHelpers.js";
|
|
12
15
|
|
|
13
16
|
/**
|
|
14
17
|
* @param {import("sharp").Sharp} pipeline - Configured pipeline (e.g. after resize)
|
|
@@ -16,22 +19,9 @@ const DEFAULT_AVIF_EFFORT = 4;
|
|
|
16
19
|
*/
|
|
17
20
|
async function encodeTriple(pipeline, q) {
|
|
18
21
|
const [avifBuf, webpBuf, jpegBuf] = await Promise.all([
|
|
19
|
-
pipeline
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
.toBuffer(),
|
|
23
|
-
pipeline
|
|
24
|
-
.clone()
|
|
25
|
-
.webp({
|
|
26
|
-
quality: q.quality,
|
|
27
|
-
effort: DEFAULT_EFFORT,
|
|
28
|
-
smartSubsample: true,
|
|
29
|
-
})
|
|
30
|
-
.toBuffer(),
|
|
31
|
-
pipeline
|
|
32
|
-
.clone()
|
|
33
|
-
.jpeg({ quality: q.jpegQuality, mozjpeg: true })
|
|
34
|
-
.toBuffer(),
|
|
22
|
+
encodeAvifBuffer(pipeline.clone(), q.avifQuality),
|
|
23
|
+
encodeWebpBuffer(pipeline.clone(), q.quality),
|
|
24
|
+
encodeJpegBuffer(pipeline.clone(), q.jpegQuality),
|
|
35
25
|
]);
|
|
36
26
|
return { avifBuf, webpBuf, jpegBuf };
|
|
37
27
|
}
|
|
@@ -85,16 +75,10 @@ export async function processPictureStack(inputPath, options) {
|
|
|
85
75
|
const inputBuffer = await readFile(absoluteIn);
|
|
86
76
|
const sizeBefore = inputBuffer.length;
|
|
87
77
|
|
|
88
|
-
let pipeline =
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
height: size,
|
|
93
|
-
fit: "inside",
|
|
94
|
-
withoutEnlargement: true,
|
|
95
|
-
background: { r: 0, g: 0, b: 0, alpha: 0 },
|
|
96
|
-
});
|
|
97
|
-
}
|
|
78
|
+
let pipeline = applyResizeInside(
|
|
79
|
+
sharp(inputBuffer, { animated: false }),
|
|
80
|
+
size
|
|
81
|
+
);
|
|
98
82
|
|
|
99
83
|
const { avifBuf, webpBuf, jpegBuf } = await encodeTriple(pipeline, {
|
|
100
84
|
quality,
|
|
@@ -40,6 +40,40 @@ const DEFAULT_EXCLUDE_DIRS = new Set([
|
|
|
40
40
|
/** Relative ratio difference threshold (1% = 0.01) */
|
|
41
41
|
const DEFAULT_RATIO_TOLERANCE = 0.01;
|
|
42
42
|
|
|
43
|
+
/** Try these folders under projectRoot for URL paths like `/images/a.png` (public 静态资源) */
|
|
44
|
+
const DEFAULT_PUBLIC_DIRS = ["public", "static"];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} ref
|
|
48
|
+
* @param {Record<string, string>} aliases
|
|
49
|
+
* @returns {object | null}
|
|
50
|
+
*/
|
|
51
|
+
function matchAliasRef(ref, aliases) {
|
|
52
|
+
const keys = Object.keys(aliases)
|
|
53
|
+
.filter((k) => k !== "~")
|
|
54
|
+
.sort((a, b) => b.length - a.length);
|
|
55
|
+
for (const key of keys) {
|
|
56
|
+
const prefix = `${key}/`;
|
|
57
|
+
if (ref.startsWith(prefix)) {
|
|
58
|
+
return { key, rest: ref.slice(prefix.length) };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} abs
|
|
66
|
+
* @returns {Promise<boolean>}
|
|
67
|
+
*/
|
|
68
|
+
async function isExistingFile(abs) {
|
|
69
|
+
try {
|
|
70
|
+
const s = await stat(abs);
|
|
71
|
+
return s.isFile();
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
43
77
|
/**
|
|
44
78
|
* @param {string} text
|
|
45
79
|
* @param {number} index
|
|
@@ -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
|
-
* @
|
|
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
|
|
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
|
-
|
|
292
|
-
|
|
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
|
|
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: {
|
|
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: {
|
|
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:
|
|
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
|
-
|
|
504
|
-
|
|
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: [
|
|
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
|
-
|
|
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": "
|
|
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
|
-
|
|
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.
|
|
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` (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
|
-
##
|
|
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:** **`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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
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
|
-
|
|
22
|
+
## `<picture>` + `srcset` + `sizes`
|
|
24
23
|
|
|
25
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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="
|
|
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.
|