@tenjuu99/blog 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/develop.md CHANGED
@@ -452,8 +452,17 @@ npm run dev
452
452
 
453
453
  ### 4. フロントマターパーサー
454
454
 
455
- - YAML完全互換ではなく独自実装(lib/pageData.js:61-93
456
- - 配列・オブジェクトはJSON形式必須
455
+ - YAML完全互換ではなく独自実装(`parseMetaData()` in lib/pageData.js)
456
+ - 配列はJSON形式(`["item1"]`)またはYAMLリスト形式(`- item`)で記述可能
457
+ - オブジェクトはJSON形式必須
458
+
459
+ ### 5. helper.js の top-level await 禁止
460
+
461
+ `lib/helper.js` では top-level await を使用してはならない。
462
+
463
+ **理由:** `.cache/helper/` 以下のヘルパーファイルが `replaceVariablesFilter.js` を静的 import し、そこから `helper.js` が再度 import される循環依存がある。top-level await があると ESM のモジュール評価が完了前に循環参照が解決されずデッドロックが発生する。
464
+
465
+ **対処:** IIFE `(async () => { ... })()` の中で await し、その Promise を `helperReady` としてエクスポートする(lib/helper.js 参照)。
457
466
 
458
467
  ## テストについて
459
468
 
@@ -490,7 +499,7 @@ test('テストの説明', () => {
490
499
 
491
500
  - ES Modules使用(`import/export`)
492
501
  - `"use strict"` は一部モジュールで使用
493
- - JSDocコメントは部分的に記載
502
+ - JSDocコメントは基本的に記載する
494
503
 
495
504
  ### 変更を加える際の確認事項
496
505
 
package/docs/spec.md CHANGED
@@ -129,7 +129,7 @@ url: /custom-url
129
129
  |--------|-------------|------|
130
130
  | `name` | ファイル名(拡張子なし) | ページの内部名 |
131
131
  | `title` | `name` の値 | ページタイトル |
132
- | `url` | `/${name}` | URLパス |
132
+ | `url` | `/${name}` | URLパス(出力先も決定する。後述) |
133
133
  | `description` | 本文先頭150文字 | ページ説明 |
134
134
  | `og_description` | `description` と同じ | OGP説明文 |
135
135
  | `published` | `"1970-01-01"` | 公開日 |
@@ -146,9 +146,41 @@ url: /custom-url
146
146
  | `markdown` | 解析後のHTML | Markdown変換後のHTML |
147
147
  | `markdown_not_parsed` | フロントマター除去後 | 変換前のMarkdown |
148
148
  | `full_url` | 自動生成 | 完全なURL |
149
- | `__output` | 自動生成 | 出力ファイルパス |
149
+ | `__output` | 自動生成 | 出力ファイルパス(`url` から導出) |
150
150
  | `__filetype` | 拡張子 | 元ファイルの拡張子 |
151
151
 
152
+ ### `url` と出力先の関係
153
+
154
+ `url` フィールドはリンクの `href` に使われるだけでなく、**出力ファイルパス(`__output`)の導出元**にもなっています。
155
+
156
+ | `url` の形式 | `__output` |
157
+ |-------------|-----------|
158
+ | `/foo/bar`(末尾スラッシュなし) | `/foo/bar.html` |
159
+ | `/foo/bar/`(末尾スラッシュあり) | `/foo/bar/index.html` |
160
+
161
+ フロントマターで `url` を上書きすると `__output` も再計算されるため、ファイルの物理的な配置とは無関係に出力先を変更できます。
162
+
163
+ ```markdown
164
+ ---
165
+ url: /book/new/book
166
+ ---
167
+ ```
168
+
169
+ この場合、ファイルが `src/pages/book/new_book.md` であっても、出力先は `dist/book/new/book.html` になります。
170
+
171
+ **例外**: ファイル名が `index` の場合は `__output = /index.html` 固定(`url` に関係なし)。また、カテゴリーパッケージの仮想ページのように `__output` を明示的に設定するケースでは、この導出は行われません。
172
+
173
+ ### URLの末尾スラッシュ規約
174
+
175
+ S3+CDN 等のファイルシステムベースのホスティングでは、`/book` というURLが `book.html` を指すのか `book/index.html` を指すのかをURLだけから判断できません。これを避けるため、以下の規約を推奨します。
176
+
177
+ | ページ種別 | URL形式 | 出力先 |
178
+ |-----------|---------|-------|
179
+ | アイテム系(記事・個別ページ) | 末尾スラッシュなし `/book/foo` | `dist/book/foo.html` |
180
+ | リスト系(一覧・カテゴリー・ページネーション) | 末尾スラッシュあり `/book/` | `dist/book/index.html` |
181
+
182
+ リスト系ページを作る場合は、フロントマターで `url` を末尾スラッシュつきで指定するか、ファイル名を `index.md` にします。カテゴリーパッケージの仮想ページはこの規約に従い、`url` に末尾スラッシュを付与しています。
183
+
152
184
  ### データ型の記述
153
185
 
154
186
  フロントマター内のデータ型:
@@ -166,6 +198,12 @@ published: true
166
198
  # 配列(JSON形式)
167
199
  tags: ["tag1", "tag2", "tag3"]
168
200
 
201
+ # 配列(YAMLリスト形式)
202
+ tags:
203
+ - tag1
204
+ - tag2
205
+ - tag3
206
+
169
207
  # オブジェクト(JSON形式)
170
208
  metadata: {"author": "名前", "year": 2024}
171
209
 
@@ -178,7 +216,7 @@ description: "これは
178
216
  url_base: config.url_base
179
217
  ```
180
218
 
181
- **重要**: 配列・オブジェクトは `JSON.parse()` で解析されるため、有効なJSON形式である必要があります。
219
+ **配列の記述形式**: JSON形式(`["item1", "item2"]`)とYAMLリスト形式(`- item`)の両方をサポートします。オブジェクトはJSON形式のみ対応です。
182
220
 
183
221
  ## テンプレート記法
184
222
 
@@ -429,6 +467,7 @@ helper.readIndex('/post')
429
467
  | `url_case` | URLの大文字小文字変換 (`lower`/`original`) | `lower` |
430
468
  | `url_separator` | スペースの置き換え文字 | `"-"` |
431
469
  | `url_prefix` | カテゴリーURLのプレフィックス | `""` |
470
+ | `per_page` | 1ページあたりの記事数。正の整数のときページネーション有効。`false`・`0`・未設定はページネーションなし | `undefined` |
432
471
 
433
472
  **設定オプションの詳細:**
434
473
 
@@ -457,10 +496,18 @@ helper.readIndex('/post')
457
496
  title: 'Frontend', // カテゴリー名
458
497
  template: 'category.html',
459
498
  category_path: ['Tech', 'Frontend'], // カテゴリーパス
460
- category_pages: ['tech/frontend/react/tutorial', ...], // このカテゴリーのページ
461
- category_children: ['/tech/frontend/react', ...], // サブカテゴリー
499
+ category_pages: ['tech/frontend/react/tutorial', ...], // このカテゴリーのページ(per_page設定時は当該ページのスライス)
500
+ category_children: [ // サブカテゴリー({url, title}[] 型)
501
+ { url: '/tech/frontend/react', title: 'React' },
502
+ ...
503
+ ],
462
504
  __is_auto_category: true,
463
- distribute: true
505
+ distribute: true,
506
+ // 常に設定されるページネーションフィールド(per_page 無効時は current_page=1, total_pages=1, per_page=undefined):
507
+ category_current_page: 1, // 現在のページ番号(1始まり)
508
+ category_total_pages: 3, // 総ページ数
509
+ category_per_page: 10, // 1ページあたりの件数(per_page 無効時は undefined)
510
+ category_pagination_base: '/tech/frontend/', // カテゴリー1ページ目URL(末尾スラッシュあり)
464
511
  }
465
512
  ```
466
513
 
@@ -468,8 +515,12 @@ helper.readIndex('/post')
468
515
 
469
516
  - `{{title}}` - カテゴリー名
470
517
  - `{{category_path}}` - カテゴリーパス配列
471
- - `{{category_pages}}` - このカテゴリーに属するページ名の配列
472
- - `{{category_children}}` - サブカテゴリーのURL配列
518
+ - `{{category_pages}}` - このカテゴリーに属するページ名の配列(ページネーション有効時は当該ページ分のスライス)
519
+ - `{{category_children}}` - サブカテゴリーの配列。各要素は `{url: string, title: string}` オブジェクト
520
+ - `{{category_current_page}}` - 現在のページ番号(常に設定、ページネーション無効時は `1`)
521
+ - `{{category_total_pages}}` - 総ページ数(常に設定、ページネーション無効時は `1`)
522
+ - `{{category_per_page}}` - 1ページあたりの件数(ページネーション無効時は `undefined`)
523
+ - `{{category_pagination_base}}` - カテゴリーの1ページ目URL、末尾スラッシュあり(常に設定)
473
524
 
474
525
  **複数カテゴリーシステムの使用:**
475
526
 
@@ -527,6 +578,115 @@ helper.readIndex('/post')
527
578
 
528
579
  `src/pages/tech/index.md` が存在する場合、自動生成はスキップされます。
529
580
 
581
+ ---
582
+
583
+ #### category パッケージ: ページネーション
584
+
585
+ `per_page` を設定すると、カテゴリーごとに複数の静的HTMLページが自動生成されます。
586
+
587
+ **設定例:**
588
+
589
+ ```json
590
+ {
591
+ "categories": [
592
+ {
593
+ "name": "books",
594
+ "url_prefix": "/book-list",
595
+ "path_filter": "book/",
596
+ "template": "category.html",
597
+ "auto_generate": true,
598
+ "per_page": 10
599
+ }
600
+ ]
601
+ }
602
+ ```
603
+
604
+ **生成されるURL:**
605
+
606
+ 10件の記事と `per_page: 3` の設定では:
607
+
608
+ | ページ | ページ名(allData キー) | 出力ファイル |
609
+ |--------|--------------------------|-------------|
610
+ | 1 | `book-list/art/index` | `dist/book-list/art/index.html` |
611
+ | 2 | `book-list/art/2/index` | `dist/book-list/art/2/index.html` |
612
+ | 3 | `book-list/art/3/index` | `dist/book-list/art/3/index.html` |
613
+
614
+ 1ページ目のURL・ファイル名は `per_page` 未設定時と同じで後方互換を維持します。
615
+
616
+ **`per_page` 未設定時との違い:**
617
+
618
+ | 項目 | per_page 未設定 | per_page 設定あり |
619
+ |------|----------------|-----------------|
620
+ | 生成ページ数 | 1 | ceil(記事数 / per_page) |
621
+ | `category_pages` | 全記事 | 当該ページの記事スライス |
622
+ | `category_current_page` 等 | あり(current_page=1, total_pages=1) | あり |
623
+
624
+ **ページネーション UI パーシャル(`_pagination.html`):**
625
+
626
+ `packages/category/template/_pagination.html` がパッケージに同梱されており、`category.html` から自動的に include されます。`total_pages <= 1` のときは何も出力しません。
627
+
628
+ 独自スタイルに合わせてカスタマイズする場合は、`src/template/_pagination.html` に同名ファイルを配置するとパッケージのデフォルトを上書きできます([ファイル優先順位](#ファイル優先順位) 参照)。
629
+
630
+ **ページネーションヘルパー(`pagination.js`):**
631
+
632
+ `packages/category/helper/pagination.js` に以下の関数が提供されています。独自テンプレートを作成する際に `{script}` ブロックから直接利用できます(`import` 不要、インライン実装)。
633
+
634
+ ```javascript
635
+ // ページ番号から URL を生成する
636
+ // page === 1 → basePath、それ以外 → basePath + page + '/'
637
+ getPaginationUrl(basePath, page)
638
+
639
+ // ウィンドウ付きページ番号リストを生成する
640
+ // 各要素: {num: number, isCurrent: boolean} または {num: null, isEllipsis: true}
641
+ buildWindowedPages(totalPages, currentPage, windowSize = 2)
642
+ ```
643
+
644
+ **使用例(カスタムテンプレート内):**
645
+
646
+ ```html
647
+ <script type="ssg">
648
+ if ((variables.category_total_pages || 0) <= 1) return ''
649
+
650
+ const totalPages = variables.category_total_pages
651
+ const currentPage = variables.category_current_page
652
+ const base = variables.category_pagination_base
653
+
654
+ // ページURLを生成(page=1 → base, page=2 → base + '2/')
655
+ const prevUrl = currentPage > 1 ? base + (currentPage - 1 === 1 ? '' : (currentPage - 1) + '/') : null
656
+ const nextUrl = currentPage < totalPages ? base + (currentPage + 1) + '/' : null
657
+
658
+ let html = '<nav class="pagination">'
659
+ if (prevUrl) html += `<a href="${prevUrl}">前へ</a>`
660
+ if (nextUrl) html += `<a href="${nextUrl}">次へ</a>`
661
+ html += '</nav>'
662
+ return html
663
+ </script>
664
+ ```
665
+
666
+ **`category_children` の型(破壊的変更):**
667
+
668
+ この変更により、`category_children` の型が `string[]`(URL文字列の配列)から `{url: string, title: string}[]`(オブジェクトの配列)に変わりました。
669
+
670
+ 既存のカスタムテンプレートで `category_children` を文字列として扱っている場合は更新が必要です。
671
+
672
+ ```html
673
+ <!-- 旧 (変更前) -->
674
+ <script type="ssg">
675
+ for (const childUrl of variables.category_children) {
676
+ html += `<a href="${childUrl}">${childUrl}</a>`
677
+ }
678
+ </script>
679
+
680
+ <!-- 新 (変更後) -->
681
+ <script type="ssg">
682
+ for (const child of variables.category_children) {
683
+ html += `<a href="${child.url}">${child.title}</a>`
684
+ }
685
+ </script>
686
+ ```
687
+
688
+ ---
689
+
530
690
  **ヘルパー関数:**
531
691
 
532
692
  ```javascript
@@ -843,7 +1003,8 @@ import { allData, config, dir } from '@tenjuu99/blog'
843
1003
  ## 制約事項
844
1004
 
845
1005
  - フロントマターは YAML 完全互換ではなく、独自パーサーを使用
846
- - 配列・オブジェクトは JSON 形式で記述必須
1006
+ - 配列は JSON 形式(`["item1"]`)または YAML リスト形式(`- item`)で記述可能
1007
+ - オブジェクトは JSON 形式で記述必須
847
1008
  - 変数名は強制的に小文字化される
848
1009
  - `include()` は同期処理のため、非同期ファイル読み込みは不可
849
1010
  - SSGスクリプト内で `import()` は使用不可(ヘルパー関数を使用)
package/lib/dir.js CHANGED
@@ -34,16 +34,34 @@ const cache = () => {
34
34
  if (config.packages) {
35
35
  const packages = config.packages.split(',')
36
36
  packages.forEach(dir => {
37
+ // TODO: 将来的には hookDir を導入し、hooks/ からフックを読み込む仕組みにする。
38
+ // 現状はフックの解決が helperDir に依存しているため、config.hooks に登録済みのファイルを
39
+ // helper 自動登録から除外することで helper として公開されないようにしている(workaround)。
40
+ const hookFiles = new Set(
41
+ config.hooks ? Object.values(config.hooks).flat() : []
42
+ )
37
43
  if (fs.existsSync(`${packageDirCore}/${dir}`)) {
38
- const helper = config.helper.split(',')
44
+ const helper = config.helper.split(',').filter(Boolean)
39
45
  helper.push(`${dir}.js`)
46
+ const pkgHelperDir = `${packageDirCore}/${dir}/helper`
47
+ if (fs.existsSync(pkgHelperDir)) {
48
+ fs.readdirSync(pkgHelperDir)
49
+ .filter(f => f.endsWith('.js') && !helper.includes(f) && !hookFiles.has(f))
50
+ .forEach(f => helper.push(f))
51
+ }
40
52
  config.helper = helper.join(',')
41
53
  console.log(styleText('blue', `[cache] enable core package: ${dir}`))
42
54
  fs.cpSync(`${packageDirCore}/${dir}`, cacheDir, { recursive: true })
43
55
  }
44
56
  if (fs.existsSync(`${packageDir}/${dir}`)) {
45
- const helper = config.helper.split(',')
57
+ const helper = config.helper.split(',').filter(Boolean)
46
58
  helper.push(`${dir}.js`)
59
+ const pkgHelperDir = `${packageDir}/${dir}/helper`
60
+ if (fs.existsSync(pkgHelperDir)) {
61
+ fs.readdirSync(pkgHelperDir)
62
+ .filter(f => f.endsWith('.js') && !helper.includes(f) && !hookFiles.has(f))
63
+ .forEach(f => helper.push(f))
64
+ }
47
65
  config.helper = helper.join(',')
48
66
  console.log(styleText('blue', `[cache] enable package: ${dir}`))
49
67
  fs.cpSync(`${packageDir}/${dir}`, cacheDir, { recursive: true })
package/lib/helper.js CHANGED
@@ -2,16 +2,21 @@ import config from './config.js'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { helperDir } from './dir.js'
4
4
 
5
- let helper = {}
5
+ const helper = {}
6
6
 
7
- if (config.helper) {
8
- const files = config.helper.split(',')
9
- for (const file of files) {
10
- if (existsSync(`${helperDir}/${file}`)) {
11
- const helperAdditional = await import(`${helperDir}/${file}`)
12
- helper = Object.assign(helper, helperAdditional)
7
+ // top-level await を使うと循環依存がある場合に ESM のデッドロックが発生するため IIFE を使用する。
8
+ // helperReady をエクスポートすることで、ヘルパーのロード完了を外部から await できる。
9
+ // (Promise は一度 resolve すれば以降の await は即座に返るため、複数箇所で await しても問題ない)
10
+ export const helperReady = (async () => {
11
+ if (config.helper) {
12
+ const files = config.helper.split(',')
13
+ for (const file of files) {
14
+ if (existsSync(`${helperDir}/${file}`)) {
15
+ const helperAdditional = await import(`${helperDir}/${file}`)
16
+ Object.assign(helper, helperAdditional)
17
+ }
13
18
  }
14
19
  }
15
- }
20
+ })()
16
21
 
17
22
  export default helper
package/lib/pageData.js CHANGED
@@ -56,7 +56,11 @@ const parse = (content, name, ext) => {
56
56
  metaDataMerged.url = '/preview' + metaDataMerged.url
57
57
  }
58
58
  metaDataMerged.full_url = fullUrl(metaDataMerged)
59
- metaDataMerged['__output'] = name === 'index' ? '/index.html' : `${metaDataMerged.url}.${metaDataMerged.ext}`
59
+ metaDataMerged['__output'] = name === 'index'
60
+ ? '/index.html'
61
+ : metaDataMerged.url.endsWith('/')
62
+ ? `${metaDataMerged.url}index.html`
63
+ : `${metaDataMerged.url}.${metaDataMerged.ext}`
60
64
 
61
65
  return metaDataMerged
62
66
  }
@@ -64,13 +68,38 @@ const parse = (content, name, ext) => {
64
68
  const parseMetaData = (data) => {
65
69
  const metaData = {}
66
70
  let isStringContinue = false
71
+ let isYamlArray = false
67
72
  let key, value
68
- for (const line of data.split('\n')) {
73
+ const lines = data.split('\n')
74
+ for (let i = 0; i < lines.length; i++) {
75
+ const line = lines[i]
76
+ if (isYamlArray) {
77
+ const itemMatch = line.match(/^\s+-\s+(.+)/)
78
+ if (itemMatch) {
79
+ const rawItem = itemMatch[1].trim()
80
+ let item
81
+ try {
82
+ item = JSON.parse(rawItem)
83
+ } catch (e) {
84
+ item = rawItem.replace(/^'|'$/g, '')
85
+ }
86
+ metaData[key].push(item)
87
+ continue
88
+ } else {
89
+ isYamlArray = false
90
+ }
91
+ }
69
92
  if (!isStringContinue) {
70
93
  const match = line.match(METADATA_KEY_REGEXP)
71
94
  if (match) {
72
95
  key = match[1]
73
96
  value = line.slice(line.indexOf(':') + 1).trim()
97
+ const nextLine = lines[i + 1] || ''
98
+ if (value === '' && nextLine.match(/^\s+-\s+/)) {
99
+ metaData[key] = []
100
+ isYamlArray = true
101
+ continue
102
+ }
74
103
  try {
75
104
  value = JSON.parse(value)
76
105
  } catch (e) {
package/lib/render.js CHANGED
@@ -6,6 +6,7 @@ import replaceVariablesFilter from './replaceVariablesFilter.js'
6
6
  import includeFilter from './includeFilter.js'
7
7
  import { marked } from "marked";
8
8
  import { applyTemplate } from './applyTemplate.js'
9
+ import { helperReady } from './helper.js'
9
10
 
10
11
  marked.use({
11
12
  gfm: true,
@@ -13,6 +14,7 @@ marked.use({
13
14
  })
14
15
 
15
16
  const render = async (templateName, data) => {
17
+ await helperReady
16
18
  let template
17
19
  if (!templateName) {
18
20
  template = '{{MARKDOWN}}'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenjuu99/blog",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "blog template",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,51 @@
1
+ .pagination {
2
+ margin: 2rem 0 1rem;
3
+ }
4
+
5
+ .pagination-list {
6
+ display: flex;
7
+ flex-wrap: wrap;
8
+ align-items: center;
9
+ gap: 0.25rem;
10
+ list-style: none;
11
+ margin: 0;
12
+ padding: 0;
13
+ }
14
+
15
+ .pagination-list li a,
16
+ .pagination-list li span {
17
+ display: inline-flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ min-width: 2rem;
21
+ height: 2rem;
22
+ padding: 0 0.5rem;
23
+ border-radius: 4px;
24
+ font-size: 0.9rem;
25
+ text-decoration: none;
26
+ line-height: 1;
27
+ }
28
+
29
+ .pagination-list li a {
30
+ border: 1px solid #ccc;
31
+ color: inherit;
32
+ }
33
+
34
+ .pagination-list li a:hover {
35
+ background-color: #f0f0f0;
36
+ }
37
+
38
+ .pagination-current span {
39
+ border: 1px solid currentColor;
40
+ font-weight: bold;
41
+ }
42
+
43
+ .pagination-ellipsis span {
44
+ border: none;
45
+ color: #999;
46
+ }
47
+
48
+ .pagination-prev,
49
+ .pagination-next {
50
+ font-size: 0.85rem;
51
+ }
@@ -1,5 +1,81 @@
1
1
  import { buildCategoryTree } from './category.js'
2
2
 
3
+ /**
4
+ * 記事名配列を perPage 件ずつのページ配列に分割する
5
+ * 空の場合は [[]] を返す(ページ数ゼロを避けるため)
6
+ */
7
+ function sliceIntoPages(items, perPage) {
8
+ if (items.length === 0) return [[]]
9
+ const pages = []
10
+ for (let i = 0; i < items.length; i += perPage) {
11
+ pages.push(items.slice(i, i + perPage))
12
+ }
13
+ return pages
14
+ }
15
+
16
+ /**
17
+ * 仮想カテゴリーページオブジェクトを生成する
18
+ *
19
+ * 通常のページオブジェクトに対して以下のカテゴリー固有プロパティを追加する:
20
+ * category_path - カテゴリーパス配列
21
+ * category_children - サブカテゴリー一覧 {url, title}[]
22
+ * category_pages - このカテゴリーのページ名配列(呼び出し側でセット)
23
+ * __is_auto_category - 自動生成フラグ
24
+ *
25
+ * 以下のページネーションフィールドは常に設定される:
26
+ * category_current_page - 現在のページ番号(1始まり)
27
+ * category_total_pages - 総ページ数
28
+ * category_per_page - 1ページあたりの件数(per_page <= 0 または未設定時は undefined)
29
+ * category_pagination_base - カテゴリー1ページ目URL(末尾スラッシュあり)
30
+ * ページネーション無効時(per_page が正の整数でない場合)は current_page=1, total_pages=1 となる。
31
+ *
32
+ * @param {string} url - ページURL
33
+ * @param {Object} categoryData - カテゴリーデータ(baseUrl を含む)
34
+ * @param {Array} children - 子カテゴリー一覧
35
+ * @param {number} currentPage - 現在のページ番号
36
+ * @param {number} totalPages - 総ページ数
37
+ * @param {Object} categoryConfig - 単一カテゴリー設定オブジェクト
38
+ * @param {Object} config - グローバル設定オブジェクト
39
+ */
40
+ function buildVirtualPage(url, categoryData, children, currentPage, totalPages, categoryConfig, config) {
41
+ const pageName = url.replace(/^\//, '') + '/index'
42
+ const paginationBase = categoryData.baseUrl + '/'
43
+ const perPage = categoryConfig.per_page
44
+
45
+ const page = {
46
+ name: pageName,
47
+ url: url + '/',
48
+ __output: `${url}/index.html`,
49
+ title: categoryData.title,
50
+ template: categoryConfig.template || 'category.html',
51
+ markdown: '',
52
+ category_path: categoryData.path,
53
+ category_children: children,
54
+ __is_auto_category: true,
55
+ distribute: true,
56
+ index: false,
57
+ noindex: false,
58
+ lang: 'ja',
59
+ published: '1970-01-01',
60
+ ext: 'html',
61
+ site_name: config.site_name || 'default',
62
+ url_base: config.url_base || 'http://localhost:8000',
63
+ relative_path: config.relative_path || '',
64
+ description: `${categoryData.title} カテゴリーのページ一覧`,
65
+ og_description: `${categoryData.title} カテゴリーのページ一覧`,
66
+ __filetype: 'md',
67
+ markdown_not_parsed: '',
68
+ full_url: `${config.url_base || 'http://localhost:8000'}${url}/`,
69
+ category_pages: [],
70
+ category_current_page: currentPage,
71
+ category_total_pages: totalPages,
72
+ category_per_page: perPage > 0 ? perPage : undefined,
73
+ category_pagination_base: paginationBase,
74
+ }
75
+
76
+ return page
77
+ }
78
+
3
79
  /**
4
80
  * 単一カテゴリー設定に対してカテゴリーページを生成する
5
81
  * @param {Object} allData - 全ページデータ(仮想ページの追加先)
@@ -7,7 +83,6 @@ import { buildCategoryTree } from './category.js'
7
83
  * @param {Object} config - グローバル設定オブジェクト
8
84
  */
9
85
  function generateCategoryPages(allData, categoryConfig, config) {
10
- // path_filter でページをフィルタ
11
86
  const pathFilter = categoryConfig.path_filter || ''
12
87
  let filteredData = allData
13
88
 
@@ -20,45 +95,40 @@ function generateCategoryPages(allData, categoryConfig, config) {
20
95
  }
21
96
  }
22
97
 
23
- // カテゴリーツリーを構築(category キーにラップして buildCategoryTree に渡す)
24
98
  const treeConfig = { category: categoryConfig }
25
99
  const tree = buildCategoryTree(filteredData, treeConfig)
26
100
 
27
- // 各カテゴリーに対して仮想ページを生成
28
101
  for (const [url, categoryData] of Object.entries(tree)) {
29
- const pageName = url.replace(/^\//, '') + '/index'
102
+ const children = Object.entries(categoryData.children).map(([childUrl, childNode]) => ({
103
+ url: childUrl + '/',
104
+ title: childNode.title,
105
+ }))
30
106
 
31
- // 既存ページ(手動作成)が存在する場合はスキップ
32
- if (allData[pageName]) {
33
- continue
34
- }
107
+ const categoryInfo = { ...categoryData, baseUrl: url }
108
+ const perPage = categoryConfig.per_page
109
+
110
+ if (perPage > 0) {
111
+ const slices = sliceIntoPages(categoryData.pages, perPage)
112
+ const totalPages = slices.length
113
+
114
+ for (let i = 0; i < slices.length; i++) {
115
+ const currentPage = i + 1
116
+ const pageUrl = currentPage === 1 ? url : `${url}/${currentPage}`
117
+ const pageName = pageUrl.replace(/^\//, '') + '/index'
118
+
119
+ if (allData[pageName]) continue
120
+
121
+ const virtualPage = buildVirtualPage(pageUrl, categoryInfo, children, currentPage, totalPages, categoryConfig, config)
122
+ virtualPage.category_pages = slices[i]
123
+ allData[pageName] = virtualPage
124
+ }
125
+ } else {
126
+ const pageName = url.replace(/^\//, '') + '/index'
127
+ if (allData[pageName]) continue
35
128
 
36
- // 仮想カテゴリーページを生成
37
- allData[pageName] = {
38
- name: pageName,
39
- url: url,
40
- __output: `${url}/index.html`,
41
- title: categoryData.title,
42
- template: categoryConfig.template || 'category.html',
43
- markdown: '',
44
- category_path: categoryData.path,
45
- category_pages: categoryData.pages,
46
- category_children: Object.keys(categoryData.children),
47
- __is_auto_category: true,
48
- distribute: true,
49
- index: false,
50
- noindex: false,
51
- lang: 'ja',
52
- published: '1970-01-01',
53
- ext: 'html',
54
- site_name: config.site_name || 'default',
55
- url_base: config.url_base || 'http://localhost:8000',
56
- relative_path: config.relative_path || '',
57
- description: `${categoryData.title} カテゴリーのページ一覧`,
58
- og_description: `${categoryData.title} カテゴリーのページ一覧`,
59
- __filetype: 'md',
60
- markdown_not_parsed: '',
61
- full_url: `${config.url_base || 'http://localhost:8000'}${url}`
129
+ const virtualPage = buildVirtualPage(url, categoryInfo, children, 1, 1, categoryConfig, config)
130
+ virtualPage.category_pages = categoryData.pages
131
+ allData[pageName] = virtualPage
62
132
  }
63
133
  }
64
134
  }
@@ -71,7 +141,6 @@ function generateCategoryPages(allData, categoryConfig, config) {
71
141
  * @param {Object} config - 設定オブジェクト
72
142
  */
73
143
  export async function afterIndexing(allData, config) {
74
- // categories 配列または category 単一設定からカテゴリーシステム一覧を取得
75
144
  const categorySystems = config.categories
76
145
  ? config.categories
77
146
  : (config.category ? [config.category] : [])
@@ -0,0 +1,36 @@
1
+ /**
2
+ * getPaginationUrl: ページ番号から URL を生成する
3
+ * page === 1 なら basePath、それ以外なら basePath + page + '/'
4
+ */
5
+ export function getPaginationUrl(basePath, page) {
6
+ const base = basePath.endsWith('/') ? basePath : `${basePath}/`
7
+ if (page === 1) return base
8
+ return `${base}${page}/`
9
+ }
10
+
11
+ /**
12
+ * buildWindowedPages: ウィンドウ付きページ番号リストを生成する
13
+ * 各要素は {num, isCurrent} または {num: null, isEllipsis: true}
14
+ */
15
+ export function buildWindowedPages(totalPages, currentPage, windowSize = 2) {
16
+ if (totalPages <= 0) return []
17
+ if (totalPages === 1) return [{ num: 1, isCurrent: currentPage === 1 }]
18
+
19
+ const rangeStart = Math.max(2, currentPage - windowSize)
20
+ const rangeEnd = Math.min(totalPages - 1, currentPage + windowSize)
21
+ const result = []
22
+
23
+ result.push({ num: 1, isCurrent: currentPage === 1 })
24
+
25
+ if (rangeStart > 2) result.push({ num: null, isEllipsis: true })
26
+
27
+ for (let i = rangeStart; i <= rangeEnd; i++) {
28
+ result.push({ num: i, isCurrent: i === currentPage })
29
+ }
30
+
31
+ if (rangeEnd < totalPages - 1) result.push({ num: null, isEllipsis: true })
32
+
33
+ result.push({ num: totalPages, isCurrent: currentPage === totalPages })
34
+
35
+ return result
36
+ }
@@ -0,0 +1,37 @@
1
+ <script type="ssg">
2
+ const totalPages = variables.category_total_pages || 0
3
+ if (totalPages <= 1) return ''
4
+
5
+ const currentPage = variables.category_current_page || 1
6
+ const paginationBase = variables.category_pagination_base || ''
7
+ const relativePath = variables.relative_path || ''
8
+
9
+ const pages = helper.buildWindowedPages(totalPages, currentPage)
10
+
11
+ let html = '<nav class="pagination" aria-label="ページネーション">'
12
+ html += '<ul class="pagination-list">'
13
+
14
+ if (currentPage > 1) {
15
+ const prevUrl = helper.getPaginationUrl(paginationBase, currentPage - 1)
16
+ html += `<li class="pagination-prev"><a href="${relativePath}${prevUrl}">&laquo; 前へ</a></li>`
17
+ }
18
+
19
+ for (const p of pages) {
20
+ if (p.isEllipsis) {
21
+ html += '<li class="pagination-ellipsis"><span aria-hidden="true">…</span></li>'
22
+ } else if (p.isCurrent) {
23
+ html += `<li class="pagination-current"><span aria-current="page">${p.num}</span></li>`
24
+ } else {
25
+ const pageUrl = helper.getPaginationUrl(paginationBase, p.num)
26
+ html += `<li><a href="${relativePath}${pageUrl}">${p.num}</a></li>`
27
+ }
28
+ }
29
+
30
+ if (currentPage < totalPages) {
31
+ const nextUrl = helper.getPaginationUrl(paginationBase, currentPage + 1)
32
+ html += `<li class="pagination-next"><a href="${relativePath}${nextUrl}">次へ &raquo;</a></li>`
33
+ }
34
+
35
+ html += '</ul></nav>'
36
+ return html
37
+ </script>
@@ -16,6 +16,9 @@
16
16
  <meta name="description" content="{{DESCRIPTION}}">
17
17
 
18
18
  {include('template/css.html')}
19
+ <style>
20
+ {include('css/pagination.css')}
21
+ </style>
19
22
  </head>
20
23
  <body>
21
24
  <header>
@@ -45,15 +48,16 @@
45
48
  html += '<h2>サブカテゴリー</h2>'
46
49
  html += '<div class="category-children">'
47
50
 
48
- for (const childUrl of children) {
51
+ for (const child of children) {
52
+ const childUrl = typeof child === 'string' ? child : child.url
53
+ const childTitle = typeof child === 'string' ? null : child.title
49
54
  const childPageName = childUrl.replace(/^\//, '') + '/index'
50
55
  const childData = helper.getPageData(childPageName)
56
+ const title = childTitle || (childData && childData.title) || childUrl
51
57
 
52
- if (childData) {
53
- html += '<div class="category-child-card">'
54
- html += `<a href="${relativePath}${childUrl}/">${childData.title}</a>`
55
- html += '</div>'
56
- }
58
+ html += '<div class="category-child-card">'
59
+ html += `<a href="${relativePath}${childUrl}/">${title}</a>`
60
+ html += '</div>'
57
61
  }
58
62
 
59
63
  html += '</div></div>'
@@ -104,6 +108,7 @@
104
108
  html += '</ul>'
105
109
  return html
106
110
  </script>
111
+ {include('template/_pagination.html')}
107
112
  </div>
108
113
  </article>
109
114
  </main>
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: After Rain
3
- category: ["Art", "Painting"]
3
+ category: ["Book", "Art", "Painting"]
4
4
  published: 2024-01-15
5
5
  ---
6
6
 
@@ -10,4 +10,4 @@ published: 2024-01-15
10
10
 
11
11
  カテゴリー: Art > Painting
12
12
 
13
- このページは `/book-list/art/painting/` カテゴリーに含まれるべきです。
13
+ このページは `/book/art/painting/` カテゴリーに含まれるべきです。
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Japanese History
3
- category: ["History", "Japan"]
3
+ category: ["Book", "History", "Japan"]
4
4
  published: 2024-03-10
5
5
  ---
6
6
 
@@ -8,6 +8,6 @@ published: 2024-03-10
8
8
 
9
9
  これは書籍「Japanese History」のページです。
10
10
 
11
- カテゴリー: [History]({{RELATIVE_PATH}}/book-list/history/) > [Japan]({{RELATIVE_PATH}}/book-list/history/japan/)
11
+ カテゴリー: [History]({{RELATIVE_PATH}}/book/history/) > [Japan]({{RELATIVE_PATH}}/book/history/japan/)
12
12
 
13
- このページは `/book-list/history/japan/` カテゴリーに含まれるべきです。
13
+ このページは `/book/history/japan/` カテゴリーに含まれるべきです。
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Modern Sculpture
3
- category: ["Art", "Sculpture"]
3
+ category: ["Book", "Art", "Sculpture"]
4
4
  published: 2024-02-20
5
5
  ---
6
6
 
@@ -8,6 +8,6 @@ published: 2024-02-20
8
8
 
9
9
  これは書籍「Modern Sculpture」のページです。
10
10
 
11
- カテゴリー: [Art]({{RELATIVE_PATH}}/book-list/art/) > [Sculpture]({{RELATIVE_PATH}}/book-list/art/sculpture/)
11
+ カテゴリー: [Art]({{RELATIVE_PATH}}/book/art/) > [Sculpture]({{RELATIVE_PATH}}/book/art/sculpture/)
12
12
 
13
- このページは `/book-list/art/sculpture/` カテゴリーに含まれるべきです。
13
+ このページは `/book/art/sculpture/` カテゴリーに含まれるべきです。
@@ -0,0 +1,9 @@
1
+ ---
2
+ title: Oil Painting Masters
3
+ category: ["Book", "Art", "Painting"]
4
+ published: 2024-05-01
5
+ ---
6
+
7
+ # Oil Painting Masters
8
+
9
+ 油彩画の巨匠たちの作品集です。
@@ -0,0 +1,9 @@
1
+ ---
2
+ title: Watercolor Landscapes
3
+ category: ["Book", "Art", "Painting"]
4
+ published: 2024-04-01
5
+ ---
6
+
7
+ # Watercolor Landscapes
8
+
9
+ 水彩風景画の画集です。