@life-and-dev/mdsite 0.4.0 → 0.5.1

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 (34) 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 +11 -17
  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/nuxt.config.ts +19 -1
  29. package/mdsite-nuxt/scripts/generate-indices.test.ts +108 -1
  30. package/mdsite-nuxt/scripts/generate-indices.ts +181 -8
  31. package/mdsite-nuxt/scripts/renderer-hooks.ts +1 -0
  32. package/mdsite-nuxt/scripts/sync-content.ts +31 -0
  33. package/mdsite-nuxt/utils/mdsite-config.ts +28 -1
  34. package/package.json +1 -1
@@ -4,6 +4,7 @@ import fs from 'fs-extra'
4
4
  import path from 'path'
5
5
  import { fileURLToPath } from 'url'
6
6
  import { parse as parseYaml } from 'yaml'
7
+ import type { MdsiteMenuItem } from '../utils/mdsite-config'
7
8
 
8
9
  const __filename = fileURLToPath(import.meta.url)
9
10
  const __dirname = path.dirname(__filename)
@@ -108,8 +109,10 @@ export interface MinimalTreeNode {
108
109
  isPrimary?: boolean
109
110
  }
110
111
 
111
- type MenuItemType = string | { [key: string]: string | null | MenuItemType[] } | null
112
-
112
+ // `MdsiteMenuItem` is imported from `~/utils/mdsite-config` (see top of file).
113
+ // It is the same recursive shape that was previously defined locally as
114
+ // `MdsiteMenuItem`. Single source of truth lives in `utils/mdsite-config.ts`
115
+ // so the Nuxt runtime-config type generator can infer it correctly.
113
116
  /**
114
117
  * Normalize URL path so a trailing /index resolves to its parent.
115
118
  * Mirrors filePathToUrlPath behavior so menu paths match content routes.
@@ -161,7 +164,7 @@ function resolvePath(menuPath: string, contextPath: string): string {
161
164
  * Process menu items recursively and build minimal tree structure
162
165
  */
