@life-and-dev/mdsite 0.3.1 → 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 (49) hide show
  1. package/README.md +65 -33
  2. package/dist/commands/commands.test.js +123 -19
  3. package/dist/commands/commands.test.js.map +1 -1
  4. package/dist/commands/generate.js +2 -0
  5. package/dist/commands/generate.js.map +1 -1
  6. package/dist/commands/init.d.ts +1 -0
  7. package/dist/commands/init.js +43 -3
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/preview.js +15 -3
  10. package/dist/commands/preview.js.map +1 -1
  11. package/dist/commands/start.js +4 -2
  12. package/dist/commands/start.js.map +1 -1
  13. package/dist/commands/workflows.test.js +15 -5
  14. package/dist/commands/workflows.test.js.map +1 -1
  15. package/dist/config/default-mdsite-config.js +1 -0
  16. package/dist/config/default-mdsite-config.js.map +1 -1
  17. package/dist/config/default-mdsite-config.test.js +1 -0
  18. package/dist/config/default-mdsite-config.test.js.map +1 -1
  19. package/dist/config/mdsite-config.d.ts +1 -0
  20. package/dist/config/mdsite-config.js +1 -0
  21. package/dist/config/mdsite-config.js.map +1 -1
  22. package/dist/config/mdsite-config.test.js +17 -0
  23. package/dist/config/mdsite-config.test.js.map +1 -1
  24. package/dist/index.js +7 -5
  25. package/dist/index.js.map +1 -1
  26. package/dist/index.test.js +47 -11
  27. package/dist/index.test.js.map +1 -1
  28. package/dist/process/runtime-state.test.js +1 -0
  29. package/dist/process/runtime-state.test.js.map +1 -1
  30. package/dist/renderer/mdsite-nuxt.d.ts +1 -0
  31. package/dist/renderer/mdsite-nuxt.js +8 -2
  32. package/dist/renderer/mdsite-nuxt.js.map +1 -1
  33. package/dist/renderer/mdsite-nuxt.test.js +2 -1
  34. package/dist/renderer/mdsite-nuxt.test.js.map +1 -1
  35. package/mdsite-nuxt/app/components/AppBar.vue +4 -2
  36. package/mdsite-nuxt/app/components/AppFooter.vue +31 -35
  37. package/mdsite-nuxt/app/components/AppTableOfContents.vue +1 -1
  38. package/mdsite-nuxt/app/composables/useFooter.ts +46 -0
  39. package/mdsite-nuxt/app/composables/useNavigationTree.test.ts +78 -0
  40. package/mdsite-nuxt/app/composables/useNavigationTree.ts +36 -0
  41. package/mdsite-nuxt/app/composables/useTableOfContents.test.ts +35 -0
  42. package/mdsite-nuxt/app/composables/useTableOfContents.ts +32 -4
  43. package/mdsite-nuxt/app/layouts/default.vue +15 -0
  44. package/mdsite-nuxt/scripts/generate-indices.test.ts +108 -1
  45. package/mdsite-nuxt/scripts/generate-indices.ts +170 -0
  46. package/mdsite-nuxt/scripts/renderer-hooks.ts +1 -0
  47. package/mdsite-nuxt/scripts/sync-content.ts +31 -0
  48. package/mdsite-nuxt/utils/mdsite-config.ts +3 -0
  49. package/package.json +1 -1
@@ -27,6 +27,33 @@ function getCacheKey(): string {
27
27
  return `navigation-tree-${hourTimestamp}`
28
28
  }
29
29
 
30
+ /**
31
+ * Recursively count the number of navigable (clickable) menu nodes.
32
+ * Excludes separators and headers. A parent link with children counts as
33
+ * one clickable node, plus all of its clickable descendants.
34
+ */
35
+ export function countClickableMenuItems(nodes: TreeNode[]): number {
36
+ let count = 0
37
+ for (const node of nodes) {
38
+ if (!node.isSeparator && !node.isHeader) {
39
+ count++
40
+ }
41
+ if (node.children?.length) {
42
+ count += countClickableMenuItems(node.children)
43
+ }
44
+ }
45
+ return count
46
+ }
47
+
48
+ /**
49
+ * Decide whether the left navigation drawer should be shown.
50
+ * Shown only when there is more than one clickable menu item to render
51
+ * (recursive count across the entire menu tree).
52
+ */
53
+ export function shouldShowNavigation(nodes: TreeNode[]): boolean {
54
+ return countClickableMenuItems(nodes) > 1
55
+ }
56
+
30
57
  /**
31
58
  * Build hierarchical navigation tree from @nuxt/content collection
32
59
  */
