@life-and-dev/mdsite 0.6.0 → 0.7.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 +16 -17
- package/dist/commands/clean.js +28 -10
- package/dist/commands/clean.js.map +1 -1
- package/dist/commands/commands.test.js +49 -21
- package/dist/commands/commands.test.js.map +1 -1
- package/dist/commands/generate.js +5 -4
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/prepare.js +2 -2
- package/dist/commands/prepare.js.map +1 -1
- package/dist/commands/prepare.test.js +9 -10
- package/dist/commands/prepare.test.js.map +1 -1
- package/dist/commands/preview.js +21 -21
- package/dist/commands/preview.js.map +1 -1
- package/dist/commands/start.js +13 -11
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/stop.js +7 -4
- package/dist/commands/stop.js.map +1 -1
- package/dist/commands/workflows.test.js +25 -24
- package/dist/commands/workflows.test.js.map +1 -1
- package/dist/config/default-mdsite-config.js +7 -8
- package/dist/config/default-mdsite-config.js.map +1 -1
- package/dist/config/default-mdsite-config.test.js +7 -8
- package/dist/config/default-mdsite-config.test.js.map +1 -1
- package/dist/config/mdsite-config.d.ts +46 -10
- package/dist/config/mdsite-config.js +46 -24
- package/dist/config/mdsite-config.js.map +1 -1
- package/dist/config/mdsite-config.test.js +55 -50
- package/dist/config/mdsite-config.test.js.map +1 -1
- package/dist/process/child-process.d.ts +4 -0
- package/dist/process/child-process.js +33 -1
- package/dist/process/child-process.js.map +1 -1
- package/dist/process/child-process.test.js +39 -3
- package/dist/process/child-process.test.js.map +1 -1
- package/dist/process/runtime-state.d.ts +13 -5
- package/dist/process/runtime-state.js +21 -13
- package/dist/process/runtime-state.js.map +1 -1
- package/dist/process/runtime-state.test.js +3 -5
- package/dist/process/runtime-state.test.js.map +1 -1
- package/dist/renderer/mdsite-nuxt.d.ts +28 -3
- package/dist/renderer/mdsite-nuxt.js +29 -12
- package/dist/renderer/mdsite-nuxt.js.map +1 -1
- package/dist/renderer/mdsite-nuxt.test.js +34 -12
- package/dist/renderer/mdsite-nuxt.test.js.map +1 -1
- package/mdsite-nuxt/app/components/AppFooter.vue +84 -22
- package/mdsite-nuxt/app/composables/useFooter.test.ts +54 -0
- package/mdsite-nuxt/app/composables/useFooter.ts +48 -31
- package/mdsite-nuxt/app/composables/useSiteConfig.test.ts +13 -87
- package/mdsite-nuxt/app/composables/useSiteConfig.ts +7 -26
- package/mdsite-nuxt/app/composables/useSourceEdit.test.ts +103 -0
- package/mdsite-nuxt/app/composables/useSourceEdit.ts +39 -51
- package/mdsite-nuxt/app/layouts/default.vue +10 -3
- package/mdsite-nuxt/content.config.ts +21 -1
- package/mdsite-nuxt/nuxt.config.ts +21 -14
- package/mdsite-nuxt/scripts/generate-favicons.test.ts +3 -3
- package/mdsite-nuxt/scripts/generate-favicons.ts +4 -4
- package/mdsite-nuxt/scripts/generate-indices.test.ts +221 -11
- package/mdsite-nuxt/scripts/generate-indices.ts +187 -28
- package/mdsite-nuxt/scripts/renderer-hooks.test.ts +0 -86
- package/mdsite-nuxt/scripts/renderer-hooks.ts +1 -48
- package/mdsite-nuxt/scripts/sync-content.ts +39 -1
- package/mdsite-nuxt/utils/mdsite-config.ts +86 -41
- package/package.json +1 -1
- package/mdsite-nuxt/example.config.yml +0 -67
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the pure `toFooterLinks` helper extracted from
|
|
3
|
+
* `useFooter`. The composable now uses `useAsyncData`, which fetches
|
|
4
|
+
* `/_footer.json` during SSR. The Nitro static preset (used by
|
|
5
|
+
* `mdsite generate`) does not serve that file as a static asset, so the
|
|
6
|
+
* prerender's `localFetch` falls through to the catch-all HTML route and
|
|
7
|
+
* returns the page HTML string. Without coercion that string ends up in
|
|
8
|
+
* `data.value` and the AppFooter's `.map()` call throws. These tests pin
|
|
9
|
+
* the defensive behaviour down.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest'
|
|
13
|
+
|
|
14
|
+
import { toFooterLinks } from './useFooter'
|
|
15
|
+
|
|
16
|
+
describe('toFooterLinks', () => {
|
|
17
|
+
it('returns the array unchanged when the response is a valid FooterLink[]', () => {
|
|
18
|
+
const links = [
|
|
19
|
+
{ path: '/about', title: 'About', type: 'link' as const, isExternal: false },
|
|
20
|
+
{ path: 'https://example.com', title: 'Example', type: 'link' as const, isExternal: true }
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
expect(toFooterLinks(links)).toBe(links)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns an empty array when the response is an empty array', () => {
|
|
27
|
+
expect(toFooterLinks([])).toEqual([])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns an empty array when the response is an HTML string (Nitro static preset prerender fallback)', () => {
|
|
31
|
+
const html = '<!DOCTYPE html><html><body>Page</body></html>'
|
|
32
|
+
|
|
33
|
+
expect(toFooterLinks(html)).toEqual([])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns an empty array when the response is null', () => {
|
|
37
|
+
expect(toFooterLinks(null)).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns an empty array when the response is undefined', () => {
|
|
41
|
+
expect(toFooterLinks(undefined)).toEqual([])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns an empty array when the response is a plain object (e.g. 404 JSON body)', () => {
|
|
45
|
+
expect(toFooterLinks({ error: true, statusCode: 404 })).toEqual([])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns an empty array when the response is a number or boolean', () => {
|
|
49
|
+
expect(toFooterLinks(0)).toEqual([])
|
|
50
|
+
expect(toFooterLinks(1)).toEqual([])
|
|
51
|
+
expect(toFooterLinks(false)).toEqual([])
|
|
52
|
+
expect(toFooterLinks(true)).toEqual([])
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -1,46 +1,63 @@
|
|
|
1
1
|
import type { FooterLink } from '../../scripts/generate-indices'
|
|
2
2
|
import { withBasePath } from '../../utils/base-url'
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Coerce an unknown `$fetch` response into a `FooterLink[]`. Returns the
|
|
6
|
+
* array unchanged when the response is an array, and an empty array for
|
|
7
|
+
* anything else (object, string, null, undefined, primitive). Extracted so
|
|
8
|
+
* the type-guard can be unit-tested without mocking `$fetch`.
|
|
9
|
+
*
|
|
10
|
+
* Guards against two known environments where `/_footer.json` is not
|
|
11
|
+
* served as JSON:
|
|
12
|
+
* - `mdsite generate` (Nitro static preset) — `localFetch('/_footer.json')`
|
|
13
|
+
* falls through to the catch-all HTML route and returns the page HTML
|
|
14
|
+
* string instead of the JSON payload.
|
|
15
|
+
* - Bare `node-server` boot before `generateFooterJson` has written the
|
|
16
|
+
* file yet — the catch-all returns the not-found HTML page.
|
|
17
|
+
*/
|
|
18
|
+
export function toFooterLinks(result: unknown): FooterLink[] {
|
|
19
|
+
return Array.isArray(result) ? (result as FooterLink[]) : []
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
/**
|
|
5
23
|
* Fetch and cache footer links from the pre-built JSON file.
|
|
6
|
-
*
|
|
24
|
+
*
|
|
25
|
+
* Uses `useAsyncData` so the fetch happens during SSR — the result is
|
|
26
|
+
* transferred to the client on hydration and the footer is part of the
|
|
27
|
+
* initial HTML payload (no FOUC). The fetch is cached by the
|
|
28
|
+
* `'footer-links'` key, so repeated calls return the same data.
|
|
7
29
|
*/
|
|
8
30
|
export function useFooter() {
|
|
9
31
|
const appBaseURL = useRuntimeConfig().app.baseURL
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
const { data, pending, error, refresh } = useAsyncData<FooterLink[]>(
|
|
33
|
+
'footer-links',
|
|
34
|
+
async () => {
|
|
35
|
+
try {
|
|
36
|
+
return toFooterLinks(
|
|
37
|
+
await $fetch<unknown>(withBasePath('/_footer.json', appBaseURL))
|
|
38
|
+
)
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error('Error loading footer links:', err)
|
|
41
|
+
return []
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
// Default to an empty array so the SSR shell can render without
|
|
46
|
+
// waiting for the data; `hasFooterEntries` stays `false` until the
|
|
47
|
+
// fetch resolves with at least one entry.
|
|
48
|
+
default: () => []
|
|
20
49
|
}
|
|
50
|
+
)
|
|
21
51
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
}
|
|
52
|
+
// `error` is still exposed for consumers that want to surface it, even
|
|
53
|
+
// though we no longer let it short-circuit the SSR pass.
|
|
54
|
+
if (error.value) {
|
|
55
|
+
console.error('Error loading footer links:', error.value)
|
|
39
56
|
}
|
|
40
57
|
|
|
41
58
|
return {
|
|
42
|
-
links,
|
|
43
|
-
isLoading,
|
|
44
|
-
|
|
59
|
+
links: data,
|
|
60
|
+
isLoading: pending,
|
|
61
|
+
refresh
|
|
45
62
|
}
|
|
46
63
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unit tests for the pure `mapSiteConfig` helper extracted from
|
|
3
|
-
* `useSiteConfig`. The `sourceEdit` Edit-
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* `useSiteConfig`. The `sourceEdit` Edit-link in `AppBar` and `AppFooter`
|
|
4
|
+
* is only rendered when `getEditUrl()` produces a URL, which in turn
|
|
5
|
+
* requires `features.sourceEdit` to be a non-empty URL prefix. These
|
|
6
|
+
* tests pin that mapping down so a future refactor cannot regress it
|
|
7
|
+
* back to a boolean flag or break the empty-string default.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { describe, expect, it } from 'vitest'
|
|
@@ -17,13 +17,10 @@ describe('mapSiteConfig', () => {
|
|
|
17
17
|
expect(result).toEqual({
|
|
18
18
|
siteName: '',
|
|
19
19
|
siteCanonical: '',
|
|
20
|
-
contentGitRepo: '',
|
|
21
|
-
contentGitBranch: 'main',
|
|
22
|
-
contentGitPath: '.',
|
|
23
20
|
contentPath: '.',
|
|
24
21
|
features: {
|
|
25
22
|
bibleTooltips: false,
|
|
26
|
-
sourceEdit:
|
|
23
|
+
sourceEdit: ''
|
|
27
24
|
},
|
|
28
25
|
themeColorLight: '#000000',
|
|
29
26
|
themeColorDark: '#ffffff'
|
|
@@ -39,76 +36,29 @@ describe('mapSiteConfig', () => {
|
|
|
39
36
|
expect(result.siteCanonical).toBe('https://example.test')
|
|
40
37
|
})
|
|
41
38
|
|
|
42
|
-
describe('contentGitRepo (Edit on GitHub source)', () => {
|
|
43
|
-
it('reads server.repo into contentGitRepo', () => {
|
|
44
|
-
const result = mapSiteConfig({
|
|
45
|
-
server: { repo: 'https://github.com/life-and-dev/mdsite' }
|
|
46
|
-
}, undefined)
|
|
47
|
-
|
|
48
|
-
expect(result.contentGitRepo).toBe('https://github.com/life-and-dev/mdsite')
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('defaults to empty string when server.repo is missing', () => {
|
|
52
|
-
const result = mapSiteConfig({ server: {} }, undefined)
|
|
53
|
-
|
|
54
|
-
expect(result.contentGitRepo).toBe('')
|
|
55
|
-
})
|
|
56
|
-
|
|
57
|
-
it('defaults to empty string when server is missing entirely', () => {
|
|
58
|
-
const result = mapSiteConfig({}, undefined)
|
|
59
|
-
|
|
60
|
-
expect(result.contentGitRepo).toBe('')
|
|
61
|
-
})
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
describe('contentGitBranch (Edit on GitHub source)', () => {
|
|
65
|
-
it('defaults to "main" when server.gitBranch is missing', () => {
|
|
66
|
-
expect(mapSiteConfig({}, undefined).contentGitBranch).toBe('main')
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
it('defaults to "main" when server is missing entirely', () => {
|
|
70
|
-
expect(mapSiteConfig({ server: {} }, undefined).contentGitBranch).toBe('main')
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('reads server.gitBranch into contentGitBranch', () => {
|
|
74
|
-
const result = mapSiteConfig({
|
|
75
|
-
server: { gitBranch: 'develop' }
|
|
76
|
-
}, undefined)
|
|
77
|
-
|
|
78
|
-
expect(result.contentGitBranch).toBe('develop')
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('treats an empty string server.gitBranch as missing and falls back to "main"', () => {
|
|
82
|
-
// Whitespace-only branches are normalised away upstream in
|
|
83
|
-
// `utils/mdsite-config.ts` `normalizeMdsiteConfig`; the mapper here
|
|
84
|
-
// only falls back on the empty string, not on whitespace.
|
|
85
|
-
expect(mapSiteConfig({ server: { gitBranch: '' } }, undefined).contentGitBranch).toBe('main')
|
|
86
|
-
})
|
|
87
|
-
})
|
|
88
|
-
|
|
89
39
|
describe('features', () => {
|
|
90
|
-
it('defaults
|
|
40
|
+
it('defaults sourceEdit to "" and bibleTooltips to false when features is missing', () => {
|
|
91
41
|
const result = mapSiteConfig({}, undefined)
|
|
92
42
|
|
|
93
43
|
expect(result.features.bibleTooltips).toBe(false)
|
|
94
|
-
expect(result.features.sourceEdit).toBe(
|
|
44
|
+
expect(result.features.sourceEdit).toBe('')
|
|
95
45
|
})
|
|
96
46
|
|
|
97
|
-
it('reads sourceEdit from features.sourceEdit', () => {
|
|
47
|
+
it('reads sourceEdit (URL prefix) from features.sourceEdit', () => {
|
|
98
48
|
const result = mapSiteConfig({
|
|
99
|
-
features: { sourceEdit:
|
|
49
|
+
features: { sourceEdit: 'https://github.com/org/repo/edit/main/', bibleTooltips: false }
|
|
100
50
|
}, undefined)
|
|
101
51
|
|
|
102
|
-
expect(result.features.sourceEdit).toBe(
|
|
52
|
+
expect(result.features.sourceEdit).toBe('https://github.com/org/repo/edit/main/')
|
|
103
53
|
expect(result.features.bibleTooltips).toBe(false)
|
|
104
54
|
})
|
|
105
55
|
|
|
106
56
|
it('reads bibleTooltips from features.bibleTooltips', () => {
|
|
107
57
|
const result = mapSiteConfig({
|
|
108
|
-
features: { sourceEdit:
|
|
58
|
+
features: { sourceEdit: '', bibleTooltips: true }
|
|
109
59
|
}, undefined)
|
|
110
60
|
|
|
111
|
-
expect(result.features.sourceEdit).toBe(
|
|
61
|
+
expect(result.features.sourceEdit).toBe('')
|
|
112
62
|
expect(result.features.bibleTooltips).toBe(true)
|
|
113
63
|
})
|
|
114
64
|
})
|
|
@@ -139,28 +89,4 @@ describe('mapSiteConfig', () => {
|
|
|
139
89
|
expect(mapSiteConfig({}, undefined).contentPath).toBe('.')
|
|
140
90
|
expect(mapSiteConfig({}, '').contentPath).toBe('.')
|
|
141
91
|
})
|
|
142
|
-
|
|
143
|
-
describe('contentGitPath (Edit on GitHub source)', () => {
|
|
144
|
-
it('defaults to "." when contentGitPath is undefined', () => {
|
|
145
|
-
// Preserves the historical cwd-relative fallback so existing
|
|
146
|
-
// callers/tests that omit the third arg keep working.
|
|
147
|
-
expect(mapSiteConfig({}, undefined).contentGitPath).toBe('.')
|
|
148
|
-
expect(mapSiteConfig({}, undefined, undefined).contentGitPath).toBe('.')
|
|
149
|
-
})
|
|
150
|
-
|
|
151
|
-
it('defaults to "." when contentGitPath is an empty string', () => {
|
|
152
|
-
// Empty string is treated as missing so an absent
|
|
153
|
-
// runtimeConfig.public.contentGitPath falls back cleanly.
|
|
154
|
-
expect(mapSiteConfig({}, undefined, '').contentGitPath).toBe('.')
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('passes an absolute contentGitPath through unchanged', () => {
|
|
158
|
-
// The renderer supplies `path.dirname(mdsite.configPath)` here so
|
|
159
|
-
// `relative(contentGitPath, contentPath)` is cwd-independent and
|
|
160
|
-
// identical on server and client (no hydration mismatch).
|
|
161
|
-
expect(
|
|
162
|
-
mapSiteConfig({}, '/home/user/site/docs', '/home/user/site').contentGitPath
|
|
163
|
-
).toBe('/home/user/site')
|
|
164
|
-
})
|
|
165
|
-
})
|
|
166
92
|
})
|
|
@@ -9,13 +9,9 @@ export interface RawSiteConfig {
|
|
|
9
9
|
name?: string
|
|
10
10
|
canonical?: string
|
|
11
11
|
}
|
|
12
|
-
server?: {
|
|
13
|
-
repo?: string
|
|
14
|
-
gitBranch?: string
|
|
15
|
-
}
|
|
16
12
|
features?: {
|
|
17
13
|
bibleTooltips?: boolean
|
|
18
|
-
sourceEdit?:
|
|
14
|
+
sourceEdit?: string
|
|
19
15
|
}
|
|
20
16
|
themes?: {
|
|
21
17
|
light?: { colors?: { primary?: string } }
|
|
@@ -26,13 +22,10 @@ export interface RawSiteConfig {
|
|
|
26
22
|
export interface SiteConfig {
|
|
27
23
|
siteName: string
|
|
28
24
|
siteCanonical: string
|
|
29
|
-
contentGitRepo: string
|
|
30
|
-
contentGitBranch: string
|
|
31
|
-
contentGitPath: string
|
|
32
25
|
contentPath: string
|
|
33
26
|
features: {
|
|
34
27
|
bibleTooltips: boolean
|
|
35
|
-
sourceEdit:
|
|
28
|
+
sourceEdit: string
|
|
36
29
|
}
|
|
37
30
|
themeColorLight: string
|
|
38
31
|
themeColorDark: string
|
|
@@ -41,33 +34,22 @@ export interface SiteConfig {
|
|
|
41
34
|
/**
|
|
42
35
|
* Pure helper that maps the raw runtime `siteConfig` object into the shape
|
|
43
36
|
* the renderer actually consumes. Extracted from `useSiteConfig` so the
|
|
44
|
-
* field mapping
|
|
45
|
-
* powers the Edit on GitHub link in `AppBar` and `AppFooter`) can be unit
|
|
46
|
-
* tested independently of the Nuxt runtime.
|
|
37
|
+
* field mapping can be unit tested independently of the Nuxt runtime.
|
|
47
38
|
*
|
|
48
|
-
* `
|
|
49
|
-
*
|
|
50
|
-
* Leaving it undefined preserves the historical cwd-relative `'.'`
|
|
51
|
-
* default, which is only correct when the renderer's cwd equals the git
|
|
52
|
-
* repo root — pass an absolute path to make `useSourceEdit`'s
|
|
53
|
-
* `relative(contentGitPath, contentPath)` computation deterministic
|
|
54
|
-
* across server and client and avoid hydration mismatches.
|
|
39
|
+
* `sourceEdit` is a user-supplied URL prefix used by `useSourceEdit` to
|
|
40
|
+
* build per-page Edit links; an empty string disables the link.
|
|
55
41
|
*/
|
|
56
42
|
export function mapSiteConfig(
|
|
57
43
|
siteConfig: RawSiteConfig | undefined,
|
|
58
44
|
contentPath: string | undefined,
|
|
59
|
-
contentGitPath: string | undefined = '.',
|
|
60
45
|
): SiteConfig {
|
|
61
46
|
return {
|
|
62
47
|
siteName: siteConfig?.site?.name || '',
|
|
63
48
|
siteCanonical: siteConfig?.site?.canonical || '',
|
|
64
|
-
contentGitRepo: siteConfig?.server?.repo || '',
|
|
65
|
-
contentGitBranch: siteConfig?.server?.gitBranch || 'main',
|
|
66
|
-
contentGitPath: contentGitPath || '.',
|
|
67
49
|
contentPath: contentPath || '.',
|
|
68
50
|
features: {
|
|
69
51
|
bibleTooltips: siteConfig?.features?.bibleTooltips ?? false,
|
|
70
|
-
sourceEdit: siteConfig?.features?.sourceEdit ??
|
|
52
|
+
sourceEdit: siteConfig?.features?.sourceEdit ?? ''
|
|
71
53
|
},
|
|
72
54
|
themeColorLight: siteConfig?.themes?.light?.colors?.primary || '#000000',
|
|
73
55
|
themeColorDark: siteConfig?.themes?.dark?.colors?.primary || '#ffffff'
|
|
@@ -83,7 +65,6 @@ export function useSiteConfig(): SiteConfig {
|
|
|
83
65
|
|
|
84
66
|
return mapSiteConfig(
|
|
85
67
|
siteConfig,
|
|
86
|
-
config.public.contentPath
|
|
87
|
-
config.public.contentGitPath as string | undefined
|
|
68
|
+
config.public.contentPath
|
|
88
69
|
)
|
|
89
70
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for `buildEditUrl`, the pure helper extracted from
|
|
3
|
+
* `useSourceEdit`. The composable itself depends on Nuxt's auto-imported
|
|
4
|
+
* `useRoute`/`useSiteConfig`, so the testable surface is the pure
|
|
5
|
+
* function. The slug-derivation logic in `mapSiteConfig` is already
|
|
6
|
+
* pinned by `useSiteConfig.test.ts`; these tests pin the URL-building
|
|
7
|
+
* behavior — most importantly that a user-supplied `source-edit` URL
|
|
8
|
+
* without a trailing slash still produces a valid path with exactly
|
|
9
|
+
* one separator (no `…/blob/mainindex.md` bug, no duplicate slashes).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, it } from 'vitest'
|
|
13
|
+
import { buildEditUrl } from './useSourceEdit'
|
|
14
|
+
|
|
15
|
+
describe('buildEditUrl', () => {
|
|
16
|
+
describe('disabled prefix', () => {
|
|
17
|
+
it('returns undefined when prefix is empty', () => {
|
|
18
|
+
expect(buildEditUrl('', '/features/source-edit')).toBeUndefined()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns undefined when prefix is whitespace-only', () => {
|
|
22
|
+
expect(buildEditUrl(' ', '/features/source-edit')).toBeUndefined()
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('does not depend on route path when prefix is empty', () => {
|
|
26
|
+
expect(buildEditUrl('', '/')).toBeUndefined()
|
|
27
|
+
expect(buildEditUrl('', '')).toBeUndefined()
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('separator normalization', () => {
|
|
32
|
+
const nestedPath = '/features/source-edit'
|
|
33
|
+
|
|
34
|
+
it('inserts a single "/" when prefix has no trailing slash', () => {
|
|
35
|
+
// Regression: previously produced `…/blob/mainfeatures/source-edit.md`.
|
|
36
|
+
expect(buildEditUrl('https://github.com/org/repo/blob/main', nestedPath))
|
|
37
|
+
.toBe('https://github.com/org/repo/blob/main/features/source-edit.md')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('keeps exactly one "/" when prefix already has a trailing slash', () => {
|
|
41
|
+
expect(buildEditUrl('https://github.com/org/repo/blob/main/', nestedPath))
|
|
42
|
+
.toBe('https://github.com/org/repo/blob/main/features/source-edit.md')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('collapses multiple trailing slashes to one', () => {
|
|
46
|
+
expect(buildEditUrl('https://github.com/org/repo/blob/main///', nestedPath))
|
|
47
|
+
.toBe('https://github.com/org/repo/blob/main/features/source-edit.md')
|
|
48
|
+
expect(buildEditUrl('https://github.com/org/repo/blob/main//', nestedPath))
|
|
49
|
+
.toBe('https://github.com/org/repo/blob/main/features/source-edit.md')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('does not double-slash when prefix path component already ends with slash', () => {
|
|
53
|
+
// Defensive: ensures normalize strips every trailing slash, not just
|
|
54
|
+
// the last one.
|
|
55
|
+
expect(buildEditUrl('https://github.com/org/repo/blob/main//', '/index'))
|
|
56
|
+
.toBe('https://github.com/org/repo/blob/main/index.md')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('route path handling', () => {
|
|
61
|
+
const prefix = 'https://github.com/org/repo/blob/main'
|
|
62
|
+
|
|
63
|
+
it('maps the root path "/" to index.md', () => {
|
|
64
|
+
expect(buildEditUrl(prefix, '/')).toBe(`${prefix}/index.md`)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('maps an empty path to index.md', () => {
|
|
68
|
+
expect(buildEditUrl(prefix, '')).toBe(`${prefix}/index.md`)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('strips a leading slash from nested paths', () => {
|
|
72
|
+
expect(buildEditUrl(prefix, '/features/source-edit'))
|
|
73
|
+
.toBe(`${prefix}/features/source-edit.md`)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('passes nested paths without leading slash through unchanged', () => {
|
|
77
|
+
expect(buildEditUrl(prefix, 'features/source-edit'))
|
|
78
|
+
.toBe(`${prefix}/features/source-edit.md`)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('handles single-segment nested paths', () => {
|
|
82
|
+
expect(buildEditUrl(prefix, '/menu'))
|
|
83
|
+
.toBe(`${prefix}/menu.md`)
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('combined normalization + route handling', () => {
|
|
88
|
+
it('produces the documented URL for the docs site root with no trailing slash', () => {
|
|
89
|
+
// The exact case from the bug report: `mdsite.yml:6` has no trailing
|
|
90
|
+
// slash and the docs root resolves to `index.md`.
|
|
91
|
+
expect(buildEditUrl('https://github.com/life-and-dev/mdsite/blob/main', '/'))
|
|
92
|
+
.toBe('https://github.com/life-and-dev/mdsite/blob/main/index.md')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('produces the same URL whether or not the user adds a trailing slash', () => {
|
|
96
|
+
const a = buildEditUrl('https://github.com/org/repo/blob/main', '/features/source-edit')
|
|
97
|
+
const b = buildEditUrl('https://github.com/org/repo/blob/main/', '/features/source-edit')
|
|
98
|
+
const c = buildEditUrl('https://github.com/org/repo/blob/main///', '/features/source-edit')
|
|
99
|
+
expect(a).toBe(b)
|
|
100
|
+
expect(b).toBe(c)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -1,66 +1,54 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Generate source edit URL for the current page.
|
|
3
|
+
*
|
|
4
|
+
* `siteConfig.features.sourceEdit` is a user-supplied URL prefix (e.g.
|
|
5
|
+
* `https://github.com/org/repo/edit/main`) to which the renderer's
|
|
6
|
+
* per-route content path (with a `.md` suffix) is appended. The prefix is
|
|
7
|
+
* normalized so the user may include or omit a trailing slash — any
|
|
8
|
+
* number of trailing slashes is collapsed to exactly one. An empty or
|
|
9
|
+
* whitespace-only prefix disables the link. The user is in control of
|
|
10
|
+
* the full URL — no provider-specific validation is performed.
|
|
11
|
+
*/
|
|
2
12
|
|
|
3
13
|
/**
|
|
4
|
-
*
|
|
14
|
+
* Build a source-edit URL from a user-supplied prefix and a route path.
|
|
15
|
+
*
|
|
16
|
+
* Exported for unit testing. Behavior:
|
|
17
|
+
* - Returns `undefined` when `prefix` is empty or whitespace-only.
|
|
18
|
+
* - Strips every trailing `/` from `prefix`, then inserts exactly one `/`
|
|
19
|
+
* between the prefix and the content path. This means a prefix with no
|
|
20
|
+
* trailing slash (`…/blob/main`) and a prefix with one (`…/blob/main/`)
|
|
21
|
+
* both produce the same result, and a prefix with several
|
|
22
|
+
* (`…/blob/main///`) does not yield duplicate slashes.
|
|
23
|
+
* - The route `/` and an empty path both map to `index.md`.
|
|
24
|
+
*
|
|
25
|
+
* @param prefix URL prefix from `features.source-edit`
|
|
26
|
+
* @param routePath Current route path (e.g. `/features/source-edit`)
|
|
27
|
+
* @returns Full edit URL, or `undefined` when disabled
|
|
5
28
|
*/
|
|
6
|
-
export function
|
|
7
|
-
|
|
8
|
-
|
|
29
|
+
export function buildEditUrl(prefix: string, routePath: string): string | undefined {
|
|
30
|
+
if (!prefix || prefix.trim() === '') {
|
|
31
|
+
return undefined
|
|
32
|
+
}
|
|
9
33
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
function getContentSubdirectory(): string {
|
|
14
|
-
const gitPath = normalize(siteConfig.contentGitPath)
|
|
15
|
-
const contentPath = normalize(siteConfig.contentPath)
|
|
34
|
+
const contentPath = !routePath || routePath === '/'
|
|
35
|
+
? 'index'
|
|
36
|
+
: (routePath.startsWith('/') ? routePath.slice(1) : routePath)
|
|
16
37
|
|
|
17
|
-
|
|
18
|
-
|
|
38
|
+
const normalizedPrefix = prefix.replace(/\/+$/, '')
|
|
39
|
+
return `${normalizedPrefix}/${contentPath}.md`
|
|
40
|
+
}
|
|
19
41
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
42
|
+
export function useSourceEdit() {
|
|
43
|
+
const route = useRoute()
|
|
44
|
+
const siteConfig = useSiteConfig()
|
|
23
45
|
|
|
24
46
|
/**
|
|
25
47
|
* Generate source edit URL for current route
|
|
26
48
|
* @returns source edit URL or undefined if not enabled or not a content page
|
|
27
49
|
*/
|
|
28
50
|
function getEditUrl(): string | undefined {
|
|
29
|
-
|
|
30
|
-
const repo = siteConfig.contentGitRepo
|
|
31
|
-
|
|
32
|
-
// Check if feature is enabled
|
|
33
|
-
if (!siteConfig.features.sourceEdit) {
|
|
34
|
-
return undefined
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// Only render if repo starts with "https://github.com" (current supported provider)
|
|
38
|
-
if (!repo || !repo.startsWith('https://github.com')) {
|
|
39
|
-
return undefined
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
var contentPath: string;
|
|
43
|
-
|
|
44
|
-
// Home page path
|
|
45
|
-
if (!path || path === '/') {
|
|
46
|
-
contentPath = 'index'
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Convert route path to content file path
|
|
50
|
-
// Example: /church/evolution/312-constantine → https://github.com/life-and-dev/church/blob/main/evolution/312-constantine.md
|
|
51
|
-
else {
|
|
52
|
-
contentPath = path.startsWith('/') ? path.slice(1) : path
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Get the subdirectory within the git repo
|
|
56
|
-
const subdir = getContentSubdirectory()
|
|
57
|
-
|
|
58
|
-
// Build the full path: subdirectory + content file path
|
|
59
|
-
const fullPath = subdir
|
|
60
|
-
? `${subdir}/${contentPath}.md`
|
|
61
|
-
: `${contentPath}.md`
|
|
62
|
-
|
|
63
|
-
return `${repo}/blob/${siteConfig.contentGitBranch}/${fullPath}`
|
|
51
|
+
return buildEditUrl(siteConfig.features.sourceEdit, route.path)
|
|
64
52
|
}
|
|
65
53
|
|
|
66
54
|
return {
|
|
@@ -35,6 +35,12 @@
|
|
|
35
35
|
<slot />
|
|
36
36
|
</div>
|
|
37
37
|
</v-container>
|
|
38
|
+
<!--
|
|
39
|
+
Footer sits inside the main column (not as a fixed bottom bar) so it
|
|
40
|
+
scrolls with the article and only appears once the user has reached
|
|
41
|
+
the end of the page.
|
|
42
|
+
-->
|
|
43
|
+
<AppFooter />
|
|
38
44
|
</v-main>
|
|
39
45
|
|
|
40
46
|
<!-- Right Sidebar (Table of Contents) -->
|
|
@@ -110,11 +116,12 @@
|
|
|
110
116
|
<slot />
|
|
111
117
|
</div>
|
|
112
118
|
</v-container>
|
|
119
|
+
<!--
|
|
120
|
+
In-flow footer for mobile: scrolls with the article content.
|
|
121
|
+
-->
|
|
122
|
+
<AppFooter />
|
|
113
123
|
</v-main>
|
|
114
124
|
</div>
|
|
115
|
-
|
|
116
|
-
<!-- Footer Bar (always visible on all layouts) -->
|
|
117
|
-
<AppFooter />
|
|
118
125
|
</div>
|
|
119
126
|
</template>
|
|
120
127
|
|
|
@@ -3,6 +3,26 @@ import { loadMdsiteConfigSync } from './utils/mdsite-config.js'
|
|
|
3
3
|
|
|
4
4
|
const { contentDir } = loadMdsiteConfigSync()
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Build/dependency directories that should never be crawled as content.
|
|
8
|
+
*
|
|
9
|
+
* @nuxt/content v3 does not exclude `node_modules` or hidden directories
|
|
10
|
+
* by default (it passes `dot: true` to all glob/match calls), so a
|
|
11
|
+
* content directory that happens to be the project root (i.e. `mdsite.yml`
|
|
12
|
+
* lives at the repo root and `paths.input` is unset) would otherwise
|
|
13
|
+
* walk into the renderer working dir (`.mdsite/`), its `node_modules`,
|
|
14
|
+
* and other build artifacts.
|
|
15
|
+
*
|
|
16
|
+
* The rule mirrors `isExcludedSourceDir` in `scripts/generate-indices.ts`
|
|
17
|
+
* and `scripts/sync-content.ts`: any hidden directory (name starts with
|
|
18
|
+
* `.`) plus `node_modules` and `dist`. Keep the three lists in sync.
|
|
19
|
+
*/
|
|
20
|
+
const excludedSourcePatterns: readonly string[] = [
|
|
21
|
+
'**/node_modules/**',
|
|
22
|
+
'**/dist/**',
|
|
23
|
+
'**/.*/**'
|
|
24
|
+
]
|
|
25
|
+
|
|
6
26
|
export default defineContentConfig({
|
|
7
27
|
collections: {
|
|
8
28
|
content: defineCollection({
|
|
@@ -10,7 +30,7 @@ export default defineContentConfig({
|
|
|
10
30
|
source: {
|
|
11
31
|
cwd: contentDir,
|
|
12
32
|
include: '**/*.md',
|
|
13
|
-
exclude: ['**/*.draft.md'],
|
|
33
|
+
exclude: [...excludedSourcePatterns, '**/*.draft.md'],
|
|
14
34
|
prefix: '/'
|
|
15
35
|
}
|
|
16
36
|
})
|