@life-and-dev/mdsite 0.4.0 → 0.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.
Files changed (33) hide show
  1. package/README.md +4 -0
  2. package/dist/commands/commands.test.js +1 -0
  3. package/dist/commands/commands.test.js.map +1 -1
  4. package/dist/commands/workflows.test.js +1 -0
  5. package/dist/commands/workflows.test.js.map +1 -1
  6. package/dist/config/default-mdsite-config.js +1 -0
  7. package/dist/config/default-mdsite-config.js.map +1 -1
  8. package/dist/config/default-mdsite-config.test.js +1 -0
  9. package/dist/config/default-mdsite-config.test.js.map +1 -1
  10. package/dist/config/mdsite-config.d.ts +1 -0
  11. package/dist/config/mdsite-config.js +1 -0
  12. package/dist/config/mdsite-config.js.map +1 -1
  13. package/dist/config/mdsite-config.test.js +17 -0
  14. package/dist/config/mdsite-config.test.js.map +1 -1
  15. package/dist/process/runtime-state.test.js +1 -0
  16. package/dist/process/runtime-state.test.js.map +1 -1
  17. package/dist/renderer/mdsite-nuxt.test.js +1 -0
  18. package/dist/renderer/mdsite-nuxt.test.js.map +1 -1
  19. package/mdsite-nuxt/app/components/AppBar.vue +4 -2
  20. package/mdsite-nuxt/app/components/AppFooter.vue +31 -35
  21. package/mdsite-nuxt/app/components/AppTableOfContents.vue +1 -1
  22. package/mdsite-nuxt/app/composables/useFooter.ts +46 -0
  23. package/mdsite-nuxt/app/composables/useNavigationTree.test.ts +78 -0
  24. package/mdsite-nuxt/app/composables/useNavigationTree.ts +36 -0
  25. package/mdsite-nuxt/app/composables/useTableOfContents.test.ts +35 -0
  26. package/mdsite-nuxt/app/composables/useTableOfContents.ts +32 -4
  27. package/mdsite-nuxt/app/layouts/default.vue +15 -0
  28. package/mdsite-nuxt/scripts/generate-indices.test.ts +108 -1
  29. package/mdsite-nuxt/scripts/generate-indices.ts +170 -0
  30. package/mdsite-nuxt/scripts/renderer-hooks.ts +1 -0
  31. package/mdsite-nuxt/scripts/sync-content.ts +31 -0
  32. package/mdsite-nuxt/utils/mdsite-config.ts +3 -0
  33. package/package.json +1 -1
@@ -445,6 +445,166 @@ async function loadMenuConfig(sourceDir: string): Promise<MenuItemType[] | null>
445
445
  return null
446
446
  }
447
447
 