163
166
  async function processMenuItems(
164
- items: MenuItemType[],
167
+ items: MdsiteMenuItem[],
165
168
  contextPath: string,
166
169
  order: number = 0
167
170
  ): Promise<{ nodes: MinimalTreeNode[], nextOrder: number }> {
@@ -371,14 +374,14 @@ async function buildFallbackNavigationTree(sourceDir: string): Promise<MinimalTr
371
374
  * Load the menu array from a candidate config file (legacy _menu.yml/yaml or mdsite.yml).
372
375
  * Returns null if the file is missing, unreadable, has no menu key, or has an empty menu.
373
376
  */
374
- async function tryReadMenuFromConfig(configPath: string): Promise<MenuItemType[] | null> {
377
+ async function tryReadMenuFromConfig(configPath: string): Promise<MdsiteMenuItem[] | null> {
375
378
  if (!await fs.pathExists(configPath)) {
376
379
  return null
377
380
  }
378
381
 
379
382
  try {
380
383
  const content = await fs.readFile(configPath, 'utf-8')
381
- const parsed = parseYaml(content) as { menu?: MenuItemType[] } | null
384
+ const parsed = parseYaml(content) as { menu?: MdsiteMenuItem[] } | null
382
385
  if (parsed && Array.isArray(parsed.menu) && parsed.menu.length > 0) {
383
386
  return parsed.menu
384
387
  }
@@ -393,14 +396,14 @@ async function tryReadMenuFromConfig(configPath: string): Promise<MenuItemType[]
393
396
  * Try to read menu items from a plain (non-wrapped) legacy _menu.yml/yaml file.
394
397
  * Returns null if the file is missing, unreadable, or doesn't contain a non-empty array.
395
398
  */
396
- async function tryReadLegacyMenuFile(menuPath: string): Promise<MenuItemType[] | null> {
399
+ async function tryReadLegacyMenuFile(menuPath: string): Promise<MdsiteMenuItem[] | null> {
397
400
  if (!await fs.pathExists(menuPath)) {
398
401
  return null
399
402
  }
400
403
 
401
404
  try {
402
405
  const content = await fs.readFile(menuPath, 'utf-8')
403
- const parsed = parseYaml(content) as MenuItemType[] | null
406
+ const parsed = parseYaml(content) as MdsiteMenuItem[] | null
404
407
  if (Array.isArray(parsed) && parsed.length > 0) {
405
408
  return parsed
406
409
  }
@@ -420,7 +423,7 @@ async function tryReadLegacyMenuFile(menuPath: string): Promise<MenuItemType[] |
420
423
  * 5. <sourceDir>/mdsite.yml
421
424
  * Returns the first non-empty menu array, or null if none resolve to a menu.
422
425
  */
423
- async function loadMenuConfig(sourceDir: string): Promise<MenuItemType[] | null> {
426
+ async function loadMenuConfig(sourceDir: string): Promise<MdsiteMenuItem[] | null> {
424
427
  const candidates: { path: string, isLegacy: boolean }[] = [
425
428
  { path: path.join(sourceDir, '_menu.yml'), isLegacy: true },
426
429
  { path: path.join(sourceDir, '_menu.yaml'), isLegacy: true }
@@ -445,6 +448,166 @@ async function loadMenuConfig(sourceDir: string): Promise<MenuItemType[] | null>
445
448
  return null
446
449
  }
447
450
 
451
+ // ----------------------------------------------------------------------------
452
+ // FOOTER LOGIC
453
+ // ----------------------------------------------------------------------------
454
+
455
+ export interface FooterLink {
456
+ path: string
457
+ title: string
458
+ }
459
+
460
+ /**
461
+ * Read the footer array from a candidate mdsite.yml config file.
462
+ * Returns null when the file is missing, unreadable, or has no footer key.
463
+ */
464
+ async function tryReadFooterFromConfig(configPath: string): Promise<string[] | null> {
465
+ if (!await fs.pathExists(configPath)) {
466
+ return null
467
+ }
468
+
469
+ try {
470
+ const content = await fs.readFile(configPath, 'utf-8')
471
+ const parsed = parseYaml(content) as { footer?: unknown } | null
472
+ if (parsed && Array.isArray(parsed.footer) && parsed.footer.length > 0) {
473
+ return parsed.footer.filter((item): item is string => typeof item === 'string')
474
+ }
475
+ } catch (e) {
476
+ // Ignore parse errors - they shouldn't abort the whole lookup chain
477
+ }
478
+
479
+ return null
480
+ }
481
+
482
+ /**
483
+ * Layered footer lookup. Tries, in order:
484
+ * 1. MDSITE_CONFIG_PATH env var
485
+ * 2. <sourceDir>/../mdsite.yml
486
+ * 3. <sourceDir>/mdsite.yml
487
+ * Returns the first non-empty footer array, or null if none resolve.
488
+ */
489
+ async function loadFooterConfig(sourceDir: string): Promise<string[] | null> {
490
+ const candidates: string[] = []
491
+ if (process.env.MDSITE_CONFIG_PATH) {
492
+ candidates.push(process.env.MDSITE_CONFIG_PATH)
493
+ }
494
+ candidates.push(path.join(sourceDir, '..', 'mdsite.yml'))
495
+ candidates.push(path.join(sourceDir, 'mdsite.yml'))
496
+
497
+ for (const candidate of candidates) {
498
+ const footer = await tryReadFooterFromConfig(candidate)
499
+ if (footer) {
500
+ return footer
501
+ }
502
+ }
503
+
504
+ return null
505
+ }
506
+
507
+ /**
508
+ * Resolve a raw footer entry to its normalized URL path. Returns null when the
509
+ * path is empty after resolution.
510
+ */
511
+ function resolveFooterPath(item: string): string | null {
512
+ const resolvedPath = resolvePath(item, '/')
513
+ if (!resolvedPath || resolvedPath === '/') {
514
+ return null
515
+ }
516
+ return normalizeIndexPath(resolvedPath)
517
+ }
518
+
519
+ /**
520
+ * Process footer items into a flat list of { path, title } links.
521
+ * Title falls back to the raw entry when the markdown file is missing or has no H1.
522
+ */
523
+ async function processFooterItems(items: string[]): Promise<FooterLink[]> {
524
+ const links: FooterLink[] = []
525
+
526
+ for (const item of items) {
527
+ const normalizedPath = resolveFooterPath(item)
528
+ if (!normalizedPath) {
529
+ continue
530
+ }
531
+
532
+ const markdownPath = getMarkdownPath(normalizedPath)
533
+ let title: string | null = null
534
+
535
+ try {
536
+ if (await fs.pathExists(markdownPath)) {
537
+ const content = await fs.readFile(markdownPath, 'utf-8')
538
+ const metadata = extractMarkdownMetadata(content)
539
+ title = metadata.title
540
+ }
541
+ } catch (e) {
542
+ // Ignore missing files
543
+ }
544
+
545
+ links.push({
546
+ path: normalizedPath,
547
+ title: title || item
548
+ })
549
+ }
550
+
551
+ return links
552
+ }
553
+
554
+ /**
555
+ * Build the set of normalized footer paths used to exclude entries from the nav tree.
556
+ * Returns an empty set when no footer section is configured.
557
+ */
558
+ async function getFooterExcludedPaths(sourceDir: string): Promise<Set<string>> {
559
+ const items = await loadFooterConfig(sourceDir)
560
+ if (!items) {
561
+ return new Set()
562
+ }
563
+
564
+ const excluded = new Set<string>()
565
+ for (const item of items) {
566
+ const normalizedPath = resolveFooterPath(item)
567
+ if (normalizedPath) {
568
+ excluded.add(normalizedPath)
569
+ }
570
+ }
571
+ return excluded
572
+ }
573
+
574
+ /**
575
+ * Recursively drop any node whose path matches the excluded set. Subtree children
576
+ * of a dropped node are removed along with the parent.
577
+ */
578
+ function filterTreeByExcludedPaths(nodes: MinimalTreeNode[], excluded: Set<string>): MinimalTreeNode[] {
579
+ return nodes
580
+ .filter(node => !excluded.has(node.path))
581
+ .map(node => ({
582
+ ...node,
583
+ children: node.children ? filterTreeByExcludedPaths(node.children, excluded) : undefined
584
+ }))
585
+ }
586
+
587
+ /**
588
+ * Generate footer links JSON file
589
+ */
590
+ export async function generateFooterJson() {
591
+ const domain = getContentDomain()
592
+ console.log(`📎 Building footer links for: ${domain}`)
593
+
594
+ const sourceDir = getSourceDir()
595
+ const items = await loadFooterConfig(sourceDir)
596
+ const links = items ? await processFooterItems(items) : []
597
+
598
+ const targetDir = getTargetDir()
599
+ const outputPath = path.join(targetDir, '_footer.json')
600
+
601
+ await fs.ensureDir(targetDir)
602
+ await fs.writeJson(outputPath, links, { spaces: 2 })
603
+
604
+ const fileSize = (await fs.stat(outputPath)).size
605
+ const fileSizeKB = (fileSize / 1024).toFixed(2)
606
+
607
+ console.log(`✓ Footer links generated: ${outputPath} (${fileSizeKB} KB)`)
608
+ console.log(`✓ Total footer links: ${links.length}\n`)
609
+ }
610
+
448
611
  /**
449
612
  * Generate navigation JSON file
450
613
  */
@@ -466,8 +629,17 @@ export async function generateNavigationJson() {
466
629
  console.error('Error building navigation tree:', error)
467
630
  }
468
631
 
632
+ // Deduplicate footer entries from the menu tree (if any footer section is configured)
633
+ const excludedPaths = await getFooterExcludedPaths(sourceDir)
634
+ if (excludedPaths.size > 0) {
635
+ tree = filterTreeByExcludedPaths(tree, excludedPaths)
636
+ }
637
+
469
638
  if (tree.length === 0) {
470
639
  tree = await buildFallbackNavigationTree(sourceDir)
640
+ if (excludedPaths.size > 0) {
641
+ tree = filterTreeByExcludedPaths(tree, excludedPaths)
642
+ }
471
643
  }
472
644
 
473
645
  const targetDir = getTargetDir()
@@ -614,6 +786,7 @@ export async function generateSearchIndexJson() {
614
786
  export async function buildContentData() {
615
787
  await generateNavigationJson()
616
788
  await generateSearchIndexJson()
789
+ await generateFooterJson()
617
790
  }
618
791
 
619
792
  // 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
  }
@@ -2,6 +2,30 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import YAML from 'yaml';
4
4
 
5
+ /**
6
+ * Recursive shape of a single `mdsite.yml` `menu:` entry.
7
+ *
8
+ * - `string` → flat link to a markdown slug
9
+ * (e.g. `- genesis`, `- resurrections`)
10
+ * - `null` → visual separator (`===` in YAML)
11
+ * - `Record<string, MdsiteMenuValue>` → group: object whose keys are group
12
+ * labels (e.g. `"Members of the Trinity":`) and whose
13
+ * values are `MdsiteMenuValue` items
14
+ * - `MdsiteMenuValue` (the value side of a group) can be:
15
+ * - `string` → alias link with custom title
16
+ * (e.g. `"Job": https://...`, `"Homepage": index`)
17
+ * - `null` → group label only (renders as a heading, no link)
18
+ * - `MdsiteMenuItem[]` → nested submenu (recursive)
19
+ *
20
+ * Keeping this a `type` alias (not an `interface`) and avoiding `any` lets
21
+ * Nuxt's runtime-config type generator infer `runtimeConfig.public.siteConfig`
22
+ * without collapsing `menu` to `{}[]`. See `nuxt.config.ts` for the cast
23
+ * history this replaces.
24
+ */
25
+ export type MdsiteMenuItem = string | null | MdsiteMenuGroup
26
+ export type MdsiteMenuGroup = { [key: string]: MdsiteMenuValue }
27
+ export type MdsiteMenuValue = string | null | MdsiteMenuItem[]
28
+
5
29
  export interface MdsiteConfig {
6
30
  content?: {
7
31
  path?: string
@@ -11,7 +35,8 @@ export interface MdsiteConfig {
11
35
  bibleTooltips: boolean
12
36
  sourceEdit: boolean
13
37
  }
14
- menu: Array<string | null | Record<string, string | null | Array<any>>>
38
+ menu: MdsiteMenuItem[]
39
+ footer: string[]
15
40
  server: {
16
41
  output: string
17
42
  path: string
@@ -201,6 +226,7 @@ function createDefaultMdsiteConfig(siteName: string): MdsiteConfig {
201
226
  sourceEdit: true
202
227
  },
203
228
  menu: [],
229
+ footer: [],
204
230
  server: {
205
231
  output: '.output',
206
232
  path: '.mdsite',
@@ -233,6 +259,7 @@ function normalizeMdsiteConfig(rawConfig: Record<string, any>, contentDir: strin
233
259
  },
234
260
  content: contentPath ? { path: contentPath } : fallbackConfig.content,
235
261
  menu: Array.isArray(rawConfig.menu) ? rawConfig.menu : fallbackConfig.menu,
262
+ footer: Array.isArray(rawConfig.footer) ? rawConfig.footer.filter((item): item is string => typeof item === 'string') : [],
236
263
  server: {
237
264
  output: typeof rawConfig.server?.output === 'string' ? rawConfig.server.output : fallbackConfig.server.output,
238
265
  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.1",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Local-first CLI that orchestrates mdsite-nuxt",