pugkit 1.0.0-beta.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.
@@ -0,0 +1,51 @@
1
+ import { resolve } from 'node:path'
2
+ import { CacheManager } from './cache.mjs'
3
+ import { DependencyGraph } from './graph.mjs'
4
+ import { createGlobPatterns } from '../config/index.mjs'
5
+
6
+ export class BuildContext {
7
+ constructor(config, mode) {
8
+ this.config = config
9
+ this.mode = mode
10
+ this.cache = new CacheManager(mode)
11
+ this.graph = new DependencyGraph()
12
+
13
+ this.paths = {
14
+ root: config.root,
15
+ src: resolve(config.root, 'src'),
16
+ dist: resolve(config.root, 'dist', config.subdir || ''),
17
+ public: resolve(config.root, 'public')
18
+ }
19
+
20
+ this.patterns = createGlobPatterns(this.paths.src)
21
+ this.server = null
22
+ this.taskRegistry = null
23
+ }
24
+
25
+ async runTask(taskName, taskFn, options = {}) {
26
+ try {
27
+ await taskFn(this, options)
28
+ } catch (error) {
29
+ console.error(`Task failed: ${taskName}`)
30
+ throw error
31
+ }
32
+ }
33
+
34
+ async runParallel(tasks) {
35
+ await Promise.all(tasks.map(({ name, fn, options = {} }) => this.runTask(name, fn, options)))
36
+ }
37
+
38
+ async runSeries(tasks) {
39
+ for (const { name, fn, options = {} } of tasks) {
40
+ await this.runTask(name, fn, options)
41
+ }
42
+ }
43
+
44
+ get isProduction() {
45
+ return this.mode === 'production'
46
+ }
47
+
48
+ get isDevelopment() {
49
+ return this.mode === 'development'
50
+ }
51
+ }
package/core/graph.mjs ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 依存関係グラフ
3
+ * Pugのパーシャル依存を管理
4
+ */
5
+ export class DependencyGraph {
6
+ constructor() {
7
+ this.edges = new Map() // 親 -> Set<依存>
8
+ this.reverseEdges = new Map() // 依存 -> Set<親>
9
+ }
10
+
11
+ /**
12
+ * 依存関係を追加
13
+ */
14
+ addDependency(parent, dependency) {
15
+ // 親 -> 依存
16
+ if (!this.edges.has(parent)) {
17
+ this.edges.set(parent, new Set())
18
+ }
19
+ this.edges.get(parent).add(dependency)
20
+
21
+ // 依存 -> 親(逆引き)
22
+ if (!this.reverseEdges.has(dependency)) {
23
+ this.reverseEdges.set(dependency, new Set())
24
+ }
25
+ this.reverseEdges.get(dependency).add(parent)
26
+ }
27
+
28
+ /**
29
+ * パーシャル変更時に再ビルドが必要な親ファイルを取得
30
+ */
31
+ getAffectedParents(dependency) {
32
+ const affected = new Set()
33
+ const queue = [dependency]
34
+ const visited = new Set()
35
+
36
+ while (queue.length > 0) {
37
+ const current = queue.shift()
38
+
39
+ if (visited.has(current)) {
40
+ continue
41
+ }
42
+ visited.add(current)
43
+
44
+ const parents = this.reverseEdges.get(current)
45
+
46
+ if (parents) {
47
+ parents.forEach(parent => {
48
+ affected.add(parent)
49
+ // 連鎖的な依存もチェック
50
+ queue.push(parent)
51
+ })
52
+ }
53
+ }
54
+
55
+ return Array.from(affected)
56
+ }
57
+
58
+ /**
59
+ * ファイルの依存関係をクリア
60
+ */
61
+ clearDependencies(file) {
62
+ // 親としての依存をクリア
63
+ const deps = this.edges.get(file)
64
+ if (deps) {
65
+ deps.forEach(dep => {
66
+ const parents = this.reverseEdges.get(dep)
67
+ if (parents) {
68
+ parents.delete(file)
69
+ if (parents.size === 0) {
70
+ this.reverseEdges.delete(dep)
71
+ }
72
+ }
73
+ })
74
+ this.edges.delete(file)
75
+ }
76
+
77
+ // 依存としての親をクリア
78
+ const parents = this.reverseEdges.get(file)
79
+ if (parents) {
80
+ parents.forEach(parent => {
81
+ const deps = this.edges.get(parent)
82
+ if (deps) {
83
+ deps.delete(file)
84
+ }
85
+ })
86
+ this.reverseEdges.delete(file)
87
+ }
88
+ }
89
+
90
+ /**
91
+ * すべてのグラフをクリア
92
+ */
93
+ clear() {
94
+ this.edges.clear()
95
+ this.reverseEdges.clear()
96
+ }
97
+ }
@@ -0,0 +1,78 @@
1
+ import browser from 'browser-sync'
2
+ import { resolve } from 'node:path'
3
+ import { existsSync } from 'node:fs'
4
+ import { mkdir } from 'node:fs/promises'
5
+ import { logger } from '../utils/logger.mjs'
6
+
7
+ /**
8
+ * 開発サーバータスク
9
+ */
10
+ export async function serverTask(context, options = {}) {
11
+ const { paths, config } = context
12
+ const distRoot = paths.dist
13
+
14
+ // distディレクトリの確認
15
+ if (!existsSync(distRoot)) {
16
+ await mkdir(distRoot, { recursive: true })
17
+ }
18
+
19
+ // BrowserSyncインスタンス作成
20
+ const bs = browser.create()
21
+ context.server = bs
22
+
23
+ const subdir = config.subdir ? '/' + config.subdir.replace(/^\/|\/$/g, '') : ''
24
+ const startPath = (config.server.startPath || '/').replace(/^\//, '')
25
+ const fullStartPath = subdir ? `${subdir}/${startPath}` : `/${startPath}`
26
+
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
+ }
73
+ }
74
+ )
75
+ })
76
+ }
77
+
78
+ export default serverTask
@@ -0,0 +1,314 @@
1
+ import chokidar from 'chokidar'
2
+ import { relative } from 'node:path'
3
+ import { logger } from '../utils/logger.mjs'
4
+
5
+ /**
6
+ * ファイル監視タスク
7
+ */
8
+ export async function watcherTask(context, options = {}) {
9
+ const watcher = new FileWatcher(context)
10
+ await watcher.start()
11
+ }
12
+
13
+ /**
14
+ * ファイルウォッチャー
15
+ */
16
+ class FileWatcher {
17
+ constructor(context) {
18
+ this.context = context
19
+ this.watchers = []
20
+ }
21
+
22
+ async start() {
23
+ const { paths } = this.context
24
+
25
+ // 初回ビルド(依存関係グラフ構築のため)
26
+ logger.info('watch', 'Building initial dependency graph...')
27
+ if (this.context.taskRegistry?.pug) {
28
+ await this.context.taskRegistry.pug(this.context)
29
+ }
30
+
31
+ // Pug監視
32
+ this.watchPug(paths.src)
33
+
34
+ // Sass監視
35
+ this.watchSass(paths.src)
36
+
37
+ // Script監視
38
+ this.watchScript(paths.src)
39
+
40
+ // SVG監視
41
+ this.watchSvg(paths.src)
42
+
43
+ // 画像監視
44
+ this.watchImages(paths.src)
45
+
46
+ // Public監視
47
+ this.watchPublic(paths.public)
48
+
49
+ logger.info('watch', 'File watching started')
50
+ }
51
+
52
+ /**
53
+ * Pug監視(依存関係を考慮)
54
+ */
55
+ watchPug(basePath) {
56
+ const watcher = chokidar.watch(basePath, {
57
+ ignoreInitial: true,
58
+ ignored: [
59
+ /(^|[\/\\])\../, // 隠しファイル
60
+ /node_modules/,
61
+ /\.git/
62
+ ],
63
+ persistent: true,
64
+ awaitWriteFinish: {
65
+ stabilityThreshold: 100,
66
+ pollInterval: 50
67
+ }
68
+ })
69
+
70
+ watcher.on('change', async path => {
71
+ // .pugファイルのみ処理
72
+ if (!path.endsWith('.pug')) {
73
+ return
74
+ }
75
+ const relPath = relative(basePath, path)
76
+ logger.info('change', `pug: ${relPath}`)
77
+
78
+ try {
79
+ // パーシャルの場合、依存する親ファイルも再ビルド
80
+ const affectedFiles = this.context.graph.getAffectedParents(path)
81
+
82
+ if (affectedFiles.length > 0) {
83
+ logger.info('pug', `Rebuilding ${affectedFiles.length} affected file(s)`)
84
+ }
85
+
86
+ // キャッシュ無効化
87
+ this.context.cache.invalidatePugTemplate(path)
88
+ affectedFiles.forEach(f => this.context.cache.invalidatePugTemplate(f))
89
+
90
+ // Pugタスクを実行(変更されたファイルのみ)
91
+ if (this.context.taskRegistry?.pug) {
92
+ await this.context.taskRegistry.pug(this.context, {
93
+ files: [path, ...affectedFiles]
94
+ })
95
+ }
96
+
97
+ this.reload()
98
+ } catch (error) {
99
+ logger.error('watch', `Pug build failed: ${error.message}`)
100
+ }
101
+ })
102
+
103
+ this.watchers.push(watcher)
104
+ }
105
+
106
+ /**
107
+ * Sass監視
108
+ */
109
+ watchSass(basePath) {
110
+ const watcher = chokidar.watch(basePath, {
111
+ ignoreInitial: true,
112
+ ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/],
113
+ persistent: true,
114
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
115
+ })
116
+
117
+ watcher.on('change', async path => {
118
+ // .scssファイルのみ処理
119
+ if (!path.endsWith('.scss')) {
120
+ return
121
+ }
122
+ const relPath = relative(basePath, path)
123
+ logger.info('change', `sass: ${relPath}`)
124
+
125
+ try {
126
+ // Sassタスクを実行
127
+ if (this.context.taskRegistry?.sass) {
128
+ await this.context.taskRegistry.sass(this.context)
129
+ }
130
+
131
+ // CSSはインジェクション(リロードせずに更新)
132
+ this.injectCSS()
133
+ } catch (error) {
134
+ logger.error('watch', `Sass build failed: ${error.message}`)
135
+ }
136
+ })
137
+
138
+ this.watchers.push(watcher)
139
+ }
140
+
141
+ /**
142
+ * Script監視
143
+ */
144
+ watchScript(basePath) {
145
+ const watcher = chokidar.watch(basePath, {
146
+ ignoreInitial: true,
147
+ ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/, /\.d\.ts$/],
148
+ persistent: true,
149
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
150
+ })
151
+
152
+ watcher.on('change', async path => {
153
+ // .ts/.jsファイルのみ処理(.d.tsを除く)
154
+ if (!(path.endsWith('.ts') || path.endsWith('.js')) || path.endsWith('.d.ts')) {
155
+ return
156
+ }
157
+ const relPath = relative(basePath, path)
158
+ logger.info('change', `script: ${relPath}`)
159
+
160
+ try {
161
+ // Scriptタスクを実行
162
+ if (this.context.taskRegistry?.script) {
163
+ await this.context.taskRegistry.script(this.context)
164
+ }
165
+
166
+ this.reload()
167
+ } catch (error) {
168
+ logger.error('watch', `Script build failed: ${error.message}`)
169
+ }
170
+ })
171
+
172
+ this.watchers.push(watcher)
173
+ }
174
+
175
+ /**
176
+ * SVG監視
177
+ */
178
+ watchSvg(basePath) {
179
+ const watcher = chokidar.watch(basePath, {
180
+ ignoreInitial: true,
181
+ ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/, /icons/], // iconsはスプライト用なので除外
182
+ persistent: true,
183
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
184
+ })
185
+
186
+ const handleSvgChange = async path => {
187
+ // .svgファイルのみ処理(iconsディレクトリは除外)
188
+ if (!path.endsWith('.svg') || path.includes('/icons/')) {
189
+ return
190
+ }
191
+ const relPath = relative(basePath, path)
192
+ logger.info('change', `svg: ${relPath}`)
193
+
194
+ try {
195
+ // SVGタスクを実行(変更されたファイルのみ)
196
+ if (this.context.taskRegistry?.svg) {
197
+ await this.context.taskRegistry.svg(this.context, {
198
+ files: [path]
199
+ })
200
+ }
201
+
202
+ this.reload()
203
+ } catch (error) {
204
+ logger.error('watch', `SVG processing failed: ${error.message}`)
205
+ }
206
+ }
207
+
208
+ watcher.on('change', handleSvgChange)
209
+ watcher.on('add', handleSvgChange)
210
+
211
+ this.watchers.push(watcher)
212
+ }
213
+
214
+ /**
215
+ * 画像監視
216
+ */
217
+ watchImages(basePath) {
218
+ const watcher = chokidar.watch(basePath, {
219
+ ignoreInitial: true,
220
+ ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/],
221
+ persistent: true,
222
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
223
+ })
224
+
225
+ const handleImageChange = async path => {
226
+ // 画像ファイルのみ処理
227
+ if (!/\.(jpg|jpeg|png|gif)$/i.test(path)) {
228
+ return
229
+ }
230
+ const relPath = relative(basePath, path)
231
+ logger.info('change', `image: ${relPath}`)
232
+
233
+ try {
234
+ // 追加・変更時: 画像を処理
235
+ if (this.context.taskRegistry?.image) {
236
+ await this.context.taskRegistry.image(this.context, {
237
+ files: [path]
238
+ })
239
+ }
240
+
241
+ this.reload()
242
+ } catch (error) {
243
+ logger.error('watch', `Image processing failed: ${error.message}`)
244
+ }
245
+ }
246
+
247
+ watcher.on('change', handleImageChange)
248
+ watcher.on('add', handleImageChange)
249
+
250
+ this.watchers.push(watcher)
251
+ }
252
+
253
+ /**
254
+ * Public監視
255
+ */
256
+ watchPublic(basePath) {
257
+ const watcher = chokidar.watch(basePath, {
258
+ ignoreInitial: true,
259
+ ignored: [/(^|[\/\\])\../, /node_modules/, /\.git/],
260
+ persistent: true,
261
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 50 }
262
+ })
263
+
264
+ watcher.on('change', async path => {
265
+ const relPath = relative(basePath, path)
266
+ logger.info('change', `public: ${relPath}`)
267
+
268
+ try {
269
+ // Copyタスクを実行
270
+ if (this.context.taskRegistry?.copy) {
271
+ await this.context.taskRegistry.copy(this.context)
272
+ }
273
+
274
+ this.reload()
275
+ } catch (error) {
276
+ logger.error('watch', `Copy failed: ${error.message}`)
277
+ }
278
+ })
279
+
280
+ this.watchers.push(watcher)
281
+ }
282
+
283
+ /**
284
+ * ブラウザリロード
285
+ */
286
+ reload() {
287
+ if (this.context.server) {
288
+ setTimeout(() => {
289
+ this.context.server.reload()
290
+ }, 100)
291
+ }
292
+ }
293
+
294
+ /**
295
+ * CSSインジェクション(リロードなし)
296
+ */
297
+ injectCSS() {
298
+ if (this.context.server) {
299
+ setTimeout(() => {
300
+ this.context.server.reload('*.css')
301
+ }, 100)
302
+ }
303
+ }
304
+
305
+ /**
306
+ * 監視停止
307
+ */
308
+ async stop() {
309
+ await Promise.all(this.watchers.map(w => w.close()))
310
+ logger.info('watch', 'File watching stopped')
311
+ }
312
+ }
313
+
314
+ export default watcherTask
@@ -0,0 +1,19 @@
1
+ import { writeFile, copyFile, mkdir } from 'node:fs/promises'
2
+ import { dirname } from 'node:path'
3
+ import { ensureFileDir } from '../utils/file.mjs'
4
+
5
+ export async function generateAsset(outputPath, data) {
6
+ await ensureFileDir(outputPath)
7
+
8
+ if (typeof data === 'string' || Buffer.isBuffer(data)) {
9
+ await writeFile(outputPath, data)
10
+ }
11
+
12
+ return outputPath
13
+ }
14
+
15
+ export async function copyAsset(srcPath, destPath) {
16
+ await ensureFileDir(destPath)
17
+ await copyFile(srcPath, destPath)
18
+ return destPath
19
+ }
@@ -0,0 +1,13 @@
1
+ import { writeFile } from 'node:fs/promises'
2
+ import { relative, resolve } from 'node:path'
3
+ import { ensureFileDir } from '../utils/file.mjs'
4
+
5
+ export async function generatePage(filePath, html, paths) {
6
+ const relativePath = relative(paths.src, filePath)
7
+ const outputPath = resolve(paths.dist, relativePath.replace('.pug', '.html'))
8
+
9
+ await ensureFileDir(outputPath)
10
+ await writeFile(outputPath, html, 'utf8')
11
+
12
+ return outputPath
13
+ }
package/index.mjs ADDED
@@ -0,0 +1,60 @@
1
+ import { loadConfig } from './config/index.mjs'
2
+ import { Builder } from './core/builder.mjs'
3
+ import pugTask from './tasks/pug.mjs'
4
+ import sassTask from './tasks/sass.mjs'
5
+ import scriptTask from './tasks/script.mjs'
6
+ import copyTask from './tasks/copy.mjs'
7
+ import imageTask from './tasks/image.mjs'
8
+ import svgTask from './tasks/svg.mjs'
9
+ import spriteTask from './tasks/svg-sprite.mjs'
10
+ import serverTask from './core/server.mjs'
11
+ import watcherTask from './core/watcher.mjs'
12
+
13
+ export async function createBuilder(root = process.cwd(), mode = 'development') {
14
+ const config = await loadConfig(root)
15
+ const builder = new Builder(config, mode)
16
+
17
+ builder.registerTasks({
18
+ pug: pugTask,
19
+ sass: sassTask,
20
+ script: scriptTask,
21
+ image: imageTask,
22
+ svg: svgTask,
23
+ sprite: spriteTask,
24
+ copy: copyTask,
25
+ server: serverTask,
26
+ watch: watcherTask
27
+ })
28
+
29
+ builder.context.taskRegistry = {
30
+ pug: pugTask,
31
+ sass: sassTask,
32
+ script: scriptTask,
33
+ image: imageTask,
34
+ svg: svgTask,
35
+ copy: copyTask
36
+ }
37
+
38
+ return builder
39
+ }
40
+
41
+ export async function build(root = process.cwd()) {
42
+ const builder = await createBuilder(root, 'production')
43
+ await builder.build()
44
+ }
45
+
46
+ export async function watch(root = process.cwd()) {
47
+ const builder = await createBuilder(root, 'development')
48
+ await builder.watch()
49
+ }
50
+
51
+ export async function runTask(taskName, root = process.cwd()) {
52
+ const builder = await createBuilder(root, 'production')
53
+ await builder.runTask(taskName)
54
+ }
55
+
56
+ export { Builder, loadConfig }
57
+ export { BuildContext } from './core/context.mjs'
58
+ export { CacheManager } from './core/cache.mjs'
59
+ export { DependencyGraph } from './core/graph.mjs'
60
+ export { defineConfig } from './config/index.mjs'
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "pugkit",
3
+ "version": "1.0.0-beta.1",
4
+ "description": "A build tool for Pug-based projects",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "mfxgu2i <mfxgu2i@gmail.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/mfxgu2i/pugkit.git",
11
+ "directory": "packages/pugkit"
12
+ },
13
+ "bugs": "https://github.com/mfxgu2i/pugkit/issues",
14
+ "homepage": "https://github.com/mfxgu2i/pugkit#readme",
15
+ "engines": {
16
+ "node": ">=18.0.0"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "bin": {
22
+ "pugkit": "./cli/index.mjs"
23
+ },
24
+ "keywords": [
25
+ "pug",
26
+ "ssg",
27
+ "static-site-generator",
28
+ "frontend-tooling"
29
+ ],
30
+ "exports": {
31
+ ".": "./index.mjs",
32
+ "./config": "./config/index.mjs"
33
+ },
34
+ "files": [
35
+ "cli",
36
+ "config",
37
+ "core",
38
+ "generate",
39
+ "index.mjs",
40
+ "tasks",
41
+ "transform",
42
+ "utils"
43
+ ],
44
+ "dependencies": {
45
+ "autoprefixer": "^10.4.21",
46
+ "browser-sync": "^3.0.4",
47
+ "cac": "^6.7.14",
48
+ "chokidar": "^4.0.3",
49
+ "cssnano": "^7.1.1",
50
+ "esbuild": "^0.25.5",
51
+ "glob": "^10.3.10",
52
+ "image-size": "^2.0.2",
53
+ "picocolors": "^1.1.1",
54
+ "postcss": "^8.4.49",
55
+ "prettier": "^3.8.0",
56
+ "pug": "^3.0.3",
57
+ "sass": "^1.89.2",
58
+ "sharp": "^0.34.2",
59
+ "svgo": "^4.0.0"
60
+ }
61
+ }