pugkit 1.0.2 → 1.2.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
@@ -9,11 +9,6 @@
9
9
  </a>
10
10
  </p>
11
11
 
12
- ## About
13
-
14
- pugkitは静的サイト制作に特化したビルドツールです。
15
- 納品向きの綺麗なHTMLと、ファイル構成に制約のないアセットファイルを出力可能です。
16
-
17
12
  ## How To Use
18
13
 
19
14
  ```sh
@@ -39,6 +34,34 @@ $ touch ./src/index.pug
39
34
  | `pugkit build` | 本番ビルド |
40
35
  | `pugkit sprite` | SVGスプライト生成 |
41
36
 
37
+ ## Directory Structure
38
+
39
+ ```
40
+ project-root/
41
+ ├── src/ # ソースファイル
42
+ │ ├── *.pug
43
+ │ ├── *.scss
44
+ │ ├── *.ts
45
+ │ ├── *.js
46
+ │ ├── *.jpg
47
+ │ ├── *.png
48
+ │ └── *.svg
49
+ ├── public/ # 静的ファイル
50
+ │ ├── ogp.jpg
51
+ │ └── favicon.ico
52
+ ├── dist/ # ビルド出力先
53
+ └── pugkit.config.mjs # ビルド設定ファイル
54
+ ```
55
+
56
+ ### File Naming Rules
57
+
58
+ `_`(アンダースコア)で始まるファイル・ディレクトリはビルド対象外です。それ以外のファイルは `src/` 配下のディレクトリ構成を維持したまま `dist/` に出力されます。
59
+
60
+ ```
61
+ src/foo/style.scss → dist/foo/style.css
62
+ src/foo/bar/script.js → dist/foo/bar/script.js
63
+ ```
64
+
42
65
  ## Configuration
43
66
 
44
67
  プロジェクトルートに`pugkit.config.mjs`を配置することで、ビルド設定をカスタマイズできます。
@@ -70,7 +93,7 @@ export default defineConfig({
70
93
  | `server.port` | 開発サーバーのポート番号 | `number` | `5555` |
71
94
  | `server.host` | 開発サーバーのホスト | `string` | `'localhost'` |
72
95
  | `server.startPath` | サーバー起動時に開くパス | `string` | `'/'` |
73
- | `server.open` | サーバー起動時にブラウザを開く | `boolean` | `false` |
96
+ | `build.clean` | ビルド前に `dist/` をクリーンするか(`false` にすると他リソースと共存可能) | `boolean` | `true` |
74
97
  | `build.imageOptimization` | 画像最適化の方式 | `'webp'` \| `'compress'` \| `false` | `'webp'` |
75
98
  | `build.imageOptions.webp` | WebP変換オプション([Sharp WebP options](https://sharp.pixelplumbing.com/api-output#webp)) | `object` | - |
76
99
  | `build.imageOptions.jpeg` | JPEG圧縮オプション([Sharp JPEG options](https://sharp.pixelplumbing.com/api-output#jpeg)) | `object` | - |
@@ -176,9 +199,13 @@ npm install --save-dev typescript
176
199
  - `'compress'` - 元の形式を維持したまま圧縮
177
200
  - `false` - 最適化を無効化
178
201
 
202
+ ### SVG Optimization
203
+
204
+ `icons/`以外に配置した SVG ファイルはSVGOで自動最適化されて出力されます。
205
+
179
206
  ### SVG Sprite
180
207
 
181
- `src/` 配下の `icons/` ディレクトリに配置したSVGを1つのスプライトファイルにまとめます。
208
+ `src/`配下の`icons/`ディレクトリに配置したSVGを1つのスプライトファイルにまとめます。
182
209
 
183
210
  ```
184
211
  src/assets/icons/arrow.svg → dist/assets/icons.svg#arrow
@@ -191,10 +218,6 @@ src/assets/icons/arrow.svg → dist/assets/icons.svg#arrow
191
218
  - SVG ファイル名がそのまま `<symbol id>` になります
192
219
  - `fill` / `stroke` は自動的に `currentColor` に変換されます
193
220
 
194
- ### SVG Optimization
195
-
196
- `icons/` 以外に配置した SVG ファイルは SVGO で自動最適化されて出力されます。
197
-
198
221
  ### Public Directory
199
222
 
200
223
  `public/` に置いたファイルはそのまま `dist/` のルートにコピーされます。faviconやOGP画像など最適化不要なファイルの置き場として使用します。
@@ -208,34 +231,6 @@ src/assets/icons/arrow.svg → dist/assets/icons.svg#arrow
208
231
  | CSS | minify済み | expanded + ソースマップ |
209
232
  | JS | minify済み・`console.*` 削除 | ソースマップ・`console.*` 保持 |
210
233
 
211
- ### File Naming Rules
212
-
213
- `_`(アンダースコア)で始まるファイル・ディレクトリはビルド対象外です。それ以外のファイルは `src/` 配下のディレクトリ構成を維持したまま `dist/` に出力されます。
214
-
215
- ```
216
- src/foo/style.scss → dist/foo/style.css
217
- src/foo/bar/script.js → dist/foo/bar/script.js
218
- ```
219
-
220
- ## Directory Structure
221
-
222
- ```
223
- project-root/
224
- ├── src/ # ソースファイル
225
- │ ├── *.pug
226
- │ ├── *.scss
227
- │ ├── *.ts
228
- │ ├── *.js
229
- │ ├── *.jpg
230
- │ ├── *.png
231
- │ └── *.svg
232
- ├── public/ # 静的ファイル
233
- │ ├── ogp.jpg
234
- │ └── favicon.ico
235
- ├── dist/ # ビルド出力先
236
- └── pugkit.config.mjs # ビルド設定ファイル
237
- ```
238
-
239
234
  ## Tech Stack
240
235
 
