@tenjuu99/blog 0.3.2 → 0.3.4

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
@@ -166,6 +166,12 @@ published: true
166
166
  # 配列(JSON形式)
167
167
  tags: ["tag1", "tag2", "tag3"]
168
168
 
169
+ # 配列(YAMLリスト形式)
170
+ tags:
171
+ - tag1
172
+ - tag2
173
+ - tag3
174
+
169
175
  # オブジェクト(JSON形式)
170
176
  metadata: {"author": "名前", "year": 2024}
171
177
 
@@ -178,7 +184,7 @@ description: "これは
178
184
  url_base: config.url_base
179
185
  ```
180
186
 
181
- **重要**: 配列・オブジェクトは `JSON.parse()` で解析されるため、有効なJSON形式である必要があります。
187
+ **配列の記述形式**: JSON形式(`["item1", "item2"]`)とYAMLリスト形式(`- item`)の両方をサポートします。オブジェクトはJSON形式のみ対応です。
182
188
 
183
189
  ## テンプレート記法
184
190
 
@@ -429,6 +435,7 @@ helper.readIndex('/post')
429
435
  | `url_case` | URLの大文字小文字変換 (`lower`/`original`) | `lower` |
430
436
  | `url_separator` | スペースの置き換え文字 | `"-"` |
431
437
  | `url_prefix` | カテゴリーURLのプレフィックス | `""` |
438
+ | `per_page` | 1ページあたりの記事数。正の整数のときページネーション有効。`false`・`0`・未設定はページネーションなし | `undefined` |
432
439
 
433
440
  **設定オプションの詳細:**
434
441
 
@@ -457,10 +464,18 @@ helper.readIndex('/post')
457
464
  title: 'Frontend', // カテゴリー名
458
465
  template: 'category.html',
459
466
  category_path: ['Tech', 'Frontend'], // カテゴリーパス
460
- category_pages: ['tech/frontend/react/tutorial', ...], // このカテゴリーのページ
461
- category_children: ['/tech/frontend/react', ...], // サブカテゴリー
467
+ category_pages: ['tech/frontend/react/tutorial', ...], // このカテゴリーのページ(per_page設定時は当該ページのスライス)
468
+ category_children: [ // サブカテゴリー({url, title}[] 型)
469
+ { url: '/tech/frontend/react', title: 'React' },
470
+ ...
471
+ ],
462
472
  __is_auto_category: true,
463
- distribute: true
473
+ distribute: true,
474
+ // 常に設定されるページネーションフィールド(per_page 無効時は current_page=1, total_pages=1, per_page=undefined):
475
+ category_current_page: 1, // 現在のページ番号(1始まり)
476
+ category_total_pages: 3, // 総ページ数
477
+ category_per_page: 10, // 1ページあたりの件数(per_page 無効時は undefined)
478
+ category_pagination_base: '/tech/frontend/', // カテゴリー1ページ目URL(末尾スラッシュあり)
464
479
  }
465
480
  ```
466
481
 
@@ -468,8 +483,12 @@ helper.readIndex('/post')
468
483
 
469
484
  - `{{title}}` - カテゴリー名
470
485
  - `{{category_path}}` - カテゴリーパス配列
471
- - `{{category_pages}}` - このカテゴリーに属するページ名の配列
472
- - `{{category_children}}` - サブカテゴリーのURL配列
486
+ - `{{category_pages}}` - このカテゴリーに属するページ名の配列(ページネーション有効時は当該ページ分のスライス)
487
+ - `{{category_children}}` - サブカテゴリーの配列。各要素は `{url: string, title: string}` オブジェクト
488
+ - `{{category_current_page}}` - 現在のページ番号(常に設定、ページネーション無効時は `1`)
489
+ - `{{category_total_pages}}` - 総ページ数(常に設定、ページネーション無効時は `1`)
490
+ - `{{category_per_page}}` - 1ページあたりの件数(ページネーション無効時は `undefined`)
491
+ - `{{category_pagination_base}}` - カテゴリーの1ページ目URL、末尾スラッシュあり(常に設定)
473
492
 
474
493
  **複数カテゴリーシステムの使用:**
475
494
 
