pugkit 1.0.0-beta.3 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  ## About
4
4
 
5
5
  pugkitは静的サイト制作に特化したビルドツールです。
6
- 納品向きの綺麗なHTMLと自由度の高いアセットを出力します。
6
+ 納品向きの綺麗なHTMLと、ファイル構成に制約のないアセットファイルを出力可能です。
7
7
 
8
8
  ## Installation
9
9
 
@@ -27,7 +27,7 @@ pugkit watch
27
27
 
28
28
  ### Production Build
29
29
 
30
- 最適化されたファイルを生成します。
30
+ 製品用ファイルを生成します。
31
31
 
32
32
  ```bash
33
33
  pugkit build
@@ -92,7 +92,7 @@ export default {
92
92
 
93
93
  ### Pug Templates
94
94
 
95
- Pugテンプレート内では、`Builder`オブジェクトと`imageSize()`関数が使用できます。
95
+ Pugテンプレート内では、`Builder` オブジェクトと `imageInfo()` 関数が使用できます。
96
96
 
97
97
  #### Builder Object
98
98
 
@@ -113,15 +113,61 @@ meta(property='og:url', content=Builder.url.href)
113
113
  | `Builder.url.pathname` | 現在のページのパス | `/about/` |
114
114
  | `Builder.url.href` | 完全なURL | `https://example.com/subdirectory/about/` |
115
115
 
116
- #### imageSize() Function
116
+ #### imageInfo()
117
117
 
118
- 画像ファイルのサイズを自動取得し、CLSを防ぎます。
118
+ `src/` 配下の画像のメタデータを取得します。`imageOptimization` の設定に応じて `src` が最適化後のパスに変換され、`@2x`/`_sp` 画像が存在する場合も自動的に解決されます。
119
119
 
120
120
  ```pug
121
- - const size = imageSize('/assets/img/photo.jpg')
122
- img(src='/assets/img/photo.jpg', width=size.width, height=size.height, alt='')
121
+ - const info = imageInfo('/assets/img/hero.jpg')
122
+ img(src=info.src width=info.width height=info.height alt='')
123
123
  ```
124
124
 
125
+ **返り値**
126
+
127
+ | Property | Type | Description |
128
+ | -------- | -------------------------------- | ----------------------------------------------------- |
129
+ | `src` | `string` | 最適化設定に応じたパス(webpモード時は `.webp` パス) |
130
+ | `width` | `number \| undefined` | 画像の幅(px) |
131
+ | `height` | `number \| undefined` | 画像の高さ(px) |
132
+ | `format` | `string \| undefined` | 画像フォーマット(`'jpg'` / `'png'` / `'svg'` など) |
133
+ | `isSvg` | `boolean` | SVG かどうか |
134
+ | `retina` | `{ src: string } \| null` | `@2x` 画像が存在する場合に自動検出 |
135
+ | `sp` | `{ src, width, height } \| null` | `_sp` 画像が存在する場合に自動検出 |
136
+
137
+ > **Note:** `imageInfo()`は`src/`配下の画像のみ対応しています。`public/`配下の画像は非対応です。
138
+
139
+ ### SVG Sprite
140
+
141
+ `src/` 配下の `icons/` ディレクトリに配置した SVG ファイルを1つのスプライトにまとめます。
142
+
143
+ #### ディレクトリ構成
144
+
145
+ ```
146
+ src/
147
+ └── assets/
148
+ └── icons/ # ここに SVG を配置
149
+ ├── arrow.svg
150
+ └── close.svg
151
+ ```
152
+
153
+ #### 出力
154
+
155
+ ```
156
+ dist/
157
+ └── assets/
158
+ └── icons.svg # 自動生成されるスプライト
159
+ ```
160
+
161
+ #### 使い方
162
+
163
+ ```html
164
+ <svg><use href="assets/icons.svg#arrow"></use></svg>
165
+ ```
166
+
167
+ - `icons/` ディレクトリは `src/` 配下の任意の場所に配置できます
168
+ - SVG ファイル名がそのまま `<symbol id>` になります
169
+ - `fill` / `stroke` は自動的に `currentColor` に変換されます(SVGの色をCSSで制御可能)
170
+
125
171
  ### Image Optimization
126
172
 
127
173
  ビルド時に自動的に画像を最適化します。
@@ -132,7 +178,7 @@ img(src='/assets/img/photo.jpg', width=size.width, height=size.height, alt='')
132
178
 
133
179
  ### File Naming Rules
134
180
 
135
- - `_`(アンダースコア)で始まるファイルは部分テンプレートとして扱われます
181
+ - `_`(アンダースコア)で始まるファイルはテンプレートとして扱われます
136
182
  - `_`で始まるディレクトリ内のファイルもビルド対象外です
137
183
  - 通常のファイル名のみがビルドされます
138
184
 
