@x-wave/blog 2.4.2 → 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 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';