pugkit 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,11 +28,12 @@ $ touch ./src/index.pug
28
28
 
29
29
  ## Commands
30
30
 
31
- | コマンド | 内容 |
32
- | --------------- | ----------------------------- |
33
- | `pugkit` | 開発モード(Ctrl + C で停止) |
34
- | `pugkit build` | 本番ビルド |
35
- | `pugkit sprite` | SVGスプライト生成 |
31
+ | コマンド | 内容 |
32
+ | --------------- | ------------------------------ |
33
+ | `pugkit` | 開発モード(Ctrl + C で停止) |
34
+ | `pugkit build` | 本番ビルド |
35
+ | `pugkit sprite` | SVGスプライト生成 |
36
+ | `pugkit bench` | 画像サイズベンチマーク(全体) |
36
37
 
37
38
  ## Directory Structure
38
39
 
@@ -81,25 +82,38 @@ export default defineConfig({
81
82
  startPath: '/'
82
83
  },
83
84
  build: {
84
- imageOptimization: 'webp'
85
+ // 'avif' | 'webp' | 'compress' | false
86
+ imageOptimization: 'webp',
87
+ html: {
88
+ indent_size: 2,
89
+ wrap_line_length: 0
90
+ }
85
91
  }
86
92
  })
87
93
  ```
88
94
 
89
- | Option | Description | Type / Values | Default |
90
- | ------------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------- | ------------- |
91
- | `siteUrl` | サイトのベースURL(`Builder.url` に使用) | `string` | `''` |
92
- | `subdir` | サブディレクトリのパス | `string` | `''` |
93
- | `outDir` | ビルド出力先ディレクトリ。相対・絶対パス・ネスト(`htdocs/v2`)・上位(`../htdocs`)も指定可 | `string` | `'dist'` |
94
- | `debug` | デバッグモード(開発時のみ有効) | `boolean` | `false` |
95
- | `server.port` | 開発サーバーのポート番号 | `number` | `5555` |
96
- | `server.host` | 開発サーバーのホスト | `string` | `'localhost'` |
97
- | `server.startPath` | サーバー起動時に開くパス | `string` | `'/'` |
98
- | `build.clean` | ビルド前に `outDir` をクリーンするか(`false` にすると他リソースと共存可能) | `boolean` | `true` |
99
- | `build.imageOptimization` | 画像最適化の方式 | `'webp'` \| `'compress'` \| `false` | `'webp'` |
100
- | `build.imageOptions.webp` | WebP変換オプション([Sharp WebP options](https://sharp.pixelplumbing.com/api-output#webp)) | `object` | - |
101
- | `build.imageOptions.jpeg` | JPEG圧縮オプション([Sharp JPEG options](https://sharp.pixelplumbing.com/api-output#jpeg)) | `object` | - |
102
- | `build.imageOptions.png` | PNG圧縮オプション([Sharp PNG options](https://sharp.pixelplumbing.com/api-output#png)) | `object` | - |
95
+ | Option | Description | Type / Values | Default |
96
+ | ------------------------------------ | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ------------- |
97
+ | `siteUrl` | サイトのベースURL(`Builder.url` に使用) | `string` | `''` |
98
+ | `subdir` | サブディレクトリのパス | `string` | `''` |
99
+ | `outDir` | ビルド出力先ディレクトリ。相対・絶対パス・ネスト(`htdocs/v2`)・上位(`../htdocs`)も指定可 | `string` | `'dist'` |
100
+ | `debug` | デバッグモード(開発時のみ有効) | `boolean` | `false` |
101
+ | `server.port` | 開発サーバーのポート番号 | `number` | `5555` |
102
+ | `server.host` | 開発サーバーのホスト | `string` | `'localhost'` |
103
+ | `server.startPath` | サーバー起動時に開くパス | `string` | `'/'` |
104
+ | `build.clean` | ビルド前に `outDir` をクリーンするか(`false` にすると他リソースと共存可能) | `boolean` | `true` |
105
+ | `build.imageOptimization` | 画像最適化の方式 | `'avif'` \| `'webp'` \| `'compress'` \| `false` | `'webp'` |
106
+ | `build.imageOptions.avif` | AVIF変換オプション([Sharp AVIF options](https://sharp.pixelplumbing.com/api-output#avif)) | `object` | - |
107
+ | `build.imageOptions.webp` | WebP変換オプション([Sharp WebP options](https://sharp.pixelplumbing.com/api-output#webp)) | `object` | - |
108
+ | `build.imageOptions.jpeg` | JPEG圧縮オプション([Sharp JPEG options](https://sharp.pixelplumbing.com/api-output#jpeg)) | `object` | - |
109
+ | `build.imageOptions.png` | PNG圧縮オプション([Sharp PNG options](https://sharp.pixelplumbing.com/api-output#png)) | `object` | - |
110
+ | `build.imageInfo.artDirectionSuffix` | アートディレクション用画像のサフィックス(`_sp`, `_tb`, `_pc` など) | `string` | `'_sp'` |
111
+ | `build.imageOverrides` | 特定画像に個別のSharpオプションを適用(グローバルオプションに上書きマージ) | `Record<string, object>` | `{}` |
112
+ | `build.html` | HTML整形オプション([js-beautify html options](https://github.com/beautify-web/js-beautify#options)) | `object` | see below |
113
+ | `benchmark.image.threshold` | `pugkit bench` で警告を出すファイルサイズ上限 | `string` (`'200KB'` / `'1MB'` など) | `'300KB'` |
114
+ | `benchmark.image.qualityMin` | シミュレーションの quality 最小値 | `number` | `40` |
115
+ | `benchmark.image.qualityMax` | シミュレーションの quality 最大値 | `number` | `90` |
116
+ | `benchmark.image.qualityStep` | シミュレーションの quality ステップ幅 | `number` | `10` |
103
117
 
104
118
  ## Features
105
119
 
@@ -128,22 +142,31 @@ meta(property='og:url', content=Builder.url.href)
128
142
 
129
143
  #### imageInfo()
130
144
 
131
- `src/` 配下の画像のメタデータを取得します。`imageOptimization` の設定に応じて `src` が最適化後のパスに変換され、`@2x`/`_sp` 画像が存在する場合も自動的に解決されます。
145
+ `src/` 配下の画像のメタデータを取得します。`imageOptimization` の設定に応じて `src` が最適化後のパスに変換され、retina / アートディレクション画像が存在する場合も自動的に解決されます。
132
146
 
133
147
  ```pug