241
236
  - [Pug](https://pugjs.org/) - HTMLテンプレートエンジン
package/cli/develop.mjs CHANGED
@@ -2,7 +2,7 @@ import { createBuilder } from '../index.mjs'
2
2
  import { logger } from './logger.mjs'
3
3
 
4
4
  export async function develop(options = {}) {
5
- const { root = process.cwd(), port, host, open } = options
5
+ const { root = process.cwd(), port, host } = options
6
6
 
7
7
  logger.info('pugkit', 'starting dev server...')
8
8
 
@@ -10,7 +10,6 @@ export async function develop(options = {}) {
10
10
 
11
11
  if (port) builder.context.config.server.port = port
12
12
  if (host) builder.context.config.server.host = host
13
- if (open) builder.context.config.server.open = open
14
13
 
15
14
  await builder.watch()
16
15
  }
package/cli/logger.mjs CHANGED
@@ -6,26 +6,22 @@ import path from 'node:path'
6
6
  const __filename = fileURLToPath(import.meta.url)
7
7
  const __dirname = path.dirname(__filename)
8
8
 
9
- function getVersion() {
10
- const pkgPath = path.join(__dirname, '../package.json')
11
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
12
- return pkg.version
13
- }
9
+ const version = JSON.parse(readFileSync(path.join(__dirname, '../package.json'), 'utf8')).version
14
10
 
15
11
  export const logger = {
16
12
  info(name, message) {
17
- console.log(`${pc.cyan(pc.bold(name.toUpperCase()))} ${pc.dim(`v${getVersion()}`)} ${message}`)
13
+ console.log(`${pc.cyan(pc.bold(name.toUpperCase()))} ${pc.dim(`v${version}`)} ${message}`)
18
14
  },
19
15
 
20
16
  success(name, message) {
21
- console.log(`${pc.cyan(pc.bold(name.toUpperCase()))} ${pc.dim(`v${getVersion()}`)} ${message}`)
17
+ console.log(`${pc.cyan(pc.bold(name.toUpperCase()))} ${pc.dim(`v${version}`)} ${message}`)
22
18
  },
23
19
 
24
20
  warn(name, message) {
25
- console.log(`${pc.cyan(pc.bold(name.toUpperCase()))} ${pc.dim(`v${getVersion()}`)} ${message}`)
21
+ console.log(`${pc.cyan(pc.bold(name.toUpperCase()))} ${pc.dim(`v${version}`)} ${message}`)
26
22
  },
27
23
 
28
24
  error(name, message) {
29
- console.error(`${pc.cyan(pc.bold(name.toUpperCase()))} ${pc.dim(`v${getVersion()}`)} ${message}`)
25
+ console.error(`${pc.cyan(pc.bold(name.toUpperCase()))} ${pc.dim(`v${version}`)} ${message}`)
30
26
  }
31
27
  }
@@ -5,10 +5,10 @@ export const defaultConfig = {
5
5
  server: {
6
6
  port: 5555,
7
7
  host: 'localhost',
8
- startPath: '/',
9
- open: false
8
+ startPath: '/'
10
9
  },
11
10
  build: {
11
+ clean: true,
12
12
  imageOptimization: 'webp',
13
13
  imageOptions: {
14
14
  webp: {
package/config/main.mjs CHANGED
@@ -25,6 +25,7 @@ function mergeConfig(defaults, user) {
25
25
  build: {
26
26
  ...defaults.build,
27
27
  ...(user.build || {}),
28
+ clean: user.build?.clean !== undefined ? user.build.clean : defaults.build.clean,
28
29
  imageOptions: {
29
30
  webp: { ...defaults.build.imageOptions.webp, ...(user.build?.imageOptions?.webp || {}) },
30
31
  jpeg: { ...defaults.build.imageOptions.jpeg, ...(user.build?.imageOptions?.jpeg || {}) },
@@ -11,20 +11,11 @@ export function createGlobPatterns(srcPath) {
11
11
  ignore: ['**/*.d.ts', '**/node_modules/**']
12
12
  },
13
13
  images: {
14
- optimize: [
15
- `${srcPath}/**/*.{png,jpg,jpeg}`,
16
- `!${srcPath}/**/sprites_*/*`,
17
- `!${srcPath}/**/_inline*/*`,
18
- `!${srcPath}/**/icons*/*`
19
- ],
20
- webp: {
21
- src: [`${srcPath}/**/*.{png,jpg,jpeg,gif}`],
22
- ignore: [`!${srcPath}/**/favicons/*`, `!${srcPath}/**/ogp.{png,jpg}`]
23
- }
14
+ optimize: [`${srcPath}/**/*.{png,jpg,jpeg}`, `!${srcPath}/**/icons/*`],
15
+ webp: [`${srcPath}/**/*.{png,jpg,jpeg,gif}`, `!${srcPath}/**/icons/*`]
24
16
  },
25
17
  svg: {
26
- src: [`${srcPath}/**/*.svg`],
27
- ignore: [`!${srcPath}/**/sprites_*/*`, `!${srcPath}/**/_inline*/*`, `!${srcPath}/**/icons*/*`]
18
+ src: [`${srcPath}/**/*.svg`, `!${srcPath}/**/icons/*`]
28
19
  }
29
20
  }
30
21
  }
package/core/builder.mjs CHANGED
@@ -34,11 +34,17 @@ export class Builder {
34
34
  const { context } = this
35
35
  const startTime = Date.now()
36
36
 
37
+ const shouldClean = context.config.build.clean
38
+
37
39
  logger.info('build', `Building in ${context.mode} mode`)
38
40
 
39
41
  try {
40
42
  // 1. クリーンアップ
41
- await this.clean()
43
+ if (shouldClean) {
44
+ await this.clean()
45
+ } else {
46
+ logger.info('build', 'Skipping clean (clean: false)')
47
+ }
42
48
 
43
49
  // 2. 並列ビルド(軽量タスク + スプライト)
44
50
  const parallelTasks = []
package/core/cache.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { stat, readFile } from 'node:fs/promises'
2
+ import { readFile } from 'node:fs/promises'
3
3
  import { existsSync } from 'node:fs'
4
4
 
5
5
  /**
@@ -76,13 +76,12 @@ export class CacheManager {
76
76
  }
77
77
 
78
78
  /**
79
- * ハッシュ計算(ファイル内容 + 更新日時)
79
+ * ハッシュ計算(ファイル内容のみ)
80
80
  */
81
81
  async computeHash(filePath) {
82
82
  try {
83
- const [stats, content] = await Promise.all([stat(filePath), readFile(filePath)])
84
-
85
- return createHash('md5').update(content).update(stats.mtime.toISOString()).digest('hex')
83
+ const content = await readFile(filePath)
84
+ return createHash('md5').update(content).digest('hex')
86
85
  } catch {
87
86
  return `error-${Math.random()}`
88
87
  }
package/core/context.mjs CHANGED
@@ -9,6 +9,8 @@ export class BuildContext {
9
9
  this.mode = mode
10
10
  this.cache = new CacheManager(mode)
11
11
  this.graph = new DependencyGraph()
12
+ this.sassGraph = new DependencyGraph()
13
+ this.scriptGraph = new DependencyGraph()
12
14
 
13
15
  this.paths = {
14
16
  root: config.root,
package/core/graph.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 依存関係グラフ
3
- * Pugのパーシャル依存を管理
3
+ * Pug・Sass・Script のパーシャル依存を管理
4
4
  */
5
5
  export class DependencyGraph {
6
6
  constructor() {
package/core/watcher.mjs CHANGED
@@ -17,7 +17,7 @@ export async function watcherTask(context, options = {}) {
17
17
  class FileWatcher {
18
18
  constructor(context) {
19
19
  this.context = context
20
- this.watchers = []
20
+ this.watcher = null
21
21
  }
22
22
 
23
23
  async start() {
@@ -25,331 +25,226 @@ class FileWatcher {
25
25
 
26
26
  // 初回ビルド(依存関係グラフ構築のため)
27
27
  logger.info('watch', 'Building initial dependency graph...')
28
+
29
+ const initialTasks = []
28
30
  if (this.context.taskRegistry?.pug) {
29
- await this.context.taskRegistry.pug(this.context)
31
+ initialTasks.push(this.context.taskRegistry.pug(this.context))
30
32
  }
33
+ if (this.context.taskRegistry?.sass) {
34
+ initialTasks.push(this.context.taskRegistry.sass(this.context))
35
+ }
36
+ if (this.context.taskRegistry?.script) {
37
+ initialTasks.push(this.context.taskRegistry.script(this.context))
38
+ }
39
+ await Promise.all(initialTasks)
40
+
41
+ this.watcher = chokidar
42
+ .watch([paths.src, paths.public], {
43
+ ignoreInitial: true,
44
+ ignored: [/(^|[\/\\])\./, /node_modules/, /\.git/],
45
+ persistent: true,
46
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
47
+ })
48
+ .on('change', filePath => this.handleChange(filePath))
49
+ .on('add', filePath => this.handleAdd(filePath))
50
+ .on('unlink', filePath => this.handleUnlink(filePath))
31
51
 
32
- // Pug監視
33
- this.watchPug(paths.src)
34
-
35
- // Sass監視
36
- this.watchSass(paths.src)
52
+ logger.info('watch', 'File watching started')
53
+ }
37
54
 
38
- // Script監視
39
- this.watchScript(paths.src)
55
+ // ---- ルーティング ----
56
+ handleChange(filePath) {
57
+ if (this.isPublic(filePath)) return this.onPublicChange(filePath, 'change')
58
+ if (filePath.endsWith('.pug')) return this.onPugChange(filePath)
59
+ if (filePath.endsWith('.scss')) return this.onSassChange(filePath)
60
+ if (this.isScript(filePath)) return this.onScriptChange(filePath)
61
+ if (filePath.endsWith('.svg') && !this.isIcons(filePath)) return this.onSvgChange(filePath, 'change')
62
+ if (/\.(jpg|jpeg|png|gif)$/i.test(filePath)) return this.onImageChange(filePath, 'change')
63
+ }
40
64
 
41
- // SVG監視
42
- this.watchSvg(paths.src)
65
+ handleAdd(filePath) {
66
+ if (this.isPublic(filePath)) return this.onPublicChange(filePath, 'add')
67
+ if (filePath.endsWith('.pug')) return this.onPugChange(filePath)
68
+ if (filePath.endsWith('.scss')) return this.onSassChange(filePath)
69
+ if (this.isScript(filePath)) return this.onScriptChange(filePath)
70
+ if (filePath.endsWith('.svg') && !this.isIcons(filePath)) return this.onSvgChange(filePath, 'add')
71
+ if (/\.(jpg|jpeg|png|gif)$/i.test(filePath)) return this.onImageChange(filePath, 'add')
72
+ }
43
73
 
44
- // 画像監視
45
- this.watchImages(paths.src)
74
+ handleUnlink(filePath) {
75
+ if (this.isPublic(filePath)) return this.onPublicUnlink(filePath)
76
+ if (filePath.endsWith('.pug')) return this.onPugUnlink(filePath)
77
+ if (filePath.endsWith('.scss')) return this.onSassUnlink(filePath)
78
+ if (this.isScript(filePath)) return this.onScriptUnlink(filePath)
79
+ if (filePath.endsWith('.svg') && !this.isIcons(filePath)) return this.onSvgUnlink(filePath)
80
+ if (/\.(jpg|jpeg|png|gif)$/i.test(filePath)) return this.onImageUnlink(filePath)
81
+ }
46
82
 
47
- // Public監視
48
- this.watchPublic(paths.public)
83
+ // ---- 判定ヘルパー ----
49
84
 
50
- logger.info('watch', 'File watching started')
85
+ isPublic(filePath) {
86
+ return filePath.startsWith(this.context.paths.public)
51
87
  }
52
88
 
53
- /**
54
- * Pug監視(依存関係を考慮)
55
- */
56
- watchPug(basePath) {
57
- const watcher = chokidar.watch(basePath, {
58
- ignoreInitial: true,
59
- ignored: [
60
- /(^|[\/\\])\../, // 隠しファイル
61
- /node_modules/,
62
- /\.git/
63
- ],
64
- persistent: true,
65
- awaitWriteFinish: {
66
- stabilityThreshold: 100,
67
- pollInterval: 50
68
- }
69
- })
70
-
71
- watcher.on('change', async path => {
72
- // .pugファイルのみ処理
73
- if (!path.endsWith('.pug')) {
74
- return
75
- }
76
- const relPath = relative(basePath, path)
77
- logger.info('change', `pug: ${relPath}`)
78
-
79
- try {
80
- // パーシャルの場合、依存する親ファイルも再ビルド
81
- const affectedFiles = this.context.graph.getAffectedParents(path)
82
-
83
- if (affectedFiles.length > 0) {
84
- logger.info('pug', `Rebuilding ${affectedFiles.length} affected file(s)`)
85
- }
86
-
87
- // キャッシュ無効化
88
- this.context.cache.invalidatePugTemplate(path)
89
- affectedFiles.forEach(f => this.context.cache.invalidatePugTemplate(f))
90
-
91
- // Pugタスクを実行(変更されたファイルのみ)
92
- if (this.context.taskRegistry?.pug) {
93
- await this.context.taskRegistry.pug(this.context, {
94
- files: [path, ...affectedFiles]
95
- })
96
- }
97
-
98
- this.reload()
99
- } catch (error) {
100
- logger.error('watch', `Pug build failed: ${error.message}`)
101
- }
102
- })
89
+ isScript(filePath) {
90
+ return (filePath.endsWith('.ts') || filePath.endsWith('.js')) && !filePath.endsWith('.d.ts')
91
+ }
103
92
 
104
- watcher.on('unlink', async path => {
105
- if (!path.endsWith('.pug')) return
106
- const relPath = relative(basePath, path)
93
+ isIcons(filePath) {
94
+ // Windows 対応: セパレータを正規化
95
+ return filePath.replace(/\\/g, '/').includes('/icons/')
96
+ }
107
97
 
108
- // キャッシュとグラフをクリア
109
- this.context.cache.invalidatePugTemplate(path)
110
- this.context.graph.clearDependencies(path)
98
+ // ---- Pug ----
111
99
 
112
- if (basename(path).startsWith('_')) {
113
- logger.info('unlink', relPath)
114
- return
100
+ async onPugChange(filePath) {
101
+ const { paths, graph, cache, taskRegistry } = this.context
102
+ const relPath = relative(paths.src, filePath)
103
+ logger.info('change', `pug: ${relPath}`)
104
+ try {
105
+ const affectedFiles = graph.getAffectedParents(filePath)
106
+ if (affectedFiles.length > 0) logger.info('pug', `Rebuilding ${affectedFiles.length} affected file(s)`)
107
+ cache.invalidatePugTemplate(filePath)
108
+ affectedFiles.forEach(f => cache.invalidatePugTemplate(f))
109
+ if (taskRegistry?.pug) {
110
+ await taskRegistry.pug(this.context, { files: [filePath, ...affectedFiles] })
115
111
  }
116
- const distPath = resolve(this.context.paths.dist, relPath.replace(/\.pug$/, '.html'))
117
- await this.deleteDistFile(distPath, relPath)
118
- })
119
-
120
- this.watchers.push(watcher)
112
+ this.reload()
113
+ } catch (error) {
114
+ logger.error('watch', `Pug build failed: ${error.message}`)
115
+ }
121
116
  }
122
117
 
123
- /**
124
- * Sass監視
125
- */
126
- watchSass(basePath) {
127
- const watcher = chokidar.watch(basePath, {
128
- ignoreInitial: true,
129
- ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/],
130
- persistent: true,
131
- awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 25 }
132
- })
133
-
134
- watcher.on('change', async path => {
135
- // .scssファイルのみ処理
136
- if (!path.endsWith('.scss')) {
137
- return
138
- }
139
- const relPath = relative(basePath, path)
140
- logger.info('change', `sass: ${relPath}`)
141
-
142
- try {
143
- // Sassタスクを実行
144
- if (this.context.taskRegistry?.sass) {
145
- await this.context.taskRegistry.sass(this.context)
146
- }
147
-
148
- this.injectCSS()
149
- } catch (error) {
150
- logger.error('watch', `Sass build failed: ${error.message}`)
151
- }
152
- })
153
-
154
- watcher.on('unlink', async path => {
155
- if (!path.endsWith('.scss')) return
156
- const relPath = relative(basePath, path)
157
- if (basename(path).startsWith('_')) {
158
- logger.info('unlink', relPath)
159
- return
160
- }
161
- const distPath = resolve(this.context.paths.dist, relPath.replace(/\.scss$/, '.css'))
162
- await this.deleteDistFile(distPath, relPath)
163
- })
164
-
165
- this.watchers.push(watcher)
118
+ async onPugUnlink(filePath) {
119
+ const { paths, cache, graph } = this.context
120
+ const relPath = relative(paths.src, filePath)
121
+ cache.invalidatePugTemplate(filePath)
122
+ graph.clearDependencies(filePath)
123
+ if (basename(filePath).startsWith('_')) {
124
+ logger.info('unlink', relPath)
125
+ return
126
+ }
127
+ const distPath = resolve(paths.dist, relPath.replace(/\.pug$/, '.html'))
128
+ await this.deleteDistFile(distPath, relPath)
166
129
  }
167
130
 
168
- /**
169
- * Script監視
170
- */
171
- watchScript(basePath) {
172
- const watcher = chokidar.watch(basePath, {
173
- ignoreInitial: true,
174
- ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/, /\.d\.ts$/],
175
- persistent: true,
176
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
177
- })
178
-
179
- watcher.on('change', async path => {
180
- // .ts/.jsファイルのみ処理(.d.tsを除く)
181
- if (!(path.endsWith('.ts') || path.endsWith('.js')) || path.endsWith('.d.ts')) {
182
- return
183
- }
184
- const relPath = relative(basePath, path)
185
- logger.info('change', `script: ${relPath}`)
186
-
187
- try {
188
- // Scriptタスクを実行
189
- if (this.context.taskRegistry?.script) {
190
- await this.context.taskRegistry.script(this.context)
191
- }
192
-
193
- this.reload()
194
- } catch (error) {
195
- logger.error('watch', `Script build failed: ${error.message}`)
196
- }
197
- })
198
-
199
- watcher.on('unlink', async path => {
200
- if (!(path.endsWith('.ts') || path.endsWith('.js')) || path.endsWith('.d.ts')) return
201
- const relPath = relative(basePath, path)
202
- if (basename(path).startsWith('_')) {
203
- logger.info('unlink', relPath)
204
- return
205
- }
206
- const distPath = resolve(this.context.paths.dist, relPath.replace(/\.ts$/, '.js'))
207
- await this.deleteDistFile(distPath, relPath)
208
- })
131
+ // ---- Sass ----
209
132
 
210
- this.watchers.push(watcher)
133
+ async onSassChange(filePath) {
134
+ const relPath = relative(this.context.paths.src, filePath)
135
+ logger.info('change', `sass: ${relPath}`)
136
+ try {
137
+ if (this.context.taskRegistry?.sass) await this.context.taskRegistry.sass(this.context, { files: [filePath] })
138
+ this.injectCSS()
139
+ } catch (error) {
140
+ logger.error('watch', `Sass build failed: ${error.message}`)
141
+ }
211
142
  }
212
143
 
213
- /**
214
- * SVG監視
215
- */
216
- watchSvg(basePath) {
217
- const watcher = chokidar.watch(basePath, {
218
- ignoreInitial: true,
219
- ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/, /icons/], // iconsはスプライト用なので除外
220
- persistent: true,
221
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
222
- })
223
-
224
- const handleSvgChange = async (path, event) => {
225
- // .svgファイルのみ処理(iconsディレクトリは除外)
226
- // Windows対応: パスセパレータを正規化
227
- const normalizedPath = path.replace(/\\/g, '/')
228
- if (!path.endsWith('.svg') || normalizedPath.includes('/icons/')) {
229
- return
230
- }
231
- const relPath = relative(basePath, path)
232
- logger.info(event, `svg: ${relPath}`)
233
-
234
- try {
235
- // SVGタスクを実行(変更されたファイルのみ)
236
- if (this.context.taskRegistry?.svg) {
237
- await this.context.taskRegistry.svg(this.context, {
238
- files: [path]
239
- })
240
- }
241
-
242
- this.reload()
243
- } catch (error) {
244
- logger.error('watch', `SVG processing failed: ${error.message}`)
245
- }
144
+ async onSassUnlink(filePath) {
145
+ const { paths, sassGraph } = this.context
146
+ const relPath = relative(paths.src, filePath)
147
+ sassGraph.clearDependencies(filePath)
148
+ if (basename(filePath).startsWith('_')) {
149
+ logger.info('unlink', relPath)
150
+ return
246
151
  }
152
+ const distPath = resolve(paths.dist, relPath.replace(/\.scss$/, '.css'))
153
+ await this.deleteDistFile(distPath, relPath)
154
+ }
247
155
 
248
- watcher.on('change', path => handleSvgChange(path, 'change'))
249
- watcher.on('add', path => handleSvgChange(path, 'add'))
250
-
251
- watcher.on('unlink', async path => {
252
- const normalizedPath = path.replace(/\\/g, '/')
253
- if (!path.endsWith('.svg') || normalizedPath.includes('/icons/')) return
254
- const relPath = relative(basePath, path)
255
- const distPath = resolve(this.context.paths.dist, relPath)
256
- await this.deleteDistFile(distPath, relPath)
257
- })
156
+ // ---- Script ----
258
157
 
259
- this.watchers.push(watcher)
158
+ async onScriptChange(filePath) {
159
+ const relPath = relative(this.context.paths.src, filePath)
160
+ logger.info('change', `script: ${relPath}`)
161
+ try {
162
+ if (this.context.taskRegistry?.script) await this.context.taskRegistry.script(this.context, { files: [filePath] })
163
+ this.reload()
164
+ } catch (error) {
165
+ logger.error('watch', `Script build failed: ${error.message}`)
166
+ }
260
167
  }
261
168
 
262
- /**
263
- * 画像監視
264
- */
265
- watchImages(basePath) {
266
- const watcher = chokidar.watch(basePath, {
267
- ignoreInitial: true,
268
- ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/],
269
- persistent: true,
270
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
271
- })
272
-
273
- const handleImageChange = async (path, event) => {
274
- // 画像ファイルのみ処理
275
- if (!/\.(jpg|jpeg|png|gif)$/i.test(path)) {
276
- return
277
- }
278
- const relPath = relative(basePath, path)
279
- logger.info(event, `image: ${relPath}`)
280
-
281
- try {
282
- // 追加・変更時: 画像を処理
283
- if (this.context.taskRegistry?.image) {
284
- await this.context.taskRegistry.image(this.context, {
285
- files: [path]
286
- })
287
- }
288
-
289
- this.reload()
290
- } catch (error) {
291
- logger.error('watch', `Image processing failed: ${error.message}`)
292
- }
169
+ async onScriptUnlink(filePath) {
170
+ const { paths, scriptGraph } = this.context
171
+ const relPath = relative(paths.src, filePath)
172
+ scriptGraph.clearDependencies(filePath)
173
+ if (basename(filePath).startsWith('_')) {
174
+ logger.info('unlink', relPath)
175
+ return
293
176
  }
177
+ const distPath = resolve(paths.dist, relPath.replace(/\.ts$/, '.js'))
178
+ await this.deleteDistFile(distPath, relPath)
179
+ }
294
180
 
295
- watcher.on('change', path => handleImageChange(path, 'change'))
296
- watcher.on('add', path => handleImageChange(path, 'add'))
181
+ // ---- SVG ----
297
182
 
298
- watcher.on('unlink', async path => {
299
- if (!/\.(jpg|jpeg|png|gif)$/i.test(path)) return
300
- const relPath = relative(basePath, path)
301
- const useWebp = this.context.config.build.imageOptimization === 'webp'
302
- const ext = extname(path)
303
- const destRelPath = useWebp ? relPath.replace(new RegExp(`\\${ext}$`, 'i'), '.webp') : relPath
304
- const distPath = resolve(this.context.paths.dist, destRelPath)
305
- await this.deleteDistFile(distPath, relPath)
306
- })
183
+ async onSvgChange(filePath, event) {
184
+ const relPath = relative(this.context.paths.src, filePath)
185
+ logger.info(event, `svg: ${relPath}`)
186
+ try {
187
+ if (this.context.taskRegistry?.svg) {
188
+ await this.context.taskRegistry.svg(this.context, { files: [filePath] })
189
+ }
190
+ this.reload()
191
+ } catch (error) {
192
+ logger.error('watch', `SVG processing failed: ${error.message}`)
193
+ }
194
+ }
307
195
 
308
- this.watchers.push(watcher)
196
+ async onSvgUnlink(filePath) {
197
+ const relPath = relative(this.context.paths.src, filePath)
198
+ const distPath = resolve(this.context.paths.dist, relPath)
199
+ await this.deleteDistFile(distPath, relPath)
309
200
  }
310
201
 
311
- /**
312
- * Public監視
313
- */
314
- watchPublic(basePath) {
315
- const watcher = chokidar.watch(basePath, {
316
- ignoreInitial: true,
317
- ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/],
318
- persistent: true,
319
- awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
320
- })
321
-
322
- const handlePublicChange = async (path, event) => {
323
- const relPath = relative(basePath, path)
324
- logger.info(event, `public: ${relPath}`)
325
-
326
- try {
327
- // Copyタスクを実行
328
- if (this.context.taskRegistry?.copy) {
329
- await this.context.taskRegistry.copy(this.context)
330
- }
331
-
332
- this.reload()
333
- } catch (error) {
334
- logger.error('watch', `Copy failed: ${error.message}`)
202
+ // ---- Image ----
203
+
204
+ async onImageChange(filePath, event) {
205
+ const relPath = relative(this.context.paths.src, filePath)
206
+ logger.info(event, `image: ${relPath}`)
207
+ try {
208
+ if (this.context.taskRegistry?.image) {
209
+ await this.context.taskRegistry.image(this.context, { files: [filePath] })
335
210
  }
211
+ this.reload()
212
+ } catch (error) {
213
+ logger.error('watch', `Image processing failed: ${error.message}`)
336
214
  }
215
+ }
337
216
 
338
- watcher.on('change', path => handlePublicChange(path, 'change'))
339
- watcher.on('add', path => handlePublicChange(path, 'add'))
217
+ async onImageUnlink(filePath) {
218
+ const { paths, config } = this.context
219
+ const relPath = relative(paths.src, filePath)
220
+ const useWebp = config.build.imageOptimization === 'webp'
221
+ const ext = extname(filePath)
222
+ const destRelPath = useWebp ? relPath.replace(new RegExp(`\\${ext}$`, 'i'), '.webp') : relPath
223
+ const distPath = resolve(paths.dist, destRelPath)
224
+ await this.deleteDistFile(distPath, relPath)
225
+ }
226
+
227
+ // ---- Public ----
340
228
 
341
- watcher.on('unlink', async path => {
342
- const relPath = relative(basePath, path)
343
- const distPath = resolve(this.context.paths.dist, relPath)
344
- await this.deleteDistFile(distPath, relPath)
345
- })
229
+ async onPublicChange(filePath, event) {
230
+ const relPath = relative(this.context.paths.public, filePath)
231
+ logger.info(event, `public: ${relPath}`)
232
+ try {
233
+ if (this.context.taskRegistry?.copy) await this.context.taskRegistry.copy(this.context, { files: [filePath] })
234
+ this.reload()
235
+ } catch (error) {
236
+ logger.error('watch', `Copy failed: ${error.message}`)
237
+ }
238
+ }
346
239
 
347
- this.watchers.push(watcher)
240
+ async onPublicUnlink(filePath) {
241
+ const relPath = relative(this.context.paths.public, filePath)
242
+ const distPath = resolve(this.context.paths.dist, relPath)
243
+ await this.deleteDistFile(distPath, relPath)
348
244
  }
349
245
 
350
- /**
351
- * ブラウザリロード
352
- */
246
+ // ---- 共通ヘルパー ----
247
+
353
248
  reload() {
354
249
  if (this.context.server) {
355
250
  setTimeout(() => {
@@ -358,9 +253,6 @@ class FileWatcher {
358
253
  }
359
254
  }
360
255
 
361
- /**
362
- * dist内のファイルを削除してブラウザをリロード
363
- */
364
256
  async deleteDistFile(distPath, relPath) {
365
257
  try {
366
258
  await rm(distPath, { force: true })
@@ -371,20 +263,14 @@ class FileWatcher {
371
263
  }
372
264
  }
373
265
 
374
- /**
375
- * CSSインジェクション
376
- */
377
266
  injectCSS() {
378
267
  if (this.context.server) {
379
268
  this.context.server.reloadCSS()
380
269
  }
381
270
  }
382
271
 
383
- /**
384
- * 監視停止
385
- */
386
272
  async stop() {
387
- await Promise.all(this.watchers.map(w => w.close()))
273
+ if (this.watcher) await this.watcher.close()
388
274
  logger.info('watch', 'File watching stopped')
389
275
  }
390
276
  }
package/generate/page.mjs CHANGED
@@ -4,7 +4,7 @@ import { ensureFileDir } from '../utils/file.mjs'
4
4
 
5
5
  export async function generatePage(filePath, html, paths) {
6
6
  const relativePath = relative(paths.src, filePath)
7
- const outputPath = resolve(paths.dist, relativePath.replace('.pug', '.html'))
7
+ const outputPath = resolve(paths.dist, relativePath.replace(/\.pug$/, '.html'))
8
8
 
9
9
  await ensureFileDir(outputPath)
10
10
  await writeFile(outputPath, html, 'utf8')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pugkit",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "A build tool for Pug-based projects",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -49,9 +49,9 @@
49
49
  "esbuild": "^0.25.5",
50
50
  "glob": "^13.0.6",
51
51
  "image-size": "^2.0.2",
52
+ "js-beautify": "^1.15.4",
52
53
  "picocolors": "^1.1.1",
53
54
  "postcss": "^8.4.49",
54
- "prettier": "^3.8.0",
55
55
  "pug": "^3.0.3",
56
56
  "sass": "^1.89.2",
57
57
  "sharp": "^0.34.2",
package/tasks/copy.mjs CHANGED
@@ -10,6 +10,21 @@ import { ensureFileDir } from '../utils/file.mjs'
10
10
  export async function copyTask(context, options = {}) {
11
11
  const { paths } = context
12
12
 
13
+ // 特定のファイルが指定されている場合(watch時)
14
+ if (options.files && Array.isArray(options.files)) {
15
+ logger.info('copy', `Copying ${options.files.length} file(s)`)
16
+ await Promise.all(
17
+ options.files.map(async file => {
18
+ const relativePath = relative(paths.public, file)
19
+ const outputPath = resolve(paths.dist, relativePath)
20
+ await ensureFileDir(outputPath)
21
+ await copyFile(file, outputPath)
22
+ })
23
+ )
24
+ logger.success('copy', `Copied ${options.files.length} file(s)`)
25
+ return
26
+ }
27
+
13
28
  const files = await glob('**/*', {
14
29
  cwd: paths.public,
15
30
  absolute: true,
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 = await formatHtml(html)
72
+ const formatted = formatHtml(html)
73
73
  await generatePage(filePath, formatted, paths)
74
74
  } catch (error) {
75
75
  logger.error('pug', `Failed: ${basename(filePath)} - ${error.message}`)
package/tasks/sass.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { glob } from 'glob'
2
2
  import { readFile, writeFile } from 'node:fs/promises'
3
3
  import { relative, resolve, basename, extname } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
4
5
  import * as sass from 'sass'
5
6
  import postcss from 'postcss'
6
7
  import autoprefixer from 'autoprefixer'
@@ -12,36 +13,64 @@ import { ensureFileDir } from '../utils/file.mjs'
12
13
  * Sassビルドタスク
13
14
  */
14
15
  export async function sassTask(context, options = {}) {
15
- const { paths, config, isProduction } = context
16
+ const { paths, config, isProduction, isDevelopment, sassGraph, cache } = context
16
17
 
17
18
  // debugモードはdevモード時のみ有効
18
19
  const isDebugMode = !isProduction && config.debug
19
20
 
20
- // 1. ビルド対象ファイルの取得
21
- const scssFiles = await glob('**/[^_]*.scss', {
21
+ // 1. ビルド対象ファイルの取得(非パーシャル)
22
+ const allEntryFiles = await glob('**/[^_]*.scss', {
22
23
  cwd: paths.src,
23
24
  absolute: true,
24
25
  ignore: ['**/_*.scss']
25
26
  })
26
27
 
27
- if (scssFiles.length === 0) {
28
+ if (allEntryFiles.length === 0) {
28
29
  logger.skip('sass', 'No files to build')
29
30
  return
30
31
  }
31
32
 
32
- logger.info('sass', `Building ${scssFiles.length} file(s)`)
33
+ // 2. dev モードでのインクリメンタルビルド
34
+ let filesToBuild = allEntryFiles
35
+
36
+ if (isDevelopment && options.files?.length > 0) {
37
+ const changedFile = options.files[0]
38
+ const isPartial = basename(changedFile).startsWith('_')
39
+
40
+ if (isPartial) {
41
+ // パーシャル変更 → 依存グラフから影響を受けるエントリファイルを特定
42
+ const affected = sassGraph.getAffectedParents(changedFile)
43
+ filesToBuild = affected.filter(f => allEntryFiles.includes(f))
44
+
45
+ if (filesToBuild.length === 0) {
46
+ // グラフにまだ情報がない場合はフルビルド
47
+ filesToBuild = allEntryFiles
48
+ } else {
49
+ logger.info('sass', `Partial changed, rebuilding ${filesToBuild.length} affected file(s)`)
50
+ }
51
+ } else {
52
+ // 非パーシャル変更 → そのファイルだけリビルド
53
+ filesToBuild = allEntryFiles.filter(f => f === changedFile)
54
+ if (filesToBuild.length === 0) {
55
+ logger.skip('sass', 'Changed file is not a build target')
56
+ return
57
+ }
58
+ }
59
+ }
60
+
61
+ logger.info('sass', `Building ${filesToBuild.length} file(s)`)
33
62
 
34
- // 2. 並列コンパイル
35
- await Promise.all(scssFiles.map(file => compileSassFile(file, context, isDebugMode)))
63
+ // 3. 並列コンパイル
64
+ await Promise.all(filesToBuild.map(file => compileSassFile(file, context, isDebugMode)))
36
65
 
37
- logger.success('sass', `Built ${scssFiles.length} file(s)`)
66
+ logger.success('sass', `Built ${filesToBuild.length} file(s)`)
38
67
  }
39
68
 
40
69
  /**
41
70
  * 個別Sassファイルのコンパイル
42
71
  */
43
72
  async function compileSassFile(filePath, context, isDebugMode) {
44
- const { paths, config, isProduction } = context
73
+ const { paths, config, isProduction, sassGraph } = context
45
74
 
46
75
  try {
47
76
  // Sassコンパイル
@@ -55,6 +84,19 @@ async function compileSassFile(filePath, context, isDebugMode) {
55
84
  sourceMapIncludeSources: isDebugMode
56
85
  })
57
86
 
87
+ // 依存グラフを更新(loadedUrls からパーシャルの依存関係を構築)
88
+ sassGraph.clearDependencies(filePath)
89
+ if (result.loadedUrls) {
90
+ for (const url of result.loadedUrls) {
91
+ if (url.protocol === 'file:') {
92
+ const depPath = fileURLToPath(url)
93
+ if (depPath !== filePath) {
94
+ sassGraph.addDependency(filePath, depPath)
95
+ }
96
+ }
97
+ }
98
+ }
99
+
58
100
  let css = result.css
59
101
 
60
102
  // PostCSS処理
@@ -78,7 +120,7 @@ async function compileSassFile(filePath, context, isDebugMode) {
78
120
  )
79
121
  }
80
122
 
81
- const outputRelativePath = relative(paths.src, filePath).replace('.scss', '.css')
123
+ const outputRelativePath = relative(paths.src, filePath).replace(/\.scss$/, '.css')
82
124
  const outputPath = resolve(paths.dist, outputRelativePath)
83
125
 
84
126
  const postcssResult = await postcss(postcssPlugins).process(css, {
package/tasks/script.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { glob } from 'glob'
2
- import { resolve, relative, dirname } from 'node:path'
2
+ import { resolve, relative, dirname, basename } from 'node:path'
3
3
  import * as esbuild from 'esbuild'
4
4
  import { logger } from '../utils/logger.mjs'
5
5
  import { ensureDir } from '../utils/file.mjs'
@@ -8,29 +8,53 @@ import { ensureDir } from '../utils/file.mjs'
8
8
  * esbuild(TypeScript/JavaScript)ビルドタスク
9
9
  */
10
10
  export async function scriptTask(context, options = {}) {
11
- const { paths, isProduction, config } = context
11
+ const { paths, isProduction, isDevelopment, config, scriptGraph } = context
12
12
 
13
13
  // 1. ビルド対象ファイルの取得
14
- const scriptFiles = await glob('**/[^_]*.{ts,js}', {
14
+ const allEntryFiles = await glob('**/[^_]*.{ts,js}', {
15
15
  cwd: paths.src,
16
16
  absolute: true,
17
17
  ignore: ['**/*.d.ts', '**/node_modules/**']
18
18
  })
19
19
 
20
- if (scriptFiles.length === 0) {
20
+ if (allEntryFiles.length === 0) {
21
21
  logger.skip('script', 'No files to build')
22
22
  return
23
23
  }
24
24
 
25
- logger.info('script', `Building ${scriptFiles.length} file(s)`)
25
+ // 2. dev モードでのインクリメンタルビルド
26
+ let filesToBuild = allEntryFiles
27
+
28
+ if (isDevelopment && options.files?.length > 0) {
29
+ const changedFile = options.files[0]
30
+ const isPartial = basename(changedFile).startsWith('_')
31
+
32
+ if (isPartial) {
33
+ // パーシャル変更 → 依存グラフから影響を受けるエントリファイルを特定
34
+ const affected = scriptGraph.getAffectedParents(changedFile)
35
+ filesToBuild = affected.filter(f => allEntryFiles.includes(f))
36
+
37
+ if (filesToBuild.length === 0) {
38
+ // グラフにまだ情報がない場合はフルビルド
39
+ filesToBuild = allEntryFiles
40
+ } else {
41
+ logger.info('script', `Partial changed, rebuilding ${filesToBuild.length} affected file(s)`)
42
+ }
43
+ } else if (allEntryFiles.includes(changedFile)) {
44
+ // 非パーシャルのエントリファイル → そのファイルだけリビルド
45
+ filesToBuild = [changedFile]
46
+ }
47
+ }
48
+
49
+ logger.info('script', `Building ${filesToBuild.length} file(s)`)
26
50
 
27
51
  // debugモードはdevモード時のみ有効
28
52
  const isDebugMode = !isProduction && config.debug
29
53
 
30
54
  try {
31
- // 2. esbuild設定
55
+ // 3. esbuild設定
32
56
  const esbuildConfig = {
33
- entryPoints: scriptFiles,
57
+ entryPoints: filesToBuild,
34
58
  outdir: paths.dist,
35
59
  outbase: paths.src,
36
60
  bundle: true,
@@ -41,7 +65,7 @@ export async function scriptTask(context, options = {}) {
41
65
  write: true,
42
66
  sourcemap: isDebugMode,
43
67
  minify: false,
44
- metafile: false,
68
+ metafile: true,
45
69
  logLevel: 'error',
46
70
  keepNames: false,
47
71
  external: [],
@@ -57,7 +81,7 @@ export async function scriptTask(context, options = {}) {
57
81
  esbuildConfig.drop = ['console', 'debugger']
58
82
  }
59
83
 
60
- // 3. ビルド実行
84
+ // 4. ビルド実行
61
85
  await ensureDir(paths.dist)
62
86
  const result = await esbuild.build(esbuildConfig)
63
87
 
@@ -65,7 +89,24 @@ export async function scriptTask(context, options = {}) {
65
89
  throw new Error(`esbuild errors: ${result.errors.length}`)
66
90
  }
67
91
 
68
- logger.success('script', `Built ${scriptFiles.length} file(s)`)
92
+ // 5. 依存グラフを更新(metafile から依存関係を構築)
93
+ if (result.metafile) {
94
+ for (const [output, meta] of Object.entries(result.metafile.outputs)) {
95
+ if (!meta.entryPoint) continue
96
+ const entryPath = resolve(paths.root, meta.entryPoint)
97
+
98
+ scriptGraph.clearDependencies(entryPath)
99
+
100
+ for (const inputPath of Object.keys(meta.inputs)) {
101
+ const absInputPath = resolve(paths.root, inputPath)
102
+ if (absInputPath !== entryPath) {
103
+ scriptGraph.addDependency(entryPath, absInputPath)
104
+ }
105
+ }
106
+ }
107
+ }
108
+
109
+ logger.success('script', `Built ${filesToBuild.length} file(s)`)
69
110
  } catch (error) {
70
111
  logger.error('script', error.message)
71
112
  throw error
@@ -1,14 +1,18 @@
1
- import prettier from 'prettier'
1
+ import pkg from 'js-beautify'
2
+ const { html: beautifyHtml } = pkg
2
3
 
3
- export async function formatHtml(html, options = {}) {
4
+ export function formatHtml(html, options = {}) {
4
5
  const cleaned = html.replace(/[\u200B-\u200D\uFEFF]/g, '')
5
6
 
6
- return prettier.format(cleaned, {
7
- parser: 'html',
8
- printWidth: options.printWidth || 100_000,
9
- tabWidth: options.tabWidth || 2,
10
- useTabs: options.useTabs || false,
11
- htmlWhitespaceSensitivity: 'ignore',
12
- singleAttributePerLine: false
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']
13
17
  })
14
18
  }