@life-and-dev/mdsite 0.6.0 → 0.7.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.
- 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/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 +71 -10
- package/mdsite-nuxt/scripts/generate-indices.ts +161 -27
- package/mdsite-nuxt/scripts/renderer-hooks.test.ts +0 -86
- package/mdsite-nuxt/scripts/renderer-hooks.ts +1 -48
- package/mdsite-nuxt/utils/mdsite-config.ts +86 -41
- package/package.json +1 -1
- package/mdsite-nuxt/example.config.yml +0 -67
|
@@ -66,12 +66,12 @@ describe('generate-favicons', () => {
|
|
|
66
66
|
})
|
|
67
67
|
|
|
68
68
|
describe('generateFavicons', () => {
|
|
69
|
-
it('uses the bundled default favicon and writes all expected assets when
|
|
69
|
+
it('uses the bundled default favicon and writes all expected assets when site.favicon is empty', async () => {
|
|
70
70
|
const outputDir = path.join(tmpDir, 'output')
|
|
71
71
|
|
|
72
72
|
const ok = await generateFavicons({
|
|
73
73
|
contentDir: tmpDir,
|
|
74
|
-
config: { favicon: '' },
|
|
74
|
+
config: { site: { favicon: '' } },
|
|
75
75
|
outputDir,
|
|
76
76
|
})
|
|
77
77
|
|
|
@@ -106,7 +106,7 @@ describe('generate-favicons', () => {
|
|
|
106
106
|
|
|
107
107
|
const ok = await generateFavicons({
|
|
108
108
|
contentDir: tmpDir,
|
|
109
|
-
config: { favicon: 'favicon.svg' },
|
|
109
|
+
config: { site: { favicon: 'favicon.svg' } },
|
|
110
110
|
outputDir,
|
|
111
111
|
})
|
|
112
112
|
|
|
@@ -38,7 +38,7 @@ export function resolveFaviconSource(
|
|
|
38
38
|
|
|
39
39
|
export interface GenerateFaviconsOptions {
|
|
40
40
|
contentDir?: string
|
|
41
|
-
config?: { favicon?: string;
|
|
41
|
+
config?: { site?: { favicon?: string; name?: string } }
|
|
42
42
|
outputDir?: string
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -50,9 +50,9 @@ export async function generateFavicons(options: GenerateFaviconsOptions = {}): P
|
|
|
50
50
|
? { contentDir: options.contentDir, config: options.config }
|
|
51
51
|
: loadMdsiteConfigSync()
|
|
52
52
|
const { contentDir, config } = resolved
|
|
53
|
-
const siteName =
|
|
53
|
+
const siteName = config.site?.name ?? 'site'
|
|
54
54
|
|
|
55
|
-
const resolvedSource = resolveFaviconSource(contentDir, config.favicon ?? '')
|
|
55
|
+
const resolvedSource = resolveFaviconSource(contentDir, config.site?.favicon ?? '')
|
|
56
56
|
|
|
57
57
|
if (!resolvedSource) {
|
|
58
58
|
console.error('❌ No favicon source available (configured source missing AND bundled default not found).')
|
|
@@ -64,7 +64,7 @@ export async function generateFavicons(options: GenerateFaviconsOptions = {}): P
|
|
|
64
64
|
await fs.ensureDir(publicDir)
|
|
65
65
|
|
|
66
66
|
if (resolvedSource.isDefault) {
|
|
67
|
-
console.log('ℹ️ No favicon source configured (
|
|
67
|
+
console.log('ℹ️ No favicon source configured (site.favicon empty or file not found). Using bundled default favicon.')
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
console.log(`🎨 Generating favicons for site: ${siteName}`)
|
|
@@ -244,7 +244,7 @@ describe('generated content indices', () => {
|
|
|
244
244
|
return JSON.parse(await fs.readFile(path.join(publicDir, '_navigation.json'), 'utf8'))
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
-
it('generates _footer.json with paths and titles from mdsite.yml footer section', async () => {
|
|
247
|
+
it('generates _footer.json with paths and titles from mdsite.yml features.footer section', async () => {
|
|
248
248
|
await fs.writeFile(path.join(contentDir, 'index.md'), '# Home\n\nWelcome home.', 'utf8')
|
|
249
249
|
await fs.writeFile(path.join(contentDir, 'about.md'), '# About\n\nAbout us.', 'utf8')
|
|
250
250
|
await fs.writeFile(path.join(contentDir, 'contacts.md'), '# Contacts\n\nContact us.', 'utf8')
|
|
@@ -253,9 +253,10 @@ describe('generated content indices', () => {
|
|
|
253
253
|
await fs.writeFile(mdsitePath, [
|
|
254
254
|
'site:',
|
|
255
255
|
' name: Test Site',
|
|
256
|
-
'
|
|
257
|
-
'
|
|
258
|
-
'
|
|
256
|
+
'features:',
|
|
257
|
+
' footer:',
|
|
258
|
+
' - about',
|
|
259
|
+
' - contacts',
|
|
259
260
|
'',
|
|
260
261
|
].join('\n'), 'utf8')
|
|
261
262
|
process.env.MDSITE_CONFIG_PATH = mdsitePath
|
|
@@ -264,8 +265,37 @@ describe('generated content indices', () => {
|
|
|
264
265
|
|
|
265
266
|
const footer = await readFooter()
|
|
266
267
|
expect(footer).toEqual([
|
|
267
|
-
{ path: '/about', title: 'About' },
|
|
268
|
-
{ path: '/contacts', title: 'Contacts' },
|
|
268
|
+
{ path: '/about', title: 'About', type: 'link', isExternal: false },
|
|
269
|
+
{ path: '/contacts', title: 'Contacts', type: 'link', isExternal: false },
|
|
270
|
+
])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('generates _footer.json with custom labels, external URLs, and separators', async () => {
|
|
274
|
+
await fs.writeFile(path.join(contentDir, 'index.md'), '# Home', 'utf8')
|
|
275
|
+
await fs.writeFile(path.join(contentDir, 'about.md'), '# About', 'utf8')
|
|
276
|
+
|
|
277
|
+
const mdsitePath = path.join(tempDir, 'mdsite.yml')
|
|
278
|
+
await fs.writeFile(mdsitePath, [
|
|
279
|
+
'site:',
|
|
280
|
+
' name: Test Site',
|
|
281
|
+
'features:',
|
|
282
|
+
' footer:',
|
|
283
|
+
' - about',
|
|
284
|
+
' - "About Page": about',
|
|
285
|
+
' - "GitHub Repo": https://github.com/life-and-dev/mdsite',
|
|
286
|
+
' - null',
|
|
287
|
+
'',
|
|
288
|
+
].join('\n'), 'utf8')
|
|
289
|
+
process.env.MDSITE_CONFIG_PATH = mdsitePath
|
|
290
|
+
|
|
291
|
+
await generateFooterJson()
|
|
292
|
+
|
|
293
|
+
const footer = await readFooter()
|
|
294
|
+
expect(footer).toEqual([
|
|
295
|
+
{ path: '/about', title: 'About', type: 'link', isExternal: false },
|
|
296
|
+
{ path: '/about', title: 'About Page', type: 'link', isExternal: false },
|
|
297
|
+
{ path: 'https://github.com/life-and-dev/mdsite', title: 'GitHub Repo', type: 'link', isExternal: true },
|
|
298
|
+
{ path: '', title: '', type: 'separator', isExternal: false },
|
|
269
299
|
])
|
|
270
300
|
})
|
|
271
301
|
|
|
@@ -297,8 +327,9 @@ describe('generated content indices', () => {
|
|
|
297
327
|
'menu:',
|
|
298
328
|
' - index',
|
|
299
329
|
' - guide',
|
|
300
|
-
'
|
|
301
|
-
'
|
|
330
|
+
'features:',
|
|
331
|
+
' footer:',
|
|
332
|
+
' - contacts',
|
|
302
333
|
'',
|
|
303
334
|
].join('\n'), 'utf8')
|
|
304
335
|
process.env.MDSITE_CONFIG_PATH = mdsitePath
|
|
@@ -314,10 +345,39 @@ describe('generated content indices', () => {
|
|
|
314
345
|
|
|
315
346
|
const footer = await readFooter()
|
|
316
347
|
expect(footer).toEqual([
|
|
317
|
-
{ path: '/contacts', title: 'Contacts' },
|
|
348
|
+
{ path: '/contacts', title: 'Contacts', type: 'link', isExternal: false },
|
|
318
349
|
])
|
|
319
350
|
})
|
|
320
351
|
|
|
352
|
+
it('excludes custom-labelled internal footer entries from the navigation tree', async () => {
|
|
353
|
+
await fs.writeFile(path.join(contentDir, 'index.md'), '# Home', 'utf8')
|
|
354
|
+
await fs.writeFile(path.join(contentDir, 'guide.md'), '# Guide', 'utf8')
|
|
355
|
+
await fs.writeFile(path.join(contentDir, 'about.md'), '# About', 'utf8')
|
|
356
|
+
|
|
357
|
+
const mdsitePath = path.join(tempDir, 'mdsite.yml')
|
|
358
|
+
await fs.writeFile(mdsitePath, [
|
|
359
|
+
'site:',
|
|
360
|
+
' name: Test Site',
|
|
361
|
+
'menu:',
|
|
362
|
+
' - index',
|
|
363
|
+
' - guide',
|
|
364
|
+
'features:',
|
|
365
|
+
' footer:',
|
|
366
|
+
' - "About Page": about',
|
|
367
|
+
'',
|
|
368
|
+
].join('\n'), 'utf8')
|
|
369
|
+
process.env.MDSITE_CONFIG_PATH = mdsitePath
|
|
370
|
+
|
|
371
|
+
await generateNavigationJson()
|
|
372
|
+
await generateFooterJson()
|
|
373
|
+
|
|
374
|
+
const navigation = await readNavigation()
|
|
375
|
+
const paths = navigation.map((n: { path: string }) => n.path)
|
|
376
|
+
expect(paths).toContain('/')
|
|
377
|
+
expect(paths).toContain('/guide')
|
|
378
|
+
expect(paths).not.toContain('/about')
|
|
379
|
+
})
|
|
380
|
+
|
|
321
381
|
it('excludes footer entries from the fallback navigation tree', async () => {
|
|
322
382
|
await fs.writeFile(path.join(contentDir, 'index.md'), '# Home\n\nWelcome home.', 'utf8')
|
|
323
383
|
await fs.writeFile(path.join(contentDir, 'about.md'), '# About\n\nAbout us.', 'utf8')
|
|
@@ -327,7 +387,8 @@ describe('generated content indices', () => {
|
|
|
327
387
|
await fs.writeFile(mdsitePath, [
|
|
328
388
|
'site:',
|
|
329
389
|
' name: Test Site',
|
|
330
|
-
'
|
|
390
|
+
'features:',
|
|
391
|
+
' footer: [contacts]',
|
|
331
392
|
'',
|
|
332
393
|
].join('\n'), 'utf8')
|
|
333
394
|
delete process.env.MDSITE_CONFIG_PATH
|
|
@@ -4,7 +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
|
+
import type { MdsiteFooterItem, MdsiteMenuItem } from '../utils/mdsite-config'
|
|
8
8
|
|
|
9
9
|
const __filename = fileURLToPath(import.meta.url)
|
|
10
10
|
const __dirname = path.dirname(__filename)
|
|
@@ -452,25 +452,60 @@ async function loadMenuConfig(sourceDir: string): Promise<MdsiteMenuItem[] | nul
|
|
|
452
452
|
// FOOTER LOGIC
|
|
453
453
|
// ----------------------------------------------------------------------------
|
|
454
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Runtime-validated footer entry. `null` is rendered as a vertical separator
|
|
457
|
+
* in the bar; external URLs keep their raw `path` and render in a new tab;
|
|
458
|
+
* internal links use the normalized path with the H1 title (or custom title).
|
|
459
|
+
*/
|
|
455
460
|
export interface FooterLink {
|
|
456
461
|
path: string
|
|
457
462
|
title: string
|
|
463
|
+
type: 'link' | 'separator'
|
|
464
|
+
isExternal: boolean
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Same filter the CLI uses, but expressed against `MdsiteFooterItem`. Keeps
|
|
469
|
+
* CLI and renderer parsers in lockstep so both drop malformed entries the
|
|
470
|
+
* same way.
|
|
471
|
+
*/
|
|
472
|
+
function isValidFooterItem(item: unknown): item is MdsiteFooterItem {
|
|
473
|
+
if (item === null) return true
|
|
474
|
+
if (typeof item === 'string') return item.trim().length > 0
|
|
475
|
+
if (typeof item === 'object') {
|
|
476
|
+
const keys = Object.keys(item as Record<string, unknown>)
|
|
477
|
+
if (keys.length !== 1) return false
|
|
478
|
+
const value = (item as Record<string, unknown>)[keys[0]]
|
|
479
|
+
return value === null || typeof value === 'string'
|
|
480
|
+
}
|
|
481
|
+
return false
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* True when the string is an absolute http(s) URL. Used to keep external
|
|
486
|
+
* links out of the menu-exclusion set (no markdown file to exclude) and to
|
|
487
|
+
* open them in a new tab in the footer.
|
|
488
|
+
*/
|
|
489
|
+
function isExternalUrl(value: string): boolean {
|
|
490
|
+
return value.startsWith('http://') || value.startsWith('https://')
|
|
458
491
|
}
|
|
459
492
|
|
|
460
493
|
/**
|
|
461
494
|
* Read the footer array from a candidate mdsite.yml config file.
|
|
462
495
|
* Returns null when the file is missing, unreadable, or has no footer key.
|
|
496
|
+
* Footer lives under `features.footer` in mdsite.yml.
|
|
463
497
|
*/
|
|
464
|
-
async function tryReadFooterFromConfig(configPath: string): Promise<
|
|
498
|
+
async function tryReadFooterFromConfig(configPath: string): Promise<MdsiteFooterItem[] | null> {
|
|
465
499
|
if (!await fs.pathExists(configPath)) {
|
|
466
500
|
return null
|
|
467
501
|
}
|
|
468
502
|
|
|
469
503
|
try {
|
|
470
504
|
const content = await fs.readFile(configPath, 'utf-8')
|
|
471
|
-
const parsed = parseYaml(content) as { footer?: unknown } | null
|
|
472
|
-
|
|
473
|
-
|
|
505
|
+
const parsed = parseYaml(content) as { features?: { footer?: unknown } } | null
|
|
506
|
+
const footerItems = parsed?.features?.footer
|
|
507
|
+
if (parsed && Array.isArray(footerItems) && footerItems.length > 0) {
|
|
508
|
+
return footerItems.filter(isValidFooterItem)
|
|
474
509
|
}
|
|
475
510
|
} catch (e) {
|
|
476
511
|
// Ignore parse errors - they shouldn't abort the whole lookup chain
|
|
@@ -486,7 +521,7 @@ async function tryReadFooterFromConfig(configPath: string): Promise<string[] | n
|
|
|
486
521
|
* 3. <sourceDir>/mdsite.yml
|
|
487
522
|
* Returns the first non-empty footer array, or null if none resolve.
|
|
488
523
|
*/
|
|
489
|
-
async function loadFooterConfig(sourceDir: string): Promise<
|
|
524
|
+
async function loadFooterConfig(sourceDir: string): Promise<MdsiteFooterItem[] | null> {
|
|
490
525
|
const candidates: string[] = []
|
|
491
526
|
if (process.env.MDSITE_CONFIG_PATH) {
|
|
492
527
|
candidates.push(process.env.MDSITE_CONFIG_PATH)
|
|
@@ -505,10 +540,13 @@ async function loadFooterConfig(sourceDir: string): Promise<string[] | null> {
|
|
|
505
540
|
}
|
|
506
541
|
|
|
507
542
|
/**
|
|
508
|
-
* Resolve a raw footer entry to its normalized URL path. Returns null when
|
|
509
|
-
* path is empty after resolution.
|
|
543
|
+
* Resolve a raw footer entry to its normalized URL path. Returns null when
|
|
544
|
+
* the path is empty after resolution or when the entry is not a usable string.
|
|
510
545
|
*/
|
|
511
546
|
function resolveFooterPath(item: string): string | null {
|
|
547
|
+
if (isExternalUrl(item)) {
|
|
548
|
+
return null
|
|
549
|
+
}
|
|
512
550
|
const resolvedPath = resolvePath(item, '/')
|
|
513
551
|
if (!resolvedPath || resolvedPath === '/') {
|
|
514
552
|
return null
|
|
@@ -517,34 +555,112 @@ function resolveFooterPath(item: string): string | null {
|
|
|
517
555
|
}
|
|
518
556
|
|
|
519
557
|
/**
|
|
520
|
-
*
|
|
521
|
-
*
|
|
558
|
+
* Pull the normalized internal path out of a footer item, if any. Returns
|
|
559
|
+
* null for external URLs and `null`/empty items so they never get added to
|
|
560
|
+
* the menu-exclusion set.
|
|
561
|
+
*/
|
|
562
|
+
function extractInternalPath(item: MdsiteFooterItem): string | null {
|
|
563
|
+
if (typeof item === 'string') {
|
|
564
|
+
return resolveFooterPath(item)
|
|
565
|
+
}
|
|
566
|
+
if (item && typeof item === 'object') {
|
|
567
|
+
const keys = Object.keys(item)
|
|
568
|
+
if (keys.length === 1) {
|
|
569
|
+
const value = item[keys[0]]
|
|
570
|
+
if (typeof value === 'string') {
|
|
571
|
+
return resolveFooterPath(value)
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
return null
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Process footer items into a flat list of FooterLink entries. Supports:
|
|
580
|
+
* - `null` → separator
|
|
581
|
+
* - string (file name) → internal link, title from H1
|
|
582
|
+
* - `{ title: path }` → internal link with custom title
|
|
583
|
+
* - `{ title: https://... }` → external link, opens in a new tab
|
|
584
|
+
* Title falls back to the raw entry / key when the markdown file is missing
|
|
585
|
+
* or has no H1.
|
|
522
586
|
*/
|
|
523
|
-
async function processFooterItems(items:
|
|
587
|
+
async function processFooterItems(items: MdsiteFooterItem[]): Promise<FooterLink[]> {
|
|
524
588
|
const links: FooterLink[] = []
|
|
525
589
|
|
|
526
590
|
for (const item of items) {
|
|
527
|
-
|
|
528
|
-
|
|
591
|
+
if (item === null) {
|
|
592
|
+
links.push({
|
|
593
|
+
path: '',
|
|
594
|
+
title: '',
|
|
595
|
+
type: 'separator',
|
|
596
|
+
isExternal: false
|
|
597
|
+
})
|
|
529
598
|
continue
|
|
530
599
|
}
|
|
531
600
|
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
if (await fs.pathExists(markdownPath)) {
|
|
537
|
-
const content = await fs.readFile(markdownPath, 'utf-8')
|
|
538
|
-
const metadata = extractMarkdownMetadata(content)
|
|
539
|
-
title = metadata.title
|
|
601
|
+
if (typeof item === 'string') {
|
|
602
|
+
const normalizedPath = resolveFooterPath(item)
|
|
603
|
+
if (!normalizedPath) {
|
|
604
|
+
continue
|
|
540
605
|
}
|
|
541
|
-
|
|
542
|
-
|
|
606
|
+
const title = await readH1Title(normalizedPath) ?? item
|
|
607
|
+
links.push({
|
|
608
|
+
path: normalizedPath,
|
|
609
|
+
title,
|
|
610
|
+
type: 'link',
|
|
611
|
+
isExternal: false
|
|
612
|
+
})
|
|
613
|
+
continue
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Object form: { title: path-or-url }
|
|
617
|
+
const keys = Object.keys(item)
|
|
618
|
+
if (keys.length !== 1) continue
|
|
619
|
+
const displayTitle = keys[0]
|
|
620
|
+
const value = item[displayTitle]
|
|
621
|
+
|
|
622
|
+
if (value === null) {
|
|
623
|
+
links.push({
|
|
624
|
+
path: '',
|
|
625
|
+
title: displayTitle,
|
|
626
|
+
type: 'separator',
|
|
627
|
+
isExternal: false
|
|
628
|
+
})
|
|
629
|
+
continue
|
|
543
630
|
}
|
|
544
631
|
|
|
632
|
+
if (typeof value !== 'string') continue
|
|
633
|
+
|
|
634
|
+
if (isExternalUrl(value)) {
|
|
635
|
+
links.push({
|
|
636
|
+
path: value,
|
|
637
|
+
title: displayTitle,
|
|
638
|
+
type: 'link',
|
|
639
|
+
isExternal: true
|
|
640
|
+
})
|
|
641
|
+
continue
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const normalizedPath = resolveFooterPath(value)
|
|
645
|
+
if (!normalizedPath) {
|
|
646
|
+
// Custom title pointing at a non-existent page — still render it
|
|
647
|
+
// using the raw value as the title so the user sees their label.
|
|
648
|
+
links.push({
|
|
649
|
+
path: value,
|
|
650
|
+
title: displayTitle,
|
|
651
|
+
type: 'link',
|
|
652
|
+
isExternal: false
|
|
653
|
+
})
|
|
654
|
+
continue
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Object form always wins for the title, mirroring how the `menu`
|
|
658
|
+
// parser treats custom labels as overrides of the file's H1.
|
|
545
659
|
links.push({
|
|
546
660
|
path: normalizedPath,
|
|
547
|
-
title:
|
|
661
|
+
title: displayTitle,
|
|
662
|
+
type: 'link',
|
|
663
|
+
isExternal: false
|
|
548
664
|
})
|
|
549
665
|
}
|
|
550
666
|
|
|
@@ -552,8 +668,26 @@ async function processFooterItems(items: string[]): Promise<FooterLink[]> {
|
|
|
552
668
|
}
|
|
553
669
|
|
|
554
670
|
/**
|
|
555
|
-
*
|
|
556
|
-
*
|
|
671
|
+
* Read the H1 from a markdown file at the given normalized path. Returns
|
|
672
|
+
* null when the file is missing or has no H1 heading.
|
|
673
|
+
*/
|
|
674
|
+
async function readH1Title(normalizedPath: string): Promise<string | null> {
|
|
675
|
+
const markdownPath = getMarkdownPath(normalizedPath)
|
|
676
|
+
try {
|
|
677
|
+
if (await fs.pathExists(markdownPath)) {
|
|
678
|
+
const content = await fs.readFile(markdownPath, 'utf-8')
|
|
679
|
+
const metadata = extractMarkdownMetadata(content)
|
|
680
|
+
return metadata.title
|
|
681
|
+
}
|
|
682
|
+
} catch (e) {
|
|
683
|
+
// Ignore missing files
|
|
684
|
+
}
|
|
685
|
+
return null
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* Build the set of normalized footer paths used to exclude entries from the
|
|
690
|
+
* nav tree. Returns an empty set when no footer section is configured.
|
|
557
691
|
*/
|
|
558
692
|
async function getFooterExcludedPaths(sourceDir: string): Promise<Set<string>> {
|
|
559
693
|
const items = await loadFooterConfig(sourceDir)
|
|
@@ -563,7 +697,7 @@ async function getFooterExcludedPaths(sourceDir: string): Promise<Set<string>> {
|
|
|
563
697
|
|
|
564
698
|
const excluded = new Set<string>()
|
|
565
699
|
for (const item of items) {
|
|
566
|
-
const normalizedPath =
|
|
700
|
+
const normalizedPath = extractInternalPath(item)
|
|
567
701
|
if (normalizedPath) {
|
|
568
702
|
excluded.add(normalizedPath)
|
|
569
703
|
}
|
|
@@ -8,51 +8,31 @@ const {
|
|
|
8
8
|
generateFaviconsMock,
|
|
9
9
|
generateWebManifestMock,
|
|
10
10
|
loadMdsiteConfigSyncMock,
|
|
11
|
-
parseYamlMock,
|
|
12
|
-
readFileSyncMock,
|
|
13
11
|
resolveMdsiteConfigPathMock,
|
|
14
12
|
rmMock,
|
|
15
13
|
startWatcherMock,
|
|
16
|
-
statSyncMock,
|
|
17
|
-
stringifyYamlMock,
|
|
18
14
|
syncContentMock,
|
|
19
|
-
writeFileSyncMock,
|
|
20
15
|
} = vi.hoisted(() => ({
|
|
21
16
|
buildContentDataMock: vi.fn(),
|
|
22
17
|
existsSyncMock: vi.fn(),
|
|
23
18
|
generateFaviconsMock: vi.fn(),
|
|
24
19
|
generateWebManifestMock: vi.fn(),
|
|
25
20
|
loadMdsiteConfigSyncMock: vi.fn(),
|
|
26
|
-
parseYamlMock: vi.fn(),
|
|
27
|
-
readFileSyncMock: vi.fn(),
|
|
28
21
|
resolveMdsiteConfigPathMock: vi.fn(),
|
|
29
22
|
rmMock: vi.fn(),
|
|
30
23
|
startWatcherMock: vi.fn(),
|
|
31
|
-
statSyncMock: vi.fn(),
|
|
32
|
-
stringifyYamlMock: vi.fn(),
|
|
33
24
|
syncContentMock: vi.fn(),
|
|
34
|
-
writeFileSyncMock: vi.fn(),
|
|
35
25
|
}))
|
|
36
26
|
|
|
37
27
|
vi.mock('fs', () => ({
|
|
38
28
|
default: {
|
|
39
29
|
existsSync: existsSyncMock,
|
|
40
|
-
statSync: statSyncMock,
|
|
41
|
-
readFileSync: readFileSyncMock,
|
|
42
|
-
writeFileSync: writeFileSyncMock,
|
|
43
30
|
promises: {
|
|
44
31
|
rm: rmMock,
|
|
45
32
|
},
|
|
46
33
|
},
|
|
47
34
|
}))
|
|
48
35
|
|
|
49
|
-
vi.mock('yaml', () => ({
|
|
50
|
-
default: {
|
|
51
|
-
parse: parseYamlMock,
|
|
52
|
-
stringify: stringifyYamlMock,
|
|
53
|
-
},
|
|
54
|
-
}))
|
|
55
|
-
|
|
56
36
|
vi.mock('./generate-indices.js', () => ({
|
|
57
37
|
buildContentData: buildContentDataMock,
|
|
58
38
|
}))
|
|
@@ -105,8 +85,6 @@ describe('renderer hooks orchestration', () => {
|
|
|
105
85
|
contentDir: '/renderer/docs',
|
|
106
86
|
})
|
|
107
87
|
existsSyncMock.mockImplementation((target: string) => target === '/renderer/docs')
|
|
108
|
-
statSyncMock.mockReturnValue({ isFile: () => true })
|
|
109
|
-
stringifyYamlMock.mockReturnValue('compat-config')
|
|
110
88
|
generateFaviconsMock.mockResolvedValue(true)
|
|
111
89
|
buildContentDataMock.mockResolvedValue(undefined)
|
|
112
90
|
generateWebManifestMock.mockResolvedValue(undefined)
|
|
@@ -146,70 +124,6 @@ describe('renderer hooks orchestration', () => {
|
|
|
146
124
|
expect(process.env.MDSITE_CONFIG_PATH).toBe('/renderer/mdsite.yml')
|
|
147
125
|
})
|
|
148
126
|
|
|
149
|
-
it('falls back to the legacy compatibility config when no explicit mdsite config resolves', () => {
|
|
150
|
-
resolveMdsiteConfigPathMock.mockReturnValue(undefined)
|
|
151
|
-
existsSyncMock.mockImplementation((target: string) => (
|
|
152
|
-
target === '/renderer/content.config.yml' || target === path.join('/renderer', 'legacy-docs')
|
|
153
|
-
))
|
|
154
|
-
parseYamlMock.mockReturnValue({
|
|
155
|
-
content: {
|
|
156
|
-
path: 'legacy-docs',
|
|
157
|
-
},
|
|
158
|
-
siteName: 'Legacy Docs',
|
|
159
|
-
})
|
|
160
|
-
loadMdsiteConfigSyncMock.mockReturnValue({
|
|
161
|
-
config: { site: { name: 'Legacy Docs' } },
|
|
162
|
-
configPath: '/renderer/.mdsite-compat.yml',
|
|
163
|
-
contentDir: path.join('/renderer', 'legacy-docs'),
|
|
164
|
-
})
|
|
165
|
-
|
|
166
|
-
const runtime = prepareRendererRuntime('/renderer')
|
|
167
|
-
|
|
168
|
-
expect(readFileSyncMock).toHaveBeenCalledWith('/renderer/content.config.yml', 'utf8')
|
|
169
|
-
expect(writeFileSyncMock).toHaveBeenCalledWith('/renderer/.mdsite-compat.yml', 'compat-config', 'utf8')
|
|
170
|
-
expect(loadMdsiteConfigSyncMock).toHaveBeenCalledWith({
|
|
171
|
-
configPath: '/renderer/.mdsite-compat.yml',
|
|
172
|
-
contentPath: path.join('/renderer', 'legacy-docs'),
|
|
173
|
-
})
|
|
174
|
-
expect(runtime.configPath).toBe('/renderer/.mdsite-compat.yml')
|
|
175
|
-
expect(process.env.MDSITE_CONFIG_PATH).toBe('/renderer/.mdsite-compat.yml')
|
|
176
|
-
expect(process.env.NUXT_CONTENT_PATH).toBe(path.join('/renderer', 'legacy-docs'))
|
|
177
|
-
})
|
|
178
|
-
|
|
179
|
-
it('supports the checked-in legacy renderer config keys', () => {
|
|
180
|
-
resolveMdsiteConfigPathMock.mockReturnValue(undefined)
|
|
181
|
-
existsSyncMock.mockImplementation((target: string) => (
|
|
182
|
-
target === '/renderer/content.config.yml' || target === '/content/docs'
|
|
183
|
-
))
|
|
184
|
-
parseYamlMock.mockReturnValue({
|
|
185
|
-
contentPath: '/content/docs',
|
|
186
|
-
contentGitRepo: 'https://example.test/docs.git',
|
|
187
|
-
siteCanonical: 'https://example.test',
|
|
188
|
-
siteName: 'Legacy Site',
|
|
189
|
-
})
|
|
190
|
-
loadMdsiteConfigSyncMock.mockReturnValue({
|
|
191
|
-
config: { site: { name: 'Legacy Site' } },
|
|
192
|
-
configPath: '/renderer/.mdsite-compat.yml',
|
|
193
|
-
contentDir: '/content/docs',
|
|
194
|
-
})
|
|
195
|
-
|
|
196
|
-
prepareRendererRuntime('/renderer')
|
|
197
|
-
|
|
198
|
-
expect(stringifyYamlMock).toHaveBeenCalledWith(expect.objectContaining({
|
|
199
|
-
server: expect.objectContaining({
|
|
200
|
-
repo: 'https://example.test/docs.git',
|
|
201
|
-
}),
|
|
202
|
-
site: expect.objectContaining({
|
|
203
|
-
canonical: 'https://example.test',
|
|
204
|
-
name: 'Legacy Site',
|
|
205
|
-
}),
|
|
206
|
-
}))
|
|
207
|
-
expect(loadMdsiteConfigSyncMock).toHaveBeenCalledWith({
|
|
208
|
-
configPath: '/renderer/.mdsite-compat.yml',
|
|
209
|
-
contentPath: '/content/docs',
|
|
210
|
-
})
|
|
211
|
-
})
|
|
212
|
-
|
|
213
127
|
it('exits when no renderer config can be resolved', () => {
|
|
214
128
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
215
129
|
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => {
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import fs from 'fs'
|
|
2
2
|
import path from 'path'
|
|
3
|
-
import YAML from 'yaml'
|
|
4
3
|
|
|
5
4
|
import { buildContentData } from './generate-indices.js'
|
|
6
5
|
import { generateFavicons, generateWebManifest } from './generate-favicons.js'
|
|
@@ -18,15 +17,11 @@ export function prepareRendererRuntime(rootDir: string, options: {
|
|
|
18
17
|
configPath?: string
|
|
19
18
|
contentPath?: string
|
|
20
19
|
} = {}): RendererRuntime {
|
|
21
|
-
|
|
20
|
+
const configPath = resolveMdsiteConfigPath({
|
|
22
21
|
configPath: options.configPath,
|
|
23
22
|
contentPath: options.contentPath ?? process.env.NUXT_CONTENT_PATH
|
|
24
23
|
})
|
|
25
24
|
|
|
26
|
-
if (!configPath) {
|
|
27
|
-
configPath = ensureLegacyCompatibilityConfig(rootDir)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
25
|
if (!configPath) {
|
|
31
26
|
console.error('❌ No mdsite.yml configuration found. Set MDSITE_CONFIG_PATH or pass a mdsite.yml path.')
|
|
32
27
|
process.exit(1)
|
|
@@ -101,45 +96,3 @@ async function generateFaviconAssets(config: MdsiteConfig): Promise<void> {
|
|
|
101
96
|
async function generateDevManifestAssets(config: MdsiteConfig): Promise<void> {
|
|
102
97
|
await generateWebManifest({ name: config.site.name, themes: config.themes })
|
|
103
98
|
}
|
|
104
|
-
|
|
105
|
-
function ensureLegacyCompatibilityConfig(rootDir: string): string | undefined {
|
|
106
|
-
const legacyConfigPath = path.join(rootDir, 'content.config.yml')
|
|
107
|
-
|
|
108
|
-
if (!fs.existsSync(legacyConfigPath) || !fs.statSync(legacyConfigPath).isFile()) {
|
|
109
|
-
return undefined
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const legacyConfig = YAML.parse(fs.readFileSync(legacyConfigPath, 'utf8')) ?? {}
|
|
113
|
-
const legacyContentPath = legacyConfig.content?.path || legacyConfig.content?.git?.path || legacyConfig.contentPath || 'docs'
|
|
114
|
-
const contentDir = path.resolve(rootDir, legacyContentPath)
|
|
115
|
-
const compatibilityConfigPath = path.join(rootDir, '.mdsite-compat.yml')
|
|
116
|
-
const compatibilityConfig = {
|
|
117
|
-
favicon: '',
|
|
118
|
-
features: {
|
|
119
|
-
bibleTooltips: legacyConfig.features?.bibleTooltips ?? true,
|
|
120
|
-
sourceEdit: legacyConfig.features?.sourceEdit ?? true
|
|
121
|
-
},
|
|
122
|
-
menu: [],
|
|
123
|
-
footer: [],
|
|
124
|
-
server: {
|
|
125
|
-
output: '.output',
|
|
126
|
-
path: '.mdsite',
|
|
127
|
-
repo: legacyConfig.content?.git?.repo || legacyConfig.contentGitRepo || '',
|
|
128
|
-
gitBranch: legacyConfig.server?.['git-branch'] || 'main'
|
|
129
|
-
},
|
|
130
|
-
site: {
|
|
131
|
-
canonical: legacyConfig.site?.canonical || legacyConfig.siteCanonical || '',
|
|
132
|
-
name: legacyConfig.site?.name || legacyConfig.siteName || path.basename(contentDir) || 'Site'
|
|
133
|
-
},
|
|
134
|
-
themes: legacyConfig.themes || {}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
fs.writeFileSync(compatibilityConfigPath, YAML.stringify(compatibilityConfig), 'utf8')
|
|
138
|
-
process.env.NUXT_CONTENT_PATH = contentDir
|
|
139
|
-
process.env.CONTENT_DIR = contentDir
|
|
140
|
-
process.env.MDSITE_CONFIG_PATH = compatibilityConfigPath
|
|
141
|
-
|
|
142
|
-
console.warn(`⚠️ Using legacy compatibility fallback from ${legacyConfigPath}`)
|
|
143
|
-
|
|
144
|
-
return compatibilityConfigPath
|
|
145
|
-
}
|