@life-and-dev/mdsite 0.0.3

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.
Files changed (133) hide show
  1. package/README.md +228 -0
  2. package/bin/mdsite.js +3 -0
  3. package/dist/commands/commands.test.d.ts +1 -0
  4. package/dist/commands/commands.test.js +302 -0
  5. package/dist/commands/commands.test.js.map +1 -0
  6. package/dist/commands/generate.d.ts +1 -0
  7. package/dist/commands/generate.js +20 -0
  8. package/dist/commands/generate.js.map +1 -0
  9. package/dist/commands/init.d.ts +1 -0
  10. package/dist/commands/init.js +16 -0
  11. package/dist/commands/init.js.map +1 -0
  12. package/dist/commands/prepare.d.ts +1 -0
  13. package/dist/commands/prepare.js +111 -0
  14. package/dist/commands/prepare.js.map +1 -0
  15. package/dist/commands/prepare.test.d.ts +1 -0
  16. package/dist/commands/prepare.test.js +131 -0
  17. package/dist/commands/prepare.test.js.map +1 -0
  18. package/dist/commands/preview.d.ts +5 -0
  19. package/dist/commands/preview.js +62 -0
  20. package/dist/commands/preview.js.map +1 -0
  21. package/dist/commands/start.d.ts +5 -0
  22. package/dist/commands/start.js +50 -0
  23. package/dist/commands/start.js.map +1 -0
  24. package/dist/commands/stop.d.ts +1 -0
  25. package/dist/commands/stop.js +26 -0
  26. package/dist/commands/stop.js.map +1 -0
  27. package/dist/commands/workflows.test.d.ts +1 -0
  28. package/dist/commands/workflows.test.js +183 -0
  29. package/dist/commands/workflows.test.js.map +1 -0
  30. package/dist/config/default-mdsite-config.d.ts +2 -0
  31. package/dist/config/default-mdsite-config.js +82 -0
  32. package/dist/config/default-mdsite-config.js.map +1 -0
  33. package/dist/config/default-mdsite-config.test.d.ts +1 -0
  34. package/dist/config/default-mdsite-config.test.js +42 -0
  35. package/dist/config/default-mdsite-config.test.js.map +1 -0
  36. package/dist/config/mdsite-config.d.ts +42 -0
  37. package/dist/config/mdsite-config.js +74 -0
  38. package/dist/config/mdsite-config.js.map +1 -0
  39. package/dist/config/mdsite-config.test.d.ts +1 -0
  40. package/dist/config/mdsite-config.test.js +115 -0
  41. package/dist/config/mdsite-config.test.js.map +1 -0
  42. package/dist/config/menu.d.ts +3 -0
  43. package/dist/config/menu.js +58 -0
  44. package/dist/config/menu.js.map +1 -0
  45. package/dist/config/menu.test.d.ts +1 -0
  46. package/dist/config/menu.test.js +53 -0
  47. package/dist/config/menu.test.js.map +1 -0
  48. package/dist/index.d.ts +2 -0
  49. package/dist/index.js +99 -0
  50. package/dist/index.js.map +1 -0
  51. package/dist/index.test.d.ts +1 -0
  52. package/dist/index.test.js +133 -0
  53. package/dist/index.test.js.map +1 -0
  54. package/dist/process/child-process.d.ts +9 -0
  55. package/dist/process/child-process.js +142 -0
  56. package/dist/process/child-process.js.map +1 -0
  57. package/dist/process/child-process.test.d.ts +1 -0
  58. package/dist/process/child-process.test.js +131 -0
  59. package/dist/process/child-process.test.js.map +1 -0
  60. package/dist/process/runtime-state.d.ts +16 -0
  61. package/dist/process/runtime-state.js +42 -0
  62. package/dist/process/runtime-state.js.map +1 -0
  63. package/dist/process/runtime-state.test.d.ts +1 -0
  64. package/dist/process/runtime-state.test.js +57 -0
  65. package/dist/process/runtime-state.test.js.map +1 -0
  66. package/dist/renderer/mdsite-nuxt.d.ts +22 -0
  67. package/dist/renderer/mdsite-nuxt.js +194 -0
  68. package/dist/renderer/mdsite-nuxt.js.map +1 -0
  69. package/dist/renderer/mdsite-nuxt.test.d.ts +1 -0
  70. package/dist/renderer/mdsite-nuxt.test.js +226 -0
  71. package/dist/renderer/mdsite-nuxt.test.js.map +1 -0
  72. package/mdsite-nuxt/app/app.vue +7 -0
  73. package/mdsite-nuxt/app/assets/css/bible-tooltips.css +87 -0
  74. package/mdsite-nuxt/app/assets/css/markdown.css +195 -0
  75. package/mdsite-nuxt/app/assets/css/print.css +133 -0
  76. package/mdsite-nuxt/app/components/AppBar.vue +138 -0
  77. package/mdsite-nuxt/app/components/AppFooter.vue +88 -0
  78. package/mdsite-nuxt/app/components/AppNavigation.vue +162 -0
  79. package/mdsite-nuxt/app/components/AppTableOfContents.vue +149 -0
  80. package/mdsite-nuxt/app/components/BreadcrumbNav.vue +139 -0
  81. package/mdsite-nuxt/app/components/NavigationTree.vue +169 -0
  82. package/mdsite-nuxt/app/components/SearchBox.vue +212 -0
  83. package/mdsite-nuxt/app/components/TocItem.vue +75 -0
  84. package/mdsite-nuxt/app/components/TreeNode.vue +264 -0
  85. package/mdsite-nuxt/app/components/content/MarkdownAlert.vue +42 -0
  86. package/mdsite-nuxt/app/components/content/Mermaid.vue +103 -0
  87. package/mdsite-nuxt/app/components/content/ProseA.vue +57 -0
  88. package/mdsite-nuxt/app/components/content/ProseBlockquote.vue +14 -0
  89. package/mdsite-nuxt/app/components/content/ProsePre.vue +49 -0
  90. package/mdsite-nuxt/app/components/content/ProseTable.vue +143 -0
  91. package/mdsite-nuxt/app/composables/useAppTheme.ts +89 -0
  92. package/mdsite-nuxt/app/composables/useBibleTooltips.ts +21 -0
  93. package/mdsite-nuxt/app/composables/useBreadcrumbs.ts +70 -0
  94. package/mdsite-nuxt/app/composables/useContentPostProcessing.ts +89 -0
  95. package/mdsite-nuxt/app/composables/useNavigationTree.ts +174 -0
  96. package/mdsite-nuxt/app/composables/useSearchIndex.ts +47 -0
  97. package/mdsite-nuxt/app/composables/useSearchRelevance.test.ts +255 -0
  98. package/mdsite-nuxt/app/composables/useSearchRelevance.ts +207 -0
  99. package/mdsite-nuxt/app/composables/useSidebarState.ts +62 -0
  100. package/mdsite-nuxt/app/composables/useSiteConfig.ts +37 -0
  101. package/mdsite-nuxt/app/composables/useSourceEdit.ts +69 -0
  102. package/mdsite-nuxt/app/composables/useTableOfContents.ts +141 -0
  103. package/mdsite-nuxt/app/composables/useTableParser.ts +106 -0
  104. package/mdsite-nuxt/app/composables/useTooltipConfig.ts +20 -0
  105. package/mdsite-nuxt/app/config/themes.ts +17 -0
  106. package/mdsite-nuxt/app/layouts/default.vue +376 -0
  107. package/mdsite-nuxt/app/pages/[...slug].vue +56 -0
  108. package/mdsite-nuxt/app/pages/index.vue +54 -0
  109. package/mdsite-nuxt/app/plugins/bible-tooltips.client.ts +401 -0
  110. package/mdsite-nuxt/app/plugins/bible-tooltips.test.ts +284 -0
  111. package/mdsite-nuxt/app/types/content.d.ts +10 -0
  112. package/mdsite-nuxt/app/types/table.ts +57 -0
  113. package/mdsite-nuxt/app/utils/bible-book-names.ts +37 -0
  114. package/mdsite-nuxt/app/utils/bible-verse-utils.test.ts +148 -0
  115. package/mdsite-nuxt/app/utils/bible-verse-utils.ts +236 -0
  116. package/mdsite-nuxt/content.config.ts +18 -0
  117. package/mdsite-nuxt/example.config.yml +67 -0
  118. package/mdsite-nuxt/nuxt.config.ts +247 -0
  119. package/mdsite-nuxt/package-lock.json +15922 -0
  120. package/mdsite-nuxt/package.json +50 -0
  121. package/mdsite-nuxt/playwright.config.js +47 -0
  122. package/mdsite-nuxt/public/robots.txt +2 -0
  123. package/mdsite-nuxt/scripts/generate-favicons.ts +192 -0
  124. package/mdsite-nuxt/scripts/generate-indices.ts +508 -0
  125. package/mdsite-nuxt/scripts/renderer-hooks.test.ts +277 -0
  126. package/mdsite-nuxt/scripts/renderer-hooks.ts +141 -0
  127. package/mdsite-nuxt/scripts/start.test.ts +164 -0
  128. package/mdsite-nuxt/scripts/start.ts +55 -0
  129. package/mdsite-nuxt/scripts/sync-content.ts +504 -0
  130. package/mdsite-nuxt/tsconfig.json +18 -0
  131. package/mdsite-nuxt/utils/mdsite-config.ts +240 -0
  132. package/mdsite-nuxt/vitest.config.ts +7 -0
  133. package/package.json +36 -0
