pugkit 1.3.0 → 1.5.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 +138 -30
- package/cli/bench.mjs +371 -0
- package/cli/index.mjs +10 -0
- package/config/defaults.mjs +31 -4
- package/config/main.mjs +20 -2
- package/core/context.mjs +1 -0
- package/core/watcher.mjs +24 -4
- package/package.json +1 -1
- package/tasks/image.mjs +34 -8
- package/tasks/pug.mjs +25 -5
- package/transform/html.mjs +1 -12
- package/transform/image-size.mjs +55 -28
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
|
-
|
|
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
|
|
90
|
-
|
|
|
91
|
-
| `siteUrl`
|
|
92
|
-
| `subdir`
|
|
93
|
-
| `outDir`
|
|
94
|
-
| `debug`
|
|
95
|
-
| `server.port`
|
|
96
|
-
| `server.host`
|
|
97
|
-
| `server.startPath`
|
|
98
|
-
| `build.clean`
|
|
99
|
-
| `build.imageOptimization`
|
|
100
|
-
| `build.imageOptions.
|
|
101
|
-
| `build.imageOptions.
|
|
102
|
-
| `build.imageOptions.
|
|
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`
|
|
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
|
|
139
|
-
|
|
|
140
|
-
| `src`
|
|
141
|
-
| `width`
|
|
142
|
-
| `height`
|
|
143
|
-
| `format`
|
|
144
|
-
| `isSvg`
|
|
145
|
-
| `retina`
|
|
146
|
-
| `
|
|
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()
|
package/config/defaults.mjs
CHANGED
|
@@ -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:
|
|
17
|
-
effort:
|
|
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/context.mjs
CHANGED
|
@@ -11,6 +11,7 @@ export class BuildContext {
|
|
|
11
11
|
this.graph = new DependencyGraph()
|
|
12
12
|
this.sassGraph = new DependencyGraph()
|
|
13
13
|
this.scriptGraph = new DependencyGraph()
|
|
14
|
+
this.imageGraph = new DependencyGraph() // Pug -> 画像ファイルの依存グラフ(dev時に構築)
|
|
14
15
|
|
|
15
16
|
const outDir = config.outDir ?? 'dist'
|
|
16
17
|
const resolvedOutDir = isAbsolute(outDir) ? outDir : resolve(config.root, outDir)
|
package/core/watcher.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import chokidar from 'chokidar'
|
|
|
2
2
|
import { rm } from 'node:fs/promises'
|
|
3
3
|
import { relative, resolve, basename, extname } from 'node:path'
|
|
4
4
|
import { logger } from '../utils/logger.mjs'
|
|
5
|
+
import { clearImageSizeCache } from '../transform/image-size.mjs'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* ファイル監視タスク
|
|
@@ -43,7 +44,7 @@ class FileWatcher {
|
|
|
43
44
|
ignoreInitial: true,
|
|
44
45
|
ignored: [/(^|[\/\\])\./, /node_modules/, /\.git/],
|
|
45
46
|
persistent: true,
|
|
46
|
-
awaitWriteFinish: { stabilityThreshold: 100, pollInterval:
|
|
47
|
+
awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 100 }
|
|
47
48
|
})
|
|
48
49
|
.on('change', filePath => this.handleChange(filePath))
|
|
49
50
|
.on('add', filePath => this.handleAdd(filePath))
|
|
@@ -116,10 +117,11 @@ class FileWatcher {
|
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
async onPugUnlink(filePath) {
|
|
119
|
-
const { paths, cache, graph } = this.context
|
|
120
|
+
const { paths, cache, graph, imageGraph } = this.context
|
|
120
121
|
const relPath = relative(paths.src, filePath)
|
|
121
122
|
cache.invalidatePugTemplate(filePath)
|
|
122
123
|
graph.clearDependencies(filePath)
|
|
124
|
+
imageGraph.clearDependencies(filePath)
|
|
123
125
|
if (basename(filePath).startsWith('_')) {
|
|
124
126
|
logger.info('unlink', relPath)
|
|
125
127
|
return
|
|
@@ -202,12 +204,19 @@ class FileWatcher {
|
|
|
202
204
|
// ---- Image ----
|
|
203
205
|
|
|
204
206
|
async onImageChange(filePath, event) {
|
|
207
|
+
clearImageSizeCache()
|
|
205
208
|
const relPath = relative(this.context.paths.src, filePath)
|
|
206
209
|
logger.info(event, `image: ${relPath}`)
|
|
207
210
|
try {
|
|
208
211
|
if (this.context.taskRegistry?.image) {
|
|
209
212
|
await this.context.taskRegistry.image(this.context, { files: [filePath] })
|
|
210
213
|
}
|
|
214
|
+
// imageGraph から影響を受ける Pug ファイルのみ再ビルド
|
|
215
|
+
// グラフ未構築(初回 dev 起動直後など)の場合は全 Pug を再ビルド
|
|
216
|
+
if (this.context.taskRegistry?.pug) {
|
|
217
|
+
const affected = this.context.imageGraph.getAffectedParents(filePath)
|
|
218
|
+
await this.context.taskRegistry.pug(this.context, { files: affected.length > 0 ? affected : undefined })
|
|
219
|
+
}
|
|
211
220
|
this.reload()
|
|
212
221
|
} catch (error) {
|
|
213
222
|
logger.error('watch', `Image processing failed: ${error.message}`)
|
|
@@ -215,13 +224,24 @@ class FileWatcher {
|
|
|
215
224
|
}
|
|
216
225
|
|
|
217
226
|
async onImageUnlink(filePath) {
|
|
227
|
+
clearImageSizeCache()
|
|
218
228
|
const { paths, config } = this.context
|
|
219
229
|
const relPath = relative(paths.src, filePath)
|
|
220
|
-
const
|
|
230
|
+
const optimization = config.build.imageOptimization
|
|
221
231
|
const ext = extname(filePath)
|
|
222
|
-
const
|
|
232
|
+
const newExt = optimization === 'avif' || optimization === 'webp' ? `.${optimization}` : ext
|
|
233
|
+
const destRelPath = relPath.replace(new RegExp(`\\${ext}$`, 'i'), newExt)
|
|
223
234
|
const distPath = resolve(paths.dist, destRelPath)
|
|
224
235
|
await this.deleteDistFile(distPath, relPath)
|
|
236
|
+
// imageGraph から影響を受ける Pug ファイルのみ再ビルド
|
|
237
|
+
if (this.context.taskRegistry?.pug) {
|
|
238
|
+
const affected = this.context.imageGraph.getAffectedParents(filePath)
|
|
239
|
+
this.context.imageGraph.clearDependencies(filePath)
|
|
240
|
+
if (affected.length > 0) {
|
|
241
|
+
await this.context.taskRegistry.pug(this.context, { files: affected })
|
|
242
|
+
this.reload()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
225
245
|
}
|
|
226
246
|
|
|
227
247
|
// ---- Public ----
|
package/package.json
CHANGED
package/tasks/image.mjs
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { glob } from 'glob'
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
3
|
-
import { relative, resolve,
|
|
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'
|
|
7
7
|
|
|
8
|
+
// libvips の内部キャッシュを制限してメモリ消費を抑える
|
|
9
|
+
sharp.cache({ memory: 50, files: 20, items: 200 })
|
|
10
|
+
sharp.concurrency(1)
|
|
11
|
+
|
|
8
12
|
/**
|
|
9
13
|
* 画像最適化タスク
|
|
10
14
|
*/
|
|
11
15
|
export async function imageTask(context, options = {}) {
|
|
12
|
-
const { paths, config, isProduction
|
|
16
|
+
const { paths, config, isProduction } = context
|
|
13
17
|
|
|
14
18
|
const optimization = config.build.imageOptimization
|
|
15
19
|
|
|
@@ -40,6 +44,19 @@ export async function imageTask(context, options = {}) {
|
|
|
40
44
|
|
|
41
45
|
logger.info('image', `Processing ${images.length} image(s)`)
|
|
42
46
|
|
|
47
|
+
if (optimization === 'avif' || optimization === 'webp') {
|
|
48
|
+
const outputMap = new Map()
|
|
49
|
+
for (const file of images) {
|
|
50
|
+
const rel = relative(paths.src, file)
|
|
51
|
+
const outPath = rel.replace(/\.(jpg|jpeg|png|gif)$/i, `.${optimization}`)
|
|
52
|
+
if (outputMap.has(outPath)) {
|
|
53
|
+
logger.warn('image', `Output conflict: "${outputMap.get(outPath)}" and "${rel}" both map to "${outPath}"`)
|
|
54
|
+
} else {
|
|
55
|
+
outputMap.set(outPath, rel)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
// 並列処理
|
|
44
61
|
await Promise.all(images.map(file => processImage(file, context, optimization, isProduction)))
|
|
45
62
|
|
|
@@ -49,30 +66,35 @@ export async function imageTask(context, options = {}) {
|
|
|
49
66
|
/**
|
|
50
67
|
* 画像を処理(最適化)
|
|
51
68
|
*/
|
|
52
|
-
async function processImage(filePath, context, optimization, isProduction) {
|
|
69
|
+
async function processImage(filePath, context, optimization, isProduction, retries = 3, retryDelay = 200) {
|
|
53
70
|
const { paths, config } = context
|
|
54
71
|
const ext = extname(filePath).toLowerCase()
|
|
55
72
|
const relativePath = relative(paths.src, filePath)
|
|
73
|
+
const overrideKey = relativePath.replace(/\\/g, '/')
|
|
74
|
+
const overrides = config.build.imageOverrides?.[overrideKey] ?? {}
|
|
56
75
|
|
|
57
76
|
try {
|
|
58
77
|
const image = sharp(filePath)
|
|
59
|
-
const metadata = await image.metadata()
|
|
60
78
|
|
|
61
79
|
let outputPath
|
|
62
80
|
let outputImage
|
|
63
81
|
|
|
64
|
-
if (optimization === '
|
|
82
|
+
if (optimization === 'avif') {
|
|
83
|
+
// AVIF変換
|
|
84
|
+
outputPath = resolve(paths.dist, relativePath.replace(/\.(jpg|jpeg|png|gif)$/i, '.avif'))
|
|
85
|
+
outputImage = image.avif({ ...config.build.imageOptions.avif, ...overrides })
|
|
86
|
+
} else if (optimization === 'webp') {
|
|
65
87
|
// WebP変換
|
|
66
88
|
outputPath = resolve(paths.dist, relativePath.replace(/\.(jpg|jpeg|png|gif)$/i, '.webp'))
|
|
67
|
-
outputImage = image.webp(config.build.imageOptions.webp)
|
|
89
|
+
outputImage = image.webp({ ...config.build.imageOptions.webp, ...overrides })
|
|
68
90
|
} else {
|
|
69
91
|
// 元の形式で圧縮
|
|
70
92
|
outputPath = resolve(paths.dist, relativePath)
|
|
71
93
|
|
|
72
94
|
if (ext === '.jpg' || ext === '.jpeg') {
|
|
73
|
-
outputImage = image.jpeg(config.build.imageOptions.jpeg)
|
|
95
|
+
outputImage = image.jpeg({ ...config.build.imageOptions.jpeg, ...overrides })
|
|
74
96
|
} else if (ext === '.png') {
|
|
75
|
-
outputImage = image.png(config.build.imageOptions.png)
|
|
97
|
+
outputImage = image.png({ ...config.build.imageOptions.png, ...overrides })
|
|
76
98
|
} else {
|
|
77
99
|
// GIFなどはそのままコピー
|
|
78
100
|
const buffer = await readFile(filePath)
|
|
@@ -86,6 +108,10 @@ async function processImage(filePath, context, optimization, isProduction) {
|
|
|
86
108
|
await ensureFileDir(outputPath)
|
|
87
109
|
await outputImage.toFile(outputPath)
|
|
88
110
|
} catch (error) {
|
|
111
|
+
if (retries > 0 && error.message.includes('unsupported image format')) {
|
|
112
|
+
await new Promise(resolve => setTimeout(resolve, retryDelay))
|
|
113
|
+
return processImage(filePath, context, optimization, isProduction, retries - 1, retryDelay * 2)
|
|
114
|
+
}
|
|
89
115
|
logger.error('image', `Failed to process ${relativePath}: ${error.message}`)
|
|
90
116
|
}
|
|
91
117
|
}
|
package/tasks/pug.mjs
CHANGED
|
@@ -24,7 +24,7 @@ export async function pugTask(context, options = {}) {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
logger.info('pug', `Building ${changed.length} file(s)`)
|
|
27
|
-
await
|
|
27
|
+
await runWithConcurrency(changed, 8, file => processFile(file, context))
|
|
28
28
|
logger.success('pug', `Built ${changed.length} file(s)`)
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -48,8 +48,18 @@ async function resolveChangedFiles(files, targetFiles, cache, isProduction) {
|
|
|
48
48
|
return files
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
async function runWithConcurrency(items, concurrency, fn) {
|
|
52
|
+
let i = 0
|
|
53
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
54
|
+
while (i < items.length) {
|
|
55
|
+
await fn(items[i++])
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
await Promise.all(workers)
|
|
59
|
+
}
|
|
60
|
+
|
|
51
61
|
async function processFile(filePath, context) {
|
|
52
|
-
const { paths, config, cache, graph } = context
|
|
62
|
+
const { paths, config, cache, graph, imageGraph } = context
|
|
53
63
|
|
|
54
64
|
try {
|
|
55
65
|
let template = cache.getPugTemplate(filePath)
|
|
@@ -65,11 +75,21 @@ async function processFile(filePath, context) {
|
|
|
65
75
|
}
|
|
66
76
|
|
|
67
77
|
const builderVars = createBuilderVars(filePath, paths, config)
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
|
|
79
|
+
// dev 時のみ: imageGraph に Pug->画像 の依存を記録して画像変更時の最小再ビルドに使う
|
|
80
|
+
const accessedImages = new Set()
|
|
81
|
+
const onAccess = context.isDevelopment ? imgPath => accessedImages.add(imgPath) : undefined
|
|
82
|
+
const imageSize = createImageSizeHelper(filePath, paths, logger, { onAccess })
|
|
83
|
+
const imageInfo = createImageInfoHelper(filePath, paths, logger, config, { onAccess })
|
|
70
84
|
|
|
71
85
|
const html = template({ Builder: builderVars, imageSize, imageInfo })
|
|
72
|
-
|
|
86
|
+
|
|
87
|
+
if (context.isDevelopment && imageGraph) {
|
|
88
|
+
imageGraph.clearDependencies(filePath)
|
|
89
|
+
accessedImages.forEach(imgPath => imageGraph.addDependency(filePath, imgPath))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const formatted = formatHtml(html, config.build.html)
|
|
73
93
|
await generatePage(filePath, formatted, paths)
|
|
74
94
|
} catch (error) {
|
|
75
95
|
logger.error('pug', `Failed: ${basename(filePath)} - ${error.message}`)
|
package/transform/html.mjs
CHANGED
|
@@ -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
|
}
|
package/transform/image-size.mjs
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
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
|
+
// ビルドセッション内で画像ファイルの内容をキャッシュし、同じファイルの重複読み込みを防ぐ
|
|
6
|
+
const _imageBufferCache = new Map()
|
|
7
|
+
|
|
8
|
+
function readImageCached(filePath) {
|
|
9
|
+
if (_imageBufferCache.has(filePath)) return _imageBufferCache.get(filePath)
|
|
10
|
+
const buf = readFileSync(filePath)
|
|
11
|
+
_imageBufferCache.set(filePath, buf)
|
|
12
|
+
return buf
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function clearImageSizeCache() {
|
|
16
|
+
_imageBufferCache.clear()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createImageSizeHelper(filePath, paths, logger, { onAccess } = {}) {
|
|
6
20
|
return src => {
|
|
7
21
|
const resolveImagePath = (imageSrc, baseDir) => {
|
|
8
22
|
if (imageSrc.startsWith('/')) {
|
|
@@ -24,21 +38,25 @@ export function createImageSizeHelper(filePath, paths, logger) {
|
|
|
24
38
|
const foundPath = findImageFile(resolvedPath)
|
|
25
39
|
|
|
26
40
|
if (foundPath) {
|
|
27
|
-
|
|
41
|
+
onAccess?.(foundPath)
|
|
42
|
+
const buffer = readImageCached(foundPath)
|
|
28
43
|
return sizeOf(buffer)
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
logger?.warn('pug', `Image not found
|
|
46
|
+
logger?.warn('pug', `Image not found "${src}" in ${relative(paths.src, filePath)}`)
|
|
32
47
|
return { width: undefined, height: undefined }
|
|
33
48
|
} catch {
|
|
34
|
-
logger?.warn('pug', `Failed to
|
|
49
|
+
logger?.warn('pug', `Failed to read "${src}" in ${relative(paths.src, filePath)}`)
|
|
35
50
|
return { width: undefined, height: undefined }
|
|
36
51
|
}
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
54
|
|
|
40
|
-
export function createImageInfoHelper(filePath, paths, logger, config) {
|
|
41
|
-
const
|
|
55
|
+
export function createImageInfoHelper(filePath, paths, logger, config, { onAccess } = {}) {
|
|
56
|
+
const optimization = config?.build?.imageOptimization
|
|
57
|
+
const newExt = optimization === 'avif' || optimization === 'webp' ? `.${optimization}` : null
|
|
58
|
+
const artDirectionSuffix = config?.build?.imageInfo?.artDirectionSuffix ?? '_sp'
|
|
59
|
+
|
|
42
60
|
return src => {
|
|
43
61
|
const resolveImagePath = (imageSrc, baseDir) => {
|
|
44
62
|
if (imageSrc.startsWith('/')) {
|
|
@@ -59,7 +77,7 @@ export function createImageInfoHelper(filePath, paths, logger, config) {
|
|
|
59
77
|
format: undefined,
|
|
60
78
|
isSvg: false,
|
|
61
79
|
retina: null,
|
|
62
|
-
|
|
80
|
+
variant: null
|
|
63
81
|
}
|
|
64
82
|
|
|
65
83
|
try {
|
|
@@ -68,50 +86,59 @@ export function createImageInfoHelper(filePath, paths, logger, config) {
|
|
|
68
86
|
const foundPath = findImageFile(resolvedPath)
|
|
69
87
|
|
|
70
88
|
if (!foundPath) {
|
|
71
|
-
logger?.warn('pug', `Image not found
|
|
89
|
+
logger?.warn('pug', `Image not found "${src}" in ${relative(paths.src, filePath)}`)
|
|
72
90
|
return fallback
|
|
73
91
|
}
|
|
74
92
|
|
|
75
|
-
const buffer =
|
|
93
|
+
const buffer = readImageCached(foundPath)
|
|
94
|
+
onAccess?.(foundPath)
|
|
76
95
|
const { width, height, type: format } = sizeOf(buffer)
|
|
77
96
|
|
|
78
97
|
const ext = extname(src)
|
|
79
98
|
const isSvg = ext.toLowerCase() === '.svg'
|
|
80
99
|
const base = src.slice(0, -ext.length)
|
|
81
|
-
// webp モード時は src
|
|
82
|
-
const resolvedSrc =
|
|
100
|
+
// avif/webp モード時は src 自体を変換後のパスに変換(SVG は除外)
|
|
101
|
+
const resolvedSrc = !isSvg && newExt ? `${base}${newExt}` : src
|
|
83
102
|
|
|
84
|
-
// 2x retina 画像の自動検出
|
|
103
|
+
// @2x retina 画像の自動検出
|
|
85
104
|
let retina = null
|
|
86
105
|
if (!isSvg) {
|
|
87
106
|
const retinaSrc = `${base}@2x${ext}`
|
|
88
107
|
const retinaResolvedPath = resolveImagePath(retinaSrc, pageDir)
|
|
89
108
|
const retinaFoundPath = findImageFile(retinaResolvedPath)
|
|
90
109
|
if (retinaFoundPath) {
|
|
91
|
-
|
|
110
|
+
const retinaBuffer = readImageCached(retinaFoundPath)
|
|
111
|
+
onAccess?.(retinaFoundPath)
|
|
112
|
+
const { width: rWidth, height: rHeight } = sizeOf(retinaBuffer)
|
|
113
|
+
retina = {
|
|
114
|
+
src: newExt ? `${base}@2x${newExt}` : retinaSrc,
|
|
115
|
+
width: rWidth,
|
|
116
|
+
height: rHeight
|
|
117
|
+
}
|
|
92
118
|
}
|
|
93
119
|
}
|
|
94
120
|
|
|
95
|
-
//
|
|
96
|
-
let
|
|
121
|
+
// アートディレクション画像の自動検出
|
|
122
|
+
let variant = null
|
|
97
123
|
if (!isSvg) {
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
124
|
+
const variantSrc = `${base}${artDirectionSuffix}${ext}`
|
|
125
|
+
const variantResolvedPath = resolveImagePath(variantSrc, pageDir)
|
|
126
|
+
const variantFoundPath = findImageFile(variantResolvedPath)
|
|
127
|
+
if (variantFoundPath) {
|
|
128
|
+
const variantBuffer = readImageCached(variantFoundPath)
|
|
129
|
+
onAccess?.(variantFoundPath)
|
|
130
|
+
const { width: vWidth, height: vHeight } = sizeOf(variantBuffer)
|
|
131
|
+
variant = {
|
|
132
|
+
src: newExt ? `${base}${artDirectionSuffix}${newExt}` : variantSrc,
|
|
133
|
+
width: vWidth,
|
|
134
|
+
height: vHeight
|
|
108
135
|
}
|
|
109
136
|
}
|
|
110
137
|
}
|
|
111
138
|
|
|
112
|
-
return { src: resolvedSrc, width, height, format, isSvg, retina,
|
|
139
|
+
return { src: resolvedSrc, width, height, format, isSvg, retina, variant }
|
|
113
140
|
} catch {
|
|
114
|
-
logger?.warn('pug', `Failed to
|
|
141
|
+
logger?.warn('pug', `Failed to read "${src}" in ${relative(paths.src, filePath)}`)
|
|
115
142
|
return fallback
|
|
116
143
|
}
|
|
117
144
|
}
|