@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.
- package/README.md +4 -0
- package/dist/commands/commands.test.js +1 -0
- package/dist/commands/commands.test.js.map +1 -1
- package/dist/commands/workflows.test.js +1 -0
- package/dist/commands/workflows.test.js.map +1 -1
- package/dist/config/default-mdsite-config.js +1 -0
- package/dist/config/default-mdsite-config.js.map +1 -1
- package/dist/config/default-mdsite-config.test.js +1 -0
- package/dist/config/default-mdsite-config.test.js.map +1 -1
- package/dist/config/mdsite-config.d.ts +1 -0
- package/dist/config/mdsite-config.js +1 -0
- package/dist/config/mdsite-config.js.map +1 -1
- package/dist/config/mdsite-config.test.js +17 -0
- package/dist/config/mdsite-config.test.js.map +1 -1
- package/dist/process/runtime-state.test.js +1 -0
- package/dist/process/runtime-state.test.js.map +1 -1
- package/dist/renderer/mdsite-nuxt.test.js +1 -0
- package/dist/renderer/mdsite-nuxt.test.js.map +1 -1
- package/mdsite-nuxt/app/components/AppBar.vue +4 -2
- package/mdsite-nuxt/app/components/AppFooter.vue +31 -35
- package/mdsite-nuxt/app/components/AppTableOfContents.vue +11 -17
- package/mdsite-nuxt/app/composables/useFooter.ts +46 -0
- package/mdsite-nuxt/app/composables/useNavigationTree.test.ts +78 -0
- package/mdsite-nuxt/app/composables/useNavigationTree.ts +36 -0
- package/mdsite-nuxt/app/composables/useTableOfContents.test.ts +35 -0
- package/mdsite-nuxt/app/composables/useTableOfContents.ts +32 -4
- package/mdsite-nuxt/app/layouts/default.vue +15 -0
- package/mdsite-nuxt/nuxt.config.ts +19 -1
- package/mdsite-nuxt/scripts/generate-indices.test.ts +108 -1
- package/mdsite-nuxt/scripts/generate-indices.ts +181 -8
- package/mdsite-nuxt/scripts/renderer-hooks.ts +1 -0
- package/mdsite-nuxt/scripts/sync-content.ts +31 -0
- package/mdsite-nuxt/utils/mdsite-config.ts +28 -1
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<template>
|
|
2
2
|
<v-app-bar
|
|
3
|
+
v-if="hasFooterEntries"
|
|
3
4
|
location="bottom"
|
|
4
5
|
height="56"
|
|
5
6
|
class="app-footer"
|
|
@@ -7,30 +8,21 @@
|
|
|
7
8
|
>
|
|
8
9
|
<v-container class="d-flex justify-center align-center">
|
|
9
10
|
<div class="footer-links">
|
|
10
|
-
<v-
|
|
11
|
-
v-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
variant="text"
|
|
26
|
-
color="on-surface-appbar"
|
|
27
|
-
class="footer-link"
|
|
28
|
-
>
|
|
29
|
-
Disclaimer
|
|
30
|
-
</v-btn>
|
|
31
|
-
|
|
32
|
-
<v-divider v-if="showEditDivider" vertical class="mx-2" />
|
|
33
|
-
|
|
11
|
+
<template v-for="(link, index) in footerHrefs" :key="link.path">
|
|
12
|
+
<v-btn
|
|
13
|
+
:href="link.path"
|
|
14
|
+
variant="text"
|
|
15
|
+
color="on-surface-appbar"
|
|
16
|
+
class="footer-link"
|
|
17
|
+
>
|
|
18
|
+
{{ link.title }}
|
|
19
|
+
</v-btn>
|
|
20
|
+
<v-divider
|
|
21
|
+
v-if="index < footerHrefs.length - 1 || editUrl"
|
|
22
|
+
vertical
|
|
23
|
+
class="mx-2"
|
|
24
|
+
/>
|
|
25
|
+
</template>
|
|
34
26
|
<v-btn
|
|
35
27
|
v-if="editUrl"
|
|
36
28
|
:href="editUrl"
|
|
@@ -49,25 +41,29 @@
|
|
|
49
41
|
|
|
50
42
|
<script setup lang="ts">
|
|
51
43
|
import { useSourceEdit } from '~/composables/useSourceEdit';
|
|
52
|
-
import {
|
|
44
|
+
import { useFooter } from '~/composables/useFooter'
|
|
53
45
|
import { withBasePath } from '../../utils/base-url'
|
|
54
46
|
|
|
55
47
|
const appBaseURL = useRuntimeConfig().app.baseURL
|
|
56
48
|
const { getEditUrl } = useSourceEdit()
|
|
57
|
-
const {
|
|
58
|
-
const footerPagePaths = ref<string[]>([])
|
|
49
|
+
const { links: footerLinks, loadFooter } = useFooter()
|
|
59
50
|
|
|
60
|
-
// Generate links to root content files
|
|
61
|
-
const aboutLink = computed(() => withBasePath('/about', appBaseURL))
|
|
62
|
-
const disclaimerLink = computed(() => withBasePath('/disclaimer', appBaseURL))
|
|
63
51
|
const editUrl = computed(() => getEditUrl())
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
52
|
+
|
|
53
|
+
// Render the bar only after the footer JSON has loaded and contains at least
|
|
54
|
+
// one entry. While loading (links === null) or when the array is empty, the
|
|
55
|
+
// whole bar (including the Edit button) is hidden.
|
|
56
|
+
const hasFooterEntries = computed(() =>
|
|
57
|
+
footerLinks.value !== null && footerLinks.value.length > 0
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const footerHrefs = computed(() => (footerLinks.value ?? []).map(link => ({
|
|
61
|
+
path: withBasePath(link.path, appBaseURL),
|
|
62
|
+
title: link.title
|
|
63
|
+
})))
|
|
67
64
|
|
|
68
65
|
onMounted(async () => {
|
|
69
|
-
|
|
70
|
-
footerPagePaths.value = searchIndex.map(entry => entry.path)
|
|
66
|
+
await loadFooter()
|
|
71
67
|
})
|
|
72
68
|
</script>
|
|
73
69
|
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
|
|
2
|
+
<!--
|
|
3
|
+
Guard at the root: the consuming composable's `shouldShowTOC` already
|
|
4
|
+
requires `tocItems.length >= TOC_MIN_HEADINGS (3)`, so by the time we
|
|
5
|
+
reach this template a usable TOC is guaranteed. Render nothing otherwise.
|
|
6
|
+
-->
|
|
7
|
+
<div
|
|
8
|
+
v-if="tocItems.length >= 3"
|
|
9
|
+
class="toc-sidebar"
|
|
10
|
+
ref="tocContainer"
|
|
11
|
+
>
|
|
3
12
|
<!-- Header -->
|
|
4
13
|
<h3 v-if="showHeader" class="toc-header">On This Page</h3>
|
|
5
14
|
<v-divider v-if="showHeader" class="mb-2" />
|
|
6
15
|
|
|
7
16
|
<!-- TOC Items -->
|
|
8
|
-
<nav
|
|
17
|
+
<nav class="toc-nav">
|
|
9
18
|
<TocItem
|
|
10
19
|
v-for="item in tocItems"
|
|
11
20
|
:key="item.id"
|
|
@@ -15,11 +24,6 @@
|
|
|
15
24
|
/>
|
|
16
25
|
</nav>
|
|
17
26
|
|
|
18
|
-
<!-- Empty state -->
|
|
19
|
-
<div v-else class="toc-empty">
|
|
20
|
-
<p class="text-caption text-center">No table of contents available</p>
|
|
21
|
-
</div>
|
|
22
|
-
|
|
23
27
|
<!-- Fade gradient for overflow -->
|
|
24
28
|
<div v-if="showFade" class="fade-gradient" />
|
|
25
29
|
</div>
|
|
@@ -122,16 +126,6 @@ watch(() => props.tocItems, () => {
|
|
|
122
126
|
scrollbar-width: none;
|
|
123
127
|
}
|
|
124
128
|
|
|
125
|
-
.toc-empty {
|
|
126
|
-
flex: 1;
|
|
127
|
-
display: flex;
|
|
128
|
-
align-items: center;
|
|
129
|
-
justify-content: center;
|
|
130
|
-
padding: 32px 16px;
|
|
131
|
-
color: rgb(var(--v-theme-on-surface-rail));
|
|
132
|
-
opacity: 0.6;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
129
|
.fade-gradient {
|
|
136
130
|
position: absolute;
|
|
137
131
|
bottom: 0;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { FooterLink } from '../../scripts/generate-indices'
|
|
2
|
+
import { withBasePath } from '../../utils/base-url'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fetch and cache footer links from the pre-built JSON file.
|
|
6
|
+
* Uses useState to prevent duplicate fetches across component instances.
|
|
7
|
+
*/
|
|
8
|
+
export function useFooter() {
|
|
9
|
+
const appBaseURL = useRuntimeConfig().app.baseURL
|
|
10
|
+
const links = useState<FooterLink[] | null>('footer-links', () => null)
|
|
11
|
+
const isLoading = useState<boolean>('footer-links-loading', () => false)
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load footer links from the pre-built JSON file.
|
|
15
|
+
* Cached result prevents duplicate fetches.
|
|
16
|
+
*/
|
|
17
|
+
async function loadFooter(): Promise<FooterLink[]> {
|
|
18
|
+
if (links.value !== null) {
|
|
19
|
+
return links.value
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (isLoading.value) {
|
|
23
|
+
return []
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
isLoading.value = true
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const data = await $fetch<FooterLink[]>(withBasePath('/_footer.json', appBaseURL))
|
|
30
|
+
links.value = data
|
|
31
|
+
return data
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error('Error loading footer links:', error)
|
|
34
|
+
links.value = []
|
|
35
|
+
return []
|
|
36
|
+
} finally {
|
|
37
|
+
isLoading.value = false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
links,
|
|
43
|
+
isLoading,
|
|
44
|
+
loadFooter
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the navigation tree visibility helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, it } from 'vitest'
|
|
6
|
+
import { countClickableMenuItems, shouldShowNavigation } from './useNavigationTree'
|
|
7
|
+
import type { TreeNode } from './useNavigationTree'
|
|
8
|
+
|
|
9
|
+
// Build a clickable link node. Defaults to no children.
|
|
10
|
+
function link(id: string, path: string, children: TreeNode[] = []): TreeNode {
|
|
11
|
+
return { id, title: id, path, order: 0, children, parent: undefined, isPrimary: true }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function separator(id: string): TreeNode {
|
|
15
|
+
return { id, title: '---', path: '#', order: 0, children: [], isSeparator: true }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function header(id: string): TreeNode {
|
|
19
|
+
return { id, title: id, path: '#', order: 0, children: [], isHeader: true }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('useNavigationTree visibility', () => {
|
|
23
|
+
describe('countClickableMenuItems', () => {
|
|
24
|
+
it('returns 0 for an empty array', () => {
|
|
25
|
+
expect(countClickableMenuItems([])).toBe(0)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('counts only non-separator, non-header top-level nodes', () => {
|
|
29
|
+
const nodes = [link('home', '/'), separator('sep-1'), header('hdr-1')]
|
|
30
|
+
expect(countClickableMenuItems(nodes)).toBe(1)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('recursively counts children of submenus', () => {
|
|
34
|
+
const nodes = [
|
|
35
|
+
link('home', '/'),
|
|
36
|
+
link('docs', '/docs', [link('a', '/docs/a'), link('b', '/docs/b')])
|
|
37
|
+
]
|
|
38
|
+
// home (1) + docs parent (1) + a (1) + b (1) = 4
|
|
39
|
+
expect(countClickableMenuItems(nodes)).toBe(4)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not recurse into separators or headers', () => {
|
|
43
|
+
const nodes = [
|
|
44
|
+
link('home', '/'),
|
|
45
|
+
link('section', '/section', [link('child', '/section/child')])
|
|
46
|
+
]
|
|
47
|
+
// home (1) + section (1) + child (1) = 3
|
|
48
|
+
expect(countClickableMenuItems(nodes)).toBe(3)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('shouldShowNavigation', () => {
|
|
53
|
+
it('returns false for an empty menu', () => {
|
|
54
|
+
expect(shouldShowNavigation([])).toBe(false)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns false when there is only 1 clickable item', () => {
|
|
58
|
+
expect(shouldShowNavigation([link('home', '/')])).toBe(false)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns true when there are 2+ clickable items at the top level', () => {
|
|
62
|
+
expect(shouldShowNavigation([
|
|
63
|
+
link('home', '/'),
|
|
64
|
+
link('about', '/about')
|
|
65
|
+
])).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns true when the only top-level item has multiple children', () => {
|
|
69
|
+
// One top-level folder with 2+ nested pages still needs a navigation sidebar
|
|
70
|
+
expect(shouldShowNavigation([
|
|
71
|
+
link('docs', '/docs', [
|
|
72
|
+
link('a', '/docs/a'),
|
|
73
|
+
link('b', '/docs/b')
|
|
74
|
+
])
|
|
75
|
+
])).toBe(true)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -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(15)
|
|
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 < 15 (and headings >= 3)', () => {
|
|
22
|
+
expect(computeShouldShowTOC(3, 0)).toBe(false)
|
|
23
|
+
expect(computeShouldShowTOC(5, 14)).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns true when headings >= 3 and lines >= 15', () => {
|
|
27
|
+
expect(computeShouldShowTOC(3, 15)).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 = 15
|
|
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 <
|
|
38
|
-
// Hide TOC if
|
|
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
|
|
148
|
+
* Check if TOC should be shown based on heading count and content line count.
|
|
122
149
|
*/
|
|
123
|
-
const shouldShowTOC = computed(() => tocItems.value.length
|
|
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)
|
|
@@ -18,7 +18,25 @@ export default defineNuxtConfig({
|
|
|
18
18
|
public: {
|
|
19
19
|
contentDomain: path.basename(mdsite.contentDir),
|
|
20
20
|
contentPath: mdsite.contentDir,
|
|
21
|
-
|
|
21
|
+
// `mdsite.config` is a valid `MdsiteConfig` at runtime, but
|
|
22
|
+
// Nuxt's runtime-config type generator collapses every complex
|
|
23
|
+
// field of `siteConfig` to a degenerate shape — `menu` becomes
|
|
24
|
+
// `Array<{}>` (regardless of whether the source type is recursive
|
|
25
|
+
// or contains `Array<any>`) and `footer` becomes `Array<any>`.
|
|
26
|
+
// We verified this by:
|
|
27
|
+
// 1. Tightening `MdsiteConfig.menu` to a proper recursive
|
|
28
|
+
// `MdsiteMenuItem` (no `any`) and clearing `.nuxt` cache:
|
|
29
|
+
// the generated `menu` was still `Array<{}>`.
|
|
30
|
+
// 2. Trying `declare module 'nuxt/schema'` augmentations of
|
|
31
|
+
// `PublicRuntimeConfig.siteConfig`: the augmentation
|
|
32
|
+
// *intersects* with the broken generated type rather than
|
|
33
|
+
// overriding it, producing an even narrower target.
|
|
34
|
+
// Since the runtime value is unchanged, `as any` is the most
|
|
35
|
+
// honest pragmatic escape hatch. The recursive `MdsiteMenuItem`
|
|
36
|
+
// type is still useful: it gives the rest of the codebase
|
|
37
|
+
// (notably `scripts/generate-indices.ts`) a single source of
|
|
38
|
+
// truth and proper types.
|
|
39
|
+
siteConfig: siteConfig as any
|
|
22
40
|
}
|
|
23
41
|
},
|
|
24
42
|
|
|
@@ -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
|
})
|