448
+ // ----------------------------------------------------------------------------
449
+ // FOOTER LOGIC
450
+ // ----------------------------------------------------------------------------
451
+
452
+ export interface FooterLink {
453
+ path: string
454
+ title: string
455
+ }
456
+
457
+ /**
458
+ * Read the footer array from a candidate mdsite.yml config file.
459
+ * Returns null when the file is missing, unreadable, or has no footer key.
460
+ */
461
+ async function tryReadFooterFromConfig(configPath: string): Promise<string[] | null> {
462
+ if (!await fs.pathExists(configPath)) {
463
+ return null
464
+ }
465
+
466
+ try {
467
+ const content = await fs.readFile(configPath, 'utf-8')
468
+ const parsed = parseYaml(content) as { footer?: unknown } | null
469
+ if (parsed && Array.isArray(parsed.footer) && parsed.footer.length > 0) {
470
+ return parsed.footer.filter((item): item is string => typeof item === 'string')
471
+ }
472
+ } catch (e) {
473
+ // Ignore parse errors - they shouldn't abort the whole lookup chain
474
+ }
475
+
476
+ return null
477
+ }
478
+
479
+ /**
480
+ * Layered footer lookup. Tries, in order:
481
+ * 1. MDSITE_CONFIG_PATH env var
482
+ * 2. <sourceDir>/../mdsite.yml
483
+ * 3. <sourceDir>/mdsite.yml
484
+ * Returns the first non-empty footer array, or null if none resolve.
485
+ */
486
+ async function loadFooterConfig(sourceDir: string): Promise<string[] | null> {
487
+ const candidates: string[] = []
488
+ if (process.env.MDSITE_CONFIG_PATH) {
489
+ candidates.push(process.env.MDSITE_CONFIG_PATH)
490
+ }
491
+ candidates.push(path.join(sourceDir, '..', 'mdsite.yml'))
492
+ candidates.push(path.join(sourceDir, 'mdsite.yml'))
493
+
494
+ for (const candidate of candidates) {
495
+ const footer = await tryReadFooterFromConfig(candidate)
496
+ if (footer) {
497
+ return footer
498
+ }
499
+ }
500
+
501
+ return null
502
+ }
503
+
504
+ /**
505
+ * Resolve a raw footer entry to its normalized URL path. Returns null when the
506
+ * path is empty after resolution.
507
+ */
508
+ function resolveFooterPath(item: string): string | null {
509
+ const resolvedPath = resolvePath(item, '/')
510
+ if (!resolvedPath || resolvedPath === '/') {
511
+ return null
512
+ }
513
+ return normalizeIndexPath(resolvedPath)
514
+ }
515
+
516
+ /**
517
+ * Process footer items into a flat list of { path, title } links.
518
+ * Title falls back to the raw entry when the markdown file is missing or has no H1.
519
+ */
520
+ async function processFooterItems(items: string[]): Promise<FooterLink[]> {
521
+ const links: FooterLink[] = []
522
+
523
+ for (const item of items) {
524
+ const normalizedPath = resolveFooterPath(item)
525
+ if (!normalizedPath) {
526
+ continue
527
+ }
528
+
529
+ const markdownPath = getMarkdownPath(normalizedPath)
530
+ let title: string | null = null
531
+
532
+ try {
533
+ if (await fs.pathExists(markdownPath)) {
534
+ const content = await fs.readFile(markdownPath, 'utf-8')
535
+ const metadata = extractMarkdownMetadata(content)
536
+ title = metadata.title
537
+ }
538
+ } catch (e) {
539
+ // Ignore missing files
540
+ }
541
+
542
+ links.push({
543
+ path: normalizedPath,
544
+ title: title || item
545
+ })
546
+ }
547
+
548
+ return links
549
+ }
550
+
551
+ /**
552
+ * Build the set of normalized footer paths used to exclude entries from the nav tree.
553
+ * Returns an empty set when no footer section is configured.
554
+ */
555
+ async function getFooterExcludedPaths(sourceDir: string): Promise<Set<string>> {
556
+ const items = await loadFooterConfig(sourceDir)
557
+ if (!items) {
558
+ return new Set()
559
+ }
560
+
561
+ const excluded = new Set<string>()
562
+ for (const item of items) {
563
+ const normalizedPath = resolveFooterPath(item)
564
+ if (normalizedPath) {
565
+ excluded.add(normalizedPath)
566
+ }
567
+ }
568
+ return excluded
569
+ }
570
+
571
+ /**
572
+ * Recursively drop any node whose path matches the excluded set. Subtree children
573
+ * of a dropped node are removed along with the parent.
574
+ */
575
+ function filterTreeByExcludedPaths(nodes: MinimalTreeNode[], excluded: Set<string>): MinimalTreeNode[] {
576
+ return nodes
577
+ .filter(node => !excluded.has(node.path))
578
+ .map(node => ({
579
+ ...node,
580
+ children: node.children ? filterTreeByExcludedPaths(node.children, excluded) : undefined
581
+ }))
582
+ }
583
+
584
+ /**
585
+ * Generate footer links JSON file
586
+ */
587
+ export async function generateFooterJson() {
588
+ const domain = getContentDomain()
589
+ console.log(`📎 Building footer links for: ${domain}`)
590
+
591
+ const sourceDir = getSourceDir()
592
+ const items = await loadFooterConfig(sourceDir)
593
+ const links = items ? await processFooterItems(items) : []
594
+
595
+ const targetDir = getTargetDir()
596
+ const outputPath = path.join(targetDir, '_footer.json')
597
+
598
+ await fs.ensureDir(targetDir)
599
+ await fs.writeJson(outputPath, links, { spaces: 2 })
600
+
601
+ const fileSize = (await fs.stat(outputPath)).size
602
+ const fileSizeKB = (fileSize / 1024).toFixed(2)
603
+
604
+ console.log(`✓ Footer links generated: ${outputPath} (${fileSizeKB} KB)`)
605
+ console.log(`✓ Total footer links: ${links.length}\n`)
606
+ }
607
+
448
608
  /**
449
609
  * Generate navigation JSON file
450
610
  */
@@ -466,8 +626,17 @@ export async function generateNavigationJson() {
466
626
  console.error('Error building navigation tree:', error)
467
627
  }
468
628
 
629
+ // Deduplicate footer entries from the menu tree (if any footer section is configured)
630
+ const excludedPaths = await getFooterExcludedPaths(sourceDir)
631
+ if (excludedPaths.size > 0) {
632
+ tree = filterTreeByExcludedPaths(tree, excludedPaths)
633
+ }
634
+
469
635
  if (tree.length === 0) {
470
636
  tree = await buildFallbackNavigationTree(sourceDir)
637
+ if (excludedPaths.size > 0) {
638
+ tree = filterTreeByExcludedPaths(tree, excludedPaths)
639
+ }
471
640
  }
472
641
 
473
642
  const targetDir = getTargetDir()
