@c-time/frelio-cli 1.4.0 → 1.4.2

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,2183 +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}/index.json',
115
- templatePath: 'news/_detail/index.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="/home/styles/index.css">
210
- <script type="module" src="/home/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.html
278
- writeFile(t('about.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.html
348
- writeFile(t('contact.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/index.html
476
- writeFile(t('news/_detail/index.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/detail/styles/index.css">
484
- <script type="module" src="/news/detail/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
- }
539
- // ========== SCSS ==========
540
- function generateScss(projectDir) {
541
- const s = (...p) => path.join(projectDir, 'frelio-data/site/templates/assets/scss', ...p);
542
- // Foundation
543
- writeFile(s('foundation/_variables.scss'), `// Colors
544
- $color-primary: #0070f3;
545
- $color-primary-dark: #0060d0;
546
- $color-text: #333;
547
- $color-text-light: #666;
548
- $color-border: #e0e0e0;
549
- $color-bg: #fff;
550
- $color-bg-light: #f8f9fa;
551
-
552
- // Typography
553
- $font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
554
- "Hiragino Sans", "Noto Sans JP", sans-serif;
555
- $line-height-base: 1.7;
556
-
557
- // Layout
558
- $max-width: 1100px;
559
- $spacing-unit: 8px;
560
-
561
- // Breakpoints
562
- $bp-tablet: 768px;
563
- $bp-desktop: 1024px;
564
- `);
565
- writeFile(s('foundation/_mixins.scss'), `@use 'variables' as *;
566
-
567
- @mixin tablet {
568
- @media (min-width: $bp-tablet) {
569
- @content;
570
- }
571
- }
572
-
573
- @mixin desktop {
574
- @media (min-width: $bp-desktop) {
575
- @content;
576
- }
577
- }
578
- `);
579
- writeFile(s('foundation/_reset.scss'), `@use 'variables' as *;
580
-
581
- *,
582
- *::before,
583
- *::after {
584
- box-sizing: border-box;
585
- margin: 0;
586
- padding: 0;
587
- }
588
-
589
- html {
590
- font-size: 100%;
591
- -webkit-text-size-adjust: 100%;
592
- }
593
-
594
- body {
595
- font-family: $font-family-base;
596
- line-height: $line-height-base;
597
- color: $color-text;
598
- background-color: $color-bg;
599
- -webkit-font-smoothing: antialiased;
600
- }
601
-
602
- img {
603
- max-width: 100%;
604
- height: auto;
605
- vertical-align: middle;
606
- }
607
-
608
- a {
609
- color: $color-primary;
610
- text-decoration: none;
611
- }
612
-
613
- ul,
614
- ol {
615
- list-style: none;
616
- }
617
-
618
- table {
619
- border-collapse: collapse;
620
- border-spacing: 0;
621
- }
622
-
623
- button,
624
- input,
625
- select,
626
- textarea {
627
- font: inherit;
628
- color: inherit;
629
- }
630
-
631
- button {
632
- cursor: pointer;
633
- background: none;
634
- border: none;
635
- }
636
- `);
637
- // Layout
638
- writeFile(s('layout/_l-header.scss'), `@use '../foundation/variables' as *;
639
- @use '../foundation/mixins' as *;
640
-
641
- .l-header {
642
- border-bottom: 1px solid $color-border;
643
- background-color: $color-bg;
644
- position: sticky;
645
- top: 0;
646
- z-index: 100;
647
- }
648
-
649
- .l-header__inner {
650
- max-width: $max-width;
651
- margin: 0 auto;
652
- padding: $spacing-unit * 2;
653
- display: flex;
654
- justify-content: space-between;
655
- align-items: center;
656
- }
657
-
658
- @include tablet {
659
- .l-header__inner {
660
- padding: $spacing-unit * 2 $spacing-unit * 4;
661
- }
662
- }
663
- `);
664
- writeFile(s('layout/_l-footer.scss'), `@use '../foundation/variables' as *;
665
-
666
- .l-footer {
667
- margin-top: $spacing-unit * 8;
668
- border-top: 1px solid $color-border;
669
- background-color: $color-bg-light;
670
- }
671
-
672
- .l-footer__inner {
673
- max-width: $max-width;
674
- margin: 0 auto;
675
- padding: $spacing-unit * 4 $spacing-unit * 2;
676
- text-align: center;
677
- }
678
-
679
- .l-footer__copyright {
680
- font-size: 0.875rem;
681
- color: $color-text-light;
682
- }
683
- `);
684
- writeFile(s('layout/_l-main.scss'), `.l-main {
685
- min-height: 60vh;
686
- }
687
- `);
688
- // Component
689
- writeFile(s('component/_c-logo.scss'), `@use '../foundation/variables' as *;
690
-
691
- .c-logo {
692
- font-size: 1.25rem;
693
- font-weight: bold;
694
- color: $color-text;
695
- text-decoration: none;
696
- }
697
- `);
698
- writeFile(s('component/_c-nav.scss'), `@use '../foundation/variables' as *;
699
- @use '../foundation/mixins' as *;
700
-
701
- .c-nav {
702
- display: flex;
703
- align-items: center;
704
- }
705
-
706
- .c-nav__toggle {
707
- display: flex;
708
- flex-direction: column;
709
- gap: 4px;
710
- width: 28px;
711
- padding: 4px 0;
712
- }
713
-
714
- .c-nav__toggle__bar {
715
- display: block;
716
- width: 100%;
717
- height: 2px;
718
- background-color: $color-text;
719
- transition: transform 0.3s, opacity 0.3s;
720
- }
721
-
722
- .c-nav__list {
723
- display: none;
724
- position: absolute;
725
- top: 100%;
726
- left: 0;
727
- right: 0;
728
- background-color: $color-bg;
729
- border-bottom: 1px solid $color-border;
730
- padding: $spacing-unit * 2;
731
- flex-direction: column;
732
- gap: $spacing-unit;
733
- }
734
-
735
- .c-nav__list--open {
736
- display: flex;
737
- }
738
-
739
- .c-nav__item a {
740
- color: $color-text-light;
741
- font-size: 0.9375rem;
742
- }
743
-
744
- .c-nav__item a:hover {
745
- color: $color-primary;
746
- }
747
-
748
- @include tablet {
749
- .c-nav__toggle {
750
- display: none;
751
- }
752
-
753
- .c-nav__list {
754
- display: flex;
755
- position: static;
756
- flex-direction: row;
757
- gap: $spacing-unit * 3;
758
- padding: 0;
759
- border-bottom: none;
760
- }
761
- }
762
- `);
763
- writeFile(s('component/_c-btn.scss'), `@use '../foundation/variables' as *;
764
-
765
- %c-btn {
766
- display: inline-block;
767
- padding: $spacing-unit * 1.5 $spacing-unit * 4;
768
- border: 1px solid $color-primary;
769
- border-radius: 4px;
770
- font-size: 0.9375rem;
771
- text-align: center;
772
- transition: background-color 0.2s, color 0.2s;
773
- }
774
-
775
- .c-btn {
776
- @extend %c-btn;
777
- color: $color-primary;
778
- background-color: transparent;
779
- }
780
-
781
- .c-btn:hover {
782
- background-color: $color-primary;
783
- color: $color-bg;
784
- }
785
-
786
- .c-btn--submit {
787
- @extend %c-btn;
788
- color: $color-bg;
789
- background-color: $color-primary;
790
- border-color: $color-primary;
791
- }
792
-
793
- .c-btn--submit:hover {
794
- background-color: $color-primary-dark;
795
- border-color: $color-primary-dark;
796
- }
797
- `);
798
- writeFile(s('component/_c-card.scss'), `@use '../foundation/variables' as *;
799
- @use '../foundation/mixins' as *;
800
-
801
- %c-card {
802
- display: block;
803
- border: 1px solid $color-border;
804
- border-radius: 8px;
805
- overflow: hidden;
806
- text-decoration: none;
807
- color: $color-text;
808
- transition: box-shadow 0.2s;
809
- }
810
-
811
- %c-card:hover {
812
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
813
- }
814
-
815
- .c-card--news {
816
- @extend %c-card;
817
- }
818
-
819
- .c-card__body {
820
- padding: $spacing-unit * 2;
821
- }
822
-
823
- .c-card__date {
824
- display: block;
825
- font-size: 0.8125rem;
826
- color: $color-text-light;
827
- margin-bottom: $spacing-unit;
828
- }
829
-
830
- .c-card__title {
831
- font-size: 1rem;
832
- font-weight: bold;
833
- line-height: 1.5;
834
- margin-bottom: $spacing-unit;
835
- }
836
-
837
- .c-card__excerpt {
838
- font-size: 0.875rem;
839
- color: $color-text-light;
840
- line-height: 1.6;
841
- }
842
-
843
- @include tablet {
844
- .c-card__body {
845
- padding: $spacing-unit * 3;
846
- }
847
-
848
- .c-card__title {
849
- font-size: 1.125rem;
850
- }
851
- }
852
- `);
853
- writeFile(s('component/_c-table.scss'), `@use '../foundation/variables' as *;
854
- @use '../foundation/mixins' as *;
855
-
856
- .c-table {
857
- width: 100%;
858
- }
859
-
860
- .c-table__body {
861
- display: block;
862
- }
863
-
864
- .c-table__row {
865
- display: flex;
866
- flex-direction: column;
867
- border-bottom: 1px solid $color-border;
868
- padding: $spacing-unit * 2 0;
869
- }
870
-
871
- .c-table__header {
872
- font-weight: bold;
873
- font-size: 0.875rem;
874
- color: $color-text-light;
875
- margin-bottom: $spacing-unit;
876
- text-align: left;
877
- }
878
-
879
- .c-table__cell {
880
- font-size: 1rem;
881
- }
882
-
883
- @include tablet {
884
- .c-table__row {
885
- flex-direction: row;
886
- }
887
-
888
- .c-table__header {
889
- width: 200px;
890
- flex-shrink: 0;
891
- margin-bottom: 0;
892
- padding-right: $spacing-unit * 2;
893
- }
894
- }
895
- `);
896
- // Element
897
- writeFile(s('element/_e-heading.scss'), `@use '../foundation/variables' as *;
898
- @use '../foundation/mixins' as *;
899
-
900
- .e-heading {
901
- font-size: 1.5rem;
902
- font-weight: bold;
903
- padding-bottom: $spacing-unit * 2;
904
- border-bottom: 3px solid $color-primary;
905
- display: inline-block;
906
- }
907
-
908
- @include tablet {
909
- .e-heading {
910
- font-size: 1.75rem;
911
- }
912
- }
913
- `);
914
- // Project
915
- writeFile(s('project/_p-hero.scss'), `@use '../foundation/variables' as *;
916
- @use '../foundation/mixins' as *;
917
-
918
- .p-hero {
919
- background-color: $color-bg-light;
920
- padding: $spacing-unit * 6 $spacing-unit * 2;
921
- text-align: center;
922
- }
923
-
924
- .p-hero__inner {
925
- max-width: $max-width;
926
- margin: 0 auto;
927
- }
928
-
929
- .p-hero__title {
930
- font-size: 1.75rem;
931
- font-weight: bold;
932
- margin-bottom: $spacing-unit * 2;
933
- }
934
-
935
- .p-hero__lead {
936
- font-size: 1rem;
937
- color: $color-text-light;
938
- line-height: 1.8;
939
- }
940
-
941
- @include tablet {
942
- .p-hero {
943
- padding: $spacing-unit * 10 $spacing-unit * 4;
944
- }
945
-
946
- .p-hero__title {
947
- font-size: 2.25rem;
948
- }
949
-
950
- .p-hero__lead {
951
- font-size: 1.125rem;
952
- }
953
- }
954
- `);
955
- writeFile(s('project/_p-news-list.scss'), `@use '../foundation/variables' as *;
956
- @use '../foundation/mixins' as *;
957
-
958
- .p-news-list {
959
- padding: $spacing-unit * 6 $spacing-unit * 2;
960
- }
961
-
962
- .p-news-list__inner {
963
- max-width: $max-width;
964
- margin: 0 auto;
965
- }
966
-
967
- .p-news-list__items {
968
- display: grid;
969
- gap: $spacing-unit * 3;
970
- margin-top: $spacing-unit * 4;
971
- }
972
-
973
- .p-news-list__item {
974
- display: block;
975
- }
976
-
977
- .p-news-list__more {
978
- margin-top: $spacing-unit * 4;
979
- text-align: center;
980
- }
981
-
982
- @include tablet {
983
- .p-news-list {
984
- padding: $spacing-unit * 8 $spacing-unit * 4;
985
- }
986
-
987
- .p-news-list__items {
988
- grid-template-columns: repeat(2, 1fr);
989
- }
990
- }
991
-
992
- @include desktop {
993
- .p-news-list__items {
994
- grid-template-columns: repeat(3, 1fr);
995
- }
996
- }
997
- `);
998
- writeFile(s('project/_p-article.scss'), `@use '../foundation/variables' as *;
999
- @use '../foundation/mixins' as *;
1000
-
1001
- .p-article {
1002
- padding: $spacing-unit * 4 $spacing-unit * 2;
1003
- }
1004
-
1005
- .p-article__inner {
1006
- max-width: 780px;
1007
- margin: 0 auto;
1008
- }
1009
-
1010
- .p-article__header {
1011
- margin-bottom: $spacing-unit * 4;
1012
- }
1013
-
1014
- .p-article__date {
1015
- display: block;
1016
- font-size: 0.875rem;
1017
- color: $color-text-light;
1018
- margin-bottom: $spacing-unit;
1019
- }
1020
-
1021
- .p-article__title {
1022
- font-size: 1.5rem;
1023
- font-weight: bold;
1024
- line-height: 1.4;
1025
- }
1026
-
1027
- .p-article__eyecatch {
1028
- margin-bottom: $spacing-unit * 4;
1029
- }
1030
-
1031
- .p-article__eyecatch__image {
1032
- width: 100%;
1033
- aspect-ratio: 16 / 9;
1034
- object-fit: cover;
1035
- border-radius: 8px;
1036
- }
1037
-
1038
- .p-article__body {
1039
- font-size: 1rem;
1040
- line-height: 1.8;
1041
- }
1042
-
1043
- .p-article__body h2 {
1044
- font-size: 1.375rem;
1045
- font-weight: bold;
1046
- margin-top: $spacing-unit * 5;
1047
- margin-bottom: $spacing-unit * 2;
1048
- padding-bottom: $spacing-unit;
1049
- border-bottom: 2px solid $color-border;
1050
- }
1051
-
1052
- .p-article__body h3 {
1053
- font-size: 1.125rem;
1054
- font-weight: bold;
1055
- margin-top: $spacing-unit * 4;
1056
- margin-bottom: $spacing-unit * 2;
1057
- }
1058
-
1059
- .p-article__body p {
1060
- margin-bottom: $spacing-unit * 2;
1061
- }
1062
-
1063
- .p-article__body ul,
1064
- .p-article__body ol {
1065
- margin-bottom: $spacing-unit * 2;
1066
- padding-left: $spacing-unit * 3;
1067
- list-style: disc;
1068
- }
1069
-
1070
- .p-article__body ol {
1071
- list-style: decimal;
1072
- }
1073
-
1074
- .p-article__footer {
1075
- margin-top: $spacing-unit * 6;
1076
- padding-top: $spacing-unit * 4;
1077
- border-top: 1px solid $color-border;
1078
- }
1079
-
1080
- @include tablet {
1081
- .p-article {
1082
- padding: $spacing-unit * 6 $spacing-unit * 4;
1083
- }
1084
-
1085
- .p-article__title {
1086
- font-size: 2rem;
1087
- }
1088
- }
1089
- `);
1090
- writeFile(s('project/_p-about.scss'), `@use '../foundation/variables' as *;
1091
- @use '../foundation/mixins' as *;
1092
-
1093
- .p-about {
1094
- padding: $spacing-unit * 6 $spacing-unit * 2;
1095
- }
1096
-
1097
- .p-about__inner {
1098
- max-width: $max-width;
1099
- margin: 0 auto;
1100
- }
1101
-
1102
- .p-about__content {
1103
- margin-top: $spacing-unit * 4;
1104
- line-height: 1.8;
1105
- }
1106
-
1107
- .p-about__content p {
1108
- margin-bottom: $spacing-unit * 2;
1109
- }
1110
-
1111
- .p-about__table {
1112
- margin-top: $spacing-unit * 6;
1113
- }
1114
-
1115
- @include tablet {
1116
- .p-about {
1117
- padding: $spacing-unit * 8 $spacing-unit * 4;
1118
- }
1119
- }
1120
- `);
1121
- writeFile(s('project/_p-contact.scss'), `@use '../foundation/variables' as *;
1122
- @use '../foundation/mixins' as *;
1123
-
1124
- .p-contact {
1125
- padding: $spacing-unit * 6 $spacing-unit * 2;
1126
- }
1127
-
1128
- .p-contact__inner {
1129
- max-width: 640px;
1130
- margin: 0 auto;
1131
- }
1132
-
1133
- .p-contact__lead {
1134
- margin-top: $spacing-unit * 2;
1135
- color: $color-text-light;
1136
- }
1137
-
1138
- .p-contact__form {
1139
- margin-top: $spacing-unit * 4;
1140
- }
1141
-
1142
- .p-contact__field {
1143
- margin-bottom: $spacing-unit * 3;
1144
- }
1145
-
1146
- .p-contact__label {
1147
- display: block;
1148
- font-size: 0.875rem;
1149
- font-weight: bold;
1150
- margin-bottom: $spacing-unit;
1151
- }
1152
-
1153
- .p-contact__input {
1154
- display: block;
1155
- width: 100%;
1156
- padding: $spacing-unit * 1.5;
1157
- border: 1px solid $color-border;
1158
- border-radius: 4px;
1159
- font-size: 1rem;
1160
- }
1161
-
1162
- .p-contact__input:focus {
1163
- outline: none;
1164
- border-color: $color-primary;
1165
- box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
1166
- }
1167
-
1168
- .p-contact__textarea {
1169
- display: block;
1170
- width: 100%;
1171
- padding: $spacing-unit * 1.5;
1172
- border: 1px solid $color-border;
1173
- border-radius: 4px;
1174
- font-size: 1rem;
1175
- resize: vertical;
1176
- }
1177
-
1178
- .p-contact__textarea:focus {
1179
- outline: none;
1180
- border-color: $color-primary;
1181
- box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
1182
- }
1183
-
1184
- .p-contact__actions {
1185
- margin-top: $spacing-unit * 4;
1186
- text-align: center;
1187
- }
1188
-
1189
- @include tablet {
1190
- .p-contact {
1191
- padding: $spacing-unit * 8 $spacing-unit * 4;
1192
- }
1193
- }
1194
- `);
1195
- }
1196
- // ========== TypeScript ==========
1197
- function generateTypeScript(projectDir) {
1198
- const ts = (...p) => path.join(projectDir, 'frelio-data/site/templates/assets/ts', ...p);
1199
- writeFile(ts('features/mobile-nav.ts'), `export function initMobileNav(): void {
1200
- const toggle = document.querySelector<HTMLButtonElement>('.c-nav__toggle')
1201
- const list = document.querySelector<HTMLUListElement>('.c-nav__list')
1202
-
1203
- if (!toggle || !list) return
1204
-
1205
- toggle.addEventListener('click', () => {
1206
- const isOpen = list.classList.contains('c-nav__list--open')
1207
- list.classList.toggle('c-nav__list--open', !isOpen)
1208
- toggle.setAttribute('aria-expanded', String(!isOpen))
1209
- })
1210
-
1211
- document.addEventListener('click', (e) => {
1212
- const target = e.target as HTMLElement
1213
- if (!target.closest('.c-nav')) {
1214
- list.classList.remove('c-nav__list--open')
1215
- toggle.setAttribute('aria-expanded', 'false')
1216
- }
1217
- })
1218
- }
1219
- `);
1220
- writeFile(ts('features/smooth-scroll.ts'), `export function initSmoothScroll(): void {
1221
- document.addEventListener('click', (e) => {
1222
- const target = e.target as HTMLElement
1223
- const anchor = target.closest<HTMLAnchorElement>('a[href^="#"]')
1224
-
1225
- if (!anchor) return
1226
-
1227
- const id = anchor.getAttribute('href')
1228
- if (!id || id === '#') return
1229
-
1230
- const element = document.querySelector(id)
1231
- if (!element) return
1232
-
1233
- e.preventDefault()
1234
- element.scrollIntoView({ behavior: 'smooth' })
1235
- })
1236
- }
1237
- `);
1238
- }
1239
- // ========== Entry Points ==========
1240
- function generateEntries(projectDir) {
1241
- const e = (...p) => path.join(projectDir, 'frelio-data/site/templates/assets/entries', ...p);
1242
- // common
1243
- writeFile(e('common/styles/index.scss'), `// Foundation
1244
- @use 'foundation/variables' as *;
1245
- @use 'foundation/mixins' as *;
1246
- @use 'foundation/reset';
1247
-
1248
- // Layout
1249
- @use 'layout/l-header';
1250
- @use 'layout/l-footer';
1251
- @use 'layout/l-main';
1252
-
1253
- // Component
1254
- @use 'component/c-logo';
1255
- @use 'component/c-nav';
1256
- @use 'component/c-btn';
1257
- @use 'component/c-card';
1258
- @use 'component/c-table';
1259
-
1260
- // Element
1261
- @use 'element/e-heading';
1262
- `);
1263
- writeFile(e('common/scripts/index.ts'), `import '../styles/index.scss'
1264
- import { initMobileNav } from '@features/mobile-nav'
1265
- import { initSmoothScroll } from '@features/smooth-scroll'
1266
-
1267
- document.addEventListener('DOMContentLoaded', () => {
1268
- initMobileNav()
1269
- initSmoothScroll()
1270
- })
1271
- `);
1272
- // home
1273
- writeFile(e('home/styles/index.scss'), `@use 'foundation/variables' as *;
1274
- @use 'foundation/mixins' as *;
1275
-
1276
- @use 'project/p-hero';
1277
- @use 'project/p-news-list';
1278
- `);
1279
- writeFile(e('home/scripts/index.ts'), `import '../styles/index.scss'
1280
- `);
1281
- // about
1282
- writeFile(e('about/styles/index.scss'), `@use 'foundation/variables' as *;
1283
- @use 'foundation/mixins' as *;
1284
-
1285
- @use 'project/p-about';
1286
- `);
1287
- writeFile(e('about/scripts/index.ts'), `import '../styles/index.scss'
1288
- `);
1289
- // contact
1290
- writeFile(e('contact/styles/index.scss'), `@use 'foundation/variables' as *;
1291
- @use 'foundation/mixins' as *;
1292
-
1293
- @use 'project/p-contact';
1294
- `);
1295
- writeFile(e('contact/scripts/index.ts'), `import '../styles/index.scss'
1296
- `);
1297
- // news
1298
- writeFile(e('news/styles/index.scss'), `@use 'foundation/variables' as *;
1299
- @use 'foundation/mixins' as *;
1300
-
1301
- @use 'project/p-news-list';
1302
- `);
1303
- writeFile(e('news/scripts/index.ts'), `import '../styles/index.scss'
1304
- `);
1305
- // news/detail
1306
- writeFile(e('news/detail/styles/index.scss'), `@use 'foundation/variables' as *;
1307
- @use 'foundation/mixins' as *;
1308
-
1309
- @use 'project/p-article';
1310
- `);
1311
- writeFile(e('news/detail/scripts/index.ts'), `import '../styles/index.scss'
1312
- `);
1313
- }
1314
- // ========== Build Scripts ==========
1315
- function generateBuildScripts(projectDir) {
1316
- const s = (...p) => path.join(projectDir, 'scripts', ...p);
1317
- writeFile(s('generate-data-json.ts'), `/**
1318
- * FrelioDataJson 生成スクリプト
1319
- *
1320
- * FrelioBuildDataRecipe に従って FrelioDataJson を生成し、
1321
- * frelio-data/site/data/data-json/ に出力する。
1322
- *
1323
- * @example
1324
- * # 差分ビルド(デフォルト)
1325
- * npx tsx scripts/generate-data-json.ts
1326
- *
1327
- * # フルリビルド
1328
- * npx tsx scripts/generate-data-json.ts --full-rebuild
1329
- *
1330
- * # ドライラン
1331
- * npx tsx scripts/generate-data-json.ts --dry-run
1332
- */
1333
-
1334
- import {
1335
- generateDataJson,
1336
- NodeFileSystem,
1337
- getGitDiff,
1338
- type GenerateDataJsonOptions,
1339
- } from '@c-time/frelio-data-json-generator'
1340
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync } from 'fs'
1341
- import { dirname, join } from 'path'
1342
- import { parseArgs } from 'util'
1343
-
1344
- const { values } = parseArgs({
1345
- options: {
1346
- 'diff-range': { type: 'string', default: 'origin/main...HEAD' },
1347
- 'full-rebuild': { type: 'boolean', default: false },
1348
- 'dry-run': { type: 'boolean', default: false },
1349
- 'log-level': { type: 'string', default: 'info' },
1350
- },
1351
- })
1352
-
1353
- const options: GenerateDataJsonOptions = {
1354
- fullRebuild: values['full-rebuild'],
1355
- dryRun: values['dry-run'],
1356
- logLevel: values['log-level'] as 'debug' | 'info' | 'quiet',
1357
- }
1358
-
1359
- const CONTENT_ROOT = 'frelio-data/site'
1360
- const OUTPUT_ROOT = 'frelio-data/site/data/data-json'
1361
- const REPORT_PATH = 'frelio-data/site/data/report.json'
1362
- const RECIPE_PATH = 'frelio-data/admin/recipes/build-data-recipe.json'
1363
- const DEPENDENCY_MAP_PATH = 'frelio-data/site/data/_dependency-map.json'
1364
-
1365
- async function main(): Promise<void> {
1366
- if (!existsSync(RECIPE_PATH)) {
1367
- console.error(\`Recipe not found: \${RECIPE_PATH}\`)
1368
- process.exit(1)
1369
- }
1370
- const recipe = JSON.parse(readFileSync(RECIPE_PATH, 'utf-8'))
1371
-
1372
- if (!existsSync(DEPENDENCY_MAP_PATH)) {
1373
- console.error(\`Dependency map not found: \${DEPENDENCY_MAP_PATH}\`)
1374
- process.exit(1)
1375
- }
1376
- const dependencyMap = JSON.parse(readFileSync(DEPENDENCY_MAP_PATH, 'utf-8'))
1377
-
1378
- const gitDiff = options.fullRebuild
1379
- ? { added: [], modified: [], deleted: [] }
1380
- : await getGitDiff(values['diff-range']!)
1381
-
1382
- if (options.logLevel !== 'quiet') {
1383
- console.log(\`Mode: \${options.fullRebuild ? 'full-rebuild' : 'incremental'}\`)
1384
- console.log(\`Dry run: \${options.dryRun}\`)
1385
- }
1386
-
1387
- const result = await generateDataJson({
1388
- recipe,
1389
- dependencyMap,
1390
- gitDiff,
1391
- fileSystem: new NodeFileSystem(),
1392
- contentRootPath: CONTENT_ROOT,
1393
- outputRootPath: OUTPUT_ROOT,
1394
- options,
1395
- })
1396
-
1397
- if (!options.dryRun) {
1398
- mkdirSync(OUTPUT_ROOT, { recursive: true })
1399
- for (const output of result.outputs) {
1400
- if (output.content.type === 'delete') {
1401
- const fullPath = join(OUTPUT_ROOT, output.path)
1402
- if (existsSync(fullPath)) unlinkSync(fullPath)
1403
- }
1404
- }
1405
- for (const output of result.outputs) {
1406
- if (output.content.type !== 'delete') {
1407
- const fullPath = join(OUTPUT_ROOT, output.path)
1408
- mkdirSync(dirname(fullPath), { recursive: true })
1409
- writeFileSync(fullPath, JSON.stringify(output.content, null, 2))
1410
- }
1411
- }
1412
- }
1413
-
1414
- mkdirSync(dirname(REPORT_PATH), { recursive: true })
1415
- writeFileSync(REPORT_PATH, JSON.stringify(result.report, null, 2))
1416
-
1417
- const { stats } = result.report
1418
- console.log(\`\\n=== Summary ===\`)
1419
- console.log(\`Updated: \${stats.updated}\`)
1420
- console.log(\`Deleted: \${stats.deleted}\`)
1421
- console.log(\`Skipped: \${stats.skipped}\`)
1422
- console.log(\`Errors: \${stats.errors}\`)
1423
- }
1424
-
1425
- main().catch((error) => {
1426
- console.error('Fatal error:', error)
1427
- process.exit(1)
1428
- })
1429
- `);
1430
- writeFile(s('generate-html.ts'), `/**
1431
- * HTML 生成スクリプト
1432
- *
1433
- * FrelioDataJson → HTML の変換(ビルドパイプライン Phase 2)
1434
- *
1435
- * @example
1436
- * npx tsx scripts/generate-html.ts
1437
- * npx tsx scripts/generate-html.ts --dry-run
1438
- */
1439
-
1440
- import {
1441
- generateHtml,
1442
- NodeFileSystem,
1443
- type GenerateHtmlOptions,
1444
- } from '@c-time/frelio-gentl'
1445
- import type { FrelioDataJson } from '@c-time/frelio-data-json'
1446
- import { readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, readdirSync } from 'fs'
1447
- import { dirname, join, extname } from 'path'
1448
- import { parseArgs } from 'util'
1449
-
1450
- const { values } = parseArgs({
1451
- options: {
1452
- 'dry-run': { type: 'boolean', default: false },
1453
- 'log-level': { type: 'string', default: 'info' },
1454
- },
1455
- })
1456
-
1457
- const options: GenerateHtmlOptions = {
1458
- logLevel: values['log-level'] as 'debug' | 'info' | 'quiet',
1459
- }
1460
-
1461
- const DATA_JSON_ROOT = 'frelio-data/site/data/data-json'
1462
- const TEMPLATE_ROOT = 'frelio-data/site/templates'
1463
- const OUTPUT_ROOT = 'public'
1464
- const REPORT_PATH = 'frelio-data/site/data/html-report.json'
1465
-
1466
- function collectJsonFiles(dir: string): string[] {
1467
- if (!existsSync(dir)) return []
1468
- const files: string[] = []
1469
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1470
- const fullPath = join(dir, entry.name)
1471
- if (entry.isDirectory()) {
1472
- files.push(...collectJsonFiles(fullPath))
1473
- } else if (extname(entry.name) === '.json') {
1474
- files.push(fullPath)
1475
- }
1476
- }
1477
- return files
1478
- }
1479
-
1480
- async function main(): Promise<void> {
1481
- if (!existsSync(DATA_JSON_ROOT)) {
1482
- console.error(\`Data JSON directory not found: \${DATA_JSON_ROOT}\`)
1483
- process.exit(1)
1484
- }
1485
- if (!existsSync(TEMPLATE_ROOT)) {
1486
- console.error(\`Template directory not found: \${TEMPLATE_ROOT}\`)
1487
- process.exit(1)
1488
- }
1489
-
1490
- const jsonFiles = collectJsonFiles(DATA_JSON_ROOT)
1491
- if (jsonFiles.length === 0) {
1492
- console.log('No data JSON files found. Nothing to generate.')
1493
- return
1494
- }
1495
-
1496
- const dataJsons: FrelioDataJson[] = jsonFiles.map(filePath => {
1497
- return JSON.parse(readFileSync(filePath, 'utf-8'))
1498
- })
1499
-
1500
- console.log(\`Found \${dataJsons.length} data JSON files\`)
1501
-
1502
- const result = await generateHtml({
1503
- dataJsons,
1504
- templateRootPath: TEMPLATE_ROOT,
1505
- fileSystem: new NodeFileSystem(),
1506
- options,
1507
- })
1508
-
1509
- if (!values['dry-run']) {
1510
- mkdirSync(OUTPUT_ROOT, { recursive: true })
1511
- for (const output of result.outputs) {
1512
- if (output.status === 'deleted') {
1513
- const fullPath = join(OUTPUT_ROOT, output.outputPath)
1514
- if (existsSync(fullPath)) unlinkSync(fullPath)
1515
- }
1516
- }
1517
- for (const output of result.outputs) {
1518
- if (output.status === 'created') {
1519
- const fullPath = join(OUTPUT_ROOT, output.outputPath)
1520
- mkdirSync(dirname(fullPath), { recursive: true })
1521
- writeFileSync(fullPath, output.html)
1522
- }
1523
- }
1524
- }
1525
-
1526
- mkdirSync(dirname(REPORT_PATH), { recursive: true })
1527
- writeFileSync(REPORT_PATH, JSON.stringify(result.report, null, 2))
1528
-
1529
- const { stats } = result.report
1530
- console.log(\`\\n=== Summary ===\`)
1531
- console.log(\`Created: \${stats.created}\`)
1532
- console.log(\`Deleted: \${stats.deleted}\`)
1533
- console.log(\`Errors: \${stats.errors}\`)
1534
- }
1535
-
1536
- main().catch((error) => {
1537
- console.error('Fatal error:', error)
1538
- process.exit(1)
1539
- })
1540
- `);
1541
- writeFile(s('generate-sitemap.ts'), `/**
1542
- * sitemap.xml 生成スクリプト
1543
- *
1544
- * public/ 配下の HTML ファイルを走査して sitemap.xml を生成する。
1545
- *
1546
- * @example
1547
- * npx tsx scripts/generate-sitemap.ts --base-url https://example.com
1548
- * npx tsx scripts/generate-sitemap.ts --full-rebuild --base-url https://example.com
1549
- */
1550
-
1551
- import { execSync } from 'child_process'
1552
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs'
1553
- import { dirname, join } from 'path'
1554
- import { parseArgs } from 'util'
1555
-
1556
- const { values } = parseArgs({
1557
- options: {
1558
- 'base-url': { type: 'string' },
1559
- 'diff-range': { type: 'string', default: 'origin/main...HEAD' },
1560
- 'full-rebuild': { type: 'boolean', default: false },
1561
- 'dry-run': { type: 'boolean', default: false },
1562
- 'log-level': { type: 'string', default: 'info' },
1563
- },
1564
- })
1565
-
1566
- const baseUrl = (values['base-url'] || process.env.SITE_BASE_URL || '').replace(/\\/$/, '')
1567
- if (!baseUrl) {
1568
- console.error('Error: --base-url or SITE_BASE_URL is required.')
1569
- process.exit(1)
1570
- }
1571
-
1572
- const logLevel = values['log-level'] as 'debug' | 'info' | 'quiet'
1573
-
1574
- const HTML_ROOT = 'public'
1575
- const OUTPUT_PATH = 'public/sitemap.xml'
1576
-
1577
- interface UrlEntry {
1578
- loc: string
1579
- lastmod: string | null
1580
- }
1581
-
1582
- function htmlPathToUrlPath(htmlPath: string): string {
1583
- const relative = htmlPath
1584
- .replace(/\\\\\\\\/g, '/')
1585
- .replace(/^public\\//, '')
1586
- .replace(/\\/index\\.html$/, '/')
1587
- return relative === 'index.html' ? '/' : '/' + relative
1588
- }
1589
-
1590
- function hasNoindex(htmlPath: string): boolean {
1591
- const html = readFileSync(htmlPath, 'utf-8')
1592
- return /<meta\\s[^>]*name\\s*=\\s*["']robots["'][^>]*content\\s*=\\s*["'][^"']*noindex[^"']*["'][^>]*>/i.test(html)
1593
- || /<meta\\s[^>]*content\\s*=\\s*["'][^"']*noindex[^"']*["'][^>]*name\\s*=\\s*["']robots["'][^>]*>/i.test(html)
1594
- }
1595
-
1596
- function getLastmod(filePath: string): string | null {
1597
- try {
1598
- const result = execSync(\`git log -1 --format=%aI -- "\${filePath}"\`, {
1599
- encoding: 'utf-8',
1600
- stdio: ['pipe', 'pipe', 'pipe'],
1601
- }).trim()
1602
- return result || null
1603
- } catch {
1604
- return null
1605
- }
1606
- }
1607
-
1608
- function collectHtmlFiles(dir: string): string[] {
1609
- if (!existsSync(dir)) return []
1610
- const files: string[] = []
1611
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
1612
- const fullPath = join(dir, entry.name)
1613
- if (entry.isDirectory()) {
1614
- files.push(...collectHtmlFiles(fullPath))
1615
- } else if (entry.name === 'index.html') {
1616
- files.push(fullPath)
1617
- }
1618
- }
1619
- return files
1620
- }
1621
-
1622
- function parseSitemap(xml: string): Map<string, UrlEntry> {
1623
- const entries = new Map<string, UrlEntry>()
1624
- const urlBlockRe = /<url>([\\s\\S]*?)<\\/url>/g
1625
- const locRe = /<loc>([\\s\\S]*?)<\\/loc>/
1626
- const lastmodRe = /<lastmod>([\\s\\S]*?)<\\/lastmod>/
1627
- let match: RegExpExecArray | null
1628
- while ((match = urlBlockRe.exec(xml)) !== null) {
1629
- const block = match[1]
1630
- const locMatch = locRe.exec(block)
1631
- if (!locMatch) continue
1632
- const loc = locMatch[1].trim()
1633
- const lastmodMatch = lastmodRe.exec(block)
1634
- entries.set(loc, { loc, lastmod: lastmodMatch ? lastmodMatch[1].trim() : null })
1635
- }
1636
- return entries
1637
- }
1638
-
1639
- function buildSitemapXml(entries: Map<string, UrlEntry>): string {
1640
- const sorted = [...entries.values()].sort((a, b) => a.loc.localeCompare(b.loc))
1641
- const urlElements = sorted.map((entry) => {
1642
- const lastmodLine = entry.lastmod ? \`\\n <lastmod>\${entry.lastmod}</lastmod>\` : ''
1643
- return \` <url>\\n <loc>\${entry.loc}</loc>\${lastmodLine}\\n </url>\`
1644
- })
1645
- return [
1646
- '<?xml version="1.0" encoding="UTF-8"?>',
1647
- '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
1648
- ...urlElements,
1649
- '</urlset>',
1650
- '',
1651
- ].join('\\n')
1652
- }
1653
-
1654
- function createEntry(htmlPath: string): UrlEntry | null {
1655
- if (hasNoindex(htmlPath)) return null
1656
- return { loc: baseUrl + htmlPathToUrlPath(htmlPath), lastmod: getLastmod(htmlPath) }
1657
- }
1658
-
1659
- function main(): void {
1660
- if (logLevel !== 'quiet') {
1661
- console.log(\`Mode: \${values['full-rebuild'] ? 'full-rebuild' : 'incremental'}\`)
1662
- console.log(\`Dry run: \${values['dry-run']}\`)
1663
- }
1664
-
1665
- const entries = new Map<string, UrlEntry>()
1666
- const htmlFiles = collectHtmlFiles(HTML_ROOT)
1667
- for (const htmlPath of htmlFiles) {
1668
- const entry = createEntry(htmlPath)
1669
- if (entry) entries.set(entry.loc, entry)
1670
- }
1671
-
1672
- const xml = buildSitemapXml(entries)
1673
-
1674
- if (!values['dry-run']) {
1675
- mkdirSync(dirname(OUTPUT_PATH), { recursive: true })
1676
- writeFileSync(OUTPUT_PATH, xml)
1677
- }
1678
-
1679
- if (logLevel !== 'quiet') {
1680
- console.log(\`Total URLs: \${entries.size}\`)
1681
- console.log(\`Output: \${OUTPUT_PATH}\`)
1682
- }
1683
- }
1684
-
1685
- main()
1686
- `);
1687
- writeFile(s('generate-dependency-map.ts'), `/**
1688
- * 依存マップ生成スクリプト
1689
- *
1690
- * FrelioBuildDataRecipe から FrelioDependencyMap を生成する。
1691
- *
1692
- * @example
1693
- * npx tsx scripts/generate-dependency-map.ts
1694
- */
1695
-
1696
- import { convertRecipeToDependencyMap } from '@c-time/frelio-data-json-recipe-to-dependency-map'
1697
- import { validateSiteRecipe, formatZodErrors } from '@c-time/frelio-types/schemas'
1698
- import { isFrelioDependencyMap } from '@c-time/frelio-dependency-map'
1699
- import type { FrelioBuildDataRecipe } from '@c-time/frelio-data-json-recipe'
1700
- import { readFileSync, writeFileSync, mkdirSync } from 'fs'
1701
- import { dirname } from 'path'
1702
-
1703
- const RECIPE_PATH = 'frelio-data/admin/recipes/build-data-recipe.json'
1704
- const OUTPUT_PATH = 'frelio-data/site/data/_dependency-map.json'
1705
-
1706
- const raw = JSON.parse(readFileSync(RECIPE_PATH, 'utf-8'))
1707
-
1708
- const result = validateSiteRecipe(raw)
1709
- if (!result.success) {
1710
- console.error('Invalid recipe:', formatZodErrors(result.errors))
1711
- process.exit(1)
1712
- }
1713
-
1714
- const recipe: FrelioBuildDataRecipe = result.data
1715
- const dependencyMap = convertRecipeToDependencyMap(recipe)
1716
-
1717
- if (!isFrelioDependencyMap(dependencyMap)) {
1718
- console.error('Generated dependency map is invalid')
1719
- process.exit(1)
1720
- }
1721
-
1722
- mkdirSync(dirname(OUTPUT_PATH), { recursive: true })
1723
- writeFileSync(OUTPUT_PATH, JSON.stringify(dependencyMap, null, 2))
1724
- console.log(\`Dependency map written to \${OUTPUT_PATH}\`)
1725
- `);
1726
- writeFile(s('rebuild-indexes.ts'), `/**
1727
- * インデックス・ダッシュボードメタデータ一括再構築スクリプト
1728
- *
1729
- * @example
1730
- * npx tsx scripts/rebuild-indexes.ts
1731
- * npx tsx scripts/rebuild-indexes.ts --dry-run
1732
- */
1733
-
1734
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from 'fs'
1735
- import { dirname } from 'path'
1736
- import { parseArgs } from 'util'
1737
- import {
1738
- rebuildAllIndexes,
1739
- calcMetadataOnSave,
1740
- SITE_CONTENTS,
1741
- CONTENT_TYPE_LIST_PATH,
1742
- DASHBOARD_METADATA_PATH,
1743
- type ContentStorePort,
1744
- type ContentData,
1745
- type FileChange,
1746
- type BasePath,
1747
- } from '@c-time/frelio-content-ops'
1748
- import type { DashboardMetadata } from '@c-time/frelio-types'
1749
-
1750
- const { values } = parseArgs({
1751
- options: {
1752
- 'dry-run': { type: 'boolean', default: false },
1753
- 'log-level': { type: 'string', default: 'info' },
1754
- },
1755
- })
1756
-
1757
- const DRY_RUN = values['dry-run']!
1758
- const LOG_LEVEL = values['log-level'] as 'debug' | 'info' | 'quiet'
1759
-
1760
- function logInfo(...args: unknown[]) {
1761
- if (LOG_LEVEL !== 'quiet') console.log('[info]', ...args)
1762
- }
1763
-
1764
- function createFsContentStore(): ContentStorePort {
1765
- return {
1766
- async readJson<T>(path: string): Promise<T | null> {
1767
- if (!existsSync(path)) return null
1768
- try {
1769
- return JSON.parse(readFileSync(path, 'utf-8')) as T
1770
- } catch {
1771
- return null
1772
- }
1773
- },
1774
- }
1775
- }
1776
-
1777
- function applyFileChanges(changes: FileChange[]) {
1778
- for (const change of changes) {
1779
- mkdirSync(dirname(change.path), { recursive: true })
1780
- writeFileSync(change.path, change.content + '\\n')
1781
- }
1782
- }
1783
-
1784
- async function main() {
1785
- const store = createFsContentStore()
1786
- const listRaw = await store.readJson<{ contentTypes: { id: string }[] }>(CONTENT_TYPE_LIST_PATH)
1787
- if (!listRaw) {
1788
- console.error(\`Content type list not found: \${CONTENT_TYPE_LIST_PATH}\`)
1789
- process.exit(1)
1790
- }
1791
- const contentTypeIds = listRaw.contentTypes.map((ct) => ct.id)
1792
- logInfo(\`Content types: \${contentTypeIds.join(', ')}\`)
1793
-
1794
- const allChanges: FileChange[] = []
1795
- let dashboardMetadata: DashboardMetadata = { contentTypes: {} }
1796
- const now = new Date().toISOString()
1797
-
1798
- for (const contentTypeId of contentTypeIds) {
1799
- const allContent: { basePath: BasePath; content: ContentData }[] = []
1800
- for (const basePath of ['published', 'private'] as const) {
1801
- const dir = \`\${SITE_CONTENTS}/\${basePath}/\${contentTypeId}\`
1802
- if (!existsSync(dir)) continue
1803
- const files = readdirSync(dir).filter((f) => f.endsWith('.json') && !f.startsWith('_'))
1804
- for (const file of files) {
1805
- const content = await store.readJson<ContentData>(\`\${dir}/\${file}\`)
1806
- if (content) allContent.push({ basePath, content })
1807
- }
1808
- }
1809
- logInfo(\`\${contentTypeId}: \${allContent.length} content(s) found\`)
1810
- const indexChanges = await rebuildAllIndexes(store, { contentTypeId, allContent })
1811
- allChanges.push(...indexChanges)
1812
- for (const { content } of allContent) {
1813
- dashboardMetadata = calcMetadataOnSave(
1814
- dashboardMetadata, contentTypeId, content.updatedBy,
1815
- null, content.status, now,
1816
- )
1817
- }
1818
- }
1819
-
1820
- allChanges.push({
1821
- path: DASHBOARD_METADATA_PATH,
1822
- content: JSON.stringify(dashboardMetadata, null, 2),
1823
- })
1824
-
1825
- logInfo(\`Files to write: \${allChanges.length}\`)
1826
- if (DRY_RUN) {
1827
- logInfo('Dry run — no files written.')
1828
- return
1829
- }
1830
- applyFileChanges(allChanges)
1831
- logInfo(\`Done. \${allChanges.length} file(s) written.\`)
1832
- }
1833
-
1834
- main().catch((error) => {
1835
- console.error('Fatal error:', error)
1836
- process.exit(1)
1837
- })
1838
- `);
1839
- writeFile(s('watch-content.ts'), `/**
1840
- * コンテンツ変更監視スクリプト
1841
- *
1842
- * frelio-data/ 内のコンテンツ JSON の変更を検知し、
1843
- * ビューインデックスとダッシュボードメタデータを自動更新する。
1844
- *
1845
- * @example
1846
- * npx tsx scripts/watch-content.ts
1847
- * npx tsx scripts/watch-content.ts --log-level debug
1848
- */
1849
-
1850
- import { watch, readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, readdirSync } from 'fs'
1851
- import { dirname } from 'path'
1852
- import { parseArgs } from 'util'
1853
- import {
1854
- computeViewIndexUpsert,
1855
- computeViewIndexRemoval,
1856
- computeDashboardMetadataOnSave,
1857
- computeDashboardMetadataOnDelete,
1858
- rebuildAllIndexes,
1859
- SITE_CONTENTS,
1860
- ADMIN_CONTENT_TYPES,
1861
- type ContentStorePort,
1862
- type ContentData,
1863
- type FileChange,
1864
- type BasePath,
1865
- } from '@c-time/frelio-content-ops'
1866
-
1867
- const { values } = parseArgs({
1868
- options: {
1869
- 'log-level': { type: 'string', default: 'info' },
1870
- 'debounce-ms': { type: 'string', default: '300' },
1871
- },
1872
- })
1873
-
1874
- const LOG_LEVEL = values['log-level'] as 'debug' | 'info' | 'quiet'
1875
- const DEBOUNCE_MS = Number(values['debounce-ms'])
1876
-
1877
- function logDebug(...args: unknown[]) {
1878
- if (LOG_LEVEL === 'debug') console.log('[debug]', ...args)
1879
- }
1880
- function logInfo(...args: unknown[]) {
1881
- if (LOG_LEVEL !== 'quiet') console.log('[info]', ...args)
1882
- }
1883
-
1884
- function createFsContentStore(): ContentStorePort {
1885
- return {
1886
- async readJson<T>(path: string): Promise<T | null> {
1887
- if (!existsSync(path)) return null
1888
- try {
1889
- return JSON.parse(readFileSync(path, 'utf-8')) as T
1890
- } catch {
1891
- return null
1892
- }
1893
- },
1894
- }
1895
- }
1896
-
1897
- type ContentChangeEvent = {
1898
- kind: 'content-save' | 'content-delete'
1899
- basePath: BasePath
1900
- contentTypeId: string
1901
- contentId: string
1902
- }
1903
-
1904
- type ViewsChangeEvent = {
1905
- kind: 'views-change'
1906
- contentTypeId: string
1907
- }
1908
-
1909
- type ChangeEvent = ContentChangeEvent | ViewsChangeEvent
1910
-
1911
- function classifyChange(filePath: string): ChangeEvent | null {
1912
- const normalized = filePath.replace(/\\\\\\\\/g, '/')
1913
- const contentMatch = normalized.match(
1914
- /^frelio-data\\/site\\/contents\\/(published|private)\\/([^/]+)\\/([^/]+)\\.json$/,
1915
- )
1916
- if (contentMatch) {
1917
- const [, basePath, contentTypeId, fileName] = contentMatch
1918
- if (fileName.startsWith('_')) return null
1919
- const exists = existsSync(filePath)
1920
- return {
1921
- kind: exists ? 'content-save' : 'content-delete',
1922
- basePath: basePath as BasePath,
1923
- contentTypeId,
1924
- contentId: fileName,
1925
- }
1926
- }
1927
- const viewsMatch = normalized.match(
1928
- /^frelio-data\\/admin\\/content_types\\/([^/]+)\\.views\\.json$/,
1929
- )
1930
- if (viewsMatch) {
1931
- return { kind: 'views-change', contentTypeId: viewsMatch[1] }
1932
- }
1933
- return null
1934
- }
1935
-
1936
- function applyFileChanges(changes: FileChange[]) {
1937
- for (const change of changes) {
1938
- if (change.delete) {
1939
- if (existsSync(change.path)) unlinkSync(change.path)
1940
- } else {
1941
- mkdirSync(dirname(change.path), { recursive: true })
1942
- writeFileSync(change.path, change.content + '\\n')
1943
- }
1944
- }
1945
- }
1946
-
1947
- const store = createFsContentStore()
1948
- const suppressedPaths = new Set<string>()
1949
-
1950
- function applyFileChangesWithSuppress(changes: FileChange[]) {
1951
- for (const change of changes) {
1952
- const normalized = change.path.replace(/\\\\\\\\/g, '/')
1953
- suppressedPaths.add(normalized)
1954
- setTimeout(() => suppressedPaths.delete(normalized), DEBOUNCE_MS + 200)
1955
- }
1956
- applyFileChanges(changes)
1957
- }
1958
-
1959
- async function handleContentSave(event: ContentChangeEvent) {
1960
- const { basePath, contentTypeId, contentId } = event
1961
- const contentPath = \`\${SITE_CONTENTS}/\${basePath}/\${contentTypeId}/\${contentId}.json\`
1962
- const content = await store.readJson<ContentData>(contentPath)
1963
- if (!content) return
1964
-
1965
- const viewChanges = await computeViewIndexUpsert(store, { basePath, contentTypeId, content })
1966
- const metaChange = await computeDashboardMetadataOnSave(store, {
1967
- contentTypeId, updatedBy: content.updatedBy,
1968
- oldStatus: null, newStatus: content.status,
1969
- })
1970
- applyFileChangesWithSuppress([...viewChanges, metaChange])
1971
- logInfo(\`Content saved: \${basePath}/\${contentTypeId}/\${contentId}\`)
1972
- }
1973
-
1974
- async function handleContentDelete(event: ContentChangeEvent) {
1975
- const { basePath, contentTypeId, contentId } = event
1976
- const viewChanges = await computeViewIndexRemoval(store, { basePath, contentTypeId, contentId })
1977
- const metaChange = await computeDashboardMetadataOnDelete(store, {
1978
- contentTypeId, deletedBy: 'unknown', deletedStatus: 'draft' as ContentData['status'],
1979
- })
1980
- applyFileChangesWithSuppress([...viewChanges, metaChange])
1981
- logInfo(\`Content deleted: \${basePath}/\${contentTypeId}/\${contentId}\`)
1982
- }
1983
-
1984
- async function handleViewsChange(event: ViewsChangeEvent) {
1985
- const { contentTypeId } = event
1986
- logInfo(\`Views changed for: \${contentTypeId}, rebuilding indexes...\`)
1987
- const allContent: { basePath: BasePath; content: ContentData }[] = []
1988
- for (const basePath of ['published', 'private'] as const) {
1989
- const dir = \`\${SITE_CONTENTS}/\${basePath}/\${contentTypeId}\`
1990
- if (!existsSync(dir)) continue
1991
- const files = readdirSync(dir).filter((f) => f.endsWith('.json') && !f.startsWith('_'))
1992
- for (const file of files) {
1993
- const content = await store.readJson<ContentData>(\`\${dir}/\${file}\`)
1994
- if (content) allContent.push({ basePath, content })
1995
- }
1996
- }
1997
- const changes = await rebuildAllIndexes(store, { contentTypeId, allContent })
1998
- applyFileChangesWithSuppress(changes)
1999
- logInfo(\`Rebuilt \${changes.length} index file(s) for \${contentTypeId}\`)
2000
- }
2001
-
2002
- const pending = new Map<string, NodeJS.Timeout>()
2003
- let processing = Promise.resolve()
2004
-
2005
- function enqueue(fn: () => Promise<void>) {
2006
- processing = processing.then(fn).catch((err) => console.error('Handler error:', err))
2007
- }
2008
-
2009
- function scheduleHandler(filePath: string, handler: () => Promise<void>) {
2010
- const existing = pending.get(filePath)
2011
- if (existing) clearTimeout(existing)
2012
- pending.set(filePath, setTimeout(() => {
2013
- pending.delete(filePath)
2014
- enqueue(handler)
2015
- }, DEBOUNCE_MS))
2016
- }
2017
-
2018
- async function main() {
2019
- logInfo('Starting content watcher...')
2020
- const watchTargets = [
2021
- { path: SITE_CONTENTS, label: 'contents' },
2022
- { path: ADMIN_CONTENT_TYPES, label: 'admin/content_types' },
2023
- ]
2024
-
2025
- for (const target of watchTargets) {
2026
- if (!existsSync(target.path)) {
2027
- console.error(\`Watch target not found: \${target.path}\`)
2028
- process.exit(1)
2029
- }
2030
- watch(target.path, { recursive: true }, (_eventType, fileName) => {
2031
- if (!fileName) return
2032
- const fullPath = \`\${target.path}/\${fileName.replace(/\\\\\\\\/g, '/')}\`
2033
- if (!fullPath.endsWith('.json')) return
2034
- if (suppressedPaths.has(fullPath)) return
2035
- const event = classifyChange(fullPath)
2036
- if (!event) return
2037
- scheduleHandler(fullPath, async () => {
2038
- const latestEvent = classifyChange(fullPath)
2039
- if (!latestEvent) return
2040
- switch (latestEvent.kind) {
2041
- case 'content-save': await handleContentSave(latestEvent); break
2042
- case 'content-delete': await handleContentDelete(latestEvent); break
2043
- case 'views-change': await handleViewsChange(latestEvent); break
2044
- }
2045
- })
2046
- })
2047
- logInfo(\`Watching: \${target.path}\`)
2048
- }
2049
-
2050
- logInfo('Ready. Press Ctrl+C to stop.')
2051
- process.on('SIGINT', () => {
2052
- logInfo('\\nShutting down...')
2053
- for (const timer of pending.values()) clearTimeout(timer)
2054
- process.exit(0)
2055
- })
2056
- }
2057
-
2058
- main().catch((error) => {
2059
- console.error('Fatal error:', error)
2060
- process.exit(1)
2061
- })
2062
- `);
2063
- }
2064
- // ========== CLAUDE.md ==========
2065
- function generateClaudeMd(projectDir) {
2066
- writeFile(path.join(projectDir, 'CLAUDE.md'), `# CLAUDE.md
2067
-
2068
- Frelio(ヘッドレス CMS)で構築されたサイトリポジトリ。
2069
- お知らせブログ付きのシンプルなコーポレートサイト。
2070
-
2071
- ## プロジェクト構成
2072
-
2073
- - \`frelio-data/\` — CMS データ(コンテンツタイプ、コンテンツ、テンプレート、レシピ)
2074
- - \`site/templates/assets/scss/\` — 共有 SCSS パーシャル(FLOCSS 亜種)
2075
- - \`site/templates/assets/ts/\` — 共有 TypeScript(features/)
2076
- - \`site/templates/assets/entries/\` — ページ別エントリーポイント
2077
- - \`public/\` — SSG 出力(HTML + ビルド済みアセット)
2078
- - \`functions/storage/\` — R2 ファイル配信(/storage/*)
2079
- - \`scripts/\` — ビルドスクリプト(tsx)
2080
- - \`public/images/\` — 静的ファイル(画像等、git 追跡対象)
2081
-
2082
- CMS 管理画面関連(\`admin/\`, \`functions/api/\`, \`workers/\`, \`wrangler.toml\`, \`_redirects\`)は
2083
- \`npx @frelio/cli update\` で追加・更新される。
2084
-
2085
- ## よく使うコマンド
2086
-
2087
- \`\`\`bash
2088
- npm run dev # Vite dev server(テンプレートプレビュー + コンテンツ監視)
2089
- npm run build # SCSS/TS ビルド(ページ別エントリー)
2090
- npm run generate # data-json 生成(差分ビルド)
2091
- npm run generate:full # data-json 生成(フルリビルド)
2092
- npm run generate:html # HTML 生成(data-json → public/)
2093
- npm run generate:sitemap # sitemap.xml 生成
2094
- npm run generate:dep-map # 依存マップ生成
2095
- npm run watch:content # コンテンツ変更監視(インデックス自動更新)
2096
- npm run rebuild:indexes # インデックス一括再構築
2097
- npx @frelio/cli update # CMS Admin バンドル更新
2098
- npx @frelio/cli add-staging # カスタムステージング追加
2099
- \`\`\`
2100
-
2101
- ## ビルドパイプライン
2102
-
2103
- \`\`\`
2104
- 1. Recipe → 依存マップ (npm run generate:dep-map)
2105
- 2. コンテンツ → data-json (npm run generate)
2106
- 3. data-json → HTML (npm run generate:html)
2107
- 4. SCSS/TS → CSS/JS (npm run build)
2108
- 5. sitemap.xml 生成 (npm run generate:sitemap)
2109
- \`\`\`
2110
-
2111
- ## ページ別エントリーポイント
2112
-
2113
- アセットはページ単位でコード分割される。各ページに \`styles/index.scss\` と \`scripts/index.ts\` がある。
2114
-
2115
- \`\`\`
2116
- assets/entries/
2117
- ├── common/ — 全ページ共通(foundation, layout, component, element + JS初期化)
2118
- ├── home/ — トップページ(p-hero, p-news-list)
2119
- ├── about/ — 会社概要(p-about)
2120
- ├── contact/ — お問い合わせ(p-contact)
2121
- ├── news/ — お知らせ一覧(p-news-list)
2122
- └── news/detail/ — 記事詳細(p-article)
2123
- \`\`\`
2124
-
2125
- - \`_parts/head.htm\` で common の CSS/JS を読み込み
2126
- - 各ページテンプレートでページ固有の CSS/JS を読み込み
2127
-
2128
- ## CSS 記法ルール(FLOCSS 亜種・厳格)
2129
-
2130
- - **プレフィックス**: \`l-\`(layout)、\`c-\`(component)、\`p-\`(project)、\`e-\`(element)のみ
2131
- - **1 class ルール**: HTML の class 属性には必ず 1 クラスのみ
2132
- - **Utility は絶対使用禁止**
2133
- - **SCSS の \`&\` でクラス名を接続することは禁止**(grep で追跡可能を維持)
2134
- - **@extend**: 同ファイル内でのみ許可。\`%placeholder\` または実体クラスに \`--variant\` サフィックス
2135
- - **子要素**: \`__\` で繋げる(深さ制限なし)
2136
- - **バリアント**: \`--\` サフィックス(数に制限なし)
2137
- - **メディアクエリ**: \`@mixin\` で定義し、1 ファイルに各 mixin を 1 回のみ \`@include\`
2138
-
2139
- ### 例
2140
-
2141
- \`\`\`scss
2142
- %c-section__inner__item {} // ベースデザイン
2143
- .c-section__inner__item--red { @extend %c-section__inner__item; color: red; }
2144
- \`\`\`
2145
-
2146
- ## TypeScript ルール
2147
-
2148
- - 共通の初期化ロジックは \`entries/common/scripts/index.ts\` に集約
2149
- - 各機能は \`assets/ts/features/\` にファイル分離
2150
- - ページ固有の JS が必要な場合は \`entries/{page}/scripts/index.ts\` に追加
2151
-
2152
- ## テンプレート規約
2153
-
2154
- - テンプレートエンジン: gentl(\`data-gen-*\` 属性ベース)
2155
- - テンプレートは valid HTML(そのままブラウザで開ける)
2156
- - 共通パーツ: \`_parts/*.htm\`(head, header, footer)
2157
- - ページテンプレート: \`*.html\`
2158
-
2159
- ## Cloudflare Pages 構成
2160
-
2161
- \`npx @frelio/cli update\` 実行後に以下が配置される:
2162
- - \`_redirects\`: \`/admin/*\` → SPA、\`/*\` → \`/public/:splat\`
2163
- - \`_routes.json\`: \`/api/*\`, \`/storage/*\` → Functions
2164
- - \`wrangler.toml\`: R2 バケットバインディング
2165
-
2166
- ## 型パッケージ活用方針
2167
-
2168
- JSON ファイルの読み書きでは、以下の型・ガード・スキーマを使用する。
2169
-
2170
- | ファイル | 型 | パッケージ |
2171
- |---|---|---|
2172
- | \`content_types/*.json\` | \`ContentType\` / \`validateContentType\` | \`@c-time/frelio-types\` |
2173
- | \`*.ui.json\` | \`ContentTypeUi\` / \`validateContentTypeUi\` | 同上 |
2174
- | \`*.views.json\` | \`ContentTypeViews\` / \`validateContentTypeViews\` | 同上 |
2175
- | \`build-data-recipe.json\` | \`FrelioBuildDataRecipe\` / \`validateSiteRecipe\` | 同上 |
2176
- | \`data-json/*.json\` | \`FrelioDataJson\` / \`isFrelioDataJson\` | \`@c-time/frelio-data-json\` |
2177
- | \`contents/*/*.json\` | \`Content\` / \`isContent\` | \`@c-time/frelio-types\` |
2178
- `);
2179
- }
2180
- // ========== Helpers ==========
2181
- function json(obj) {
2182
- return JSON.stringify(obj, null, 2);
2183
- }