@@ -0,0 +1,508 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs-extra'
4
+ import path from 'path'
5
+ import { fileURLToPath } from 'url'
6
+ import { parse as parseYaml } from 'yaml'
7
+
8
+ const __filename = fileURLToPath(import.meta.url)
9
+ const __dirname = path.dirname(__filename)
10
+
11
+ // ----------------------------------------------------------------------------
12
+ // SHARED TYPES & HELPERS
13
+ // ----------------------------------------------------------------------------
14
+
15
+ /**
16
+ * Get content domain from environment variable
17
+ */
18
+ function getContentDomain(): string {
19
+ return process.env.CONTENT || 'cms'
20
+ }
21
+
22
+ /**
23
+ * Get source directory for content domain
24
+ */
25
+ function getSourceDir(): string {
26
+ if (process.env.CONTENT_DIR) return process.env.CONTENT_DIR
27
+ return path.resolve(__dirname, '..', '../../md-content', getContentDomain())
28
+ }
29
+
30
+ /**
31
+ * Get target public directory
32
+ */
33
+ function getTargetDir(): string {
34
+ return path.resolve(__dirname, '..', 'public')
35
+ }
36
+
37
+ /**
38
+ * Extract H1 title from markdown content
39
+ */
40
+ function extractH1Title(content: string): string | null {
41
+ const h1Match = content.match(/^#\s+(.+)$/m)
42
+ return h1Match?.[1]?.trim() ?? null
43
+ }
44
+
45
+ /**
46
+ * Extract frontmatter metadata from markdown content
47
+ * Unified to support both navigation (description) and search (keywords)
48
+ */
49
+ function extractMarkdownMetadata(content: string): {
50
+ title: string | null
51
+ description?: string
52
+ keywords?: string[]
53
+ } {
54
+ const title = extractH1Title(content)
55
+
56
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/)
57
+ const frontmatter = frontmatterMatch?.[1]
58
+
59
+ if (!frontmatter) {
60
+ return { title }
61
+ }
62
+
63
+ // Extract description
64
+ const descMatch = frontmatter.match(/^description:\s*(.+)$/m)
65
+ const description = descMatch?.[1]?.trim()
66
+
67
+ // Extract keywords (YAML array format)
68
+ const keywordsMatch = frontmatter.match(/^keywords:\s*\[(.*)\]$/m)
69
+ const keywordsStr = keywordsMatch?.[1]
70
+ let keywords: string[] | undefined
71
+ if (keywordsStr) {
72
+ keywords = keywordsStr
73
+ .split(',')
74
+ .map(k => k.trim().replace(/['"]/g, ''))
75
+ .filter(Boolean)
76
+ }
77
+
78
+ return { title, description, keywords }
79
+ }
80
+
81
+ /**
82
+ * Get markdown file path from relative path
83
+ */
84
+ function getMarkdownPath(relativePath: string): string {
85
+ const sourceDir = getSourceDir()
86
+ // If it already has an extension, don't append .md
87
+ if (relativePath.endsWith('.md')) {
88
+ return path.join(sourceDir, relativePath)
89
+ }
90
+ return path.join(sourceDir, `${relativePath}.md`)
91
+ }
92
+
93
+ // ----------------------------------------------------------------------------
94
+ // NAVIGATION LOGIC
95
+ // ----------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Minimal navigation tree node for client consumption
99
+ */
100
+ export interface MinimalTreeNode {
101
+ id: string
102
+ title: string
103
+ path: string
104
+ type: 'link' | 'separator' | 'header' | 'external'
105
+ children?: MinimalTreeNode[]
106
+ description?: string
107
+ isPrimary?: boolean
108
+ }
109
+
110
+ type MenuItemType = string | { [key: string]: string | null | MenuItemType[] } | null
111
+
112
+ /**
113
+ * Resolve relative/absolute paths in menu
114
+ */
115
+ function resolvePath(menuPath: string, contextPath: string): string {
116
+ // Absolute path
117
+ if (menuPath.startsWith('/')) {
118
+ return menuPath
119
+ }
120
+
121
+ // Parent directory (..)
122
+ if (menuPath.startsWith('../')) {
123
+ const contextSegments = contextPath.split('/').filter(Boolean)
124
+ contextSegments.pop() // Remove last segment
125
+ const relativePart = menuPath.substring(3) // Remove '../'
126
+ return contextSegments.length > 0
127
+ ? `/${contextSegments.join('/')}/${relativePart}`
128
+ : `/${relativePart}`
129
+ }
130
+
131
+ // Current directory (./)
132
+ if (menuPath.startsWith('./')) {
133
+ const relativePart = menuPath.substring(2)
134
+ return contextPath === '/' ? `/${relativePart}` : `${contextPath}/${relativePart}`
135
+ }
136
+
137
+ // Relative to context (no prefix)
138
+ return contextPath === '/' ? `/${menuPath}` : `${contextPath}/${menuPath}`
139
+ }
140
+
141
+ /**
142
+ * Process menu items recursively and build minimal tree structure
143
+ */
144
+ async function processMenuItems(
145
+ items: MenuItemType[],
146
+ contextPath: string,
147
+ order: number = 0
148
+ ): Promise<{ nodes: MinimalTreeNode[], nextOrder: number }> {
149
+ const nodes: MinimalTreeNode[] = []
150
+
151
+ for (const item of items) {
152
+ // Handle null/blank (separator) - legacy support
153
+ if (item === null) {
154
+ nodes.push({
155
+ id: `separator-${order}`,
156
+ title: '---',
157
+ path: `${contextPath}/__separator-${order}`,
158
+ type: 'separator'
159
+ })
160
+ order++
161
+ continue
162
+ }
163
+
164
+ // Handle string items
165
+ if (typeof item === 'string') {
166
+ // Check for separator marker
167
+ if (item === '===') {
168
+ nodes.push({
169
+ id: `separator-${order}`,
170
+ title: '---',
171
+ path: `${contextPath}/__separator-${order}`,
172
+ type: 'separator'
173
+ })
174
+ order++
175
+ continue
176
+ }
177
+
178
+ // String → lookup H1 and description from markdown file
179
+ const resolvedPath = resolvePath(item, contextPath)
180
+ const markdownPath = getMarkdownPath(resolvedPath)
181
+
182
+ let title: string | null = null
183
+ let description: string | undefined = undefined
184
+
185
+ try {
186
+ if (await fs.pathExists(markdownPath)) {
187
+ const content = await fs.readFile(markdownPath, 'utf-8')
188
+ const metadata = extractMarkdownMetadata(content)
189
+ title = metadata.title
190
+ description = metadata.description
191
+ }
192
+ } catch (e) {
193
+ // Ignore missing files
194
+ }
195
+
196
+ nodes.push({
197
+ id: `${resolvedPath.split('/').filter(Boolean).pop() || 'home'}-${order}`,
198
+ title: title || item,
199
+ path: resolvedPath,
200
+ type: 'link',
201
+ description,
202
+ isPrimary: true
203
+ })
204
+ order++
205
+ continue
206
+ }
207
+
208
+ // Handle object (custom title, external link, header, or submenu)
209
+ if (typeof item === 'object') {
210
+ for (const [key, value] of Object.entries(item)) {
211
+ // Handle header marker (value === '===')
212
+ if (value === '===') {
213
+ nodes.push({
214
+ id: `header-${order}`,
215
+ title: key,
216
+ path: `${contextPath}/__header-${order}`,
217
+ type: 'header'
218
+ })
219
+ order++
220
+ continue
221
+ }
222
+
223
+ // Handle null/empty value (legacy header support)
224
+ if (value === null || value === '') {
225
+ nodes.push({
226
+ id: `header-${order}`,
227
+ title: key,
228
+ path: `${contextPath}/__header-${order}`,
229
+ type: 'header'
230
+ })
231
+ order++
232
+ continue
233
+ }
234
+
235
+ // Handle external URL
236
+ if (typeof value === 'string' && (value.startsWith('http://') || value.startsWith('https://'))) {
237
+ nodes.push({
238
+ id: `external-${order}`,
239
+ title: key,
240
+ path: value,
241
+ type: 'external'
242
+ })
243
+ order++
244
+ continue
245
+ }
246
+
247
+ // Handle submenu (array value) - key is markdown filename
248
+ if (Array.isArray(value)) {
249
+ const submenuPath = resolvePath(key, contextPath)
250
+ const markdownPath = getMarkdownPath(submenuPath)
251
+
252
+ let title: string | null = null
253
+ let description: string | undefined = undefined
254
+
255
+ try {
256
+ if (await fs.pathExists(markdownPath)) {
257
+ const content = await fs.readFile(markdownPath, 'utf-8')
258
+ const metadata = extractMarkdownMetadata(content)
259
+ title = metadata.title
260
+ description = metadata.description
261
+ }
262
+ } catch (e) {
263
+ // Ignore missing files
264
+ }
265
+
266
+ // Process children recursively
267
+ const { nodes: childNodes } = await processMenuItems(value, submenuPath, 0)
268
+
269
+ nodes.push({
270
+ id: `${submenuPath.split('/').filter(Boolean).pop() || 'home'}-${order}`,
271
+ title: title || key,
272
+ path: submenuPath,
273
+ type: 'link',
274
+ description,
275
+ isPrimary: true,
276
+ children: childNodes
277
+ })
278
+ order++
279
+ continue
280
+ }
281
+
282
+ // Handle custom title with path (alias link)
283
+ if (typeof value === 'string') {
284
+ const resolvedPath = resolvePath(value, contextPath)
285
+ const markdownPath = getMarkdownPath(resolvedPath)
286
+ let description: string | undefined = undefined
287
+
288
+ try {
289
+ if (await fs.pathExists(markdownPath)) {
290
+ const content = await fs.readFile(markdownPath, 'utf-8')
291
+ const metadata = extractMarkdownMetadata(content)
292
+ description = metadata.description
293
+ }
294
+ } catch (e) {
295
+ // Ignore missing files
296
+ }
297
+
298
+ nodes.push({
299
+ id: `link-${key.replace(/\s+/g, '-').toLowerCase()}-${order}`,
300
+ title: key,
301
+ path: resolvedPath,
302
+ type: 'link',
303
+ description,
304
+ isPrimary: false // Aliases are NOT primary
305
+ })
306
+ order++
307
+ }
308
+ }
309
+ }
310
+ }
311
+
312
+ return { nodes, nextOrder: order }
313
+ }
314
+
315
+ /**
316
+ * Count total nodes in tree
317
+ */
318
+ function countNodes(nodes: MinimalTreeNode[]): number {
319
+ let count = nodes.length
320
+ for (const node of nodes) {
321
+ if (node.children) {
322
+ count += countNodes(node.children)
323
+ }
324
+ }
325
+ return count
326
+ }
327
+
328
+ /**
329
+ * Generate navigation JSON file
330
+ */
331
+ export async function generateNavigationJson() {
332
+ const domain = getContentDomain()
333
+ console.log(`📋 Building navigation tree for: ${domain}`)
334
+
335
+ const sourceDir = getSourceDir()
336
+ let menuPath = path.join(sourceDir, '_menu.yml')
337
+
338
+ if (!await fs.pathExists(menuPath)) {
339
+ menuPath = path.join(sourceDir, '_menu.yaml')
340
+ }
341
+
342
+ let tree: MinimalTreeNode[] = []
343
+ try {
344
+ if (await fs.pathExists(menuPath)) {
345
+ const menuContent = await fs.readFile(menuPath, 'utf-8')
346
+ const menuItems = parseYaml(menuContent) as MenuItemType[]
347
+ const result = await processMenuItems(menuItems, '/')
348
+ tree = result.nodes
349
+ } else {
350
+ console.warn('⚠️ No _menu.yml or _menu.yaml found at:', menuPath)
351
+ }
352
+ } catch (error) {
353
+ console.error('Error building navigation tree:', error)
354
+ }
355
+
356
+ const targetDir = getTargetDir()
357
+ const outputPath = path.join(targetDir, '_navigation.json')
358
+
359
+ await fs.ensureDir(targetDir)
360
+ await fs.writeJson(outputPath, tree, { spaces: 2 })
361
+
362
+ const fileSize = (await fs.stat(outputPath)).size
363
+ const fileSizeKB = (fileSize / 1024).toFixed(2)
364
+
365
+ console.log(`✓ Navigation tree generated: ${outputPath} (${fileSizeKB} KB)`)
366
+ console.log(`✓ Total menu items: ${countNodes(tree)}\n`)
367
+ }
368
+
369
+
370
+ // ----------------------------------------------------------------------------
371
+ // SEARCH INDEX LOGIC
372
+ // ----------------------------------------------------------------------------
373
+
374
+ export interface SearchIndexEntry {
375
+ path: string
376
+ title: string
377
+ description?: string
378
+ keywords?: string[]
379
+ excerpt?: string
380
+ }
381
+
382
+ /**
383
+ * Extract excerpt from markdown content (first 150 chars after frontmatter and H1)
384
+ */
385
+ function extractExcerpt(content: string): string | undefined {
386
+ // Remove frontmatter
387
+ let cleanContent = content.replace(/^---\n[\s\S]*?\n---\n/, '')
388
+
389
+ // Remove H1
390
+ cleanContent = cleanContent.replace(/^#\s+.+$/m, '')
391
+
392
+ // Get first paragraph, trim whitespace
393
+ const firstParagraph = cleanContent.trim().split('\n\n')[0]
394
+ if (!firstParagraph) return undefined
395
+
396
+ // Trim to 150 chars
397
+ const excerpt = firstParagraph.trim().substring(0, 150)
398
+ return excerpt.length > 0 ? excerpt : undefined
399
+ }
400
+
401
+ /**
402
+ * Convert file path to URL path
403
+ */
404
+ function filePathToUrlPath(filePath: string, sourceDir: string): string {
405
+ const relativePath = path.relative(sourceDir, filePath)
406
+ const urlPath = '/' + relativePath
407
+ .replace(/\.md$/, '')
408
+ .replace(/index$/, '')
409
+ .replace(/\\/g, '/')
410
+ .replace(/\/+$/, '')
411
+
412
+ return urlPath === '' ? '/' : urlPath
413
+ }
414
+
415
+ /**
416
+ * Get all markdown files recursively
417
+ */
418
+ async function getAllMarkdownFiles(dir: string): Promise<string[]> {
419
+ const files: string[] = []
420
+
421
+ if (!await fs.pathExists(dir)) {
422
+ return files
423
+ }
424
+
425
+ const items = await fs.readdir(dir)
426
+
427
+ for (const item of items) {
428
+ const itemPath = path.join(dir, item)
429
+ const stat = await fs.stat(itemPath)
430
+
431
+ if (stat.isDirectory()) {
432
+ const subFiles = await getAllMarkdownFiles(itemPath)
433
+ files.push(...subFiles)
434
+ } else if (item.endsWith('.md') && !item.endsWith('.draft.md')) {
435
+ // Only include published markdown files
436
+ files.push(itemPath)
437
+ }
438
+ }
439
+
440
+ return files
441
+ }
442
+
443
+ /**
444
+ * Generate search index JSON file
445
+ */
446
+ export async function generateSearchIndexJson() {
447
+ const domain = getContentDomain()
448
+ console.log(`🔍 Building search index for: ${domain}`)
449
+
450
+ const sourceDir = getSourceDir()
451
+ const markdownFiles = await getAllMarkdownFiles(sourceDir)
452
+
453
+ const index: SearchIndexEntry[] = []
454
+
455
+ for (const filePath of markdownFiles) {
456
+ try {
457
+ const content = await fs.readFile(filePath, 'utf-8')
458
+ const metadata = extractMarkdownMetadata(content)
459
+ const excerpt = extractExcerpt(content)
460
+ const urlPath = filePathToUrlPath(filePath, sourceDir)
461
+
462
+ // Skip files without H1 (malformed)
463
+ if (!metadata.title) {
464
+ console.warn(`⚠️ No H1 found in: ${path.relative(sourceDir, filePath)}`)
465
+ continue
466
+ }
467
+
468
+ index.push({
469
+ path: urlPath,
470
+ title: metadata.title,
471
+ description: metadata.description,
472
+ keywords: metadata.keywords,
473
+ excerpt
474
+ })
475
+ } catch (error) {
476
+ console.error(`❌ Failed to process ${filePath}:`, error)
477
+ }
478
+ }
479
+
480
+ const targetDir = getTargetDir()
481
+ const outputPath = path.join(targetDir, '_search-index.json')
482
+
483
+ await fs.ensureDir(targetDir)
484
+ await fs.writeJson(outputPath, index, { spaces: 2 })
485
+
486
+ const fileSize = (await fs.stat(outputPath)).size
487
+ const fileSizeKB = (fileSize / 1024).toFixed(2)
488
+
489
+ console.log(`✓ Search index generated: ${outputPath} (${fileSizeKB} KB)`)
490
+ console.log(`✓ Total indexed pages: ${index.length}\n`)
491
+ }
492
+
493
+ // ----------------------------------------------------------------------------
494
+ // MAIN EXECUTION
495
+ // ----------------------------------------------------------------------------
496
+
497
+ export async function buildContentData() {
498
+ await generateNavigationJson()
499
+ await generateSearchIndexJson()
500
+ }
501
+
502
+ // Run if called directly
503
+ if (import.meta.url === `file://${process.argv[1]}`) {
504
+ buildContentData().catch((error) => {
505
+ console.error(error)
506
+ process.exit(1)
507
+ })
508
+ }