@c-time/frelio-cli 1.4.1 → 1.4.3

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.
@@ -1,2215 +0,0 @@
1
- /**
2
- * 初期コンテンツ・テンプレート・レシピの生成
3
- * frelio-demo を踏襲したデモサイト構造
4
- */
5
- import { writeFile } from './templates.js';
6
- import path from 'node:path';
7
- export function generateInitialContent(projectDir) {
8
- const base = (...p) => path.join(projectDir, ...p);
9
- // --- Content Type ---
10
- writeFile(base('frelio-data/site/content_types/article.json'), json({
11
- id: 'article',
12
- fields: [
13
- { key: 'slug', type: 'text', multiple: false, useAsUrl: true },
14
- { key: 'title', type: 'text', multiple: false },
15
- { key: 'excerpt', type: 'text', multiple: false },
16
- { key: 'body', type: 'html', multiple: false },
17
- { key: 'eyecatch', type: 'image', multiple: false },
18
- { key: 'publishDate', type: 'date', multiple: false },
19
- ],
20
- }));
21
- // --- Content Type UI ---
22
- writeFile(base('frelio-data/admin/content_types/article.ui.json'), json({
23
- id: 'article',
24
- label: '記事',
25
- fields: [
26
- {
27
- key: 'slug', label: 'スラッグ', required: true,
28
- defaultValue: '', placeholder: 'my-article-slug',
29
- description: 'URLに使用される識別子。英数字とハイフンのみ',
30
- warnLength: null, columns: 12, break: false, hidden: false, readOnly: false,
31
- pattern: '^[a-z0-9-]+$', patternMessage: '小文字英数字とハイフンのみ使用できます',
32
- autoFormat: 'slug',
33
- },
34
- {
35
- key: 'title', label: 'タイトル', required: true,
36
- defaultValue: '', placeholder: '記事のタイトルを入力',
37
- description: '検索結果・OGPに表示されます。推奨: 60文字以内',
38
- warnLength: 60, columns: 12, break: false, hidden: false, readOnly: false,
39
- pattern: null, patternMessage: null, autoFormat: null,
40
- },
41
- {
42
- key: 'excerpt', label: '概要', required: false,
43
- defaultValue: '', placeholder: '記事の概要を入力',
44
- description: '一覧ページや検索結果に表示される短い説明文',
45
- warnLength: 200, columns: 12, break: false, hidden: false, readOnly: false,
46
- pattern: null, patternMessage: null, autoFormat: null,
47
- },
48
- {
49
- key: 'body', label: '本文', required: true,
50
- defaultValue: '', placeholder: 'HTML形式で本文を入力',
51
- description: null, warnLength: null, columns: 12, break: false, hidden: false, readOnly: false,
52
- pattern: null, patternMessage: null, autoFormat: null,
53
- },
54
- {
55
- key: 'eyecatch', label: 'アイキャッチ画像', required: false,
56
- defaultValue: null, placeholder: null,
57
- description: '記事のサムネイル画像',
58
- warnLength: null, columns: 6, break: true, hidden: false, readOnly: false,
59
- aspectRatio: '16:9', pattern: null, patternMessage: null, autoFormat: null,
60
- },
61
- {
62
- key: 'publishDate', label: '公開日', required: false,
63
- defaultValue: '', placeholder: null,
64
- description: '記事の公開日',
65
- warnLength: null, columns: 4, break: false, hidden: false, readOnly: false,
66
- pattern: null, patternMessage: null, autoFormat: null,
67
- },
68
- ],
69
- groups: [],
70
- }));
71
- // --- Content Type Views ---
72
- writeFile(base('frelio-data/admin/content_types/article.views.json'), json({
73
- defaultViewId: 'default',
74
- views: [
75
- {
76
- id: 'default',
77
- label: 'デフォルト',
78
- columns: [
79
- { field: 'title', label: 'タイトル' },
80
- { field: 'status', label: '状態', width: '100px' },
81
- { field: 'publishDate', label: '公開日', width: '150px' },
82
- { field: 'updatedAt', label: '更新日', width: '150px' },
83
- ],
84
- defaultSort: { field: 'updatedAt', direction: 'desc' },
85
- },
86
- ],
87
- }));
88
- // --- Content Types Metadata (override the empty one) ---
89
- writeFile(base('frelio-data/admin/metadata/content_types.json'), json({
90
- contentTypes: [
91
- { id: 'article', label: '記事', icon: 'Article' },
92
- ],
93
- }));
94
- // --- Dashboard Metadata ---
95
- writeFile(base('frelio-data/admin/metadata/_dashboard.json'), json({
96
- contentTypes: {
97
- article: {
98
- lastUpdatedAt: null,
99
- lastUpdatedBy: null,
100
- totalCount: 0,
101
- draftCount: 0,
102
- publishedCount: 0,
103
- },
104
- },
105
- }));
106
- // --- Build Recipe ---
107
- writeFile(base('frelio-data/admin/recipes/build-data-recipe.json'), json({
108
- version: '1.0',
109
- contentTypes: {
110
- article: {
111
- details: [
112
- {
113
- type: 'detail',
114
- outputPath: 'news/{$this.slug}.json',
115
- templatePath: 'news/detail.html',
116
- },
117
- ],
118
- lists: [
119
- {
120
- type: 'list',
121
- outputPath: 'news/index.json',
122
- templatePath: 'news/index.html',
123
- pagination: { perPage: 10, numberFormatCount: 1 },
124
- },
125
- ],
126
- },
127
- },
128
- staticPages: [
129
- {
130
- type: 'static',
131
- outputPath: 'index.json',
132
- templatePath: 'index.html',
133
- customFields: [
134
- {
135
- field: 'latestNews',
136
- type: 'relationList',
137
- contentTypeId: 'article',
138
- limit: 5,
139
- sort: { field: 'publishDate', direction: 'desc' },
140
- listFields: ['slug', 'title', 'excerpt', 'eyecatch', 'publishDate'],
141
- },
142
- ],
143
- },
144
- { type: 'static', outputPath: 'about/index.json', templatePath: 'about/index.html' },
145
- { type: 'static', outputPath: 'contact/index.json', templatePath: 'contact/index.html' },
146
- ],
147
- templateIncludes: {
148
- '_parts/*.htm': ['*'],
149
- },
150
- }));
151
- // --- HTML Templates ---
152
- generateTemplates(projectDir);
153
- // --- SCSS ---
154
- generateScss(projectDir);
155
- // --- TypeScript ---
156
- generateTypeScript(projectDir);
157
- // --- Entry Points ---
158
- generateEntries(projectDir);
159
- // --- Build Scripts ---
160
- generateBuildScripts(projectDir);
161
- // --- CLAUDE.md ---
162
- generateClaudeMd(projectDir);
163
- }
164
- // ========== HTML Templates ==========
165
- function generateTemplates(projectDir) {
166
- const t = (...p) => path.join(projectDir, 'frelio-data/site/templates', ...p);
167
- // _parts/head.htm
168
- writeFile(t('_parts/head.htm'), `<meta charset="UTF-8">
169
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
170
- <title data-gen-text="meta.title">Frelio Demo</title>
171
- <link rel="stylesheet" href="/common/styles/index.css">
172
- <script type="module" src="/common/scripts/index.js"></script>
173
- `);
174
- // _parts/header.htm
175
- writeFile(t('_parts/header.htm'), `<header class="l-header">
176
- <div class="l-header__inner">
177
- <a href="/" class="c-logo">Frelio Demo</a>
178
- <nav class="c-nav">
179
- <button type="button" class="c-nav__toggle" aria-label="メニュー" aria-expanded="false">
180
- <span class="c-nav__toggle__bar"></span>
181
- <span class="c-nav__toggle__bar"></span>
182
- <span class="c-nav__toggle__bar"></span>
183
- </button>
184
- <ul class="c-nav__list">
185
- <li class="c-nav__item"><a href="/">ホーム</a></li>
186
- <li class="c-nav__item"><a href="/news/">お知らせ</a></li>
187
- <li class="c-nav__item"><a href="/about/">会社概要</a></li>
188
- <li class="c-nav__item"><a href="/contact/">お問い合わせ</a></li>
189
- </ul>
190
- </nav>
191
- </div>
192
- </header>
193
- `);
194
- // _parts/footer.htm
195
- writeFile(t('_parts/footer.htm'), `<footer class="l-footer">
196
- <div class="l-footer__inner">
197
- <p class="l-footer__copyright">&copy; 2026 Frelio Demo. All rights reserved.</p>
198
- </div>
199
- </footer>
200
- `);
201
- // index.html
202
- writeFile(t('index.html'), `<!DOCTYPE html>
203
- <html lang="ja">
204
- <head>
205
- <template data-gen-scope="" data-gen-include="_parts/head.htm"></template>
206
- <meta charset="UTF-8">
207
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
208
- <title>Frelio Demo</title>
209
- <link rel="stylesheet" href="/styles/index.css">
210
- <script type="module" src="/scripts/index.js"></script>
211
- </head>
212
- <body>
213
- <template data-gen-scope="" data-gen-include="_parts/header.htm"></template>
214
- <header class="l-header">
215
- <div class="l-header__inner">
216
- <a href="/" class="c-logo">Frelio Demo</a>
217
- <nav class="c-nav">
218
- <ul class="c-nav__list">
219
- <li class="c-nav__item"><a href="/">ホーム</a></li>
220
- <li class="c-nav__item"><a href="/news/">お知らせ</a></li>
221
- <li class="c-nav__item"><a href="/about/">会社概要</a></li>
222
- <li class="c-nav__item"><a href="/contact/">お問い合わせ</a></li>
223
- </ul>
224
- </nav>
225
- </div>
226
- </header>
227
-
228
- <main class="l-main">
229
- <section class="p-hero">
230
- <div class="p-hero__inner">
231
- <h1 class="p-hero__title">Frelio Demo</h1>
232
- <p class="p-hero__lead">GitHub + Cloudflare で動く、シンプルなヘッドレス CMS のデモサイトです。</p>
233
- </div>
234
- </section>
235
-
236
- <section class="p-news-list">
237
- <div class="p-news-list__inner">
238
- <h2 class="e-heading">お知らせ</h2>
239
- <ul class="p-news-list__items">
240
- <template data-gen-scope="" data-gen-repeat="latestNews" data-gen-repeat-name="article">
241
- <li class="p-news-list__item">
242
- <a class="c-card--news" data-gen-attrs="href:article.href">
243
- <div class="c-card__body">
244
- <time class="c-card__date" data-gen-text="article.publishDate">2026-03-01</time>
245
- <h3 class="c-card__title" data-gen-text="article.title">お知らせタイトル</h3>
246
- <p class="c-card__excerpt" data-gen-text="article.excerpt">お知らせの概要がここに入ります。</p>
247
- </div>
248
- </a>
249
- </li>
250
- </template>
251
- <li class="p-news-list__item" data-gen-comment="">
252
- <a class="c-card--news" href="/news/sample/">
253
- <div class="c-card__body">
254
- <time class="c-card__date">2026-03-01</time>
255
- <h3 class="c-card__title">サンプルお知らせ</h3>
256
- <p class="c-card__excerpt">これはプレビュー用のダミーデータです。</p>
257
- </div>
258
- </a>
259
- </li>
260
- </ul>
261
- <div class="p-news-list__more">
262
- <a href="/news/" class="c-btn">お知らせ一覧へ</a>
263
- </div>
264
- </div>
265
- </section>
266
- </main>
267
-
268
- <template data-gen-scope="" data-gen-include="_parts/footer.htm"></template>
269
- <footer class="l-footer">
270
- <div class="l-footer__inner">
271
- <p class="l-footer__copyright">&copy; 2026 Frelio Demo. All rights reserved.</p>
272
- </div>
273
- </footer>
274
- </body>
275
- </html>
276
- `);
277
- // about/index.html
278
- writeFile(t('about/index.html'), `<!DOCTYPE html>
279
- <html lang="ja">
280
- <head>
281
- <template data-gen-scope="" data-gen-include="_parts/head.htm"></template>
282
- <meta charset="UTF-8">
283
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
284
- <title>会社概要 | Frelio Demo</title>
285
- <link rel="stylesheet" href="/about/styles/index.css">
286
- <script type="module" src="/about/scripts/index.js"></script>
287
- </head>
288
- <body>
289
- <template data-gen-scope="" data-gen-include="_parts/header.htm"></template>
290
- <header class="l-header">
291
- <div class="l-header__inner">
292
- <a href="/" class="c-logo">Frelio Demo</a>
293
- <nav class="c-nav">
294
- <ul class="c-nav__list">
295
- <li class="c-nav__item"><a href="/">ホーム</a></li>
296
- <li class="c-nav__item"><a href="/news/">お知らせ</a></li>
297
- <li class="c-nav__item"><a href="/about/">会社概要</a></li>
298
- <li class="c-nav__item"><a href="/contact/">お問い合わせ</a></li>
299
- </ul>
300
- </nav>
301
- </div>
302
- </header>
303
-
304
- <main class="l-main">
305
- <section class="p-about">
306
- <div class="p-about__inner">
307
- <h1 class="e-heading">会社概要</h1>
308
- <div class="p-about__content">
309
- <p>Frelio Demo は、ヘッドレス CMS「Frelio」の機能を紹介するためのデモサイトです。</p>
310
- <p>Frelio は GitHub をデータストア、Cloudflare を配信基盤として活用し、サーバーレスで運用できる次世代の CMS です。</p>
311
- </div>
312
- <div class="p-about__table">
313
- <table class="c-table">
314
- <tbody class="c-table__body">
315
- <tr class="c-table__row">
316
- <th class="c-table__header">会社名</th>
317
- <td class="c-table__cell">株式会社フレリオ(デモ)</td>
318
- </tr>
319
- <tr class="c-table__row">
320
- <th class="c-table__header">所在地</th>
321
- <td class="c-table__cell">東京都渋谷区</td>
322
- </tr>
323
- <tr class="c-table__row">
324
- <th class="c-table__header">設立</th>
325
- <td class="c-table__cell">2026年</td>
326
- </tr>
327
- <tr class="c-table__row">
328
- <th class="c-table__header">事業内容</th>
329
- <td class="c-table__cell">ヘッドレス CMS の開発・提供</td>
330
- </tr>
331
- </tbody>
332
- </table>
333
- </div>
334
- </div>
335
- </section>
336
- </main>
337
-
338
- <template data-gen-scope="" data-gen-include="_parts/footer.htm"></template>
339
- <footer class="l-footer">
340
- <div class="l-footer__inner">
341
- <p class="l-footer__copyright">&copy; 2026 Frelio Demo. All rights reserved.</p>
342
- </div>
343
- </footer>
344
- </body>
345
- </html>
346
- `);
347
- // contact/index.html
348
- writeFile(t('contact/index.html'), `<!DOCTYPE html>
349
- <html lang="ja">
350
- <head>
351
- <template data-gen-scope="" data-gen-include="_parts/head.htm"></template>
352
- <meta charset="UTF-8">
353
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
354
- <title>お問い合わせ | Frelio Demo</title>
355
- <link rel="stylesheet" href="/contact/styles/index.css">
356
- <script type="module" src="/contact/scripts/index.js"></script>
357
- </head>
358
- <body>
359
- <template data-gen-scope="" data-gen-include="_parts/header.htm"></template>
360
- <header class="l-header">
361
- <div class="l-header__inner">
362
- <a href="/" class="c-logo">Frelio Demo</a>
363
- <nav class="c-nav">
364
- <ul class="c-nav__list">
365
- <li class="c-nav__item"><a href="/">ホーム</a></li>
366
- <li class="c-nav__item"><a href="/news/">お知らせ</a></li>
367
- <li class="c-nav__item"><a href="/about/">会社概要</a></li>
368
- <li class="c-nav__item"><a href="/contact/">お問い合わせ</a></li>
369
- </ul>
370
- </nav>
371
- </div>
372
- </header>
373
-
374
- <main class="l-main">
375
- <section class="p-contact">
376
- <div class="p-contact__inner">
377
- <h1 class="e-heading">お問い合わせ</h1>
378
- <p class="p-contact__lead">お気軽にお問い合わせください。</p>
379
- <form class="p-contact__form" action="#" method="post">
380
- <div class="p-contact__field">
381
- <label class="p-contact__label" for="contact-name">お名前</label>
382
- <input class="p-contact__input" type="text" id="contact-name" name="name" required>
383
- </div>
384
- <div class="p-contact__field">
385
- <label class="p-contact__label" for="contact-email">メールアドレス</label>
386
- <input class="p-contact__input" type="email" id="contact-email" name="email" required>
387
- </div>
388
- <div class="p-contact__field">
389
- <label class="p-contact__label" for="contact-message">メッセージ</label>
390
- <textarea class="p-contact__textarea" id="contact-message" name="message" rows="6" required></textarea>
391
- </div>
392
- <div class="p-contact__actions">
393
- <button type="submit" class="c-btn--submit">送信する</button>
394
- </div>
395
- </form>
396
- </div>
397
- </section>
398
- </main>
399
-
400
- <template data-gen-scope="" data-gen-include="_parts/footer.htm"></template>
401
- <footer class="l-footer">
402
- <div class="l-footer__inner">
403
- <p class="l-footer__copyright">&copy; 2026 Frelio Demo. All rights reserved.</p>
404
- </div>
405
- </footer>
406
- </body>
407
- </html>
408
- `);
409
- // news/index.html
410
- writeFile(t('news/index.html'), `<!DOCTYPE html>
411
- <html lang="ja">
412
- <head>
413
- <template data-gen-scope="" data-gen-include="_parts/head.htm"></template>
414
- <meta charset="UTF-8">
415
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
416
- <title>お知らせ | Frelio Demo</title>
417
- <link rel="stylesheet" href="/news/styles/index.css">
418
- <script type="module" src="/news/scripts/index.js"></script>
419
- </head>
420
- <body>
421
- <template data-gen-scope="" data-gen-include="_parts/header.htm"></template>
422
- <header class="l-header">
423
- <div class="l-header__inner">
424
- <a href="/" class="c-logo">Frelio Demo</a>
425
- <nav class="c-nav">
426
- <ul class="c-nav__list">
427
- <li class="c-nav__item"><a href="/">ホーム</a></li>
428
- <li class="c-nav__item"><a href="/news/">お知らせ</a></li>
429
- <li class="c-nav__item"><a href="/about/">会社概要</a></li>
430
- <li class="c-nav__item"><a href="/contact/">お問い合わせ</a></li>
431
- </ul>
432
- </nav>
433
- </div>
434
- </header>
435
-
436
- <main class="l-main">
437
- <section class="p-news-list">
438
- <div class="p-news-list__inner">
439
- <h1 class="e-heading">お知らせ</h1>
440
- <ul class="p-news-list__items">
441
- <template data-gen-scope="" data-gen-repeat="pagedItems" data-gen-repeat-name="article">
442
- <li class="p-news-list__item">
443
- <a class="c-card--news" data-gen-attrs="href:article.href">
444
- <div class="c-card__body">
445
- <time class="c-card__date" data-gen-text="article.publishDate">2026-03-01</time>
446
- <h3 class="c-card__title" data-gen-text="article.title">お知らせタイトル</h3>
447
- <p class="c-card__excerpt" data-gen-text="article.excerpt">お知らせの概要がここに入ります。</p>
448
- </div>
449
- </a>
450
- </li>
451
- </template>
452
- <li class="p-news-list__item" data-gen-comment="">
453
- <a class="c-card--news" href="/news/sample/">
454
- <div class="c-card__body">
455
- <time class="c-card__date">2026-03-01</time>
456
- <h3 class="c-card__title">サンプルお知らせ</h3>
457
- <p class="c-card__excerpt">これはプレビュー用のダミーデータです。</p>
458
- </div>
459
- </a>
460
- </li>
461
- </ul>
462
- </div>
463
- </section>
464
- </main>
465
-
466
- <template data-gen-scope="" data-gen-include="_parts/footer.htm"></template>
467
- <footer class="l-footer">
468
- <div class="l-footer__inner">
469
- <p class="l-footer__copyright">&copy; 2026 Frelio Demo. All rights reserved.</p>
470
- </div>
471
- </footer>
472
- </body>
473
- </html>
474
- `);
475
- // news/detail.html
476
- writeFile(t('news/detail.html'), `<!DOCTYPE html>
477
- <html lang="ja">
478
- <head>
479
- <template data-gen-scope="" data-gen-include="_parts/head.htm"></template>
480
- <meta charset="UTF-8">
481
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
482
- <title>記事タイトル | Frelio Demo</title>
483
- <link rel="stylesheet" href="/news/styles/index.css">
484
- <script type="module" src="/news/scripts/index.js"></script>
485
- </head>
486
- <body>
487
- <template data-gen-scope="" data-gen-include="_parts/header.htm"></template>
488
- <header class="l-header">
489
- <div class="l-header__inner">
490
- <a href="/" class="c-logo">Frelio Demo</a>
491
- <nav class="c-nav">
492
- <ul class="c-nav__list">
493
- <li class="c-nav__item"><a href="/">ホーム</a></li>
494
- <li class="c-nav__item"><a href="/news/">お知らせ</a></li>
495
- <li class="c-nav__item"><a href="/about/">会社概要</a></li>
496
- <li class="c-nav__item"><a href="/contact/">お問い合わせ</a></li>
497
- </ul>
498
- </nav>
499
- </div>
500
- </header>
501
-
502
- <main class="l-main">
503
- <article class="p-article">
504
- <div class="p-article__inner">
505
- <header class="p-article__header">
506
- <time class="p-article__date" data-gen-text="publishDate">2026-03-01</time>
507
- <h1 class="p-article__title" data-gen-text="title">記事タイトル</h1>
508
- </header>
509
- <template data-gen-scope="" data-gen-if="eyecatch">
510
- <div class="p-article__eyecatch">
511
- <img class="p-article__eyecatch__image" data-gen-attrs="src:eyecatch.url,alt:title" src="/images/placeholder.jpg" alt="記事タイトル">
512
- </div>
513
- </template>
514
- <div class="p-article__eyecatch" data-gen-comment="">
515
- <img class="p-article__eyecatch__image" src="/images/placeholder.jpg" alt="プレビュー画像">
516
- </div>
517
- <div class="p-article__body" data-gen-html="body">
518
- <p>これはプレビュー用のダミー本文です。実際の記事コンテンツがここに表示されます。</p>
519
- <h2>見出し</h2>
520
- <p>記事の本文が続きます。HTMLリッチテキストで記述されます。</p>
521
- </div>
522
- <div class="p-article__footer">
523
- <a href="/news/" class="c-btn">お知らせ一覧に戻る</a>
524
- </div>
525
- </div>
526
- </article>
527
- </main>
528
-
529
- <template data-gen-scope="" data-gen-include="_parts/footer.htm"></template>
530
- <footer class="l-footer">
531
- <div class="l-footer__inner">
532
- <p class="l-footer__copyright">&copy; 2026 Frelio Demo. All rights reserved.</p>
533
- </div>
534
- </footer>
535
- </body>
536
- </html>
537
- `);
538
- // .gitkeep for images directories
539
- const gitkeepDirs = [
540
- 'common/images',
541
- 'images',
542
- 'about/images',
543
- 'contact/images',
544
- 'news/images',
545
- ];
546
- for (const dir of gitkeepDirs) {
547
- writeFile(t(`${dir}/.gitkeep`), '');
548
- }
549
- }
550
- // ========== SCSS ==========
551
- function generateScss(projectDir) {
552
- const s = (...p) => path.join(projectDir, 'frelio-data/site/templates/common/styles', ...p);
553
- // Foundation
554
- writeFile(s('foundation/_variables.scss'), `// Colors
555
- $color-primary: #0070f3;
556
- $color-primary-dark: #0060d0;
557
- $color-text: #333;
558
- $color-text-light: #666;
559
- $color-border: #e0e0e0;
560
- $color-bg: #fff;
561
- $color-bg-light: #f8f9fa;
562
-
563
- // Typography
564
- $font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
565
- "Hiragino Sans", "Noto Sans JP", sans-serif;
566
- $line-height-base: 1.7;
567
-
568
- // Layout
569
- $max-width: 1100px;
570
- $spacing-unit: 8px;
571
-
572
- // Breakpoints
573
- $bp-tablet: 768px;
574
- $bp-desktop: 1024px;
575
- `);
576
- writeFile(s('foundation/_mixins.scss'), `@use 'variables' as *;
577
-
578
- @mixin tablet {
579
- @media (min-width: $bp-tablet) {
580
- @content;
581
- }
582
- }
583
-
584
- @mixin desktop {
585
- @media (min-width: $bp-desktop) {
586
- @content;
587
- }
588
- }
589
- `);
590
- writeFile(s('foundation/_reset.scss'), `@use 'variables' as *;
591
-
592
- *,
593
- *::before,
594
- *::after {
595
- box-sizing: border-box;
596
- margin: 0;
597
- padding: 0;
598
- }
599
-
600
- html {
601
- font-size: 100%;
602
- -webkit-text-size-adjust: 100%;
603
- }
604
-
605
- body {
606
- font-family: $font-family-base;
607
- line-height: $line-height-base;
608
- color: $color-text;
609
- background-color: $color-bg;
610
- -webkit-font-smoothing: antialiased;
611
- }
612
-
613
- img {
614
- max-width: 100%;
615
- height: auto;
616
- vertical-align: middle;
617
- }
618
-
619
- a {
620
- color: $color-primary;
621
- text-decoration: none;
622
- }
623
-
624
- ul,
625
- ol {
626
- list-style: none;
627
- }
628
-
629
- table {
630
- border-collapse: collapse;
631
- border-spacing: 0;
632
- }
633
-
634
- button,
635
- input,
636
- select,
637
- textarea {
638
- font: inherit;
639
- color: inherit;
640
- }
641
-
642
- button {
643
- cursor: pointer;
644
- background: none;
645
- border: none;
646
- }
647
- `);
648
- // Layout
649
- writeFile(s('layout/_l-header.scss'), `@use '../foundation/variables' as *;
650
- @use '../foundation/mixins' as *;
651
-
652
- .l-header {
653
- border-bottom: 1px solid $color-border;
654
- background-color: $color-bg;
655
- position: sticky;
656
- top: 0;
657
- z-index: 100;
658
- }
659
-
660
- .l-header__inner {
661
- max-width: $max-width;
662
- margin: 0 auto;
663
- padding: $spacing-unit * 2;
664
- display: flex;
665
- justify-content: space-between;
666
- align-items: center;
667
- }
668
-
669
- @include tablet {
670
- .l-header__inner {
671
- padding: $spacing-unit * 2 $spacing-unit * 4;
672
- }
673
- }
674
- `);
675
- writeFile(s('layout/_l-footer.scss'), `@use '../foundation/variables' as *;
676
-
677
- .l-footer {
678
- margin-top: $spacing-unit * 8;
679
- border-top: 1px solid $color-border;
680
- background-color: $color-bg-light;
681
- }
682
-
683
- .l-footer__inner {
684
- max-width: $max-width;
685
- margin: 0 auto;
686
- padding: $spacing-unit * 4 $spacing-unit * 2;
687
- text-align: center;
688
- }
689
-
690
- .l-footer__copyright {
691
- font-size: 0.875rem;
692
- color: $color-text-light;
693
- }
694
- `);
695
- writeFile(s('layout/_l-main.scss'), `.l-main {
696
- min-height: 60vh;
697
- }
698
- `);
699
- // Component
700
- writeFile(s('component/_c-logo.scss'), `@use '../foundation/variables' as *;
701
-
702
- .c-logo {
703
- font-size: 1.25rem;
704
- font-weight: bold;
705
- color: $color-text;
706
- text-decoration: none;
707
- }
708
- `);
709
- writeFile(s('component/_c-nav.scss'), `@use '../foundation/variables' as *;
710
- @use '../foundation/mixins' as *;
711
-
712
- .c-nav {
713
- display: flex;
714
- align-items: center;
715
- }
716
-
717
- .c-nav__toggle {
718
- display: flex;
719
- flex-direction: column;
720
- gap: 4px;
721
- width: 28px;
722
- padding: 4px 0;
723
- }
724
-
725
- .c-nav__toggle__bar {
726
- display: block;
727
- width: 100%;
728
- height: 2px;
729
- background-color: $color-text;
730
- transition: transform 0.3s, opacity 0.3s;
731
- }
732
-
733
- .c-nav__list {
734
- display: none;
735
- position: absolute;
736
- top: 100%;
737
- left: 0;
738
- right: 0;
739
- background-color: $color-bg;
740
- border-bottom: 1px solid $color-border;
741
- padding: $spacing-unit * 2;
742
- flex-direction: column;
743
- gap: $spacing-unit;
744
- }
745
-
746
- .c-nav__list--open {
747
- display: flex;
748
- }
749
-
750
- .c-nav__item a {
751
- color: $color-text-light;
752
- font-size: 0.9375rem;
753
- }
754
-
755
- .c-nav__item a:hover {
756
- color: $color-primary;
757
- }
758
-
759
- @include tablet {
760
- .c-nav__toggle {
761
- display: none;
762
- }
763
-
764
- .c-nav__list {
765
- display: flex;
766
- position: static;
767
- flex-direction: row;
768
- gap: $spacing-unit * 3;
769
- padding: 0;
770
- border-bottom: none;
771
- }
772
- }
773
- `);
774
- writeFile(s('component/_c-btn.scss'), `@use '../foundation/variables' as *;
775
-
776
- %c-btn {
777
- display: inline-block;
778
- padding: $spacing-unit * 1.5 $spacing-unit * 4;
779
- border: 1px solid $color-primary;
780
- border-radius: 4px;
781
- font-size: 0.9375rem;
782
- text-align: center;
783
- transition: background-color 0.2s, color 0.2s;
784
- }
785
-
786
- .c-btn {
787
- @extend %c-btn;
788
- color: $color-primary;
789
- background-color: transparent;
790
- }
791
-
792
- .c-btn:hover {
793
- background-color: $color-primary;
794
- color: $color-bg;
795
- }
796
-
797
- .c-btn--submit {
798
- @extend %c-btn;
799
- color: $color-bg;
800
- background-color: $color-primary;
801
- border-color: $color-primary;
802
- }
803
-
804
- .c-btn--submit:hover {
805
- background-color: $color-primary-dark;
806
- border-color: $color-primary-dark;
807
- }
808
- `);
809
- writeFile(s('component/_c-card.scss'), `@use '../foundation/variables' as *;
810
- @use '../foundation/mixins' as *;
811
-
812
- %c-card {
813
- display: block;
814
- border: 1px solid $color-border;
815
- border-radius: 8px;
816
- overflow: hidden;
817
- text-decoration: none;
818
- color: $color-text;
819
- transition: box-shadow 0.2s;
820
- }
821
-
822
- %c-card:hover {
823
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
824
- }
825
-
826
- .c-card--news {
827
- @extend %c-card;
828
- }
829
-
830
- .c-card__body {
831
- padding: $spacing-unit * 2;
832
- }
833
-
834
- .c-card__date {
835
- display: block;
836
- font-size: 0.8125rem;
837
- color: $color-text-light;
838
- margin-bottom: $spacing-unit;
839
- }
840
-
841
- .c-card__title {
842
- font-size: 1rem;
843
- font-weight: bold;
844
- line-height: 1.5;
845
- margin-bottom: $spacing-unit;
846
- }
847
-
848
- .c-card__excerpt {
849
- font-size: 0.875rem;
850
- color: $color-text-light;
851
- line-height: 1.6;
852
- }
853
-
854
- @include tablet {
855
- .c-card__body {
856
- padding: $spacing-unit * 3;
857
- }
858
-
859
- .c-card__title {
860
- font-size: 1.125rem;
861
- }
862
- }
863
- `);
864
- writeFile(s('component/_c-table.scss'), `@use '../foundation/variables' as *;
865
- @use '../foundation/mixins' as *;
866
-
867
- .c-table {
868
- width: 100%;
869
- }
870
-
871
- .c-table__body {
872
- display: block;
873
- }
874
-
875
- .c-table__row {
876
- display: flex;
877
- flex-direction: column;
878
- border-bottom: 1px solid $color-border;
879
- padding: $spacing-unit * 2 0;
880
- }
881
-
882
- .c-table__header {
883
- font-weight: bold;
884
- font-size: 0.875rem;
885
- color: $color-text-light;
886
- margin-bottom: $spacing-unit;
887
- text-align: left;
888
- }
889
-
890
- .c-table__cell {
891
- font-size: 1rem;
892
- }
893
-
894
- @include tablet {
895
- .c-table__row {
896
- flex-direction: row;
897
- }
898
-
899
- .c-table__header {
900
- width: 200px;
901
- flex-shrink: 0;
902
- margin-bottom: 0;
903
- padding-right: $spacing-unit * 2;
904
- }
905
- }
906
- `);
907
- // Element
908
- writeFile(s('element/_e-heading.scss'), `@use '../foundation/variables' as *;
909
- @use '../foundation/mixins' as *;
910
-
911
- .e-heading {
912
- font-size: 1.5rem;
913
- font-weight: bold;
914
- padding-bottom: $spacing-unit * 2;
915
- border-bottom: 3px solid $color-primary;
916
- display: inline-block;
917
- }
918
-
919
- @include tablet {
920
- .e-heading {
921
- font-size: 1.75rem;
922
- }
923
- }
924
- `);
925
- // Project
926
- writeFile(s('project/_p-hero.scss'), `@use '../foundation/variables' as *;
927
- @use '../foundation/mixins' as *;
928
-
929
- .p-hero {
930
- background-color: $color-bg-light;
931
- padding: $spacing-unit * 6 $spacing-unit * 2;
932
- text-align: center;
933
- }
934
-
935
- .p-hero__inner {
936
- max-width: $max-width;
937
- margin: 0 auto;
938
- }
939
-
940
- .p-hero__title {
941
- font-size: 1.75rem;
942
- font-weight: bold;
943
- margin-bottom: $spacing-unit * 2;
944
- }
945
-
946
- .p-hero__lead {
947
- font-size: 1rem;
948
- color: $color-text-light;
949
- line-height: 1.8;
950
- }
951
-
952
- @include tablet {
953
- .p-hero {
954
- padding: $spacing-unit * 10 $spacing-unit * 4;
955
- }
956
-
957
- .p-hero__title {
958
- font-size: 2.25rem;
959
- }
960
-
961
- .p-hero__lead {
962
- font-size: 1.125rem;
963
- }
964
- }
965
- `);
966
- writeFile(s('project/_p-news-list.scss'), `@use '../foundation/variables' as *;
967
- @use '../foundation/mixins' as *;
968
-
969
- .p-news-list {
970
- padding: $spacing-unit * 6 $spacing-unit * 2;
971
- }
972
-
973
- .p-news-list__inner {
974
- max-width: $max-width;
975
- margin: 0 auto;
976
- }
977
-
978
- .p-news-list__items {
979
- display: grid;
980
- gap: $spacing-unit * 3;
981
- margin-top: $spacing-unit * 4;
982
- }
983
-
984
- .p-news-list__item {
985
- display: block;
986
- }
987
-
988
- .p-news-list__more {
989
- margin-top: $spacing-unit * 4;
990
- text-align: center;
991
- }
992
-
993
- @include tablet {
994
- .p-news-list {
995
- padding: $spacing-unit * 8 $spacing-unit * 4;
996
- }
997
-
998
- .p-news-list__items {
999
- grid-template-columns: repeat(2, 1fr);
1000
- }
1001
- }
1002
-
1003
- @include desktop {
1004
- .p-news-list__items {
1005
- grid-template-columns: repeat(3, 1fr);
1006
- }
1007
- }
1008
- `);
1009
- writeFile(s('project/_p-article.scss'), `@use '../foundation/variables' as *;
1010
- @use '../foundation/mixins' as *;
1011
-
1012
- .p-article {
1013
- padding: $spacing-unit * 4 $spacing-unit * 2;
1014
- }
1015
-
1016
- .p-article__inner {
1017
- max-width: 780px;
1018
- margin: 0 auto;
1019
- }
1020
-
1021
- .p-article__header {
1022
- margin-bottom: $spacing-unit * 4;
1023
- }
1024
-
1025
- .p-article__date {
1026
- display: block;
1027
- font-size: 0.875rem;
1028
- color: $color-text-light;
1029
- margin-bottom: $spacing-unit;
1030
- }
1031
-
1032
- .p-article__title {
1033
- font-size: 1.5rem;
1034
- font-weight: bold;
1035
- line-height: 1.4;
1036
- }
1037
-
1038
- .p-article__eyecatch {
1039
- margin-bottom: $spacing-unit * 4;
1040
- }
1041
-
1042
- .p-article__eyecatch__image {
1043
- width: 100%;
1044
- aspect-ratio: 16 / 9;
1045
- object-fit: cover;
1046
- border-radius: 8px;
1047
- }
1048
-
1049
- .p-article__body {
1050
- font-size: 1rem;
1051
- line-height: 1.8;
1052
- }
1053
-
1054
- .p-article__body h2 {
1055
- font-size: 1.375rem;
1056
- font-weight: bold;
1057
- margin-top: $spacing-unit * 5;
1058
- margin-bottom: $spacing-unit * 2;
1059
- padding-bottom: $spacing-unit;
1060
- border-bottom: 2px solid $color-border;
1061
- }
1062
-
1063
- .p-article__body h3 {
1064
- font-size: 1.125rem;
1065
- font-weight: bold;
1066
- margin-top: $spacing-unit * 4;
1067
- margin-bottom: $spacing-unit * 2;
1068
- }
1069
-
1070
- .p-article__body p {
1071
- margin-bottom: $spacing-unit * 2;
1072
- }
1073
-
1074
- .p-article__body ul,
1075
- .p-article__body ol {
1076
- margin-bottom: $spacing-unit * 2;
1077
- padding-left: $spacing-unit * 3;
1078
- list-style: disc;
1079
- }
1080
-
1081
- .p-article__body ol {
1082
- list-style: decimal;
1083
- }
1084
-
1085
- .p-article__footer {
1086
- margin-top: $spacing-unit * 6;
1087
- padding-top: $spacing-unit * 4;
1088
- border-top: 1px solid $color-border;
1089
- }
1090
-
1091
- @include tablet {
1092
- .p-article {
1093
- padding: $spacing-unit * 6 $spacing-unit * 4;
1094
- }
1095
-
1096
- .p-article__title {
1097
- font-size: 2rem;
1098
- }
1099
- }
1100
- `);
1101
- writeFile(s('project/_p-about.scss'), `@use '../foundation/variables' as *;
1102
- @use '../foundation/mixins' as *;
1103
-
1104
- .p-about {
1105
- padding: $spacing-unit * 6 $spacing-unit * 2;
1106
- }
1107
-
1108
- .p-about__inner {
1109
- max-width: $max-width;
1110
- margin: 0 auto;
1111
- }
1112
-
1113
- .p-about__content {
1114
- margin-top: $spacing-unit * 4;
1115
- line-height: 1.8;
1116
- }
1117
-
1118
- .p-about__content p {
1119
- margin-bottom: $spacing-unit * 2;
1120
- }
1121
-
1122
- .p-about__table {
1123
- margin-top: $spacing-unit * 6;
1124
- }
1125
-
1126
- @include tablet {
1127
- .p-about {
1128
- padding: $spacing-unit * 8 $spacing-unit * 4;
1129
- }
1130
- }
1131
- `);
1132
- writeFile(s('project/_p-contact.scss'), `@use '../foundation/variables' as *;
1133
- @use '../foundation/mixins' as *;
1134
-
1135
- .p-contact {
1136
- padding: $spacing-unit * 6 $spacing-unit * 2;
1137
- }
1138
-
1139
- .p-contact__inner {
1140
- max-width: 640px;
1141
- margin: 0 auto;
1142
- }
1143
-
1144
- .p-contact__lead {
1145
- margin-top: $spacing-unit * 2;
1146
- color: $color-text-light;
1147
- }
1148
-
1149
- .p-contact__form {
1150
- margin-top: $spacing-unit * 4;
1151
- }
1152
-
1153
- .p-contact__field {
1154
- margin-bottom: $spacing-unit * 3;
1155
- }
1156
-
1157
- .p-contact__label {
1158
- display: block;
1159
- font-size: 0.875rem;
1160
- font-weight: bold;
1161
- margin-bottom: $spacing-unit;
1162
- }
1163
-
1164
- .p-contact__input {
1165
- display: block;
1166
- width: 100%;
1167
- padding: $spacing-unit * 1.5;
1168
- border: 1px solid $color-border;
1169
- border-radius: 4px;
1170
- font-size: 1rem;
1171
- }
1172
-
1173
- .p-contact__input:focus {
1174
- outline: none;
1175
- border-color: $color-primary;
1176
- box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
1177
- }
1178
-
1179
- .p-contact__textarea {
1180
- display: block;
1181
- width: 100%;
1182
- padding: $spacing-unit * 1.5;
1183
- border: 1px solid $color-border;
1184
- border-radius: 4px;
1185
- font-size: 1rem;
1186
- resize: vertical;
1187
- }
1188
-
1189
- .p-contact__textarea:focus {
1190
- outline: none;
1191
- border-color: $color-primary;
1192
- box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
1193
- }
1194
-
1195
- .p-contact__actions {
1196
- margin-top: $spacing-unit * 4;
1197
- text-align: center;
1198
- }
1199
-
1200
- @include tablet {
1201
- .p-contact {
1202
- padding: $spacing-unit * 8 $spacing-unit * 4;
1203
- }
1204
- }
1205
- `);
1206
- }
1207
- // ========== TypeScript ==========
1208
- function generateTypeScript(projectDir) {
1209
- const ts = (...p) => path.join(projectDir, 'frelio-data/site/templates/common/scripts', ...p);
1210
- writeFile(ts('features/mobile-nav.ts'), `export function initMobileNav(): void {
1211
- const toggle = document.querySelector<HTMLButtonElement>('.c-nav__toggle')
1212
- const list = document.querySelector<HTMLUListElement>('.c-nav__list')
1213
-
1214
- if (!toggle || !list) return
1215
-
1216
- toggle.addEventListener('click', () => {
1217
- const isOpen = list.classList.contains('c-nav__list--open')
1218
- list.classList.toggle('c-nav__list--open', !isOpen)
1219
- toggle.setAttribute('aria-expanded', String(!isOpen))
1220
- })
1221
-
1222
- document.addEventListener('click', (e) => {
1223
- const target = e.target as HTMLElement
1224
- if (!target.closest('.c-nav')) {
1225
- list.classList.remove('c-nav__list--open')
1226
- toggle.setAttribute('aria-expanded', 'false')
1227
- }
1228
- })
1229
- }
1230
- `);
1231
- writeFile(ts('features/smooth-scroll.ts'), `export function initSmoothScroll(): void {
1232
- document.addEventListener('click', (e) => {
1233
- const target = e.target as HTMLElement
1234
- const anchor = target.closest<HTMLAnchorElement>('a[href^="#"]')
1235
-
1236
- if (!anchor) return
1237
-
1238
- const id = anchor.getAttribute('href')
1239
- if (!id || id === '#') return
1240
-
1241
- const element = document.querySelector(id)
1242
- if (!element) return
1243
-
1244
- e.preventDefault()
1245
- element.scrollIntoView({ behavior: 'smooth' })
1246
- })
1247
- }
1248
- `);
1249
- }
1250
- // ========== Entry Points ==========
1251
- function generateEntries(projectDir) {
1252
- const t = (...p) => path.join(projectDir, 'frelio-data/site/templates', ...p);
1253
- // common
1254
- writeFile(t('common/styles/index.scss'), `// Foundation
1255
- @use 'foundation/variables' as *;
1256
- @use 'foundation/mixins' as *;
1257
- @use 'foundation/reset';
1258
-
1259
- // Layout
1260
- @use 'layout/l-header';
1261
- @use 'layout/l-footer';
1262
- @use 'layout/l-main';
1263
-
1264
- // Component
1265
- @use 'component/c-logo';
1266
- @use 'component/c-nav';
1267
- @use 'component/c-btn';
1268
- @use 'component/c-card';
1269
- @use 'component/c-table';
1270
-
1271
- // Element
1272
- @use 'element/e-heading';
1273
- `);
1274
- writeFile(t('common/scripts/index.ts'), `import '../styles/index.scss'
1275
- import { initMobileNav } from './features/mobile-nav'
1276
- import { initSmoothScroll } from './features/smooth-scroll'
1277
-
1278
- document.addEventListener('DOMContentLoaded', () => {
1279
- initMobileNav()
1280
- initSmoothScroll()
1281
- })
1282
- `);
1283
- // home (root level — / maps to templates root)
1284
- writeFile(t('styles/index.scss'), `@use 'foundation/variables' as *;
1285
- @use 'foundation/mixins' as *;
1286
-
1287
- @use 'project/p-hero';
1288
- @use 'project/p-news-list';
1289
- `);
1290
- writeFile(t('scripts/index.ts'), `import '../styles/index.scss'
1291
- `);
1292
- // about
1293
- writeFile(t('about/styles/index.scss'), `@use 'foundation/variables' as *;
1294
- @use 'foundation/mixins' as *;
1295
-
1296
- @use 'project/p-about';
1297
- `);
1298
- writeFile(t('about/scripts/index.ts'), `import '../styles/index.scss'
1299
- `);
1300
- // contact
1301
- writeFile(t('contact/styles/index.scss'), `@use 'foundation/variables' as *;
1302
- @use 'foundation/mixins' as *;
1303
-
1304
- @use 'project/p-contact';
1305
- `);
1306
- writeFile(t('contact/scripts/index.ts'), `import '../styles/index.scss'
1307
- `);
1308
- // news (list + detail share the same scripts/styles)
1309
- writeFile(t('news/styles/index.scss'), `@use 'foundation/variables' as *;
1310
- @use 'foundation/mixins' as *;
1311
-
1312
- @use 'project/p-news-list';
1313
- @use 'project/p-article';
1314
- `);
1315
- writeFile(t('news/scripts/index.ts'), `import '../styles/index.scss'
1316
- `);
1317
- }
1318
- // ========== Build Scripts ==========
1319
- function generateBuildScripts(projectDir) {
1320
- const s = (...p) => path.join(projectDir, 'scripts', ...p);
1321
- writeFile(s('generate-data-json.ts'), `/**
1322
- * FrelioDataJson 生成スクリプト
1323
- *
1324
- * FrelioBuildDataRecipe に従って FrelioDataJson を生成し、
1325
- * frelio-data/site/data/data-json/ に出力する。
1326
- *
1327
- * @example
1328
- * # 差分ビルド(デフォルト)
1329
- * npx tsx scripts/generate-data-json.ts
1330
- *
1331
- * # フルリビルド
1332
- * npx tsx scripts/generate-data-json.ts --full-rebuild
1333
- *
1334
- * # ドライラン
1335
- * npx tsx scripts/generate-data-json.ts --dry-run
1336
- */
1337
-
1338
- import {
1339
- generateDataJson,
1340
- NodeFileSystem,
1341
- getGitDiff,
1342
- type GenerateDataJsonOptions,
1343
- } from '@c-time/frelio-data-json-generator'
1344
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs'
1345
- import { dirname, join } from 'path'
1346
- import { parseArgs } from 'util'
1347
-
1348
- const { values } = parseArgs({
1349
- options: {
1350
- 'diff-range': { type: 'string', default: 'origin/main...HEAD' },
1351
- 'full-rebuild': { type: 'boolean', default: false },
1352
- 'dry-run': { type: 'boolean', default: false },
1353
- 'log-level': { type: 'string', default: 'info' },
1354
- },
1355
- })
1356
-
1357
- const options: GenerateDataJsonOptions = {
1358
- fullRebuild: values['full-rebuild'],
1359
- dryRun: values['dry-run'],
1360
- logLevel: values['log-level'] as 'debug' | 'info' | 'quiet',
1361
- }
1362
-
1363
- const CONTENT_ROOT = 'frelio-data/site'
1364
- const OUTPUT_ROOT = 'frelio-data/site/data/data-json'
1365
- const REPORT_PATH = 'frelio-data/site/data/report.json'
1366
- const RECIPE_PATH = 'frelio-data/admin/recipes/build-data-recipe.json'
1367
- const DEPENDENCY_MAP_PATH = 'frelio-data/site/data/_dependency-map.json'
1368
-
1369
- async function main(): Promise<void> {
1370
- if (!existsSync(RECIPE_PATH)) {
1371
- console.error(\`Recipe not found: \${RECIPE_PATH}\`)
1372
- process.exit(1)
1373
- }
1374
- const recipe = JSON.parse(readFileSync(RECIPE_PATH, 'utf-8'))
1375
-
1376
- if (!existsSync(DEPENDENCY_MAP_PATH)) {
1377
- console.error(\`Dependency map not found: \${DEPENDENCY_MAP_PATH}\`)
1378
- process.exit(1)
1379
- }
1380
- const dependencyMap = JSON.parse(readFileSync(DEPENDENCY_MAP_PATH, 'utf-8'))
1381
-
1382
- const gitDiff = options.fullRebuild
1383
- ? { added: [], modified: [], deleted: [] }
1384
- : await getGitDiff(values['diff-range']!)
1385
-
1386
- if (options.logLevel !== 'quiet') {
1387
- console.log(\`Mode: \${options.fullRebuild ? 'full-rebuild' : 'incremental'}\`)
1388
- console.log(\`Dry run: \${options.dryRun}\`)
1389
- }
1390
-
1391
- const result = await generateDataJson({
1392
- recipe,
1393
- dependencyMap,
1394
- gitDiff,
1395
- fileSystem: new NodeFileSystem(),
1396
- contentRootPath: CONTENT_ROOT,
1397
- outputRootPath: OUTPUT_ROOT,
1398
- options,
1399
- })
1400
-
1401
- if (!options.dryRun) {
1402
- mkdirSync(OUTPUT_ROOT, { recursive: true })
1403
- for (const output of result.outputs) {
1404
- if (output.content.type === 'delete') {
1405
- const fullPath = join(OUTPUT_ROOT, output.path)
1406
- if (existsSync(fullPath)) unlinkSync(fullPath)
1407
- }
1408
- }
1409
- for (const output of result.outputs) {
1410
- if (output.content.type !== 'delete') {
1411
- const fullPath = join(OUTPUT_ROOT, output.path)
1412
- mkdirSync(dirname(fullPath), { recursive: true })
1413
- writeFileSync(fullPath, JSON.stringify(output.content, null, 2))
1414
- }
1415
- }
1416
- }
1417
-
1418
- mkdirSync(dirname(REPORT_PATH), { recursive: true })
1419
- writeFileSync(REPORT_PATH, JSON.stringify(result.report, null, 2))
1420
-
1421
- const { stats } = result.report
1422
- console.log(\`\\n=== Summary ===\`)
1423
- console.log(\`Updated: \${stats.updated}\`)
1424
- console.log(\`Deleted: \${stats.deleted}\`)
1425
- console.log(\`Skipped: \${stats.skipped}\`)
1426
- console.log(\`Errors: \${stats.errors}\`)
1427
- }
1428
-
1429
- main().catch((error) => {
1430
- console.error('Fatal error:', error)
1431
- process.exit(1)
1432
- })
1433
- `);
1434
- writeFile(s('generate-html.ts'), `/**
1435
- * HTML 生成スクリプト
1436
- *
1437
- * FrelioDataJson → HTML の変換(ビルドパイプライン Phase 2)
1438
- *
1439
- * @example
1440
- * npx tsx scripts/generate-html.ts
1441
- * npx tsx scripts/generate-html.ts --dry-run
1442
- */
1443
-
1444
- import {
1445
- generateHtml,
1446
- NodeFileSystem,
1447
- type GenerateHtmlOptions,
1448
- } from '@c-time/frelio-gentl'
1449
- import type { FrelioDataJson } from '@c-time/frelio-data-json'
1450
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, readdirSync } from 'fs'
1451
- import { dirname, join, extname } from 'path'
1452
- import { parseArgs } from 'util'
1453
-
1454
- const { values } = parseArgs({
1455
- options: {
1456
- 'dry-run': { type: 'boolean', default: false },
1457
- 'log-level': { type: 'string', default: 'info' },
1458
- },
1459
- })
1460
-
1461
- const options: GenerateHtmlOptions = {
1462
- logLevel: values['log-level'] as 'debug' | 'info' | 'quiet',
1463
- }
1464
-
1465
- const DATA_JSON_ROOT = 'frelio-data/site/data/data-json'
1466
- const TEMPLATE_ROOT = 'frelio-data/site/templates'
1467
- const OUTPUT_ROOT = 'public'
1468
- const REPORT_PATH = 'frelio-data/site/data/html-report.json'
1469
-
1470
- function collectJsonFiles(dir: string): string[] {
1471
- if (!existsSync(dir)) return []
1472
- const files: string[] = []
1473
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1474
- const fullPath = join(dir, entry.name)
1475
- if (entry.isDirectory()) {
1476
- files.push(...collectJsonFiles(fullPath))
1477
- } else if (extname(entry.name) === '.json') {
1478
- files.push(fullPath)
1479
- }
1480
- }
1481
- return files
1482
- }
1483
-
1484
- async function main(): Promise<void> {
1485
- if (!existsSync(DATA_JSON_ROOT)) {
1486
- console.error(\`Data JSON directory not found: \${DATA_JSON_ROOT}\`)
1487
- process.exit(1)
1488
- }
1489
- if (!existsSync(TEMPLATE_ROOT)) {
1490
- console.error(\`Template directory not found: \${TEMPLATE_ROOT}\`)
1491
- process.exit(1)
1492
- }
1493
-
1494
- const jsonFiles = collectJsonFiles(DATA_JSON_ROOT)
1495
- if (jsonFiles.length === 0) {
1496
- console.log('No data JSON files found. Nothing to generate.')
1497
- return
1498
- }
1499
-
1500
- const dataJsons: FrelioDataJson[] = jsonFiles.map(filePath => {
1501
- return JSON.parse(readFileSync(filePath, 'utf-8'))
1502
- })
1503
-
1504
- console.log(\`Found \${dataJsons.length} data JSON files\`)
1505
-
1506
- const result = await generateHtml({
1507
- dataJsons,
1508
- templateRootPath: TEMPLATE_ROOT,
1509
- fileSystem: new NodeFileSystem(),
1510
- options,
1511
- })
1512
-
1513
- if (!values['dry-run']) {
1514
- mkdirSync(OUTPUT_ROOT, { recursive: true })
1515
- for (const output of result.outputs) {
1516
- if (output.status === 'deleted') {
1517
- const fullPath = join(OUTPUT_ROOT, output.outputPath)
1518
- if (existsSync(fullPath)) unlinkSync(fullPath)
1519
- }
1520
- }
1521
- for (const output of result.outputs) {
1522
- if (output.status === 'created') {
1523
- const fullPath = join(OUTPUT_ROOT, output.outputPath)
1524
- mkdirSync(dirname(fullPath), { recursive: true })
1525
- writeFileSync(fullPath, output.html)
1526
- }
1527
- }
1528
- }
1529
-
1530
- mkdirSync(dirname(REPORT_PATH), { recursive: true })
1531
- writeFileSync(REPORT_PATH, JSON.stringify(result.report, null, 2))
1532
-
1533
- const { stats } = result.report
1534
- console.log(\`\\n=== Summary ===\`)
1535
- console.log(\`Created: \${stats.created}\`)
1536
- console.log(\`Deleted: \${stats.deleted}\`)
1537
- console.log(\`Errors: \${stats.errors}\`)
1538
- }
1539
-
1540
- main().catch((error) => {
1541
- console.error('Fatal error:', error)
1542
- process.exit(1)
1543
- })
1544
- `);
1545
- writeFile(s('generate-sitemap.ts'), `/**
1546
- * sitemap.xml 生成スクリプト
1547
- *
1548
- * public/ 配下の HTML ファイルを走査して sitemap.xml を生成する。
1549
- *
1550
- * @example
1551
- * npx tsx scripts/generate-sitemap.ts --base-url https://example.com
1552
- * npx tsx scripts/generate-sitemap.ts --full-rebuild --base-url https://example.com
1553
- */
1554
-
1555
- import { execSync } from 'child_process'
1556
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs'
1557
- import { dirname, join } from 'path'
1558
- import { parseArgs } from 'util'
1559
-
1560
- const { values } = parseArgs({
1561
- options: {
1562
- 'base-url': { type: 'string' },
1563
- 'diff-range': { type: 'string', default: 'origin/main...HEAD' },
1564
- 'full-rebuild': { type: 'boolean', default: false },
1565
- 'dry-run': { type: 'boolean', default: false },
1566
- 'log-level': { type: 'string', default: 'info' },
1567
- },
1568
- })
1569
-
1570
- const baseUrl = (values['base-url'] || process.env.SITE_BASE_URL || '').replace(/\\/$/, '')
1571
- if (!baseUrl) {
1572
- console.error('Error: --base-url or SITE_BASE_URL is required.')
1573
- process.exit(1)
1574
- }
1575
-
1576
- const logLevel = values['log-level'] as 'debug' | 'info' | 'quiet'
1577
-
1578
- const HTML_ROOT = 'public'
1579
- const OUTPUT_PATH = 'public/sitemap.xml'
1580
-
1581
- interface UrlEntry {
1582
- loc: string
1583
- lastmod: string | null
1584
- }
1585
-
1586
- function htmlPathToUrlPath(htmlPath: string): string {
1587
- const relative = htmlPath
1588
- .replace(/\\\\\\\\/g, '/')
1589
- .replace(/^public\\//, '')
1590
- .replace(/\\/index\\.html$/, '/')
1591
- return relative === 'index.html' ? '/' : '/' + relative
1592
- }
1593
-
1594
- function hasNoindex(htmlPath: string): boolean {
1595
- const html = readFileSync(htmlPath, 'utf-8')
1596
- return /<meta\\s[^>]*name\\s*=\\s*["']robots["'][^>]*content\\s*=\\s*["'][^"']*noindex[^"']*["'][^>]*>/i.test(html)
1597
- || /<meta\\s[^>]*content\\s*=\\s*["'][^"']*noindex[^"']*["'][^>]*name\\s*=\\s*["']robots["'][^>]*>/i.test(html)
1598
- }
1599
-
1600
- function getLastmod(filePath: string): string | null {
1601
- try {
1602
- const result = execSync(\`git log -1 --format=%aI -- "\${filePath}"\`, {
1603
- encoding: 'utf-8',
1604
- stdio: ['pipe', 'pipe', 'pipe'],
1605
- }).trim()
1606
- return result || null
1607
- } catch {
1608
- return null
1609
- }
1610
- }
1611
-
1612
- function collectHtmlFiles(dir: string): string[] {
1613
- if (!existsSync(dir)) return []
1614
- const files: string[] = []
1615
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1616
- const fullPath = join(dir, entry.name)
1617
- if (entry.isDirectory()) {
1618
- files.push(...collectHtmlFiles(fullPath))
1619
- } else if (entry.name === 'index.html') {
1620
- files.push(fullPath)
1621
- }
1622
- }
1623
- return files
1624
- }
1625
-
1626
- function parseSitemap(xml: string): Map<string, UrlEntry> {
1627
- const entries = new Map<string, UrlEntry>()
1628
- const urlBlockRe = /<url>([\\s\\S]*?)<\\/url>/g
1629
- const locRe = /<loc>([\\s\\S]*?)<\\/loc>/
1630
- const lastmodRe = /<lastmod>([\\s\\S]*?)<\\/lastmod>/
1631
- let match: RegExpExecArray | null
1632
- while ((match = urlBlockRe.exec(xml)) !== null) {
1633
- const block = match[1]
1634
- const locMatch = locRe.exec(block)
1635
- if (!locMatch) continue
1636
- const loc = locMatch[1].trim()
1637
- const lastmodMatch = lastmodRe.exec(block)
1638
- entries.set(loc, { loc, lastmod: lastmodMatch ? lastmodMatch[1].trim() : null })
1639
- }
1640
- return entries
1641
- }
1642
-
1643
- function buildSitemapXml(entries: Map<string, UrlEntry>): string {
1644
- const sorted = [...entries.values()].sort((a, b) => a.loc.localeCompare(b.loc))
1645
- const urlElements = sorted.map((entry) => {
1646
- const lastmodLine = entry.lastmod ? \`\\n <lastmod>\${entry.lastmod}</lastmod>\` : ''
1647
- return \` <url>\\n <loc>\${entry.loc}</loc>\${lastmodLine}\\n </url>\`
1648
- })
1649
- return [
1650
- '<?xml version="1.0" encoding="UTF-8"?>',
1651
- '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
1652
- ...urlElements,
1653
- '</urlset>',
1654
- '',
1655
- ].join('\\n')
1656
- }
1657
-
1658
- function createEntry(htmlPath: string): UrlEntry | null {
1659
- if (hasNoindex(htmlPath)) return null
1660
- return { loc: baseUrl + htmlPathToUrlPath(htmlPath), lastmod: getLastmod(htmlPath) }
1661
- }
1662
-
1663
- function main(): void {
1664
- if (logLevel !== 'quiet') {
1665
- console.log(\`Mode: \${values['full-rebuild'] ? 'full-rebuild' : 'incremental'}\`)
1666
- console.log(\`Dry run: \${values['dry-run']}\`)
1667
- }
1668
-
1669
- const entries = new Map<string, UrlEntry>()
1670
- const htmlFiles = collectHtmlFiles(HTML_ROOT)
1671
- for (const htmlPath of htmlFiles) {
1672
- const entry = createEntry(htmlPath)
1673
- if (entry) entries.set(entry.loc, entry)
1674
- }
1675
-
1676
- const xml = buildSitemapXml(entries)
1677
-
1678
- if (!values['dry-run']) {
1679
- mkdirSync(dirname(OUTPUT_PATH), { recursive: true })
1680
- writeFileSync(OUTPUT_PATH, xml)
1681
- }
1682
-
1683
- if (logLevel !== 'quiet') {
1684
- console.log(\`Total URLs: \${entries.size}\`)
1685
- console.log(\`Output: \${OUTPUT_PATH}\`)
1686
- }
1687
- }
1688
-
1689
- main()
1690
- `);
1691
- writeFile(s('generate-dependency-map.ts'), `/**
1692
- * 依存マップ生成スクリプト
1693
- *
1694
- * FrelioBuildDataRecipe から FrelioDependencyMap を生成する。
1695
- *
1696
- * @example
1697
- * npx tsx scripts/generate-dependency-map.ts
1698
- */
1699
-
1700
- import { convertRecipeToDependencyMap } from '@c-time/frelio-data-json-recipe-to-dependency-map'
1701
- import { validateSiteRecipe, formatZodErrors } from '@c-time/frelio-types/schemas'
1702
- import { isFrelioDependencyMap } from '@c-time/frelio-dependency-map'
1703
- import type { FrelioBuildDataRecipe } from '@c-time/frelio-data-json-recipe'
1704
- import { readFileSync, writeFileSync, mkdirSync } from 'fs'
1705
- import { dirname } from 'path'
1706
-
1707
- const RECIPE_PATH = 'frelio-data/admin/recipes/build-data-recipe.json'
1708
- const OUTPUT_PATH = 'frelio-data/site/data/_dependency-map.json'
1709
-
1710
- const raw = JSON.parse(readFileSync(RECIPE_PATH, 'utf-8'))
1711
-
1712
- const result = validateSiteRecipe(raw)
1713
- if (!result.success) {
1714
- console.error('Invalid recipe:', formatZodErrors(result.errors))
1715
- process.exit(1)
1716
- }
1717
-
1718
- const recipe: FrelioBuildDataRecipe = result.data
1719
- const dependencyMap = convertRecipeToDependencyMap(recipe)
1720
-
1721
- if (!isFrelioDependencyMap(dependencyMap)) {
1722
- console.error('Generated dependency map is invalid')
1723
- process.exit(1)
1724
- }
1725
-
1726
- mkdirSync(dirname(OUTPUT_PATH), { recursive: true })
1727
- writeFileSync(OUTPUT_PATH, JSON.stringify(dependencyMap, null, 2))
1728
- console.log(\`Dependency map written to \${OUTPUT_PATH}\`)
1729
- `);
1730
- writeFile(s('rebuild-indexes.ts'), `/**
1731
- * インデックス・ダッシュボードメタデータ一括再構築スクリプト
1732
- *
1733
- * @example
1734
- * npx tsx scripts/rebuild-indexes.ts
1735
- * npx tsx scripts/rebuild-indexes.ts --dry-run
1736
- */
1737
-
1738
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs'
1739
- import { dirname } from 'path'
1740
- import { parseArgs } from 'util'
1741
- import {
1742
- rebuildAllIndexes,
1743
- calcMetadataOnSave,
1744
- SITE_CONTENTS,
1745
- CONTENT_TYPE_LIST_PATH,
1746
- DASHBOARD_METADATA_PATH,
1747
- type ContentStorePort,
1748
- type ContentData,
1749
- type FileChange,
1750
- type BasePath,
1751
- } from '@c-time/frelio-content-ops'
1752
- import type { DashboardMetadata } from '@c-time/frelio-types'
1753
-
1754
- const { values } = parseArgs({
1755
- options: {
1756
- 'dry-run': { type: 'boolean', default: false },
1757
- 'log-level': { type: 'string', default: 'info' },
1758
- },
1759
- })
1760
-
1761
- const DRY_RUN = values['dry-run']!
1762
- const LOG_LEVEL = values['log-level'] as 'debug' | 'info' | 'quiet'
1763
-
1764
- function logInfo(...args: unknown[]) {
1765
- if (LOG_LEVEL !== 'quiet') console.log('[info]', ...args)
1766
- }
1767
-
1768
- function createFsContentStore(): ContentStorePort {
1769
- return {
1770
- async readJson<T>(path: string): Promise<T | null> {
1771
- if (!existsSync(path)) return null
1772
- try {
1773
- return JSON.parse(readFileSync(path, 'utf-8')) as T
1774
- } catch {
1775
- return null
1776
- }
1777
- },
1778
- }
1779
- }
1780
-
1781
- function applyFileChanges(changes: FileChange[]) {
1782
- for (const change of changes) {
1783
- mkdirSync(dirname(change.path), { recursive: true })
1784
- writeFileSync(change.path, change.content + '\\n')
1785
- }
1786
- }
1787
-
1788
- async function main() {
1789
- const store = createFsContentStore()
1790
- const listRaw = await store.readJson<{ contentTypes: { id: string }[] }>(CONTENT_TYPE_LIST_PATH)
1791
- if (!listRaw) {
1792
- console.error(\`Content type list not found: \${CONTENT_TYPE_LIST_PATH}\`)
1793
- process.exit(1)
1794
- }
1795
- const contentTypeIds = listRaw.contentTypes.map((ct) => ct.id)
1796
- logInfo(\`Content types: \${contentTypeIds.join(', ')}\`)
1797
-
1798
- const allChanges: FileChange[] = []
1799
- let dashboardMetadata: DashboardMetadata = { contentTypes: {} }
1800
- const now = new Date().toISOString()
1801
-
1802
- for (const contentTypeId of contentTypeIds) {
1803
- const allContent: { basePath: BasePath; content: ContentData }[] = []
1804
- for (const basePath of ['published', 'private'] as const) {
1805
- const dir = \`\${SITE_CONTENTS}/\${basePath}/\${contentTypeId}\`
1806
- if (!existsSync(dir)) continue
1807
- const files = readdirSync(dir).filter((f) => f.endsWith('.json') && !f.startsWith('_'))
1808
- for (const file of files) {
1809
- const content = await store.readJson<ContentData>(\`\${dir}/\${file}\`)
1810
- if (content) allContent.push({ basePath, content })
1811
- }
1812
- }
1813
- logInfo(\`\${contentTypeId}: \${allContent.length} content(s) found\`)
1814
- const indexChanges = await rebuildAllIndexes(store, { contentTypeId, allContent })
1815
- allChanges.push(...indexChanges)
1816
- for (const { content } of allContent) {
1817
- dashboardMetadata = calcMetadataOnSave(
1818
- dashboardMetadata, contentTypeId, content.updatedBy,
1819
- null, content.status, now,
1820
- )
1821
- }
1822
- }
1823
-
1824
- allChanges.push({
1825
- path: DASHBOARD_METADATA_PATH,
1826
- content: JSON.stringify(dashboardMetadata, null, 2),
1827
- })
1828
-
1829
- logInfo(\`Files to write: \${allChanges.length}\`)
1830
- if (DRY_RUN) {
1831
- logInfo('Dry run — no files written.')
1832
- return
1833
- }
1834
- applyFileChanges(allChanges)
1835
- logInfo(\`Done. \${allChanges.length} file(s) written.\`)
1836
- }
1837
-
1838
- main().catch((error) => {
1839
- console.error('Fatal error:', error)
1840
- process.exit(1)
1841
- })
1842
- `);
1843
- writeFile(s('watch-content.ts'), `/**
1844
- * コンテンツ変更監視スクリプト
1845
- *
1846
- * frelio-data/ 内のコンテンツ JSON の変更を検知し、
1847
- * ビューインデックスとダッシュボードメタデータを自動更新する。
1848
- *
1849
- * @example
1850
- * npx tsx scripts/watch-content.ts
1851
- * npx tsx scripts/watch-content.ts --log-level debug
1852
- */
1853
-
1854
- import { watch, readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, readdirSync } from 'fs'
1855
- import { dirname } from 'path'
1856
- import { parseArgs } from 'util'
1857
- import {
1858
- computeViewIndexUpsert,
1859
- computeViewIndexRemoval,
1860
- computeDashboardMetadataOnSave,
1861
- computeDashboardMetadataOnDelete,
1862
- rebuildAllIndexes,
1863
- SITE_CONTENTS,
1864
- ADMIN_CONTENT_TYPES,
1865
- type ContentStorePort,
1866
- type ContentData,
1867
- type FileChange,
1868
- type BasePath,
1869
- } from '@c-time/frelio-content-ops'
1870
-
1871
- const { values } = parseArgs({
1872
- options: {
1873
- 'log-level': { type: 'string', default: 'info' },
1874
- 'debounce-ms': { type: 'string', default: '300' },
1875
- },
1876
- })
1877
-
1878
- const LOG_LEVEL = values['log-level'] as 'debug' | 'info' | 'quiet'
1879
- const DEBOUNCE_MS = Number(values['debounce-ms'])
1880
-
1881
- function logDebug(...args: unknown[]) {
1882
- if (LOG_LEVEL === 'debug') console.log('[debug]', ...args)
1883
- }
1884
- function logInfo(...args: unknown[]) {
1885
- if (LOG_LEVEL !== 'quiet') console.log('[info]', ...args)
1886
- }
1887
-
1888
- function createFsContentStore(): ContentStorePort {
1889
- return {
1890
- async readJson<T>(path: string): Promise<T | null> {
1891
- if (!existsSync(path)) return null
1892
- try {
1893
- return JSON.parse(readFileSync(path, 'utf-8')) as T
1894
- } catch {
1895
- return null
1896
- }
1897
- },
1898
- }
1899
- }
1900
-
1901
- type ContentChangeEvent = {
1902
- kind: 'content-save' | 'content-delete'
1903
- basePath: BasePath
1904
- contentTypeId: string
1905
- contentId: string
1906
- }
1907
-
1908
- type ViewsChangeEvent = {
1909
- kind: 'views-change'
1910
- contentTypeId: string
1911
- }
1912
-
1913
- type ChangeEvent = ContentChangeEvent | ViewsChangeEvent
1914
-
1915
- function classifyChange(filePath: string): ChangeEvent | null {
1916
- const normalized = filePath.replace(/\\\\\\\\/g, '/')
1917
- const contentMatch = normalized.match(
1918
- /^frelio-data\\/site\\/contents\\/(published|private)\\/([^/]+)\\/([^/]+)\\.json$/,
1919
- )
1920
- if (contentMatch) {
1921
- const [, basePath, contentTypeId, fileName] = contentMatch
1922
- if (fileName.startsWith('_')) return null
1923
- const exists = existsSync(filePath)
1924
- return {
1925
- kind: exists ? 'content-save' : 'content-delete',
1926
- basePath: basePath as BasePath,
1927
- contentTypeId,
1928
- contentId: fileName,
1929
- }
1930
- }
1931
- const viewsMatch = normalized.match(
1932
- /^frelio-data\\/admin\\/content_types\\/([^/]+)\\.views\\.json$/,
1933
- )
1934
- if (viewsMatch) {
1935
- return { kind: 'views-change', contentTypeId: viewsMatch[1] }
1936
- }
1937
- return null
1938
- }
1939
-
1940
- function applyFileChanges(changes: FileChange[]) {
1941
- for (const change of changes) {
1942
- if (change.delete) {
1943
- if (existsSync(change.path)) unlinkSync(change.path)
1944
- } else {
1945
- mkdirSync(dirname(change.path), { recursive: true })
1946
- writeFileSync(change.path, change.content + '\\n')
1947
- }
1948
- }
1949
- }
1950
-
1951
- const store = createFsContentStore()
1952
- const suppressedPaths = new Set<string>()
1953
-
1954
- function applyFileChangesWithSuppress(changes: FileChange[]) {
1955
- for (const change of changes) {
1956
- const normalized = change.path.replace(/\\\\\\\\/g, '/')
1957
- suppressedPaths.add(normalized)
1958
- setTimeout(() => suppressedPaths.delete(normalized), DEBOUNCE_MS + 200)
1959
- }
1960
- applyFileChanges(changes)
1961
- }
1962
-
1963
- async function handleContentSave(event: ContentChangeEvent) {
1964
- const { basePath, contentTypeId, contentId } = event
1965
- const contentPath = \`\${SITE_CONTENTS}/\${basePath}/\${contentTypeId}/\${contentId}.json\`
1966
- const content = await store.readJson<ContentData>(contentPath)
1967
- if (!content) return
1968
-
1969
- const viewChanges = await computeViewIndexUpsert(store, { basePath, contentTypeId, content })
1970
- const metaChange = await computeDashboardMetadataOnSave(store, {
1971
- contentTypeId, updatedBy: content.updatedBy,
1972
- oldStatus: null, newStatus: content.status,
1973
- })
1974
- applyFileChangesWithSuppress([...viewChanges, metaChange])
1975
- logInfo(\`Content saved: \${basePath}/\${contentTypeId}/\${contentId}\`)
1976
- }
1977
-
1978
- async function handleContentDelete(event: ContentChangeEvent) {
1979
- const { basePath, contentTypeId, contentId } = event
1980
- const viewChanges = await computeViewIndexRemoval(store, { basePath, contentTypeId, contentId })
1981
- const metaChange = await computeDashboardMetadataOnDelete(store, {
1982
- contentTypeId, deletedBy: 'unknown', deletedStatus: 'draft' as ContentData['status'],
1983
- })
1984
- applyFileChangesWithSuppress([...viewChanges, metaChange])
1985
- logInfo(\`Content deleted: \${basePath}/\${contentTypeId}/\${contentId}\`)
1986
- }
1987
-
1988
- async function handleViewsChange(event: ViewsChangeEvent) {
1989
- const { contentTypeId } = event
1990
- logInfo(\`Views changed for: \${contentTypeId}, rebuilding indexes...\`)
1991
- const allContent: { basePath: BasePath; content: ContentData }[] = []
1992
- for (const basePath of ['published', 'private'] as const) {
1993
- const dir = \`\${SITE_CONTENTS}/\${basePath}/\${contentTypeId}\`
1994
- if (!existsSync(dir)) continue
1995
- const files = readdirSync(dir).filter((f) => f.endsWith('.json') && !f.startsWith('_'))
1996
- for (const file of files) {
1997
- const content = await store.readJson<ContentData>(\`\${dir}/\${file}\`)
1998
- if (content) allContent.push({ basePath, content })
1999
- }
2000
- }
2001
- const changes = await rebuildAllIndexes(store, { contentTypeId, allContent })
2002
- applyFileChangesWithSuppress(changes)
2003
- logInfo(\`Rebuilt \${changes.length} index file(s) for \${contentTypeId}\`)
2004
- }
2005
-
2006
- const pending = new Map<string, NodeJS.Timeout>()
2007
- let processing = Promise.resolve()
2008
-
2009
- function enqueue(fn: () => Promise<void>) {
2010
- processing = processing.then(fn).catch((err) => console.error('Handler error:', err))
2011
- }
2012
-
2013
- function scheduleHandler(filePath: string, handler: () => Promise<void>) {
2014
- const existing = pending.get(filePath)
2015
- if (existing) clearTimeout(existing)
2016
- pending.set(filePath, setTimeout(() => {
2017
- pending.delete(filePath)
2018
- enqueue(handler)
2019
- }, DEBOUNCE_MS))
2020
- }
2021
-
2022
- async function main() {
2023
- logInfo('Starting content watcher...')
2024
- const watchTargets = [
2025
- { path: SITE_CONTENTS, label: 'contents' },
2026
- { path: ADMIN_CONTENT_TYPES, label: 'admin/content_types' },
2027
- ]
2028
-
2029
- for (const target of watchTargets) {
2030
- if (!existsSync(target.path)) {
2031
- console.error(\`Watch target not found: \${target.path}\`)
2032
- process.exit(1)
2033
- }
2034
- watch(target.path, { recursive: true }, (_eventType, fileName) => {
2035
- if (!fileName) return
2036
- const fullPath = \`\${target.path}/\${fileName.replace(/\\\\\\\\/g, '/')}\`
2037
- if (!fullPath.endsWith('.json')) return
2038
- if (suppressedPaths.has(fullPath)) return
2039
- const event = classifyChange(fullPath)
2040
- if (!event) return
2041
- scheduleHandler(fullPath, async () => {
2042
- const latestEvent = classifyChange(fullPath)
2043
- if (!latestEvent) return
2044
- switch (latestEvent.kind) {
2045
- case 'content-save': await handleContentSave(latestEvent); break
2046
- case 'content-delete': await handleContentDelete(latestEvent); break
2047
- case 'views-change': await handleViewsChange(latestEvent); break
2048
- }
2049
- })
2050
- })
2051
- logInfo(\`Watching: \${target.path}\`)
2052
- }
2053
-
2054
- logInfo('Ready. Press Ctrl+C to stop.')
2055
- process.on('SIGINT', () => {
2056
- logInfo('\\nShutting down...')
2057
- for (const timer of pending.values()) clearTimeout(timer)
2058
- process.exit(0)
2059
- })
2060
- }
2061
-
2062
- main().catch((error) => {
2063
- console.error('Fatal error:', error)
2064
- process.exit(1)
2065
- })
2066
- `);
2067
- }
2068
- // ========== CLAUDE.md ==========
2069
- function generateClaudeMd(projectDir) {
2070
- writeFile(path.join(projectDir, 'CLAUDE.md'), `# CLAUDE.md
2071
-
2072
- Frelio(ヘッドレス CMS)で構築されたサイトリポジトリ。
2073
- お知らせブログ付きのシンプルなコーポレートサイト。
2074
-
2075
- ## プロジェクト構成
2076
-
2077
- - \`frelio-data/\` — CMS データ(コンテンツタイプ、コンテンツ、テンプレート、レシピ)
2078
- - \`site/templates/\` — テンプレート(配置 = 出力先 URL 構造)
2079
- - \`site/templates/common/styles/\` — 共有 SCSS パーシャル(FLOCSS 亜種)
2080
- - \`site/templates/common/scripts/\` — 共有 TypeScript(features/)
2081
- - \`site/data/data-json/\` — SSG 中間データ(git 追跡対象)
2082
- - \`public/\` — SSG 出力(HTML + ビルド済みアセット、git 管理外)
2083
- - \`functions/storage/\` — R2 ファイル配信(/storage/*)
2084
- - \`scripts/\` — ビルドスクリプト(tsx)
2085
-
2086
- CMS 管理画面関連(\`admin/\`, \`functions/api/\`, \`workers/\`, \`wrangler.toml\`, \`_redirects\`)は
2087
- \`npx @frelio/cli update\` で追加・更新される。
2088
-
2089
- ## よく使うコマンド
2090
-
2091
- \`\`\`bash
2092
- npm run dev # Vite dev server(テンプレートプレビュー + コンテンツ監視)
2093
- npm run build # 静的アセットコピー + SCSS/TS ビルド(ページ別エントリー)
2094
- npm run generate # data-json 生成(差分ビルド)
2095
- npm run generate:full # data-json 生成(フルリビルド)
2096
- npm run generate:html # HTML 生成(data-json → public/)
2097
- npm run generate:sitemap # sitemap.xml 生成
2098
- npm run generate:dep-map # 依存マップ生成
2099
- npm run watch:content # コンテンツ変更監視(インデックス自動更新)
2100
- npm run rebuild:indexes # インデックス一括再構築
2101
- npx @frelio/cli update # CMS Admin バンドル更新
2102
- npx @frelio/cli add-staging # カスタムステージング追加
2103
- \`\`\`
2104
-
2105
- ## ビルドパイプライン
2106
-
2107
- \`\`\`
2108
- 1. Recipe → 依存マップ (npm run generate:dep-map)
2109
- 2. コンテンツ → data-json (npm run generate)
2110
- 3. data-json → HTML (npm run generate:html)
2111
- 4. SCSS/TS → CSS/JS (npm run build)
2112
- 5. sitemap.xml 生成 (npm run generate:sitemap)
2113
- \`\`\`
2114
-
2115
- ## テンプレート構造(= URL 構造)
2116
-
2117
- テンプレートの配置がそのまま出力先の URL パスになる。各ページに \`styles/index.scss\` と \`scripts/index.ts\` がある。
2118
-
2119
- \`\`\`
2120
- frelio-data/site/templates/
2121
- ├── _parts/ — 共通パーツ(head.htm, header.htm, footer.htm)
2122
- ├── common/ — 全ページ共通
2123
- │ ├── scripts/ — JS 初期化 + features/
2124
- │ ├── styles/ — SCSS パーシャル(FLOCSS: foundation/, layout/, component/, element/, project/)
2125
- │ └── images/ — favicon, logo 等
2126
- ├── index.html — / (ホーム)
2127
- ├── scripts/index.ts — ホーム用 JS
2128
- ├── styles/index.scss — ホーム用 CSS(p-hero, p-news-list)
2129
- ├── images/ — ホーム画像
2130
- ├── about/ — /about/
2131
- │ ├── index.html
2132
- │ ├── scripts/index.ts
2133
- │ ├── styles/index.scss(p-about)
2134
- │ └── images/
2135
- ├── contact/ — /contact/
2136
- │ ├── index.html
2137
- │ ├── scripts/index.ts
2138
- │ ├── styles/index.scss(p-contact)
2139
- │ └── images/
2140
- └── news/ — /news/
2141
- ├── index.html — 一覧テンプレート
2142
- ├── detail.html — 詳細テンプレート(レシピで news/{slug}.html に展開)
2143
- ├── scripts/index.ts — 一覧・詳細で共有
2144
- ├── styles/index.scss(p-news-list, p-article)
2145
- └── images/
2146
- \`\`\`
2147
-
2148
- - \`_parts/head.htm\` で common の CSS/JS を読み込み
2149
- - 各ページテンプレートでページ固有の CSS/JS を読み込み
2150
-
2151
- ### スラッグ展開
2152
-
2153
- テンプレートファイル名と出力パスの対応はレシピ(\`build-data-recipe.json\`)で制御する。
2154
- gentl の規約ではなく、レシピの書き方次第。
2155
-
2156
- - **ファイルベース**: \`news/detail.html\` → \`news/{slug}.html\`(現在の設定)
2157
- - **ディレクトリベース**: \`news/_detail/index.html\` → \`news/{slug}/index.html\`(別方式、必要に応じて変更可)
2158
-
2159
- ## CSS 記法ルール(FLOCSS 亜種・厳格)
2160
-
2161
- - **プレフィックス**: \`l-\`(layout)、\`c-\`(component)、\`p-\`(project)、\`e-\`(element)のみ
2162
- - **1 class ルール**: HTML の class 属性には必ず 1 クラスのみ
2163
- - **Utility は絶対使用禁止**
2164
- - **SCSS の \`&\` でクラス名を接続することは禁止**(grep で追跡可能を維持)
2165
- - **@extend**: 同ファイル内でのみ許可。\`%placeholder\` または実体クラスに \`--variant\` サフィックス
2166
- - **子要素**: \`__\` で繋げる(深さ制限なし)
2167
- - **バリアント**: \`--\` サフィックス(数に制限なし)
2168
- - **メディアクエリ**: \`@mixin\` で定義し、1 ファイルに各 mixin を 1 回のみ \`@include\`
2169
-
2170
- ### 例
2171
-
2172
- \`\`\`scss
2173
- %c-section__inner__item {} // ベースデザイン
2174
- .c-section__inner__item--red { @extend %c-section__inner__item; color: red; }
2175
- \`\`\`
2176
-
2177
- ## TypeScript ルール
2178
-
2179
- - 共通の初期化ロジックは \`common/scripts/index.ts\` に集約
2180
- - 各機能は \`common/scripts/features/\` にファイル分離
2181
- - ページ固有の JS が必要な場合は \`{page}/scripts/index.ts\` に追加
2182
-
2183
- ## テンプレート規約
2184
-
2185
- - テンプレートエンジン: gentl(\`data-gen-*\` 属性ベース)
2186
- - テンプレートは valid HTML(そのままブラウザで開ける)
2187
- - 共通パーツ: \`_parts/*.htm\`(head, header, footer)
2188
- - ページテンプレート: \`{page}/index.html\`(ホームは \`index.html\`)
2189
- - 詳細テンプレート: \`{page}/detail.html\`(レシピでスラッグ展開)
2190
-
2191
- ## Cloudflare Pages 構成
2192
-
2193
- \`npx @frelio/cli update\` 実行後に以下が配置される:
2194
- - \`_redirects\`: \`/admin/*\` → SPA、\`/*\` → \`/public/:splat\`
2195
- - \`_routes.json\`: \`/api/*\`, \`/storage/*\` → Functions
2196
- - \`wrangler.toml\`: R2 バケットバインディング
2197
-
2198
- ## 型パッケージ活用方針
2199
-
2200
- JSON ファイルの読み書きでは、以下の型・ガード・スキーマを使用する。
2201
-
2202
- | ファイル | 型 | パッケージ |
2203
- |---|---|---|
2204
- | \`content_types/*.json\` | \`ContentType\` / \`validateContentType\` | \`@c-time/frelio-types\` |
2205
- | \`*.ui.json\` | \`ContentTypeUi\` / \`validateContentTypeUi\` | 同上 |
2206
- | \`*.views.json\` | \`ContentTypeViews\` / \`validateContentTypeViews\` | 同上 |
2207
- | \`build-data-recipe.json\` | \`FrelioBuildDataRecipe\` / \`validateSiteRecipe\` | 同上 |
2208
- | \`data-json/*.json\` | \`FrelioDataJson\` / \`isFrelioDataJson\` | \`@c-time/frelio-data-json\` |
2209
- | \`contents/*/*.json\` | \`Content\` / \`isContent\` | \`@c-time/frelio-types\` |
2210
- `);
2211
- }
2212
- // ========== Helpers ==========
2213
- function json(obj) {
2214
- return JSON.stringify(obj, null, 2);
2215
- }