134
148
  - const info = imageInfo('/assets/img/hero.jpg')
135
149
  img(src=info.src width=info.width height=info.height alt='')
136
150
  ```
137
151
 
138
- | Property | Type | Description |
139
- | -------- | -------------------------------- | ----------------------------------------------------- |
140
- | `src` | `string` | 最適化設定に応じたパス(webpモード時は `.webp` パス) |
141
- | `width` | `number \| undefined` | 画像の幅(px) |
142
- | `height` | `number \| undefined` | 画像の高さ(px) |
143
- | `format` | `string \| undefined` | 画像フォーマット(`'jpg'` / `'png'` / `'svg'` など) |
144
- | `isSvg` | `boolean` | SVG かどうか |
145
- | `retina` | `{ src: string } \| null` | `@2x` 画像が存在する場合に自動検出 |
146
- | `sp` | `{ src, width, height } \| null` | `_sp` 画像が存在する場合に自動検出 |
152
+ | Property | Type | Description |
153
+ | --------- | -------------------------------------------------------- | ----------------------------------------------------------------------------- |
154
+ | `src` | `string` | 最適化設定に応じたパス(avifモード時は `.avif`、webpモード時は `.webp` パス) |
155
+ | `width` | `number \| undefined` | 画像の幅(px) |
156
+ | `height` | `number \| undefined` | 画像の高さ(px) |
157
+ | `format` | `string \| undefined` | 画像フォーマット(`'jpg'` / `'png'` / `'svg'` など) |
158
+ | `isSvg` | `boolean` | SVG かどうか |
159
+ | `retina` | `{ src: string, width: number, height: number } \| null` | `@2x` 画像が存在する場合に自動検出 |
160
+ | `variant` | `{ src: string, width: number, height: number } \| null` | `imageInfo.artDirectionSuffix` に応じて検出したアートディレクション画像 |
161
+
162
+ ```pug
163
+ - const info = imageInfo('/assets/img/hero.jpg')
164
+ //- retina srcset
165
+ - const srcset = info.retina ? `${info.src} 1x, ${info.retina.src} 2x` : undefined
166
+ //- アートディレクション
167
+ if info.variant
168
+ source(media='(max-width: 767px)' srcset=info.variant.src width=info.variant.width height=info.variant.height)
169
+ ```
147
170
 
148
171
  > `imageInfo()` は `src/` 配下の画像のみ対応しています。`public/` 配下の画像は非対応です。
149
172
 
@@ -197,10 +220,95 @@ npm install --save-dev typescript
197
220
 
198
221
  ビルド時に `src/` 配下の画像(JPEG・PNG)を自動的に最適化します。
199
222
 
223
+ - `'avif'` - PNG/JPEGをAVIFに変換
200
224
  - `'webp'` - PNG/JPEGをWebPに変換
201
225
  - `'compress'` - 元の形式を維持したまま圧縮
202
226
  - `false` - 最適化を無効化
203
227
 
228
+ #### 特定画像の個別オプション指定
229
+
230
+ `build.imageOverrides` で特定の画像にのみ別の圧縮オプションを適用できます。キーは `src/` からの相対パス、値はグローバル設定に上書きマージされる [Sharp](https://sharp.pixelplumbing.com/api-output) オプションオブジェクトです。
231
+
232
+ ```js
233
+ build: {
234
+ imageOptimization: 'webp',
235
+ imageOverrides: {
236
+ // 品質を上げたい画像
237
+ 'assets/img/bg-hero.jpg': { quality: 100 },
238
+ }
239
+ }
240
+ ```
241
+
242
+ ### Image Benchmark
243
+
244
+ `pugkit bench` は `src/` 配下の画像を現在の `imageOptimization` 設定でシミュレートし、`benchmark.image.threshold` を超えるファイルを検出・報告します。ビルドは行わずメモリ上で処理するため、実ファイルへの影響はありません。
245
+
246
+ ```sh
247
+ # src/ 全体をスキャン
248
+ pugkit bench
249
+
250
+ # ディレクトリ指定
251
+ pugkit bench src/assets/img/top/
252
+
253
+ # 単一ファイル指定
254
+ pugkit bench src/assets/img/hero.jpg
255
+ ```
256
+
257
+ 閾値を超えた画像に対して、qualityを変化させたシミュレーション結果と、閾値以内で品質を最大限維持できるqualityの推奨値を表示します。
258
+
259
+ ```
260
+ ⚠ 1 image(s) exceed threshold (300KB) with current config
261
+ ──────────────────────────┬─────────┬──────────
262
+ file │ format │ size
263
+ ──────────────────────────┼─────────┼──────────
264
+ assets/img/top/kv@2x.jpg │ webp │ 490 KB
265
+ ──────────────────────────┴─────────┴──────────
266
+
267
+ Simulation: assets/img/top/kv@2x.jpg (original: 2.62 MB 2800×1576)
268
+ format: webp (effort / other options follow config)
269
+ ─────────┬───────────┬───────────┬────────
270
+ quality │ size │ ratio │ budget
271
+ ─────────┼───────────┼───────────┼────────
272
+ 90 │ 490 KB │ -82% │ ⚠ ← current
273
+ 40 │ 132 KB │ -95% │ ✔
274
+ 80 │ 268 KB │ -90% │ ✔
275
+ ─────────┴───────────┴───────────┴────────
276
+ ★ Recommended: quality 80 → 268 KB (-90%)
277
+ ```
278
+
279
+ `pugkit.config.mjs` で閾値やシミュレーション範囲を設定できます。
280
+
281
+ ```js
282
+ export default defineConfig({
283
+ benchmark: {
284
+ image: {
285
+ threshold: '300KB', // 警告を出す上限サイズ
286
+ qualityMin: 50, // シミュレーション quality の最小値
287
+ qualityMax: 90, // シミュレーション quality の最大値
288
+ qualityStep: 10 // quality のステップ幅
289
+ }
290
+ }
291
+ })
292
+ ```
293
+
294
+ ### HTML Formatting
295
+
296
+ ビルド時にPugから生成されたHTMLを[js-beautify](https://github.com/beautify-web/js-beautify)で整形します。`build.html` で js-beautify の設定をそのまま渡せます。
297
+
298
+ ```js
299
+ build: {
300
+ html: {
301
+ indent_size: 2,
302
+ indent_with_tabs: false,
303
+ wrap_line_length: 0,
304
+ inline: [],
305
+ content_unformatted: ['script', 'style', 'pre']
306
+ }
307
+ }
308
+ ```
309
+
310
+ 利用可能なオプションは [js-beautify のドキュメント](https://github.com/beautify-web/js-beautify#options)を参照してください。
311
+
204
312
  ### SVG Optimization
205
313
 
206
314
  `icons/`以外に配置した SVG ファイルはSVGOで自動最適化されて出力されます。
package/cli/bench.mjs ADDED
@@ -0,0 +1,371 @@
1
+ import { glob } from 'glob'
2
+ import { stat, readFile } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import { resolve, extname, relative } from 'node:path'
5
+ import sharp from 'sharp'
6
+ import { logger } from '../utils/logger.mjs'
7
+ import { loadConfig } from '../config/index.mjs'
8
+
9
+ // ──────────────────────────────────────────────
10
+ // Helpers
11
+ // ──────────────────────────────────────────────
12
+
13
+ /**
14
+ * '400KB' / '1.5MB' / '102400' などをバイト数に変換
15
+ */
16
+ function parseThreshold(value) {
17
+ if (typeof value === 'number') return value
18
+ const str = String(value).trim().toUpperCase()
19
+ const match = str.match(/^([\d.]+)\s*(KB|MB|B)?$/)
20
+ if (!match) throw new Error(`Invalid threshold value: "${value}"`)
21
+ const num = parseFloat(match[1])
22
+ const unit = match[2] || 'B'
23
+ if (unit === 'MB') return Math.round(num * 1024 * 1024)
24
+ if (unit === 'KB') return Math.round(num * 1024)
25
+ return Math.round(num)
26
+ }
27
+
28
+ function formatBytes(bytes) {
29
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)} MB`
30
+ if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`
31
+ return `${bytes} B`
32
+ }
33
+
34
+ function formatRatio(original, compressed) {
35
+ const pct = ((original - compressed) / original) * 100
36
+ return `-${pct.toFixed(0)}%`
37
+ }
38
+
39
+ function pad(str, len) {
40
+ return String(str).padEnd(len)
41
+ }
42
+
43
+ function padL(str, len) {
44
+ return String(str).padStart(len)
45
+ }
46
+
47
+ /** ANSIカラー */
48
+ const c = {
49
+ reset: '\x1b[0m',
50
+ dim: '\x1b[2m',
51
+ cyan: '\x1b[36m',
52
+ green: '\x1b[32m',
53
+ yellow: '\x1b[33m',
54
+ red: '\x1b[31m',
55
+ bold: '\x1b[1m'
56
+ }
57
+
58
+ // ──────────────────────────────────────────────
59
+ // Sharp シミュレーション
60
+ // ──────────────────────────────────────────────
61
+
62
+ /**
63
+ * 画像を1回だけデコードして raw ピクセルデータとメタ情報を返す
64
+ * 以降のシミュレーションはこれを使い回す(ディスク再読み込みなし)
65
+ */
66
+ async function decodeImage(filePath) {
67
+ const fileBuffer = await readFile(filePath)
68
+ const { data, info } = await sharp(fileBuffer).raw().toBuffer({ resolveWithObject: true })
69
+ return { fileBuffer, rawData: data, info }
70
+ }
71
+
72
+ /**
73
+ * raw ピクセルデータから sharp インスタンスを生成
74
+ */
75
+ function fromRaw(rawData, info) {
76
+ return sharp(rawData, {
77
+ raw: { width: info.width, height: info.height, channels: info.channels }
78
+ })
79
+ }
80
+
81
+ /**
82
+ * 現在の config 設定でシミュレートしたバイト数を返す(rawData 使い回し)
83
+ */
84
+ async function getCurrentSimulatedSize(rawData, info, ext, config) {
85
+ const optimization = config.build.imageOptimization
86
+ const opts = config.build.imageOptions
87
+ const image = fromRaw(rawData, info)
88
+
89
+ if (optimization === 'webp') {
90
+ return (await image.webp(opts.webp).toBuffer()).length
91
+ }
92
+ if (optimization === 'avif') {
93
+ return (await image.avif(opts.avif).toBuffer()).length
94
+ }
95
+ if (ext === '.jpg' || ext === '.jpeg') {
96
+ return (await image.jpeg(opts.jpeg).toBuffer()).length
97
+ }
98
+ if (ext === '.png') {
99
+ return (await image.png(opts.png).toBuffer()).length
100
+ }
101
+ return null // GIF 等は非対応
102
+ }
103
+
104
+ /**
105
+ * 指定フォーマット・品質でシミュレートしたバイト数を返す(rawData 使い回し)
106
+ */
107
+ async function simulateFormat(rawData, info, format, quality, imageOptions) {
108
+ const image = fromRaw(rawData, info)
109
+ if (format === 'webp') {
110
+ return (await image.webp({ ...imageOptions.webp, quality }).toBuffer()).length
111
+ }
112
+ if (format === 'avif') {
113
+ return (await image.avif({ ...imageOptions.avif, quality }).toBuffer()).length
114
+ }
115
+ if (format === 'jpeg') {
116
+ return (await image.jpeg({ ...imageOptions.jpeg, quality }).toBuffer()).length
117
+ }
118
+ if (format === 'png') {
119
+ return (await image.png(imageOptions.png).toBuffer()).length
120
+ }
121
+ throw new Error(`Unknown format: ${format}`)
122
+ }
123
+
124
+ /**
125
+ * 現在の config から "current format" と "current quality" を取得
126
+ */
127
+ function getCurrentFormatInfo(filePath, config) {
128
+ const optimization = config.build.imageOptimization
129
+ if (optimization === 'webp') {
130
+ return { format: 'webp', quality: config.build.imageOptions.webp.quality }
131
+ }
132
+ if (optimization === 'avif') {
133
+ return { format: 'avif', quality: config.build.imageOptions.avif.quality }
134
+ }
135
+ const ext = extname(filePath).toLowerCase().replace('.', '')
136
+ const format = ext === 'jpg' ? 'jpeg' : ext
137
+ const quality = config.build.imageOptions[format]?.quality ?? null
138
+ return { format, quality }
139
+ }
140
+
141
+ // ──────────────────────────────────────────────
142
+ // ファイル収集
143
+ // ──────────────────────────────────────────────
144
+
145
+ async function collectImages(root, target) {
146
+ const srcDir = resolve(root, 'src')
147
+
148
+ if (!target) {
149
+ // デフォルト: src/ 以下全体
150
+ return await glob('**/*.{jpg,jpeg,png}', {
151
+ cwd: srcDir,
152
+ absolute: true,
153
+ ignore: ['**/_*/**']
154
+ })
155
+ }
156
+
157
+ const targetPath = resolve(root, target)
158
+
159
+ if (!existsSync(targetPath)) {
160
+ throw new Error(`Target not found: ${targetPath}`)
161
+ }
162
+
163
+ const s = await stat(targetPath)
164
+ if (s.isDirectory()) {
165
+ return await glob('**/*.{jpg,jpeg,png}', {
166
+ cwd: targetPath,
167
+ absolute: true,
168
+ ignore: ['**/_*/**']
169
+ })
170
+ }
171
+
172
+ // 単一ファイル
173
+ const ext = extname(targetPath).toLowerCase()
174
+ if (!/\.(jpg|jpeg|png)$/.test(ext)) {
175
+ throw new Error(`Unsupported file type: ${targetPath}`)
176
+ }
177
+ return [targetPath]
178
+ }
179
+
180
+ // ──────────────────────────────────────────────
181
+ // テーブル表示
182
+ // ──────────────────────────────────────────────
183
+
184
+ function printWarningTable(rows, thresholdStr) {
185
+ const W_FILE = Math.max(30, ...rows.map(r => r.rel.length))
186
+ const FMT_W = 7 // format 列幅
187
+ const SZ_W = 9 // size 列幅
188
+ // 各セル幅: col1=W_FILE+1, col2=FMT_W+2, col3=SZ_W+1
189
+ const sep = `${'─'.repeat(W_FILE + 1)}┬${'─'.repeat(FMT_W + 2)}┬${'─'.repeat(SZ_W + 1)}`
190
+
191
+ console.log()
192
+ console.log(`${c.yellow}⚠ ${rows.length} image(s) exceed threshold (${thresholdStr}) with current config${c.reset}`)
193
+ console.log(sep)
194
+ console.log(` ${pad('file', W_FILE)}│ ${pad('format', FMT_W)} │ ${padL('size', SZ_W)}`)
195
+ console.log(sep)
196
+ for (const r of rows) {
197
+ const sizeStr = formatBytes(r.simulatedSize) // ← simulatedSize を使用
198
+ console.log(` ${pad(r.rel, W_FILE)}│ ${pad(r.format, FMT_W)} │ ${c.yellow}${padL(sizeStr, SZ_W)}${c.reset}`)
199
+ }
200
+ console.log(sep)
201
+ }
202
+
203
+ function printSimulationTable(rows, originalSize, currentQuality) {
204
+ const Q_W = 7 // quality 列幅
205
+ const S_W = 9 // size 列幅
206
+ const R_W = 9 // ratio 列幅
207
+ // 各セル幅: col1=Q_W+2, col2=S_W+2, col3=R_W+2, col4=8
208
+ const sep = `${'─'.repeat(Q_W + 2)}┬${'─'.repeat(S_W + 2)}┬${'─'.repeat(R_W + 2)}┬${'─'.repeat(8)}`
209
+
210
+ console.log(sep)
211
+ console.log(` ${padL('quality', Q_W)} │ ${padL('size', S_W)} │ ${padL('ratio', R_W)} │ budget`)
212
+ console.log(sep)
213
+
214
+ for (const row of rows) {
215
+ const sizeStr = formatBytes(row.size)
216
+ const ratioStr = formatRatio(originalSize, row.size)
217
+ const budgetStr = row.ok ? `${c.green}✔${c.reset}` : `${c.yellow}⚠${c.reset}`
218
+ const isCurrent = row.quality === currentQuality
219
+ const currentMark = isCurrent ? `${c.dim} ← current${c.reset}` : ''
220
+
221
+ console.log(
222
+ ` ${padL(row.quality ?? '-', Q_W)} │ ${padL(sizeStr, S_W)} │ ${padL(ratioStr, R_W)} │ ${budgetStr}${currentMark}`
223
+ )
224
+ }
225
+ console.log(sep)
226
+ }
227
+
228
+ // ──────────────────────────────────────────────
229
+ // メイン
230
+ // ──────────────────────────────────────────────
231
+
232
+ export async function benchImage(options = {}) {
233
+ const { root = process.cwd(), target } = options
234
+
235
+ const config = await loadConfig(root)
236
+ const benchCfg = config.benchmark?.image ?? {}
237
+
238
+ // オプションは config のみで解決
239
+ const thresholdStr = benchCfg.threshold ?? '400KB'
240
+ const threshold = parseThreshold(String(thresholdStr))
241
+ const qualityMin = parseInt(benchCfg.qualityMin ?? 40)
242
+ const qualityMax = parseInt(benchCfg.qualityMax ?? 90)
243
+ const qualityStep = parseInt(benchCfg.qualityStep ?? 10)
244
+
245
+ // ファイル収集
246
+ let images
247
+ try {
248
+ images = await collectImages(root, target)
249
+ } catch (err) {
250
+ logger.error('bench', err.message)
251
+ process.exit(1)
252
+ }
253
+
254
+ if (images.length === 0) {
255
+ logger.skip('bench', 'No images found')
256
+ return
257
+ }
258
+
259
+ const srcDir = resolve(root, 'src')
260
+ logger.info('bench', `Scanning ${images.length} image(s) (threshold: ${thresholdStr})`)
261
+
262
+ // ── Step 1: 現在の config でシミュレート → 閾値超えを検出 ──
263
+ // 1枚ずつ順番に処理(大量画像でのメモリスパイクを防ぐ)
264
+ const overThreshold = []
265
+
266
+ for (const filePath of images) {
267
+ const ext = extname(filePath).toLowerCase()
268
+ const s = await stat(filePath)
269
+
270
+ // GIF 等はスキップ
271
+ if (!/\.(jpg|jpeg|png)$/.test(ext)) continue
272
+
273
+ // 1回だけデコード(このスコープを抜けると GC 対象)
274
+ const { rawData, info } = await decodeImage(filePath)
275
+ const simulatedSize = await getCurrentSimulatedSize(rawData, info, ext, config)
276
+
277
+ if (simulatedSize !== null && simulatedSize > threshold) {
278
+ const { format } = getCurrentFormatInfo(filePath, config)
279
+ overThreshold.push({
280
+ filePath,
281
+ rel: relative(srcDir, filePath),
282
+ originalSize: s.size,
283
+ simulatedSize,
284
+ format,
285
+ rawData, // Step 2 で使い回す
286
+ info
287
+ })
288
+ }
289
+ // rawData を overThreshold に入れない場合はここで GC される
290
+ }
291
+
292
+ if (overThreshold.length === 0) {
293
+ logger.success('bench', `All images are within threshold (${thresholdStr})`)
294
+ return
295
+ }
296
+
297
+ // 警告テーブル
298
+ printWarningTable(overThreshold, thresholdStr)
299
+
300
+ // imageOverrides サジェスト収集
301
+ const suggestions = []
302
+
303
+ // ── Step 2: 閾値超え画像ごとにシミュレーション ──
304
+ for (const item of overThreshold) {
305
+ const { filePath, rel, originalSize, rawData, info } = item
306
+ const { format: currentFormat, quality: currentQuality } = getCurrentFormatInfo(filePath, config)
307
+
308
+ // メタ情報は Step 1 で取得済みの info を使う(再読み込みなし)
309
+ const dim = info.width && info.height ? ` ${info.width}×${info.height}` : ''
310
+
311
+ console.log()
312
+ console.log(
313
+ `${c.bold}Simulation: ${rel}${c.reset} ${c.dim}(original: ${formatBytes(originalSize)}${dim})${c.reset}`
314
+ )
315
+
316
+ const rows = []
317
+
318
+ // 現在の設定を先頭に
319
+ rows.push({
320
+ format: currentFormat,
321
+ quality: currentQuality,
322
+ size: item.simulatedSize,
323
+ ok: item.simulatedSize <= threshold
324
+ })
325
+
326
+ // 現在のフォーマット固定で quality のみ変化(既存の imageOptions の他設定は維持)
327
+ for (let q = qualityMin; q <= qualityMax; q += qualityStep) {
328
+ if (q === currentQuality) continue
329
+ const size = await simulateFormat(rawData, info, currentFormat, q, config.build.imageOptions)
330
+ rows.push({ format: currentFormat, quality: q, size, ok: size <= threshold })
331
+ }
332
+
333
+ // quality 昇順ソート(current は固定で先頭)
334
+ const [current, ...rest] = rows
335
+ rest.sort((a, b) => a.quality - b.quality)
336
+
337
+ console.log(`${c.dim} format: ${currentFormat} (effort / other options follow config)${c.reset}`)
338
+ printSimulationTable([current, ...rest], originalSize, currentQuality)
339
+
340
+ // 推奨サジェスト:閾値以内で最も高い quality(品質を最大限残す)
341
+ const passing = rest.filter(r => r.ok)
342
+ if (passing.length > 0) {
343
+ const best = passing[passing.length - 1] // quality 昇順の末尾 = 最高品質
344
+ console.log(
345
+ `${c.green}★ Recommended: quality ${best.quality} → ${formatBytes(best.size)} (${formatRatio(originalSize, best.size)})${c.reset}`
346
+ )
347
+ suggestions.push({ rel, quality: best.quality })
348
+ } else {
349
+ console.log(`${c.yellow} No quality within threshold. Consider resizing the source image.${c.reset}`)
350
+ }
351
+ }
352
+
353
+ // ── imageOverrides スニペット出力 ──
354
+ if (suggestions.length > 0) {
355
+ console.log()
356
+ console.log(`${c.cyan}💡 Apply to pugkit.config.mjs:${c.reset}`)
357
+ console.log(`${c.dim} build: {${c.reset}`)
358
+ console.log(`${c.dim} imageOverrides: {${c.reset}`)
359
+ for (const s of suggestions) {
360
+ console.log(`${c.dim} '${s.rel}': { quality: ${s.quality} },${c.reset}`)
361
+ }
362
+ console.log(`${c.dim} }${c.reset}`)
363
+ console.log(`${c.dim} }${c.reset}`)
364
+ }
365
+
366
+ console.log()
367
+ }
368
+
369
+ export async function bench(options = {}) {
370
+ await benchImage(options)
371
+ }
package/cli/index.mjs CHANGED
@@ -7,6 +7,7 @@ import { cac } from 'cac'
7
7
  import { develop } from './develop.mjs'
8
8
  import { build } from './build.mjs'
9
9
  import { sprite } from './sprite.mjs'
10
+ import { benchImage } from './bench.mjs'
10
11
 
11
12
  const __filename = fileURLToPath(import.meta.url)
12
13
  const __dirname = path.dirname(__filename)
@@ -50,6 +51,15 @@ cli.command('sprite [root]', 'Generate SVG sprite').action(async root => {
50
51
  }
51
52
  })
52
53
 
54
+ cli.command('bench [target]', 'Benchmark images in src/ against pugkit.config.mjs').action(async target => {
55
+ try {
56
+ await benchImage({ root: process.cwd(), target })
57
+ } catch (err) {
58
+ console.error(err)
59
+ process.exit(1)
60
+ }
61
+ })
62
+
53
63
  cli.help()
54
64
  cli.version(pkgVersion())
55
65
  cli.parse()
@@ -11,13 +11,26 @@ export const defaultConfig = {
11
11
  build: {
12
12
  clean: true,
13
13
  imageOptimization: 'webp',
14
+ imageInfo: {
15
+ artDirectionSuffix: '_sp'
16
+ },
17
+ imageOverrides: {},
18
+ html: {
19
+ indent_size: 2,
20
+ indent_with_tabs: false,
21
+ max_preserve_newlines: 1,
22
+ preserve_newlines: false,
23
+ end_with_newline: true,
24
+ extra_liners: [],
25
+ wrap_line_length: 0,
26
+ inline: [],
27
+ content_unformatted: ['script', 'style', 'pre']
28
+ },
14
29
  imageOptions: {
15
30
  webp: {
16
- quality: 90,
17
- effort: 6,
31
+ quality: 80,
32
+ effort: 4,
18
33
  smartSubsample: true,
19
- method: 6,
20
- reductionEffort: 6,
21
34
  alphaQuality: 100,
22
35
  lossless: false
23
36
  },
@@ -31,7 +44,21 @@ export const defaultConfig = {
31
44
  compressionLevel: 6,
32
45
  adaptiveFiltering: true,
33
46
  palette: true
47
+ },
48
+ avif: {
49
+ quality: 70,
50
+ lossless: false,
51
+ effort: 4,
52
+ chromaSubsampling: '4:4:4'
34
53
  }
35
54
  }
55
+ },
56
+ benchmark: {
57
+ image: {
58
+ threshold: '300KB',
59
+ qualityMin: 40,
60
+ qualityMax: 90,
61
+ qualityStep: 10
62
+ }
36
63
  }
37
64
  }
package/config/main.mjs CHANGED
@@ -30,7 +30,26 @@ function mergeConfig(defaults, user) {
30
30
  imageOptions: {
31
31
  webp: { ...defaults.build.imageOptions.webp, ...(user.build?.imageOptions?.webp || {}) },
32
32
  jpeg: { ...defaults.build.imageOptions.jpeg, ...(user.build?.imageOptions?.jpeg || {}) },
33
- png: { ...defaults.build.imageOptions.png, ...(user.build?.imageOptions?.png || {}) }
33
+ png: { ...defaults.build.imageOptions.png, ...(user.build?.imageOptions?.png || {}) },
34
+ avif: { ...defaults.build.imageOptions.avif, ...(user.build?.imageOptions?.avif || {}) }
35
+ },
36
+ imageInfo: {
37
+ ...defaults.build.imageInfo,
38
+ ...(user.build?.imageInfo || {})
39
+ },
40
+ imageOverrides: {
41
+ ...defaults.build.imageOverrides,
42
+ ...(user.build?.imageOverrides || {})
43
+ },
44
+ html: {
45
+ ...defaults.build.html,
46
+ ...(user.build?.html || {})
47
+ }
48
+ },
49
+ benchmark: {
50
+ image: {
51
+ ...defaults.benchmark.image,
52
+ ...(user.benchmark?.image || {})
34
53
  }
35
54
  }
36
55
  }
@@ -41,7 +60,6 @@ function validateConfig(config) {
41
60
  const outDir = config.outDir
42
61
  const resolvedOutDir = isAbsolute(outDir) ? outDir : resolve(root, outDir)
43
62
 
44
- // Vite同様、outDirがrootと同一またはrootの親ディレクトリの場合に警告
45
63
  // relative() を使うことでWindows(バックスラッシュ)でも正しく動作する
46
64
  const isSameAsRoot = resolvedOutDir === root
47
65
  const relToRoot = relative(resolvedOutDir, root)
package/core/watcher.mjs CHANGED
@@ -217,9 +217,10 @@ class FileWatcher {
217
217
  async onImageUnlink(filePath) {
218
218
  const { paths, config } = this.context
219
219
  const relPath = relative(paths.src, filePath)
220
- const useWebp = config.build.imageOptimization === 'webp'
220
+ const optimization = config.build.imageOptimization
221
221
  const ext = extname(filePath)
222
- const destRelPath = useWebp ? relPath.replace(new RegExp(`\\${ext}$`, 'i'), '.webp') : relPath
222
+ const newExt = optimization === 'avif' || optimization === 'webp' ? `.${optimization}` : ext
223
+ const destRelPath = relPath.replace(new RegExp(`\\${ext}$`, 'i'), newExt)
223
224
  const distPath = resolve(paths.dist, destRelPath)
224
225
  await this.deleteDistFile(distPath, relPath)
225
226
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pugkit",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "A build tool for Pug-based projects",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/tasks/image.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { glob } from 'glob'
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
- import { relative, resolve, dirname, extname } from 'node:path'
3
+ import { relative, resolve, extname } from 'node:path'
4
4
  import sharp from 'sharp'
5
5
  import { logger } from '../utils/logger.mjs'
6
6
  import { ensureFileDir } from '../utils/file.mjs'
@@ -9,7 +9,7 @@ import { ensureFileDir } from '../utils/file.mjs'
9
9
  * 画像最適化タスク
10
10
  */
11
11
  export async function imageTask(context, options = {}) {
12
- const { paths, config, isProduction, cache } = context
12
+ const { paths, config, isProduction } = context
13
13
 
14
14
  const optimization = config.build.imageOptimization
15
15
 
@@ -40,6 +40,19 @@ export async function imageTask(context, options = {}) {
40
40
 
41
41
  logger.info('image', `Processing ${images.length} image(s)`)
42
42
 
43
+ if (optimization === 'avif' || optimization === 'webp') {
44
+ const outputMap = new Map()
45
+ for (const file of images) {
46
+ const rel = relative(paths.src, file)
47
+ const outPath = rel.replace(/\.(jpg|jpeg|png|gif)$/i, `.${optimization}`)
48
+ if (outputMap.has(outPath)) {
49
+ logger.warn('image', `Output conflict: "${outputMap.get(outPath)}" and "${rel}" both map to "${outPath}"`)
50
+ } else {
51
+ outputMap.set(outPath, rel)
52
+ }
53
+ }
54
+ }
55
+
43
56
  // 並列処理
44
57
  await Promise.all(images.map(file => processImage(file, context, optimization, isProduction)))
45
58
 
@@ -53,26 +66,31 @@ async function processImage(filePath, context, optimization, isProduction) {
53
66
  const { paths, config } = context
54
67
  const ext = extname(filePath).toLowerCase()
55
68
  const relativePath = relative(paths.src, filePath)
69
+ const overrideKey = relativePath.replace(/\\/g, '/')
70
+ const overrides = config.build.imageOverrides?.[overrideKey] ?? {}
56
71
 
57
72
  try {
58
73
  const image = sharp(filePath)
59
- const metadata = await image.metadata()
60
74
 
61
75
  let outputPath
62
76
  let outputImage
63
77
 
64
- if (optimization === 'webp') {
78
+ if (optimization === 'avif') {
79
+ // AVIF変換
80
+ outputPath = resolve(paths.dist, relativePath.replace(/\.(jpg|jpeg|png|gif)$/i, '.avif'))
81
+ outputImage = image.avif({ ...config.build.imageOptions.avif, ...overrides })
82
+ } else if (optimization === 'webp') {
65
83
  // WebP変換
66
84
  outputPath = resolve(paths.dist, relativePath.replace(/\.(jpg|jpeg|png|gif)$/i, '.webp'))
67
- outputImage = image.webp(config.build.imageOptions.webp)
85
+ outputImage = image.webp({ ...config.build.imageOptions.webp, ...overrides })
68
86
  } else {
69
87
  // 元の形式で圧縮
70
88
  outputPath = resolve(paths.dist, relativePath)
71
89
 
72
90
  if (ext === '.jpg' || ext === '.jpeg') {
73
- outputImage = image.jpeg(config.build.imageOptions.jpeg)
91
+ outputImage = image.jpeg({ ...config.build.imageOptions.jpeg, ...overrides })
74
92
  } else if (ext === '.png') {
75
- outputImage = image.png(config.build.imageOptions.png)
93
+ outputImage = image.png({ ...config.build.imageOptions.png, ...overrides })
76
94
  } else {
77
95
  // GIFなどはそのままコピー
78
96
  const buffer = await readFile(filePath)
package/tasks/pug.mjs CHANGED
@@ -69,7 +69,7 @@ async function processFile(filePath, context) {
69
69
  const imageInfo = createImageInfoHelper(filePath, paths, logger, config)
70
70
 
71
71
  const html = template({ Builder: builderVars, imageSize, imageInfo })
72
- const formatted = formatHtml(html)
72
+ const formatted = formatHtml(html, config.build.html)
73
73
  await generatePage(filePath, formatted, paths)
74
74
  } catch (error) {
75
75
  logger.error('pug', `Failed: ${basename(filePath)} - ${error.message}`)
@@ -3,16 +3,5 @@ const { html: beautifyHtml } = pkg
3
3
 
4
4
  export function formatHtml(html, options = {}) {
5
5
  const cleaned = html.replace(/[\u200B-\u200D\uFEFF]/g, '')
6
-
7
- return beautifyHtml(cleaned, {
8
- indent_size: options.tabWidth || 2,
9
- indent_with_tabs: options.useTabs || false,
10
- max_preserve_newlines: 1,
11
- preserve_newlines: false,
12
- end_with_newline: true,
13
- extra_liners: [],
14
- wrap_line_length: 0,
15
- inline: [],
16
- content_unformatted: ['script', 'style', 'pre']
17
- })
6
+ return beautifyHtml(cleaned, options)
18
7
  }
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from 'node:fs'
2
- import { resolve, dirname, basename, extname } from 'node:path'
2
+ import { resolve, relative, dirname, basename, extname } from 'node:path'
3
3
  import sizeOf from 'image-size'
4
4
 
5
5
  export function createImageSizeHelper(filePath, paths, logger) {
@@ -28,17 +28,20 @@ export function createImageSizeHelper(filePath, paths, logger) {
28
28
  return sizeOf(buffer)
29
29
  }
30
30
 
31
- logger?.warn('pug', `Image not found: ${basename(resolvedPath)}`)
31
+ logger?.warn('pug', `Image not found "${src}" in ${relative(paths.src, filePath)}`)
32
32
  return { width: undefined, height: undefined }
33
33
  } catch {
34
- logger?.warn('pug', `Failed to get image size: ${basename(src)}`)
34
+ logger?.warn('pug', `Failed to read "${src}" in ${relative(paths.src, filePath)}`)
35
35
  return { width: undefined, height: undefined }
36
36
  }
37
37
  }
38
38
  }
39
39
 
40
40
  export function createImageInfoHelper(filePath, paths, logger, config) {
41
- const useWebp = config?.build?.imageOptimization === 'webp'
41
+ const optimization = config?.build?.imageOptimization
42
+ const newExt = optimization === 'avif' || optimization === 'webp' ? `.${optimization}` : null
43
+ const artDirectionSuffix = config?.build?.imageInfo?.artDirectionSuffix ?? '_sp'
44
+
42
45
  return src => {
43
46
  const resolveImagePath = (imageSrc, baseDir) => {
44
47
  if (imageSrc.startsWith('/')) {
@@ -59,7 +62,7 @@ export function createImageInfoHelper(filePath, paths, logger, config) {
59
62
  format: undefined,
60
63
  isSvg: false,
61
64
  retina: null,
62
- sp: null
65
+ variant: null
63
66
  }
64
67
 
65
68
  try {
@@ -68,7 +71,7 @@ export function createImageInfoHelper(filePath, paths, logger, config) {
68
71
  const foundPath = findImageFile(resolvedPath)
69
72
 
70
73
  if (!foundPath) {
71
- logger?.warn('pug', `Image not found: ${basename(resolvedPath)}`)
74
+ logger?.warn('pug', `Image not found "${src}" in ${relative(paths.src, filePath)}`)
72
75
  return fallback
73
76
  }
74
77
 
@@ -78,40 +81,46 @@ export function createImageInfoHelper(filePath, paths, logger, config) {
78
81
  const ext = extname(src)
79
82
  const isSvg = ext.toLowerCase() === '.svg'
80
83
  const base = src.slice(0, -ext.length)
81
- // webp モード時は src 自体を .webp パスに変換(SVG は除外)
82
- const resolvedSrc = useWebp && !isSvg ? `${base}.webp` : src
84
+ // avif/webp モード時は src 自体を変換後のパスに変換(SVG は除外)
85
+ const resolvedSrc = !isSvg && newExt ? `${base}${newExt}` : src
83
86
 
84
- // 2x retina 画像の自動検出
87
+ // @2x retina 画像の自動検出
85
88
  let retina = null
86
89
  if (!isSvg) {
87
90
  const retinaSrc = `${base}@2x${ext}`
88
91
  const retinaResolvedPath = resolveImagePath(retinaSrc, pageDir)
89
92
  const retinaFoundPath = findImageFile(retinaResolvedPath)
90
93
  if (retinaFoundPath) {
91
- retina = { src: useWebp ? `${base}@2x.webp` : retinaSrc }
94
+ const retinaBuffer = readFileSync(retinaFoundPath)
95
+ const { width: rWidth, height: rHeight } = sizeOf(retinaBuffer)
96
+ retina = {
97
+ src: newExt ? `${base}@2x${newExt}` : retinaSrc,
98
+ width: rWidth,
99
+ height: rHeight
100
+ }
92
101
  }
93
102
  }
94
103
 
95
- // SP 画像の自動検出
96
- let sp = null
104
+ // アートディレクション画像の自動検出
105
+ let variant = null
97
106
  if (!isSvg) {
98
- const spSrc = `${base}_sp${ext}`
99
- const spResolvedPath = resolveImagePath(spSrc, pageDir)
100
- const spFoundPath = findImageFile(spResolvedPath)
101
- if (spFoundPath) {
102
- const spBuffer = readFileSync(spFoundPath)
103
- const { width: spWidth, height: spHeight } = sizeOf(spBuffer)
104
- sp = {
105
- src: useWebp ? `${base}_sp.webp` : spSrc,
106
- width: spWidth,
107
- height: spHeight
107
+ const variantSrc = `${base}${artDirectionSuffix}${ext}`
108
+ const variantResolvedPath = resolveImagePath(variantSrc, pageDir)
109
+ const variantFoundPath = findImageFile(variantResolvedPath)
110
+ if (variantFoundPath) {
111
+ const variantBuffer = readFileSync(variantFoundPath)
112
+ const { width: vWidth, height: vHeight } = sizeOf(variantBuffer)
113
+ variant = {
114
+ src: newExt ? `${base}${artDirectionSuffix}${newExt}` : variantSrc,
115
+ width: vWidth,
116
+ height: vHeight
108
117
  }
109
118
  }
110
119
  }
111
120
 
112
- return { src: resolvedSrc, width, height, format, isSvg, retina, sp }
121
+ return { src: resolvedSrc, width, height, format, isSvg, retina, variant }
113
122
  } catch {
114
- logger?.warn('pug', `Failed to get image info: ${basename(src)}`)
123
+ logger?.warn('pug', `Failed to read "${src}" in ${relative(paths.src, filePath)}`)
115
124
  return fallback
116
125
  }
117
126
  }