@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 +12 -3
- package/docs/spec.md +170 -9
- package/lib/dir.js +20 -2
- package/lib/helper.js +13 -8
- package/lib/pageData.js +31 -2
- 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/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
|
@@ -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
|
-
|
|
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: [
|
|
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}}` -
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
@@ -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'
|
|
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
|
-
|
|
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
|
@@ -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>
|
|
@@ -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/` カテゴリーに含まれるべきです。
|