@@ -117,9 +144,18 @@ export function useNavigationTree() {
117
144
  return node.parent.children.filter(child => child.path !== nodePath)
118
145
  }
119
146
 
147
+ /**
148
+ * Whether more than one clickable menu item exists in the tree.
149
+ * Used to gate the left navigation drawer and the hamburger toggle button.
150
+ */
151
+ const hasMultipleMenuItems = computed(() =>
152
+ tree.value !== null && shouldShowNavigation(tree.value.children)
153
+ )
154
+
120
155
  return {
121
156
  tree,
122
157
  isLoading,
158
+ hasMultipleMenuItems,
123
159
  loadTree,
124
160
  findNodeByPath,
125
161
  findPrimaryNodeByPath,
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Unit tests for the TOC visibility thresholds.
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest'
6
+ import { computeShouldShowTOC, TOC_MIN_HEADINGS, TOC_MIN_LINES } from './useTableOfContents'
7
+
8
+ describe('useTableOfContents threshold', () => {
9
+ it('exposes the expected minimum constants', () => {
10
+ expect(TOC_MIN_HEADINGS).toBe(3)
11
+ expect(TOC_MIN_LINES).toBe(40)
12
+ })
13
+
14
+ describe('computeShouldShowTOC', () => {
15
+ it('returns false when headings < 3', () => {
16
+ expect(computeShouldShowTOC(0, 100)).toBe(false)
17
+ expect(computeShouldShowTOC(1, 100)).toBe(false)
18
+ expect(computeShouldShowTOC(2, 100)).toBe(false)
19
+ })
20
+
21
+ it('returns false when lines < 40 (and headings >= 3)', () => {
22
+ expect(computeShouldShowTOC(3, 0)).toBe(false)
23
+ expect(computeShouldShowTOC(5, 39)).toBe(false)
24
+ })
25
+
26
+ it('returns true when headings >= 3 and lines >= 40', () => {
27
+ expect(computeShouldShowTOC(3, 40)).toBe(true)
28
+ expect(computeShouldShowTOC(5, 100)).toBe(true)
29
+ })
30
+
31
+ it('returns true when headings >= 3 and lineCount is null (not yet measured)', () => {
32
+ expect(computeShouldShowTOC(3, null)).toBe(true)
33
+ })
34
+ })
35
+ })
@@ -5,6 +5,23 @@ export interface TocItem {
5
5
  element?: HTMLElement
6
6
  }
7
7
 
8
+ /** Minimum number of headings required to show the TOC. */
9
+ export const TOC_MIN_HEADINGS = 3
10
+ /** Minimum number of non-empty lines in the rendered content required to show the TOC. */
11
+ export const TOC_MIN_LINES = 40
12
+
13
+ /**
14
+ * Pure helper that decides whether the TOC should be shown.
15
+ * Returns true when the page has enough headings AND enough content lines.
16
+ * A null lineCount (not yet measured) is treated as "not too short" so the
17
+ * TOC is not hidden by the line threshold before measurement completes.
18
+ */
19
+ export function computeShouldShowTOC(headingsCount: number, lineCount: number | null): boolean {
20
+ if (headingsCount < TOC_MIN_HEADINGS) return false
21
+ if (lineCount !== null && lineCount < TOC_MIN_LINES) return false
22
+ return true
23
+ }
24
+
8
25
  /**
9
26
  * Generate and manage table of contents from page headings
10
27
  */
@@ -12,6 +29,7 @@ export function useTableOfContents() {
12
29
  const tocItems = ref<TocItem[]>([])
13
30
  const activeId = ref<string>('')
14
31
  const observer = ref<IntersectionObserver | null>(null)
32
+ const lineCount = ref<number | null>(null)
15
33
 
16
34
  /**
17
35
  * Generate TOC from a content container element
@@ -28,14 +46,23 @@ export function useTableOfContents() {
28
46
  }
29
47
 
30
48
  if (!container) {
49
+ // Reset line count when no container is provided so a stale value
50
+ // does not gate TOC visibility after the container is unmounted.
51
+ lineCount.value = null
31
52
  return
32
53
  }
33
54
 
55
+ // Measure line count of the rendered content (non-empty lines).
56
+ // Always update this even when there are too few headings so the value
57
+ // stays in sync with the latest rendered content.
58
+ const text = container.innerText || ''
59
+ lineCount.value = text.split('\n').filter(l => l.trim().length > 0).length
60
+
34
61
  // Find only H2 and H3 headings (skip H1 as it's the page title)
35
62
  const headings = container.querySelectorAll('article h2, article h3, .content-body h2, .content-body h3')
36
63
 
37
- if (headings.length < 2) {
38
- // Hide TOC if less than 2 headings
64
+ if (headings.length < TOC_MIN_HEADINGS) {
65
+ // Hide TOC if fewer than the minimum required headings
39
66
  tocItems.value = []
40
67
  return
41
68
  }
@@ -118,9 +145,9 @@ export function useTableOfContents() {
118
145
  }
119
146
 
120
147
  /**
121
- * Check if TOC should be shown (2+ headings)
148
+ * Check if TOC should be shown based on heading count and content line count.
122
149
  */
123
- const shouldShowTOC = computed(() => tocItems.value.length >= 2)
150
+ const shouldShowTOC = computed(() => computeShouldShowTOC(tocItems.value.length, lineCount.value))
124
151
 
125
152
  /**
126
153
  * Clean up observer on unmount
@@ -134,6 +161,7 @@ export function useTableOfContents() {
134
161
  return {
135
162
  tocItems,
136
163
  activeId,
164
+ lineCount,
137
165
  shouldShowTOC,
138
166
  generateTOC,
139
167
  scrollToHeading
@@ -11,6 +11,7 @@
11
11
  <div class="desktop-layout">
12
12
  <!-- Left Sidebar (Navigation) -->
13
13
  <v-navigation-drawer
14
+ v-if="hasMultipleMenuItems"
14
15
  v-model="sidebarsVisible"
15
16
  :permanent="!isPrinting"
16
17
  absolute
@@ -61,6 +62,7 @@
61
62
  <div v-else class="mobile-layout">
62
63
  <!-- Mobile Drawer -->
63
64
  <v-navigation-drawer
65
+ v-if="hasMultipleMenuItems"
64
66
  v-model="drawerOpen"
65
67
  temporary
66
68
  location="left"
@@ -118,6 +120,7 @@
118
120
 
119
121
  <script setup lang="ts">
120
122
  import { useTableOfContents } from '~/composables/useTableOfContents'
123
+ import { useNavigationTree } from '~/composables/useNavigationTree'
121
124
 
122
125
  const { mdAndUp } = useDisplay()
123
126
  const route = useRoute()
@@ -127,6 +130,13 @@ const desktopContentContainer = ref<HTMLElement>()
127
130
  const mobileContentContainer = ref<HTMLElement>()
128
131
  const { tocItems, activeId: activeHeadingId, shouldShowTOC, generateTOC } = useTableOfContents()
129
132
 
133
+ // Navigation state - gates the left drawer and hamburger toggle button.
134
+ // loadTree() is invoked eagerly from the layout so the tree is populated even
135
+ // when the drawer (which would normally host the only other call site) is
136
+ // hidden due to a small menu. Without this, hasMultipleMenuItems stays false
137
+ // forever and the menu never appears.
138
+ const { hasMultipleMenuItems, loadTree } = useNavigationTree()
139
+
130
140
  // Provide TOC generation function to child pages
131
141
  provide('generateTOC', () => {
132
142
  const container = mdAndUp.value ? desktopContentContainer.value : mobileContentContainer.value
@@ -197,6 +207,11 @@ const onAfterPrint = () => {
197
207
  }
198
208
 
199
209
  onMounted(() => {
210
+ // Eagerly load the navigation tree so hasMultipleMenuItems can reactively
211
+ // flip to true once data arrives, even when the menu drawer is initially
212
+ // hidden because the tree is still empty.
213
+ loadTree()
214
+
200
215
  if (import.meta.client) {
201
216
  window.addEventListener('beforeprint', onBeforePrint)
202
217
  window.addEventListener('afterprint', onAfterPrint)
@@ -4,7 +4,7 @@ import path from 'node:path'
4
4
 
5
5
  import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6
6
 
7
- import { generateNavigationJson, generateSearchIndexJson } from './generate-indices.js'
7
+ import { generateNavigationJson, generateSearchIndexJson, generateFooterJson } from './generate-indices.js'
8
8
 
9
9
  describe('generated content indices', () => {
10
10
  const originalEnv = { ...process.env }
@@ -234,4 +234,111 @@ describe('generated content indices', () => {
234
234
  ])
235
235
  })
236
236
  })
237
+
238
+ describe('mdsite.yml footer lookup', () => {
239
+ async function readFooter() {
240
+ return JSON.parse(await fs.readFile(path.join(publicDir, '_footer.json'), 'utf8'))
241
+ }
242
+
243
+ async function readNavigation() {
244
+ return JSON.parse(await fs.readFile(path.join(publicDir, '_navigation.json'), 'utf8'))
245
+ }
246
+
247
+ it('generates _footer.json with paths and titles from mdsite.yml footer section', async () => {
248
+ await fs.writeFile(path.join(contentDir, 'index.md'), '# Home\n\nWelcome home.', 'utf8')
249
+ await fs.writeFile(path.join(contentDir, 'about.md'), '# About\n\nAbout us.', 'utf8')
250
+ await fs.writeFile(path.join(contentDir, 'contacts.md'), '# Contacts\n\nContact us.', 'utf8')
251
+
252
+ const mdsitePath = path.join(tempDir, 'mdsite.yml')
253
+ await fs.writeFile(mdsitePath, [
254
+ 'site:',
255
+ ' name: Test Site',
256
+ 'footer:',
257
+ ' - about',
258
+ ' - contacts',
259
+ '',
260
+ ].join('\n'), 'utf8')
261
+ process.env.MDSITE_CONFIG_PATH = mdsitePath
262
+
263
+ await generateFooterJson()
264
+
265
+ const footer = await readFooter()
266
+ expect(footer).toEqual([
267
+ { path: '/about', title: 'About' },
268
+ { path: '/contacts', title: 'Contacts' },
269
+ ])
270
+ })
271
+
272
+ it('returns an empty footer array when no footer section is configured', async () => {
273
+ await fs.writeFile(path.join(contentDir, 'index.md'), '# Home\n\nWelcome.', 'utf8')
274
+ await fs.writeFile(path.join(contentDir, 'mdsite.yml'), [
275
+ 'site:',
276
+ ' name: Test Site',
277
+ '',
278
+ ].join('\n'), 'utf8')
279
+ process.env.MDSITE_CONFIG_PATH = path.join(contentDir, 'mdsite.yml')
280
+
281
+ await generateFooterJson()
282
+
283
+ const footer = await readFooter()
284
+ expect(footer).toEqual([])
285
+ })
286
+
287
+ it('excludes footer entries from the navigation tree', async () => {
288
+ await fs.writeFile(path.join(contentDir, 'index.md'), '# Home\n\nWelcome home.', 'utf8')
289
+ await fs.writeFile(path.join(contentDir, 'guide.md'), '# Guide\n\nUseful guide.', 'utf8')
290
+ await fs.writeFile(path.join(contentDir, 'about.md'), '# About\n\nAbout us.', 'utf8')
291
+ await fs.writeFile(path.join(contentDir, 'contacts.md'), '# Contacts\n\nContact us.', 'utf8')
292
+
293
+ const mdsitePath = path.join(tempDir, 'mdsite.yml')
294
+ await fs.writeFile(mdsitePath, [
295
+ 'site:',
296
+ ' name: Test Site',
297
+ 'menu:',
298
+ ' - index',
299
+ ' - guide',
300
+ 'footer:',
301
+ ' - contacts',
302
+ '',
303
+ ].join('\n'), 'utf8')
304
+ process.env.MDSITE_CONFIG_PATH = mdsitePath
305
+
306
+ await generateNavigationJson()
307
+ await generateFooterJson()
308
+
309
+ const navigation = await readNavigation()
310
+ const paths = navigation.map((n: { path: string }) => n.path)
311
+ expect(paths).toContain('/')
312
+ expect(paths).toContain('/guide')
313
+ expect(paths).not.toContain('/contacts')
314
+
315
+ const footer = await readFooter()
316
+ expect(footer).toEqual([
317
+ { path: '/contacts', title: 'Contacts' },
318
+ ])
319
+ })
320
+
321
+ it('excludes footer entries from the fallback navigation tree', async () => {
322
+ await fs.writeFile(path.join(contentDir, 'index.md'), '# Home\n\nWelcome home.', 'utf8')
323
+ await fs.writeFile(path.join(contentDir, 'about.md'), '# About\n\nAbout us.', 'utf8')
324
+ await fs.writeFile(path.join(contentDir, 'contacts.md'), '# Contacts\n\nContact us.', 'utf8')
325
+
326
+ const mdsitePath = path.join(tempDir, 'mdsite.yml')
327
+ await fs.writeFile(mdsitePath, [
328
+ 'site:',
329
+ ' name: Test Site',
330
+ 'footer: [contacts]',
331
+ '',
332
+ ].join('\n'), 'utf8')
333
+ delete process.env.MDSITE_CONFIG_PATH
334
+
335
+ await generateNavigationJson()
336
+
337
+ const navigation = await readNavigation()
338
+ const paths = navigation.map((n: { path: string }) => n.path)
339
+ expect(paths).toContain('/')
340
+ expect(paths).toContain('/about')
341
+ expect(paths).not.toContain('/contacts')
342
+ })
343
+ })
237
344
  })
@@ -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.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Local-first CLI that orchestrates mdsite-nuxt",