@@ -527,6 +546,115 @@ helper.readIndex('/post')
527
546
 
528
547
  `src/pages/tech/index.md` が存在する場合、自動生成はスキップされます。
529
548
 
549
+ ---
550
+
551
+ #### category パッケージ: ページネーション
552
+
553
+ `per_page` を設定すると、カテゴリーごとに複数の静的HTMLページが自動生成されます。
554
+
555
+ **設定例:**
556
+
557
+ ```json
558
+ {
559
+ "categories": [
560
+ {
561
+ "name": "books",
562
+ "url_prefix": "/book-list",
563
+ "path_filter": "book/",
564
+ "template": "category.html",
565
+ "auto_generate": true,
566
+ "per_page": 10
567
+ }
568
+ ]
569
+ }
570
+ ```
571
+
572
+ **生成されるURL:**
573
+
574
+ 10件の記事と `per_page: 3` の設定では:
575
+
576
+ | ページ | ページ名(allData キー) | 出力ファイル |
577
+ |--------|--------------------------|-------------|
578
+ | 1 | `book-list/art/index` | `dist/book-list/art/index.html` |
579
+ | 2 | `book-list/art/2/index` | `dist/book-list/art/2/index.html` |
580
+ | 3 | `book-list/art/3/index` | `dist/book-list/art/3/index.html` |
581
+
582
+ 1ページ目のURL・ファイル名は `per_page` 未設定時と同じで後方互換を維持します。
583
+
584
+ **`per_page` 未設定時との違い:**
585
+
586
+ | 項目 | per_page 未設定 | per_page 設定あり |
587
+ |------|----------------|-----------------|
588
+ | 生成ページ数 | 1 | ceil(記事数 / per_page) |
589
+ | `category_pages` | 全記事 | 当該ページの記事スライス |
590
+ | `category_current_page` 等 | あり(current_page=1, total_pages=1) | あり |
591
+
592
+ **ページネーション UI パーシャル(`_pagination.html`):**
593
+
594
+ `packages/category/template/_pagination.html` がパッケージに同梱されており、`category.html` から自動的に include されます。`total_pages <= 1` のときは何も出力しません。
595
+
596
+ 独自スタイルに合わせてカスタマイズする場合は、`src/template/_pagination.html` に同名ファイルを配置するとパッケージのデフォルトを上書きできます([ファイル優先順位](#ファイル優先順位) 参照)。
597
+
598
+ **ページネーションヘルパー(`pagination.js`):**
599
+
600
+ `packages/category/helper/pagination.js` に以下の関数が提供されています。独自テンプレートを作成する際に `{script}` ブロックから直接利用できます(`import` 不要、インライン実装)。
601
+
602
+ ```javascript
603
+ // ページ番号から URL を生成する
604
+ // page === 1 → basePath、それ以外 → basePath + page + '/'
605
+ getPaginationUrl(basePath, page)
606
+
607
+ // ウィンドウ付きページ番号リストを生成する
608
+ // 各要素: {num: number, isCurrent: boolean} または {num: null, isEllipsis: true}
609
+ buildWindowedPages(totalPages, currentPage, windowSize = 2)
610
+ ```
611
+
612
+ **使用例(カスタムテンプレート内):**
613
+
614
+ ```html
615
+ <script type="ssg">
616
+ if ((variables.category_total_pages || 0) <= 1) return ''
617
+
618
+ const totalPages = variables.category_total_pages
619
+ const currentPage = variables.category_current_page
620
+ const base = variables.category_pagination_base
621
+
622
+ // ページURLを生成(page=1 → base, page=2 → base + '2/')
623
+ const prevUrl = currentPage > 1 ? base + (currentPage - 1 === 1 ? '' : (currentPage - 1) + '/') : null
624
+ const nextUrl = currentPage < totalPages ? base + (currentPage + 1) + '/' : null
625
+
626
+ let html = '<nav class="pagination">'
627
+ if (prevUrl) html += `<a href="${prevUrl}">前へ</a>`
628
+ if (nextUrl) html += `<a href="${nextUrl}">次へ</a>`
629
+ html += '</nav>'
630
+ return html
631
+ </script>
632
+ ```
633
+
634
+ **`category_children` の型(破壊的変更):**
635
+
636
+ この変更により、`category_children` の型が `string[]`(URL文字列の配列)から `{url: string, title: string}[]`(オブジェクトの配列)に変わりました。
637
+
638
+ 既存のカスタムテンプレートで `category_children` を文字列として扱っている場合は更新が必要です。
639
+
640
+ ```html
641
+ <!-- 旧 (変更前) -->
642
+ <script type="ssg">
643
+ for (const childUrl of variables.category_children) {
644
+ html += `<a href="${childUrl}">${childUrl}</a>`
645
+ }
646
+ </script>
647
+
648
+ <!-- 新 (変更後) -->
649
+ <script type="ssg">
650
+ for (const child of variables.category_children) {
651
+ html += `<a href="${child.url}">${child.title}</a>`
652
+ }
653
+ </script>
654
+ ```
655
+
656
+ ---
657
+
530
658
  **ヘルパー関数:**
531
659
 
532
660
  ```javascript
@@ -843,7 +971,8 @@ import { allData, config, dir } from '@tenjuu99/blog'
843
971
  ## 制約事項
844
972
 
845
973
  - フロントマターは YAML 完全互換ではなく、独自パーサーを使用
846
- - 配列・オブジェクトは JSON 形式で記述必須
974
+ - 配列は JSON 形式(`["item1"]`)または YAML リスト形式(`- item`)で記述可能
975
+ - オブジェクトは JSON 形式で記述必須
847
976
  - 変数名は強制的に小文字化される
848
977
  - `include()` は同期処理のため、非同期ファイル読み込みは不可
849
978
  - 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
@@ -64,13 +64,38 @@ const parse = (content, name, ext) => {
64
64
  const parseMetaData = (data) => {
65
65
  const metaData = {}
66
66
  let isStringContinue = false
67
+ let isYamlArray = false
67
68
  let key, value
68
- for (const line of data.split('\n')) {
69
+ const lines = data.split('\n')
70
+ for (let i = 0; i < lines.length; i++) {
71
+ const line = lines[i]
72
+ if (isYamlArray) {
73
+ const itemMatch = line.match(/^\s+-\s+(.+)/)
74
+ if (itemMatch) {
75
+ const rawItem = itemMatch[1].trim()
76
+ let item
77
+ try {
78
+ item = JSON.parse(rawItem)
79
+ } catch (e) {
80
+ item = rawItem.replace(/^'|'$/g, '')
81
+ }
82
+ metaData[key].push(item)
83
+ continue
84
+ } else {
85
+ isYamlArray = false
86
+ }
87
+ }
69
88
  if (!isStringContinue) {
70
89
  const match = line.match(METADATA_KEY_REGEXP)
71
90
  if (match) {
72
91
  key = match[1]
73
92
  value = line.slice(line.indexOf(':') + 1).trim()
93
+ const nextLine = lines[i + 1] || ''
94
+ if (value === '' && nextLine.match(/^\s+-\s+/)) {
95
+ metaData[key] = []
96
+ isYamlArray = true
97
+ continue
98
+ }
74
99
  try {
75
100
  value = JSON.parse(value)
76
101
  } 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.2",
3
+ "version": "0.3.4",
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>
@@ -38,17 +38,16 @@ const transition = async (href) => {
38
38
  }
39
39
  }
40
40
  const newScripts = [...doc.getElementsByTagName('script')].filter(s => {
41
- return s.dataset.name && [...document.scripts].find(ds => ds.dataset.name !== s.dataset.name)
41
+ return s.dataset.name && ![...document.scripts].find(ds => ds.dataset.name === s.dataset.name)
42
42
  })
43
43
  newScripts.forEach(script => {
44
- document.head.appendChild(script)
45
44
  const newScript = document.createElement('script')
45
+ newScript.dataset.name = script.dataset.name
46
46
  const newScriptHtml = `(() => {
47
47
  ${script.innerHTML}
48
48
  })()`
49
49
  newScript.innerHTML = newScriptHtml
50
- script.parentNode.append(newScript)
51
- script.remove()
50
+ document.head.appendChild(newScript)
52
51
  })
53
52
 
54
53
  // set body
@@ -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
+ 水彩風景画の画集です。