@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
|
@@ -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
|
|
543
614
|
}
|
|
544
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
|
|
630
|
+
}
|
|
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
|
}
|
|
@@ -702,7 +836,29 @@ function filePathToUrlPath(filePath: string, sourceDir: string): string {
|
|
|
702
836
|
}
|
|
703
837
|
|
|
704
838
|
/**
|
|
705
|
-
*
|
|
839
|
+
* Directory names that are never user-authored content. The recursive
|
|
840
|
+
* markdown walker skips these so a content directory that happens to be
|
|
841
|
+
* the project root (i.e. `mdsite.yml` lives at the repo root and
|
|
842
|
+
* `paths.input` is unset) does not crawl into the renderer working dir
|
|
843
|
+
* (`.mdsite/`), its `node_modules`, or other build/dependency artifacts.
|
|
844
|
+
*
|
|
845
|
+
* The rule is broad on purpose: any directory whose name starts with `.`
|
|
846
|
+
* (hidden dirs like `.git`, `.mdsite`, `.nuxt`, `.vscode`, `.idea`,
|
|
847
|
+
* `.history`, `.data`, `.output`, …) plus the two non-hidden directories
|
|
848
|
+
* that are always tooling artifacts (`node_modules`, `dist`). Keeping the
|
|
849
|
+
* list narrow would require enumerating every CI/editor/build tool that
|
|
850
|
+
* might leave a hidden directory next to the content.
|
|
851
|
+
*
|
|
852
|
+
* Keep this in sync with the Nuxt Content collection `exclude` list in
|
|
853
|
+
* `content.config.ts` — both are the same safety net at two layers.
|
|
854
|
+
*/
|
|
855
|
+
function isExcludedSourceDir(name: string): boolean {
|
|
856
|
+
return name.startsWith('.') || name === 'node_modules' || name === 'dist'
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
/**
|
|
860
|
+
* Get all markdown files recursively, skipping build/dependency directories
|
|
861
|
+
* (see `isExcludedSourceDir`).
|
|
706
862
|
*/
|
|
707
863
|
async function getAllMarkdownFiles(dir: string): Promise<string[]> {
|
|
708
864
|
const files: string[] = []
|
|
@@ -718,6 +874,9 @@ async function getAllMarkdownFiles(dir: string): Promise<string[]> {
|
|
|
718
874
|
const stat = await fs.stat(itemPath)
|
|
719
875
|
|
|
720
876
|
if (stat.isDirectory()) {
|
|
877
|
+
if (isExcludedSourceDir(item)) {
|
|
878
|
+
continue
|
|
879
|
+
}
|
|
721
880
|
const subFiles = await getAllMarkdownFiles(itemPath)
|
|
722
881
|
files.push(...subFiles)
|
|
723
882
|
} else if (item.endsWith('.md') && !item.endsWith('.draft.md')) {
|
|
@@ -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
|
-
}
|
|
@@ -209,9 +209,22 @@ export async function startWatcher() {
|
|
|
209
209
|
`${sourceDir}/**/*.md` // Watch all markdown files
|
|
210
210
|
]
|
|
211
211
|
|
|
212
|
+
// Skip build/dependency directories (see `isExcludedSourceDir`). The
|
|
213
|
+
// matcher inspects every path segment so a hidden dir or
|
|
214
|
+
// `node_modules`/`dist` nested anywhere under the content dir is
|
|
215
|
+
// ignored, regardless of depth.
|
|
216
|
+
const ignored = (filePath: string, stats?: { isDirectory?: () => boolean }) => {
|
|
217
|
+
if (stats?.isDirectory && stats.isDirectory()) {
|
|
218
|
+
return isExcludedSourceDir(path.basename(filePath))
|
|
219
|
+
}
|
|
220
|
+
const segments = filePath.split(path.sep)
|
|
221
|
+
return segments.some((segment) => isExcludedSourceDir(segment))
|
|
222
|
+
}
|
|
223
|
+
|
|
212
224
|
const watcher = chokidar.watch(patterns, {
|
|
213
225
|
persistent: true,
|
|
214
226
|
ignoreInitial: true, // Files already copied above
|
|
227
|
+
ignored,
|
|
215
228
|
awaitWriteFinish: {
|
|
216
229
|
stabilityThreshold: 500,
|
|
217
230
|
pollInterval: 100
|
|
@@ -361,7 +374,29 @@ async function isDraftOnlyImage(imagePath: string): Promise<boolean> {
|
|
|
361
374
|
}
|
|
362
375
|
|
|
363
376
|
/**
|
|
364
|
-
*
|
|
377
|
+
* Directory names that are never user-authored content. The recursive image
|
|
378
|
+
* walker and the dev-mode file watcher both skip these so a content
|
|
379
|
+
* directory that happens to be the project root (i.e. `mdsite.yml` lives
|
|
380
|
+
* at the repo root and `paths.input` is unset) does not crawl into the
|
|
381
|
+
* renderer working dir (`.mdsite/`), its `node_modules`, or other
|
|
382
|
+
* build/dependency artifacts.
|
|
383
|
+
*
|
|
384
|
+
* The rule is broad on purpose: any directory whose name starts with `.`
|
|
385
|
+
* (hidden dirs like `.git`, `.mdsite`, `.nuxt`, `.vscode`, `.idea`,
|
|
386
|
+
* `.history`, `.data`, `.output`, …) plus the two non-hidden directories
|
|
387
|
+
* that are always tooling artifacts (`node_modules`, `dist`).
|
|
388
|
+
*
|
|
389
|
+
* Keep this in sync with the same predicate in
|
|
390
|
+
* `scripts/generate-indices.ts` and the Nuxt Content collection `exclude`
|
|
391
|
+
* list in `content.config.ts`.
|
|
392
|
+
*/
|
|
393
|
+
function isExcludedSourceDir(name: string): boolean {
|
|
394
|
+
return name.startsWith('.') || name === 'node_modules' || name === 'dist'
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Get all files with specific extension recursively, skipping
|
|
399
|
+
* build/dependency directories (see `isExcludedSourceDir`).
|
|
365
400
|
*/
|
|
366
401
|
async function getAllFiles(dir: string, ext: string): Promise<string[]> {
|
|
367
402
|
const files: string[] = []
|
|
@@ -377,6 +412,9 @@ async function getAllFiles(dir: string, ext: string): Promise<string[]> {
|
|
|
377
412
|
const stat = await fs.stat(itemPath)
|
|
378
413
|
|
|
379
414
|
if (stat.isDirectory()) {
|
|
415
|
+
if (isExcludedSourceDir(item)) {
|
|
416
|
+
continue
|
|
417
|
+
}
|
|
380
418
|
const subFiles = await getAllFiles(itemPath, ext)
|
|
381
419
|
files.push(...subFiles)
|
|
382
420
|
} else if (item.toLowerCase().endsWith(`.${ext}`)) {
|