@@ -614,6 +783,7 @@ export async function generateSearchIndexJson() {
614
783
  export async function buildContentData() {
615
784
  await generateNavigationJson()
616
785
  await generateSearchIndexJson()
786
+ await generateFooterJson()
617
787
  }
618
788
 
619
789
  // Run if called directly
@@ -122,6 +122,7 @@ function ensureLegacyCompatibilityConfig(rootDir: string): string | undefined {
122
122
  sourceEdit: legacyConfig.features?.sourceEdit ?? true
123
123
  },
124
124
  menu: [],
125
+ footer: [],
125
126
  server: {
126
127
  output: '.output',
127
128
  path: '.mdsite',
@@ -22,6 +22,7 @@ const STATIC_FILES = [
22
22
  // Debounce timers for JSON regeneration (5 second delay)
23
23
  let navigationDebounceTimer: NodeJS.Timeout | null = null
24
24
  let searchDebounceTimer: NodeJS.Timeout | null = null
25
+ let footerDebounceTimer: NodeJS.Timeout | null = null
25
26
 
26
27
  /**
27
28
  * Get content domain from environment variable (read at runtime, not import time)
@@ -87,6 +88,26 @@ function regenerateSearchIndex() {
87
88
  }, 5000)
88
89
  }
89
90
 
91
+ /**
92
+ * Regenerate footer links JSON with debouncing (5 second delay)
93
+ */
94
+ function regenerateFooter() {
95
+ if (footerDebounceTimer) {
96
+ clearTimeout(footerDebounceTimer)
97
+ }
98
+
99
+ footerDebounceTimer = setTimeout(async () => {
100
+ console.log('🔄 Regenerating footer links...')
101
+ try {
102
+ const { generateFooterJson } = await import('./generate-indices.js')
103
+ await generateFooterJson()
104
+ } catch (error) {
105
+ console.error('❌ Failed to regenerate footer links:', error)
106
+ }
107
+ footerDebounceTimer = null
108
+ }, 5000)
109
+ }
110
+
90
111
  /**
91
112
  * Generate navigation and search JSON files (one-time on startup)
92
113
  */
@@ -106,6 +127,13 @@ export async function generateJsonFiles() {
106
127
  } catch (error) {
107
128
  console.error('❌ Failed to generate search index:', error)
108
129
  }
130
+
131
+ try {
132
+ const { generateFooterJson } = await import('./generate-indices.js')
133
+ await generateFooterJson()
134
+ } catch (error) {
135
+ console.error('❌ Failed to generate footer links:', error)
136
+ }
109
137
  }
110
138
 
111
139
  /**
@@ -198,6 +226,7 @@ export async function startWatcher() {
198
226
  console.log(`📝 Markdown added: ${fileName}`)
199
227
  regenerateNavigation()
200
228
  regenerateSearchIndex()
229
+ regenerateFooter()
201
230
  } else {
202
231
  copyImage(filePath, true, 'added')
203
232
  }
@@ -209,6 +238,7 @@ export async function startWatcher() {
209
238
  console.log(`📝 Markdown updated: ${fileName}`)
210
239
  regenerateNavigation()
211
240
  regenerateSearchIndex()
241
+ regenerateFooter()
212
242
  } else {
213
243
  copyImage(filePath, true, 'updated')
214
244
  }
@@ -220,6 +250,7 @@ export async function startWatcher() {
220
250
  console.log(`📝 Markdown deleted: ${fileName}`)
221
251
  regenerateNavigation()
222
252
  regenerateSearchIndex()
253
+ regenerateFooter()
223
254
  } else {
224
255
  deleteImage(filePath)
225
256
  }
@@ -12,6 +12,7 @@ export interface MdsiteConfig {
12
12
  sourceEdit: boolean
13
13
  }
14
14
  menu: Array<string | null | Record<string, string | null | Array<any>>>
15
+ footer: string[]
15
16
  server: {
16
17
  output: string
17
18
  path: string
@@ -201,6 +202,7 @@ function createDefaultMdsiteConfig(siteName: string): MdsiteConfig {
201
202
  sourceEdit: true
202
203
  },
203
204
  menu: [],
205
+ footer: [],
204
206
  server: {
205
207
  output: '.output',
206
208
  path: '.mdsite',
@@ -233,6 +235,7 @@ function normalizeMdsiteConfig(rawConfig: Record<string, any>, contentDir: strin
233
235
  },
234
236
  content: contentPath ? { path: contentPath } : fallbackConfig.content,
235
237
  menu: Array.isArray(rawConfig.menu) ? rawConfig.menu : fallbackConfig.menu,
238
+ footer: Array.isArray(rawConfig.footer) ? rawConfig.footer.filter((item): item is string => typeof item === 'string') : [],
236
239
  server: {
237
240
  output: typeof rawConfig.server?.output === 'string' ? rawConfig.server.output : fallbackConfig.server.output,
238
241
  path: typeof rawConfig.server?.path === 'string' ? rawConfig.server.path : fallbackConfig.server.path,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@life-and-dev/mdsite",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Local-first CLI that orchestrates mdsite-nuxt",