pugkit 1.0.0-beta.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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,29 @@ 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
+
125
139
  ### Image Optimization
126
140
 
127
141
  ビルド時に自動的に画像を最適化します。
@@ -132,7 +146,7 @@ img(src='/assets/img/photo.jpg', width=size.width, height=size.height, alt='')
132
146
 
133
147
  ### File Naming Rules
134
148
 
135
- - `_`(アンダースコア)で始まるファイルは部分テンプレートとして扱われます
149
+ - `_`(アンダースコア)で始まるファイルはテンプレートとして扱われます
136
150
  - `_`で始まるディレクトリ内のファイルもビルド対象外です
137
151
  - 通常のファイル名のみがビルドされます
138
152
 
@@ -164,7 +178,8 @@ project-root/
164
178
  - [Sharp](https://sharp.pixelplumbing.com/) - 画像最適化
165
179
  - [SVGO](https://svgo.dev/) - SVG最適化
166
180
  - [Chokidar](https://github.com/paulmillr/chokidar) - ファイル監視
167
- - [BrowserSync](https://browsersync.io/) - 開発サーバー
181
+ - [sirv](https://github.com/lukeed/sirv) - 開発サーバー
182
+ - SSE(Server-Sent Events) - ライブリロード
168
183
  - [Prettier](https://prettier.io/) - HTML整形
169
184
 
170
185
  ## License
package/core/server.mjs CHANGED
@@ -1,77 +1,144 @@
1
- import browser from 'browser-sync'
2
- import { resolve } from 'node:path'
3
- import { existsSync } from 'node:fs'
1
+ import http from 'node:http'
2
+ import path from 'node:path'
3
+ import { existsSync, readFileSync } from 'node:fs'
4
4
  import { mkdir } from 'node:fs/promises'
5
+ import sirv from 'sirv'
5
6
  import { logger } from '../utils/logger.mjs'
6
7
 
8
+ const SSE_PATH = '/__pugkit_sse'
9
+
10
+ /**
11
+ * HTMLに挿入するライブリロードクライアントスクリプト。
12
+ */
13
+ const liveReloadScript = `<script>
14
+ (function() {
15
+ var es = new EventSource('${SSE_PATH}');
16
+ es.addEventListener('reload', function() {
17
+ location.reload();
18
+ });
19
+ es.addEventListener('css-update', function() {
20
+ // 同一オリジンの <link rel="stylesheet"> のみ再読み込み(外部フォント等は除外)
21
+ document.querySelectorAll('link[rel="stylesheet"]').forEach(function(link) {
22
+ var url = new URL(link.href);
23
+ if (url.origin !== location.origin) return;
24
+ url.searchParams.set('t', Date.now());
25
+ link.href = url.toString();
26
+ });
27
+ });
28
+ es.onerror = function() {
29
+ es.close();
30
+ setTimeout(function() { location.reload(); }, 1000);
31
+ };
32
+ })();
33
+ </script>`
34
+
7
35
  /**
8
- * 開発サーバータスク
36
+ * 開発サーバータスク(SSE + sirv)
9
37
  */
10
38
  export async function serverTask(context, options = {}) {
11
39
  const { paths, config } = context
12
- const distRoot = paths.dist
13
40
 
14
- // distディレクトリの確認
15
- if (!existsSync(distRoot)) {
16
- await mkdir(distRoot, { recursive: true })
41
+ if (!existsSync(paths.dist)) {
42
+ await mkdir(paths.dist, { recursive: true })
17
43
  }
18
44
 
19
- // BrowserSyncインスタンス作成
20
- const bs = browser.create()
21
- context.server = bs
22
-
45
+ const port = config.server?.port ?? 3000
46
+ const host = config.server?.host ?? 'localhost'
23
47
  const subdir = config.subdir ? '/' + config.subdir.replace(/^\/|\/$/g, '') : ''
24
- const startPath = (config.server.startPath || '/').replace(/^\//, '')
48
+ const startPath = (config.server?.startPath || '/').replace(/^\//, '')
25
49
  const fullStartPath = subdir ? `${subdir}/${startPath}` : `/${startPath}`
26
50
 
27
- return new Promise((resolve, reject) => {
28
- bs.init(
29
- {
30
- notify: false,
31
- server: {
32
- baseDir: distRoot,
33
- serveStaticOptions: {
34
- extensions: ['html'],
35
- setHeaders: (res, path) => {
36
- if (path.endsWith('.css') || path.endsWith('.js')) {
37
- res.setHeader('Cache-Control', 'no-cache')
38
- }
39
- }
40
- }
41
- },
42
- open: false,
43
- scrollProportionally: false,
44
- ghostMode: false,
45
- ui: false,
46
- startPath: fullStartPath,
47
- port: config.server.port,
48
- host: config.server.host,
49
- socket: {
50
- namespace: '/browser-sync'
51
- },
52
- logLevel: 'silent',
53
- logFileChanges: false,
54
- logConnections: false,
55
- minify: false,
56
- timestamps: false,
57
- codeSync: true,
58
- online: false,
59
- files: false, // 手動でリロード制御
60
- injectChanges: true,
61
- reloadDelay: 0,
62
- reloadDebounce: 50
63
- },
64
- err => {
65
- if (err) {
66
- logger.error('server', err.message)
67
- reject(err)
68
- } else {
69
- const urls = bs.getOption('urls')
70
- logger.success('server', `Running at ${urls.get('local')}`)
71
- resolve()
72
- }
51
+ const clients = new Set()
52
+
53
+ const staticServe = sirv(paths.dist, {
54
+ dev: true,
55
+ extensions: ['html'],
56
+ setHeaders(res, filePath) {
57
+ if (filePath.endsWith('.css') || filePath.endsWith('.js')) {
58
+ res.setHeader('Cache-Control', 'no-cache')
73
59
  }
74
- )
60
+ }
61
+ })
62
+
63
+ const httpServer = http.createServer((req, res) => {
64
+ const urlPath = req.url?.split('?')[0] ?? '/'
65
+
66
+ // ── SSE エンドポイント ──────────────────────────────
67
+ if (urlPath === SSE_PATH) {
68
+ res.writeHead(200, {
69
+ 'Content-Type': 'text/event-stream',
70
+ 'Cache-Control': 'no-cache',
71
+ Connection: 'keep-alive',
72
+ 'X-Accel-Buffering': 'no'
73
+ })
74
+ res.write('retry: 1000\n\n')
75
+ clients.add(res)
76
+ req.on('close', () => clients.delete(res))
77
+ return
78
+ }
79
+
80
+ // ── HTML へのライブリロードスクリプト注入 ───────────
81
+ const decoded = decodeURIComponent(urlPath)
82
+ const candidates = [
83
+ path.join(paths.dist, decoded === '/' ? 'index.html' : decoded.replace(/\/$/, '') + '/index.html'),
84
+ path.join(paths.dist, decoded === '/' ? 'index.html' : decoded + '.html'),
85
+ path.join(paths.dist, decoded)
86
+ ]
87
+ const htmlFile = candidates.find(p => p.endsWith('.html') && existsSync(p))
88
+
89
+ if (htmlFile) {
90
+ try {
91
+ let html = readFileSync(htmlFile, 'utf-8')
92
+ html = html.includes('</body>')
93
+ ? html.replace('</body>', liveReloadScript + '</body>')
94
+ : html + liveReloadScript
95
+ const buf = Buffer.from(html, 'utf-8')
96
+ res.writeHead(200, {
97
+ 'Content-Type': 'text/html; charset=utf-8',
98
+ 'Content-Length': buf.length,
99
+ 'Cache-Control': 'no-cache'
100
+ })
101
+ res.end(buf)
102
+ return
103
+ } catch {
104
+ // 読み込み失敗時は sirv にフォールスルー
105
+ }
106
+ }
107
+
108
+ // ── sirv で静的ファイルを配信 ───────────────────────
109
+ staticServe(req, res, () => {
110
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
111
+ res.end('404 Not Found')
112
+ })
113
+ })
114
+
115
+ function broadcast(event, data = '') {
116
+ const msg = `event: ${event}\ndata: ${data}\n\n`
117
+ for (const res of clients) {
118
+ res.write(msg)
119
+ }
120
+ }
121
+
122
+ context.server = {
123
+ reload() {
124
+ broadcast('reload')
125
+ },
126
+ reloadCSS() {
127
+ broadcast('css-update')
128
+ },
129
+ close() {
130
+ for (const res of clients) res.end()
131
+ clients.clear()
132
+ httpServer.close()
133
+ }
134
+ }
135
+
136
+ return new Promise((resolve, reject) => {
137
+ httpServer.listen(port, host, () => {
138
+ logger.success('server', `Running at http://${host}:${port}${fullStartPath}`)
139
+ resolve()
140
+ })
141
+ httpServer.on('error', reject)
75
142
  })
76
143
  }
77
144
 
package/core/watcher.mjs CHANGED
@@ -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,12 +295,12 @@ class FileWatcher {
294
295
  }
295
296
 
296
297
  /**
297
- * CSSインジェクション(リロードなし)
298
+ * CSSインジェクション
298
299
  */
299
300
  injectCSS() {
300
301
  if (this.context.server) {
301
302
  setTimeout(() => {
302
- this.context.server.reload('*.css')
303
+ this.context.server.reloadCSS()
303
304
  }, 100)
304
305
  }
305
306
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "pugkit",
3
- "version": "1.0.0-beta.2",
3
+ "version": "1.0.0",
4
4
  "description": "A build tool for Pug-based projects",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "mfxgu2i <mfxgu2i@gmail.com>",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "https://github.com/mfxgu2i/pugkit.git",
10
+ "url": "git+https://github.com/mfxgu2i/pugkit.git",
11
11
  "directory": "packages/pugkit"
12
12
  },
13
13
  "bugs": "https://github.com/mfxgu2i/pugkit/issues",
@@ -19,7 +19,7 @@
19
19
  "access": "public"
20
20
  },
21
21
  "bin": {
22
- "pugkit": "./cli/index.mjs"
22
+ "pugkit": "cli/index.mjs"
23
23
  },
24
24
  "keywords": [
25
25
  "pug",
@@ -43,12 +43,11 @@
43
43
  ],
44
44
  "dependencies": {
45
45
  "autoprefixer": "^10.4.21",
46
- "browser-sync": "^3.0.4",
47
46
  "cac": "^6.7.14",
48
47
  "chokidar": "^4.0.3",
49
48
  "cssnano": "^7.1.1",
50
49
  "esbuild": "^0.25.5",
51
- "glob": "^10.3.10",
50
+ "glob": "^13.0.6",
52
51
  "image-size": "^2.0.2",
53
52
  "picocolors": "^1.1.1",
54
53
  "postcss": "^8.4.49",
@@ -56,6 +55,7 @@
56
55
  "pug": "^3.0.3",
57
56
  "sass": "^1.89.2",
58
57
  "sharp": "^0.34.2",
58
+ "sirv": "^3.0.2",
59
59
  "svgo": "^4.0.0"
60
60
  }
61
61
  }
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
+ }