@x-wave/blog 2.4.3 → 2.5.0
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/README.md +83 -0
- package/cli/index.js +285 -0
- package/context.d.ts +10 -0
- package/index.d.ts +1 -0
- package/index.js +857 -771
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -568,6 +568,89 @@ keywords: [getting started, tutorial, basics]
|
|
|
568
568
|
---
|
|
569
569
|
```
|
|
570
570
|
|
|
571
|
+
### Static article indexer (`blog-indexer`)
|
|
572
|
+
|
|
573
|
+
The framework ships a CLI tool that pre-generates static JSON index files from your MDX content. The home page and month-based archive views read these files at runtime instead of discovering articles on each request.
|
|
574
|
+
|
|
575
|
+
**Install** (the CLI is included in `@x-wave/blog`):
|
|
576
|
+
|
|
577
|
+
```bash
|
|
578
|
+
npx blog-indexer --docs src/docs --output public/blog-index
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Or add it as a project script:
|
|
582
|
+
|
|
583
|
+
```jsonc
|
|
584
|
+
// package.json
|
|
585
|
+
{
|
|
586
|
+
"scripts": {
|
|
587
|
+
"index": "blog-indexer --docs src/docs --output public/blog-index"
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
**Options:**
|
|
593
|
+
|
|
594
|
+
| Flag | Description |
|
|
595
|
+
|---|---|
|
|
596
|
+
| `--docs` | Path to the docs directory (must contain language sub-directories, e.g. `en/`, `es/`) |
|
|
597
|
+
| `--output` | Path to the output directory where JSON index files will be written |
|
|
598
|
+
|
|
599
|
+
**Output structure** (one set per language):
|
|
600
|
+
|
|
601
|
+
```
|
|
602
|
+
public/blog-index/
|
|
603
|
+
├── en/
|
|
604
|
+
│ ├── latest.json # Latest 20 articles (home page)
|
|
605
|
+
│ ├── months-index.json # Sorted list of YYYY-MM strings
|
|
606
|
+
│ └── months/
|
|
607
|
+
│ └── 2026-02.json # Articles for that month
|
|
608
|
+
├── es/
|
|
609
|
+
│ └── ...
|
|
610
|
+
└── zh/
|
|
611
|
+
└── ...
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
Run the indexer whenever you add, remove, or update articles, and commit the output so it is available at deploy time.
|
|
615
|
+
|
|
616
|
+
#### CI: keeping the index in sync
|
|
617
|
+
|
|
618
|
+
Add a GitHub Actions workflow to verify the committed index is up to date on every push. The workflow re-runs the indexer and fails if the output differs from what is committed:
|
|
619
|
+
|
|
620
|
+
```yaml
|
|
621
|
+
# .github/workflows/check-blog-index.yml
|
|
622
|
+
name: Check blog index is up to date
|
|
623
|
+
|
|
624
|
+
on: push
|
|
625
|
+
|
|
626
|
+
jobs:
|
|
627
|
+
check-index:
|
|
628
|
+
runs-on: ubuntu-latest
|
|
629
|
+
steps:
|
|
630
|
+
- uses: actions/checkout@v4
|
|
631
|
+
|
|
632
|
+
- uses: pnpm/action-setup@v4
|
|
633
|
+
|
|
634
|
+
- uses: actions/setup-node@v4
|
|
635
|
+
with:
|
|
636
|
+
node-version: 22
|
|
637
|
+
cache: pnpm
|
|
638
|
+
|
|
639
|
+
- run: pnpm install --frozen-lockfile
|
|
640
|
+
|
|
641
|
+
- name: Run blog indexer
|
|
642
|
+
run: npx blog-indexer --docs src/docs --output public/blog-index
|
|
643
|
+
|
|
644
|
+
- name: Verify index is up to date
|
|
645
|
+
run: |
|
|
646
|
+
if ! git diff --exit-code public/blog-index/; then
|
|
647
|
+
echo "::error::Blog index is out of date. Run the indexer and commit the result."
|
|
648
|
+
exit 1
|
|
649
|
+
fi
|
|
650
|
+
```
|
|
651
|
+
|
|
652
|
+
`git diff --exit-code` compares file content hashes internally, so no separate hash file is needed — if any generated file differs from the committed version the job fails with a clear error message.
|
|
653
|
+
|
|
571
654
|
### Custom headers
|
|
572
655
|
|
|
573
656
|
If you need a custom header instead of the built-in one, simply omit the `header` config and use the provided hooks:
|
package/cli/index.js
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @x-wave/blog Article Indexer CLI
|
|
4
|
+
*
|
|
5
|
+
* Generates static JSON index files from MDX article files.
|
|
6
|
+
* Run this whenever your content changes to keep the index up to date.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* blog-indexer --docs <path-to-docs-dir> --output <output-dir>
|
|
10
|
+
*
|
|
11
|
+
* Options:
|
|
12
|
+
* --docs Path to the docs directory (must contain language sub-directories, e.g. en/, es/)
|
|
13
|
+
* --output Path to the output directory where JSON index files will be written
|
|
14
|
+
*
|
|
15
|
+
* Output structure (one set of files per language):
|
|
16
|
+
* <output>/<lang>/latest.json - Latest 20 articles (for the home page)
|
|
17
|
+
* <output>/<lang>/months-index.json - Sorted list of YYYY-MM strings with articles
|
|
18
|
+
* <output>/<lang>/months/<YYYY-MM>.json - Articles for a specific month
|
|
19
|
+
*
|
|
20
|
+
* Example:
|
|
21
|
+
* blog-indexer --docs src/docs --output public/blog-index
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs'
|
|
25
|
+
import path from 'node:path'
|
|
26
|
+
|
|
27
|
+
const LATEST_COUNT = 20
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse CLI arguments from process.argv.
|
|
31
|
+
*/
|
|
32
|
+
function parseArgs(argv) {
|
|
33
|
+
const args = {}
|
|
34
|
+
for (let i = 2; i < argv.length; i++) {
|
|
35
|
+
if (argv[i] === '--docs' && argv[i + 1]) {
|
|
36
|
+
args.docs = argv[++i]
|
|
37
|
+
} else if (argv[i] === '--output' && argv[i + 1]) {
|
|
38
|
+
args.output = argv[++i]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return args
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Parse YAML frontmatter from MDX content.
|
|
46
|
+
* Supports string, boolean, and array values.
|
|
47
|
+
*/
|
|
48
|
+
function parseFrontmatter(content) {
|
|
49
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---\s*\n/)
|
|
50
|
+
if (!match) return { frontmatter: {} }
|
|
51
|
+
|
|
52
|
+
const frontmatter = {}
|
|
53
|
+
const frontmatterText = match[1]
|
|
54
|
+
let currentKey = ''
|
|
55
|
+
let isArrayContext = false
|
|
56
|
+
const arrayValues = []
|
|
57
|
+
|
|
58
|
+
for (const line of frontmatterText.split('\n')) {
|
|
59
|
+
const trimmed = line.trim()
|
|
60
|
+
|
|
61
|
+
// Handle array items (lines starting with -)
|
|
62
|
+
if (trimmed.startsWith('-')) {
|
|
63
|
+
if (isArrayContext) {
|
|
64
|
+
arrayValues.push(trimmed.substring(1).trim())
|
|
65
|
+
}
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// If we were in array context and hit a non-array line, save the array
|
|
70
|
+
if (isArrayContext && !trimmed.startsWith('-')) {
|
|
71
|
+
frontmatter[currentKey] = arrayValues.slice()
|
|
72
|
+
arrayValues.length = 0
|
|
73
|
+
isArrayContext = false
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Handle key-value pairs
|
|
77
|
+
const colonIndex = trimmed.indexOf(':')
|
|
78
|
+
if (colonIndex !== -1) {
|
|
79
|
+
const key = trimmed.substring(0, colonIndex).trim()
|
|
80
|
+
const value = trimmed.substring(colonIndex + 1).trim()
|
|
81
|
+
currentKey = key
|
|
82
|
+
|
|
83
|
+
// Empty value (nothing after the colon) means an array follows on subsequent lines
|
|
84
|
+
if (value === '') {
|
|
85
|
+
isArrayContext = true
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (value === 'true') frontmatter[currentKey] = true
|
|
90
|
+
else if (value === 'false') frontmatter[currentKey] = false
|
|
91
|
+
else frontmatter[currentKey] = value
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Flush trailing array
|
|
96
|
+
if (isArrayContext && arrayValues.length > 0) {
|
|
97
|
+
frontmatter[currentKey] = arrayValues
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { frontmatter }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Extract the article slug from an MDX filename.
|
|
105
|
+
* Strips the `-advanced` suffix and `.mdx` extension.
|
|
106
|
+
*/
|
|
107
|
+
function extractSlug(fileName) {
|
|
108
|
+
return fileName.replace(/(-advanced)?\.mdx$/, '')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Extract the article title from frontmatter or the first H1 heading.
|
|
113
|
+
*/
|
|
114
|
+
function extractTitle(frontmatter, content, fallback) {
|
|
115
|
+
if (typeof frontmatter.title === 'string' && frontmatter.title) {
|
|
116
|
+
return frontmatter.title
|
|
117
|
+
}
|
|
118
|
+
const match = content.match(/^#\s+(.+)$/m)
|
|
119
|
+
return match ? match[1].trim() : fallback
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Convert a date string to a YYYY-MM key, or null if not parseable.
|
|
124
|
+
*/
|
|
125
|
+
function toYearMonth(dateStr) {
|
|
126
|
+
if (!dateStr) return null
|
|
127
|
+
const date = new Date(dateStr)
|
|
128
|
+
if (Number.isNaN(date.getTime())) return null
|
|
129
|
+
const year = date.getFullYear()
|
|
130
|
+
const month = String(date.getMonth() + 1).padStart(2, '0')
|
|
131
|
+
return `${year}-${month}`
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Sort articles by date (newest first), then alphabetically by title.
|
|
136
|
+
*/
|
|
137
|
+
function sortArticles(articles) {
|
|
138
|
+
return [...articles].sort((a, b) => {
|
|
139
|
+
const timeA = a.date ? new Date(a.date).getTime() : null
|
|
140
|
+
const timeB = b.date ? new Date(b.date).getTime() : null
|
|
141
|
+
const validA = timeA !== null && !Number.isNaN(timeA) ? timeA : null
|
|
142
|
+
const validB = timeB !== null && !Number.isNaN(timeB) ? timeB : null
|
|
143
|
+
|
|
144
|
+
if (validA !== null && validB !== null) {
|
|
145
|
+
if (validA !== validB) return validB - validA
|
|
146
|
+
return a.title.localeCompare(b.title)
|
|
147
|
+
}
|
|
148
|
+
if (validA !== null) return -1
|
|
149
|
+
if (validB !== null) return 1
|
|
150
|
+
return a.title.localeCompare(b.title)
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Process all MDX files for a given language directory.
|
|
156
|
+
* Returns an array of article metadata objects.
|
|
157
|
+
*/
|
|
158
|
+
function processLanguage(docsDir, language) {
|
|
159
|
+
const langDir = path.join(docsDir, language)
|
|
160
|
+
if (!fs.existsSync(langDir)) return []
|
|
161
|
+
|
|
162
|
+
const files = fs.readdirSync(langDir)
|
|
163
|
+
const articles = []
|
|
164
|
+
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
// Skip non-MDX files and advanced variants
|
|
167
|
+
if (!file.endsWith('.mdx')) continue
|
|
168
|
+
if (file.endsWith('-advanced.mdx')) continue
|
|
169
|
+
|
|
170
|
+
const filePath = path.join(langDir, file)
|
|
171
|
+
let content
|
|
172
|
+
try {
|
|
173
|
+
content = fs.readFileSync(filePath, 'utf-8')
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.warn(` ⚠ Could not read ${filePath}: ${err.message}`)
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { frontmatter } = parseFrontmatter(content)
|
|
180
|
+
const slug = extractSlug(file)
|
|
181
|
+
const title = extractTitle(frontmatter, content, slug)
|
|
182
|
+
|
|
183
|
+
articles.push({
|
|
184
|
+
slug,
|
|
185
|
+
title,
|
|
186
|
+
date: typeof frontmatter.date === 'string' ? frontmatter.date : undefined,
|
|
187
|
+
author:
|
|
188
|
+
typeof frontmatter.author === 'string' ? frontmatter.author : undefined,
|
|
189
|
+
description:
|
|
190
|
+
typeof frontmatter.description === 'string'
|
|
191
|
+
? frontmatter.description
|
|
192
|
+
: undefined,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return articles
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Write data as formatted JSON to a file, creating any missing directories.
|
|
201
|
+
*/
|
|
202
|
+
function writeJson(filePath, data) {
|
|
203
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true })
|
|
204
|
+
fs.writeFileSync(filePath, `${JSON.stringify(data, null, 2)}\n`, 'utf-8')
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Main entry point.
|
|
209
|
+
*/
|
|
210
|
+
async function main() {
|
|
211
|
+
const args = parseArgs(process.argv)
|
|
212
|
+
|
|
213
|
+
if (!args.docs || !args.output) {
|
|
214
|
+
console.error('Usage: blog-indexer --docs <docs-dir> --output <output-dir>')
|
|
215
|
+
process.exit(1)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const docsDir = path.resolve(args.docs)
|
|
219
|
+
const outputDir = path.resolve(args.output)
|
|
220
|
+
|
|
221
|
+
if (!fs.existsSync(docsDir)) {
|
|
222
|
+
console.error(`Docs directory not found: ${docsDir}`)
|
|
223
|
+
process.exit(1)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Discover language sub-directories
|
|
227
|
+
const entries = fs.readdirSync(docsDir, { withFileTypes: true })
|
|
228
|
+
const languages = entries.filter((e) => e.isDirectory()).map((e) => e.name)
|
|
229
|
+
|
|
230
|
+
if (languages.length === 0) {
|
|
231
|
+
console.warn('No language directories found in docs dir, nothing to index.')
|
|
232
|
+
process.exit(0)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
for (const language of languages) {
|
|
236
|
+
console.log(`📝 Indexing language: ${language}`)
|
|
237
|
+
|
|
238
|
+
const articles = processLanguage(docsDir, language)
|
|
239
|
+
const sorted = sortArticles(articles)
|
|
240
|
+
const langOutputDir = path.join(outputDir, language)
|
|
241
|
+
|
|
242
|
+
// Write latest.json (top N articles for the home page)
|
|
243
|
+
const latest = sorted.slice(0, LATEST_COUNT)
|
|
244
|
+
writeJson(path.join(langOutputDir, 'latest.json'), latest)
|
|
245
|
+
console.log(` ✓ latest.json (${latest.length} articles)`)
|
|
246
|
+
|
|
247
|
+
// Group articles by month (only articles with a parseable date)
|
|
248
|
+
/** @type {Map<string, typeof sorted>} */
|
|
249
|
+
const monthMap = new Map()
|
|
250
|
+
for (const article of sorted) {
|
|
251
|
+
const yearMonth = toYearMonth(article.date)
|
|
252
|
+
if (yearMonth) {
|
|
253
|
+
let monthArticles = monthMap.get(yearMonth)
|
|
254
|
+
if (!monthArticles) {
|
|
255
|
+
monthArticles = []
|
|
256
|
+
monthMap.set(yearMonth, monthArticles)
|
|
257
|
+
}
|
|
258
|
+
monthArticles.push(article)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Write months-index.json (sorted newest month first)
|
|
263
|
+
const monthsIndex = Array.from(monthMap.keys()).sort((a, b) =>
|
|
264
|
+
b.localeCompare(a),
|
|
265
|
+
)
|
|
266
|
+
writeJson(path.join(langOutputDir, 'months-index.json'), monthsIndex)
|
|
267
|
+
console.log(` ✓ months-index.json (${monthsIndex.length} months)`)
|
|
268
|
+
|
|
269
|
+
// Write one file per month
|
|
270
|
+
const monthsDir = path.join(langOutputDir, 'months')
|
|
271
|
+
for (const [yearMonth, monthArticles] of monthMap) {
|
|
272
|
+
writeJson(path.join(monthsDir, `${yearMonth}.json`), monthArticles)
|
|
273
|
+
}
|
|
274
|
+
if (monthMap.size > 0) {
|
|
275
|
+
console.log(` ✓ ${monthMap.size} month file(s) in months/`)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log(`\n✅ Article index written to: ${outputDir}`)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
main().catch((err) => {
|
|
283
|
+
console.error('Error:', err.message)
|
|
284
|
+
process.exit(1)
|
|
285
|
+
})
|
package/context.d.ts
CHANGED
|
@@ -101,6 +101,16 @@ export interface BlogConfig {
|
|
|
101
101
|
* Example: 'Roboto, sans-serif' or '"Segoe UI", sans-serif'
|
|
102
102
|
*/
|
|
103
103
|
articleTitleFont?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Optional base path to the pre-built article index files generated by the blog-indexer CLI.
|
|
106
|
+
* When provided, UI components load pre-built JSON files instead of scanning MDX files at
|
|
107
|
+
* runtime, which significantly improves performance for large article collections.
|
|
108
|
+
*
|
|
109
|
+
* Run `npx blog-indexer --docs src/docs --output public/blog-index` to generate the index.
|
|
110
|
+
*
|
|
111
|
+
* Example: '/blog-index' → fetches /blog-index/{language}/latest.json etc.
|
|
112
|
+
*/
|
|
113
|
+
indexPath?: string;
|
|
104
114
|
}
|
|
105
115
|
/**
|
|
106
116
|
* A navigation link in the header.
|
package/index.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { ContentPage } from './components/ContentPage';
|
|
|
9
9
|
export { DocumentationLayout } from './components/DocumentationLayout';
|
|
10
10
|
export { DocumentationRoutes } from './components/DocumentationRoutes';
|
|
11
11
|
export { Header } from './components/Header';
|
|
12
|
+
export type { BlogArticle } from './components/HomePage';
|
|
12
13
|
export { HomePage } from './components/HomePage';
|
|
13
14
|
export type { MetadataProps } from './components/Metadata';
|
|
14
15
|
export { Metadata } from './components/Metadata';
|