@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 +12 -3
- package/docs/spec.md +136 -7
- package/lib/dir.js +20 -2
- package/lib/helper.js +13 -8
- package/lib/pageData.js +26 -1
- package/lib/render.js +2 -0
- package/package.json +1 -1
- package/packages/category/css/pagination.css +51 -0
- package/packages/category/helper/categoryIndexer.js +104 -35
- package/packages/category/helper/pagination.js +36 -0
- package/packages/category/template/_pagination.html +37 -0
- package/packages/category/template/category.html +11 -6
- package/packages/turbolink/template/turbolink.js +3 -4
- package/src-sample/pages/book/after-rain.md +2 -2
- package/src-sample/pages/book/japanese-history.md +3 -3
- package/src-sample/pages/book/modern-sculpture.md +3 -3
- package/src-sample/pages/book/oil-painting-masters.md +9 -0
- package/src-sample/pages/book/watercolor-landscapes.md +9 -0
package/docs/develop.md
CHANGED
|
@@ -452,8 +452,17 @@ npm run dev
|
|
|
452
452
|
|
|
453
453
|
### 4. フロントマターパーサー
|
|
454
454
|
|
|
455
|
-
- YAML
|
|
456
|
-
-
|
|
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
|
-
|
|
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: [
|
|
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}}` -
|
|
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
|
-
-
|
|
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
|
-
|
|
5
|
+
const helper = {}
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
|
102
|
+
const children = Object.entries(categoryData.children).map(([childUrl, childNode]) => ({
|
|
103
|
+
url: childUrl,
|
|
104
|
+
title: childNode.title,
|
|
105
|
+
}))
|
|
30
106
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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}">« 前へ</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}">次へ »</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
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
11
|
+
カテゴリー: [History]({{RELATIVE_PATH}}/book/history/) > [Japan]({{RELATIVE_PATH}}/book/history/japan/)
|
|
12
12
|
|
|
13
|
-
このページは `/book
|
|
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
|
|
11
|
+
カテゴリー: [Art]({{RELATIVE_PATH}}/book/art/) > [Sculpture]({{RELATIVE_PATH}}/book/art/sculpture/)
|
|
12
12
|
|
|
13
|
-
このページは `/book
|
|
13
|
+
このページは `/book/art/sculpture/` カテゴリーに含まれるべきです。
|