package/core/server.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import http from 'node:http'
2
2
  import path from 'node:path'
3
- import { existsSync, readFileSync } from 'node:fs'
4
- import { mkdir } from 'node:fs/promises'
3
+ import { existsSync } from 'node:fs'
4
+ import { mkdir, readFile } from 'node:fs/promises'
5
5
  import sirv from 'sirv'
6
6
  import { logger } from '../utils/logger.mjs'
7
7
 
@@ -19,6 +19,7 @@ const liveReloadScript = `<script>
19
19
  es.addEventListener('css-update', function() {
20
20
  document.querySelectorAll('link[rel="stylesheet"]').forEach(function(link) {
21
21
  var url = new URL(link.href);
22
+ if (url.origin !== location.origin) return;
22
23
  url.searchParams.set('t', Date.now());
23
24
  link.href = url.toString();
24
25
  });
@@ -27,6 +28,9 @@ const liveReloadScript = `<script>
27
28
  es.close();
28
29
  setTimeout(function() { location.reload(); }, 1000);
29
30
  };
31
+ window.addEventListener('beforeunload', function() {
32
+ es.close();
33
+ });
30
34
  })();
31
35
  </script>`
32
36
 
@@ -71,7 +75,11 @@ export async function serverTask(context, options = {}) {
71
75
  })
72
76
  res.write('retry: 1000\n\n')
73
77
  clients.add(res)
74
- req.on('close', () => clients.delete(res))
78
+
79
+ const cleanup = () => clients.delete(res)
80
+ req.on('close', cleanup)
81
+ req.socket.on('close', cleanup)
82
+ res.on('error', cleanup)
75
83
  return
76
84
  }
77
85
 
@@ -85,22 +93,26 @@ export async function serverTask(context, options = {}) {
85
93
  const htmlFile = candidates.find(p => p.endsWith('.html') && existsSync(p))
86
94
 
87
95
  if (htmlFile) {
88
- try {
89
- let html = readFileSync(htmlFile, 'utf-8')
90
- html = html.includes('</body>')
91
- ? html.replace('</body>', liveReloadScript + '</body>')
92
- : html + liveReloadScript
93
- const buf = Buffer.from(html, 'utf-8')
94
- res.writeHead(200, {
95
- 'Content-Type': 'text/html; charset=utf-8',
96
- 'Content-Length': buf.length,
97
- 'Cache-Control': 'no-cache'
96
+ readFile(htmlFile, 'utf-8')
97
+ .then(html => {
98
+ html = html.includes('</body>')
99
+ ? html.replace('</body>', liveReloadScript + '</body>')
100
+ : html + liveReloadScript
101
+ const buf = Buffer.from(html, 'utf-8')
102
+ res.writeHead(200, {
103
+ 'Content-Type': 'text/html; charset=utf-8',
104
+ 'Content-Length': buf.length,
105
+ 'Cache-Control': 'no-cache'
106
+ })
107
+ res.end(buf)
98
108
  })
99
- res.end(buf)
100
- return
101
- } catch {
102
- // 読み込み失敗時は sirv にフォールスルー
103
- }
109
+ .catch(() => {
110
+ staticServe(req, res, () => {
111
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
112
+ res.end('404 Not Found')
113
+ })
114
+ })
115
+ return
104
116
  }
105
117
 
106
118
  // ── sirv で静的ファイルを配信 ───────────────────────
@@ -113,7 +125,11 @@ export async function serverTask(context, options = {}) {
113
125
  function broadcast(event, data = '') {
114
126
  const msg = `event: ${event}\ndata: ${data}\n\n`
115
127
  for (const res of clients) {
116
- res.write(msg)
128
+ try {
129
+ res.write(msg)
130
+ } catch {
131
+ clients.delete(res)
132
+ }
117
133
  }
118
134
  }
119
135
 
package/core/watcher.mjs CHANGED
@@ -111,7 +111,7 @@ class FileWatcher {
111
111
  ignoreInitial: true,
112
112
  ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/],
113
113
  persistent: true,
114
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
114
+ awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
115
115
  })
116
116
 
117
117
  watcher.on('change', async path => {
@@ -128,8 +128,9 @@ class FileWatcher {
128
128
  await this.context.taskRegistry.sass(this.context)
129
129
  }
130
130
 
131
- // CSSはインジェクション(リロードせずに更新)
132
- this.injectCSS()
131
+ // CSSはインジェクション
132
+ const cssUrlPath = '/' + relPath.replace(/\\/g, '/').replace(/\.scss$/, '.css')
133
+ this.injectCSS(cssUrlPath)
133
134
  } catch (error) {
134
135
  logger.error('watch', `Sass build failed: ${error.message}`)
135
136
  }
@@ -294,13 +295,11 @@ class FileWatcher {
294
295
  }
295
296
 
296
297
  /**
297
- * CSSインジェクション(リロードなし)
298
+ * CSSインジェクション
298
299
  */
299
300
  injectCSS() {
300
301
  if (this.context.server) {
301
- setTimeout(() => {
302
- this.context.server.reloadCSS()
303
- }, 100)
302
+ this.context.server.reloadCSS()
304
303
  }
305
304
  }
306
305
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pugkit",
3
- "version": "1.0.0-beta.3",
3
+ "version": "1.0.1",
4
4
  "description": "A build tool for Pug-based projects",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/tasks/pug.mjs CHANGED
@@ -3,7 +3,7 @@ import { basename } from 'node:path'
3
3
  import { compilePugFile } from '../transform/pug.mjs'
4
4
  import { formatHtml } from '../transform/html.mjs'
5
5
  import { createBuilderVars } from '../transform/builder-vars.mjs'
6
- import { createImageSizeHelper } from '../transform/image-size.mjs'
6
+ import { createImageSizeHelper, createImageInfoHelper } from '../transform/image-size.mjs'
7
7
  import { generatePage } from '../generate/page.mjs'
8
8
  import { logger } from '../utils/logger.mjs'
9
9
 
@@ -68,8 +68,9 @@ async function processFile(filePath, context) {
68
68
 
69
69
  const builderVars = createBuilderVars(filePath, paths, config)
70
70
  const imageSize = createImageSizeHelper(filePath, paths, logger)
71
+ const imageInfo = createImageInfoHelper(filePath, paths, logger, config)
71
72
 
72
- const html = template({ Builder: builderVars, imageSize })
73
+ const html = template({ Builder: builderVars, imageSize, imageInfo })
73
74
  const formatted = await formatHtml(html)
74
75
  await generatePage(filePath, formatted, paths)
75
76
  } catch (error) {
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from 'node:fs'
2
- import { resolve, dirname, basename } from 'node:path'
2
+ import { resolve, dirname, basename, extname } from 'node:path'
3
3
  import sizeOf from 'image-size'
4
4
 
5
5
  export function createImageSizeHelper(filePath, paths, logger) {
@@ -36,3 +36,83 @@ export function createImageSizeHelper(filePath, paths, logger) {
36
36
  }
37
37
  }
38
38
  }
39
+
40
+ export function createImageInfoHelper(filePath, paths, logger, config) {
41
+ const useWebp = config?.build?.imageOptimization === 'webp'
42
+ return src => {
43
+ const resolveImagePath = (imageSrc, baseDir) => {
44
+ if (imageSrc.startsWith('/')) {
45
+ return resolve(paths.src, imageSrc.slice(1))
46
+ }
47
+ return resolve(baseDir, imageSrc)
48
+ }
49
+
50
+ const findImageFile = resolvedPath => {
51
+ if (existsSync(resolvedPath)) return resolvedPath
52
+ return null
53
+ }
54
+
55
+ const fallback = {
56
+ src,
57
+ width: undefined,
58
+ height: undefined,
59
+ format: undefined,
60
+ isSvg: false,
61
+ retina: null,
62
+ sp: null
63
+ }
64
+
65
+ try {
66
+ const pageDir = dirname(filePath)
67
+ const resolvedPath = resolveImagePath(src, pageDir)
68
+ const foundPath = findImageFile(resolvedPath)
69
+
70
+ if (!foundPath) {
71
+ logger?.warn('pug', `Image not found: ${basename(resolvedPath)}`)
72
+ return fallback
73
+ }
74
+
75
+ const buffer = readFileSync(foundPath)
76
+ const { width, height, type: format } = sizeOf(buffer)
77
+
78
+ const ext = extname(src)
79
+ const isSvg = ext.toLowerCase() === '.svg'
80
+ const base = src.slice(0, -ext.length)
81
+ // webp モード時は src 自体を .webp パスに変換(SVG は除外)
82
+ const resolvedSrc = useWebp && !isSvg ? `${base}.webp` : src
83
+
84
+ // 2x retina 画像の自動検出
85
+ let retina = null
86
+ if (!isSvg) {
87
+ const retinaSrc = `${base}@2x${ext}`
88
+ const retinaResolvedPath = resolveImagePath(retinaSrc, pageDir)
89
+ const retinaFoundPath = findImageFile(retinaResolvedPath)
90
+ if (retinaFoundPath) {
91
+ retina = { src: useWebp ? `${base}@2x.webp` : retinaSrc }
92
+ }
93
+ }
94
+
95
+ // SP 画像の自動検出
96
+ let sp = null
97
+ 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
108
+ }
109
+ }
110
+ }
111
+
112
+ return { src: resolvedSrc, width, height, format, isSvg, retina, sp }
113
+ } catch {
114
+ logger?.warn('pug', `Failed to get image info: ${basename(src)}`)
115
+ return fallback
116
+ }
117
+ }
118
+ }