pugkit 1.2.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 +142 -32
- package/cli/bench.mjs +371 -0
- package/cli/index.mjs +10 -0
- package/config/defaults.mjs +32 -4
- package/config/main.mjs +43 -2
- package/core/context.mjs +6 -2
- package/core/server.mjs +1 -1
- package/core/watcher.mjs +3 -2
- package/package.json +1 -1
- package/tasks/image.mjs +25 -7
- package/tasks/pug.mjs +1 -1
- package/transform/html.mjs +1 -12
- package/transform/image-size.mjs +33 -24
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
|
|
|
@@ -55,7 +56,7 @@ project-root/
|
|
|
55
56
|
|
|
56
57
|
### File Naming Rules
|
|
57
58
|
|
|
58
|
-
`_`(アンダースコア)で始まるファイル・ディレクトリはビルド対象外です。それ以外のファイルは `src/` 配下のディレクトリ構成を維持したまま `dist
|
|
59
|
+
`_`(アンダースコア)で始まるファイル・ディレクトリはビルド対象外です。それ以外のファイルは `src/` 配下のディレクトリ構成を維持したまま `outDir`(デフォルト: `dist/`)に出力されます。
|
|
59
60
|
|
|
60
61
|
```
|
|
61
62
|
src/foo/style.scss → dist/foo/style.css
|
|
@@ -73,6 +74,7 @@ import { defineConfig } from 'pugkit'
|
|
|
73
74
|
export default defineConfig({
|
|
74
75
|
siteUrl: 'https://example.com/',
|
|
75
76
|
subdir: '',
|
|
77
|
+
outDir: 'dist',
|
|
76
78
|
debug: false,
|
|
77
79
|
server: {
|
|
78
80
|
port: 5555,
|
|
@@ -80,24 +82,38 @@ export default defineConfig({
|
|
|
80
82
|
startPath: '/'
|
|
81
83
|
},
|
|
82
84
|
build: {
|
|
83
|
-
|
|
85
|
+
// 'avif' | 'webp' | 'compress' | false
|
|
86
|
+
imageOptimization: 'webp',
|
|
87
|
+
html: {
|
|
88
|
+
indent_size: 2,
|
|
89
|
+
wrap_line_length: 0
|
|
90
|
+
}
|
|
84
91
|
}
|
|
85
92
|
})
|
|
86
93
|
```
|
|
87
94
|
|
|
88
|
-
| Option
|
|
89
|
-
|
|
|
90
|
-
| `siteUrl`
|
|
91
|
-
| `subdir`
|
|
92
|
-
| `
|
|
93
|
-
| `
|
|
94
|
-
| `server.
|
|
95
|
-
| `server.
|
|
96
|
-
| `
|
|
97
|
-
| `build.
|
|
98
|
-
| `build.
|
|
99
|
-
| `build.imageOptions.
|
|
100
|
-
| `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` |
|
|
101
117
|
|
|
102
118
|
## Features
|
|
103
119
|
|
|
@@ -126,22 +142,31 @@ meta(property='og:url', content=Builder.url.href)
|
|
|
126
142
|
|
|
127
143
|
#### imageInfo()
|
|
128
144
|
|
|
129
|
-
`src/` 配下の画像のメタデータを取得します。`imageOptimization` の設定に応じて `src`
|
|
145
|
+
`src/` 配下の画像のメタデータを取得します。`imageOptimization` の設定に応じて `src` が最適化後のパスに変換され、retina / アートディレクション画像が存在する場合も自動的に解決されます。
|
|
130
146
|
|
|
131
147
|
```pug
|
|
132
148
|
- const info = imageInfo('/assets/img/hero.jpg')
|
|
133
149
|
img(src=info.src width=info.width height=info.height alt='')
|
|
134
150
|
```
|
|
135
151
|
|
|
136
|
-
| Property
|
|
137
|
-
|
|
|
138
|
-
| `src`
|
|
139
|
-
| `width`
|
|
140
|
-
| `height`
|
|
141
|
-
| `format`
|
|
142
|
-
| `isSvg`
|
|
143
|
-
| `retina`
|
|
144
|
-
| `
|
|
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
|
+
```
|
|
145
170
|
|
|
146
171
|
> `imageInfo()` は `src/` 配下の画像のみ対応しています。`public/` 配下の画像は非対応です。
|
|
147
172
|
|
|
@@ -195,10 +220,95 @@ npm install --save-dev typescript
|
|
|
195
220
|
|
|
196
221
|
ビルド時に `src/` 配下の画像(JPEG・PNG)を自動的に最適化します。
|
|
197
222
|
|
|
223
|
+
- `'avif'` - PNG/JPEGをAVIFに変換
|
|
198
224
|
- `'webp'` - PNG/JPEGをWebPに変換
|
|
199
225
|
- `'compress'` - 元の形式を維持したまま圧縮
|
|
200
226
|
- `false` - 最適化を無効化
|
|
201
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
|
+
|
|
202
312
|
### SVG Optimization
|
|
203
313
|
|
|
204
314
|
`icons/`以外に配置した SVG ファイルはSVGOで自動最適化されて出力されます。
|
|
@@ -208,7 +318,7 @@ npm install --save-dev typescript
|
|
|
208
318
|
`src/`配下の`icons/`ディレクトリに配置したSVGを1つのスプライトファイルにまとめます。
|
|
209
319
|
|
|
210
320
|
```
|
|
211
|
-
src/assets/icons/arrow.svg →
|
|
321
|
+
`src/assets/icons/arrow.svg → <outDir>/assets/icons.svg#arrow`
|
|
212
322
|
```
|
|
213
323
|
|
|
214
324
|
```html
|
|
@@ -220,7 +330,7 @@ src/assets/icons/arrow.svg → dist/assets/icons.svg#arrow
|
|
|
220
330
|
|
|
221
331
|
### Public Directory
|
|
222
332
|
|
|
223
|
-
`public/` に置いたファイルはそのまま `
|
|
333
|
+
`public/` に置いたファイルはそのまま `outDir` のルートにコピーされます。faviconやOGP画像など最適化不要なファイルの置き場として使用します。
|
|
224
334
|
|
|
225
335
|
### Debug Mode
|
|
226
336
|
|
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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export const defaultConfig = {
|
|
2
2
|
siteUrl: '',
|
|
3
3
|
subdir: '',
|
|
4
|
+
outDir: 'dist',
|
|
4
5
|
debug: false,
|
|
5
6
|
server: {
|
|
6
7
|
port: 5555,
|
|
@@ -10,13 +11,26 @@ export const defaultConfig = {
|
|
|
10
11
|
build: {
|
|
11
12
|
clean: true,
|
|
12
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
|
+
},
|
|
13
29
|
imageOptions: {
|
|
14
30
|
webp: {
|
|
15
|
-
quality:
|
|
16
|
-
effort:
|
|
31
|
+
quality: 80,
|
|
32
|
+
effort: 4,
|
|
17
33
|
smartSubsample: true,
|
|
18
|
-
method: 6,
|
|
19
|
-
reductionEffort: 6,
|
|
20
34
|
alphaQuality: 100,
|
|
21
35
|
lossless: false
|
|
22
36
|
},
|
|
@@ -30,7 +44,21 @@ export const defaultConfig = {
|
|
|
30
44
|
compressionLevel: 6,
|
|
31
45
|
adaptiveFiltering: true,
|
|
32
46
|
palette: true
|
|
47
|
+
},
|
|
48
|
+
avif: {
|
|
49
|
+
quality: 70,
|
|
50
|
+
lossless: false,
|
|
51
|
+
effort: 4,
|
|
52
|
+
chromaSubsampling: '4:4:4'
|
|
33
53
|
}
|
|
34
54
|
}
|
|
55
|
+
},
|
|
56
|
+
benchmark: {
|
|
57
|
+
image: {
|
|
58
|
+
threshold: '300KB',
|
|
59
|
+
qualityMin: 40,
|
|
60
|
+
qualityMax: 90,
|
|
61
|
+
qualityStep: 10
|
|
62
|
+
}
|
|
35
63
|
}
|
|
36
64
|
}
|
package/config/main.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve } from 'node:path'
|
|
1
|
+
import { resolve, isAbsolute, relative } from 'node:path'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
3
|
import { defaultConfig } from './defaults.mjs'
|
|
4
4
|
|
|
@@ -20,6 +20,7 @@ function mergeConfig(defaults, user) {
|
|
|
20
20
|
return {
|
|
21
21
|
siteUrl: user.siteUrl || defaults.siteUrl,
|
|
22
22
|
subdir: user.subdir || defaults.subdir,
|
|
23
|
+
outDir: user.outDir !== undefined ? user.outDir : defaults.outDir,
|
|
23
24
|
debug: user.debug !== undefined ? user.debug : defaults.debug,
|
|
24
25
|
server: { ...defaults.server, ...(user.server || {}) },
|
|
25
26
|
build: {
|
|
@@ -29,16 +30,56 @@ function mergeConfig(defaults, user) {
|
|
|
29
30
|
imageOptions: {
|
|
30
31
|
webp: { ...defaults.build.imageOptions.webp, ...(user.build?.imageOptions?.webp || {}) },
|
|
31
32
|
jpeg: { ...defaults.build.imageOptions.jpeg, ...(user.build?.imageOptions?.jpeg || {}) },
|
|
32
|
-
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 || {})
|
|
33
53
|
}
|
|
34
54
|
}
|
|
35
55
|
}
|
|
36
56
|
}
|
|
37
57
|
|
|
58
|
+
function validateConfig(config) {
|
|
59
|
+
const root = config.root
|
|
60
|
+
const outDir = config.outDir
|
|
61
|
+
const resolvedOutDir = isAbsolute(outDir) ? outDir : resolve(root, outDir)
|
|
62
|
+
|
|
63
|
+
// relative() を使うことでWindows(バックスラッシュ)でも正しく動作する
|
|
64
|
+
const isSameAsRoot = resolvedOutDir === root
|
|
65
|
+
const relToRoot = relative(resolvedOutDir, root)
|
|
66
|
+
const isParentOfRoot = relToRoot !== '' && !relToRoot.startsWith('..')
|
|
67
|
+
|
|
68
|
+
if (isSameAsRoot || isParentOfRoot) {
|
|
69
|
+
console.warn(
|
|
70
|
+
`[pugkit] outDir "${outDir}" はプロジェクトルートと同じか親ディレクトリです。` +
|
|
71
|
+
`ソースファイルが上書きされる可能性があるため、別のディレクトリを指定してください。`
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return config
|
|
76
|
+
}
|
|
77
|
+
|
|
38
78
|
export async function loadConfig(root = process.cwd()) {
|
|
39
79
|
const userConfig = await loadUserConfig(root)
|
|
40
80
|
const config = mergeConfig(defaultConfig, userConfig)
|
|
41
81
|
config.root = root
|
|
82
|
+
validateConfig(config)
|
|
42
83
|
return config
|
|
43
84
|
}
|
|
44
85
|
|
package/core/context.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { resolve } from 'node:path'
|
|
1
|
+
import { resolve, isAbsolute } from 'node:path'
|
|
2
2
|
import { CacheManager } from './cache.mjs'
|
|
3
3
|
import { DependencyGraph } from './graph.mjs'
|
|
4
4
|
import { createGlobPatterns } from '../config/index.mjs'
|
|
@@ -12,10 +12,14 @@ export class BuildContext {
|
|
|
12
12
|
this.sassGraph = new DependencyGraph()
|
|
13
13
|
this.scriptGraph = new DependencyGraph()
|
|
14
14
|
|
|
15
|
+
const outDir = config.outDir ?? 'dist'
|
|
16
|
+
const resolvedOutDir = isAbsolute(outDir) ? outDir : resolve(config.root, outDir)
|
|
17
|
+
|
|
15
18
|
this.paths = {
|
|
16
19
|
root: config.root,
|
|
17
20
|
src: resolve(config.root, 'src'),
|
|
18
|
-
|
|
21
|
+
outDir: resolvedOutDir,
|
|
22
|
+
dist: config.subdir ? resolve(resolvedOutDir, config.subdir) : resolvedOutDir,
|
|
19
23
|
public: resolve(config.root, 'public')
|
|
20
24
|
}
|
|
21
25
|
|
package/core/server.mjs
CHANGED
|
@@ -50,7 +50,7 @@ export async function serverTask(context, options = {}) {
|
|
|
50
50
|
const startPath = (config.server?.startPath || '/').replace(/^\//, '')
|
|
51
51
|
const fullStartPath = subdir ? `${subdir}/${startPath}` : `/${startPath}`
|
|
52
52
|
|
|
53
|
-
const serveRoot =
|
|
53
|
+
const serveRoot = paths.outDir
|
|
54
54
|
|
|
55
55
|
const clients = new Set()
|
|
56
56
|
|
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
|
|
220
|
+
const optimization = config.build.imageOptimization
|
|
221
221
|
const ext = extname(filePath)
|
|
222
|
-
const
|
|
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
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,
|
|
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
|
|
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 === '
|
|
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}`)
|
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,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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
82
|
-
const resolvedSrc =
|
|
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
|
-
|
|
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
|
-
//
|
|
96
|
-
let
|
|
104
|
+
// アートディレクション画像の自動検出
|
|
105
|
+
let variant = null
|
|
97
106
|
if (!isSvg) {
|
|
98
|
-
const
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
102
|
-
const
|
|
103
|
-
const { width:
|
|
104
|
-
|
|
105
|
-
src:
|
|
106
|
-
width:
|
|
107
|
-
height:
|
|
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,
|
|
121
|
+
return { src: resolvedSrc, width, height, format, isSvg, retina, variant }
|
|
113
122
|
} catch {
|
|
114
|
-
logger?.warn('pug', `Failed to
|
|
123
|
+
logger?.warn('pug', `Failed to read "${src}" in ${relative(paths.src, filePath)}`)
|
|
115
124
|
return fallback
|
|
116
125
|
}
|
|
117
126
|
}
|