@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.
Files changed (65) hide show
  1. package/README.md +16 -17
  2. package/dist/commands/clean.js +28 -10
  3. package/dist/commands/clean.js.map +1 -1
  4. package/dist/commands/commands.test.js +49 -21
  5. package/dist/commands/commands.test.js.map +1 -1
  6. package/dist/commands/generate.js +5 -4
  7. package/dist/commands/generate.js.map +1 -1
  8. package/dist/commands/init.js +2 -2
  9. package/dist/commands/init.js.map +1 -1
  10. package/dist/commands/prepare.js +2 -2
  11. package/dist/commands/prepare.js.map +1 -1
  12. package/dist/commands/prepare.test.js +9 -10
  13. package/dist/commands/prepare.test.js.map +1 -1
  14. package/dist/commands/preview.js +21 -21
  15. package/dist/commands/preview.js.map +1 -1
  16. package/dist/commands/start.js +13 -11
  17. package/dist/commands/start.js.map +1 -1
  18. package/dist/commands/stop.js +7 -4
  19. package/dist/commands/stop.js.map +1 -1
  20. package/dist/commands/workflows.test.js +25 -24
  21. package/dist/commands/workflows.test.js.map +1 -1
  22. package/dist/config/default-mdsite-config.js +7 -8
  23. package/dist/config/default-mdsite-config.js.map +1 -1
  24. package/dist/config/default-mdsite-config.test.js +7 -8
  25. package/dist/config/default-mdsite-config.test.js.map +1 -1
  26. package/dist/config/mdsite-config.d.ts +46 -10
  27. package/dist/config/mdsite-config.js +46 -24
  28. package/dist/config/mdsite-config.js.map +1 -1
  29. package/dist/config/mdsite-config.test.js +55 -50
  30. package/dist/config/mdsite-config.test.js.map +1 -1
  31. package/dist/process/child-process.d.ts +4 -0
  32. package/dist/process/child-process.js +33 -1
  33. package/dist/process/child-process.js.map +1 -1
  34. package/dist/process/child-process.test.js +39 -3
  35. package/dist/process/child-process.test.js.map +1 -1
  36. package/dist/process/runtime-state.d.ts +13 -5
  37. package/dist/process/runtime-state.js +21 -13
  38. package/dist/process/runtime-state.js.map +1 -1
  39. package/dist/process/runtime-state.test.js +3 -5
  40. package/dist/process/runtime-state.test.js.map +1 -1
  41. package/dist/renderer/mdsite-nuxt.d.ts +28 -3
  42. package/dist/renderer/mdsite-nuxt.js +29 -12
  43. package/dist/renderer/mdsite-nuxt.js.map +1 -1
  44. package/dist/renderer/mdsite-nuxt.test.js +34 -12
  45. package/dist/renderer/mdsite-nuxt.test.js.map +1 -1
  46. package/mdsite-nuxt/app/components/AppFooter.vue +84 -22
  47. package/mdsite-nuxt/app/composables/useFooter.test.ts +54 -0
  48. package/mdsite-nuxt/app/composables/useFooter.ts +48 -31
  49. package/mdsite-nuxt/app/composables/useSiteConfig.test.ts +13 -87
  50. package/mdsite-nuxt/app/composables/useSiteConfig.ts +7 -26
  51. package/mdsite-nuxt/app/composables/useSourceEdit.test.ts +103 -0
  52. package/mdsite-nuxt/app/composables/useSourceEdit.ts +39 -51
  53. package/mdsite-nuxt/app/layouts/default.vue +10 -3
  54. package/mdsite-nuxt/content.config.ts +21 -1
  55. package/mdsite-nuxt/nuxt.config.ts +21 -14
  56. package/mdsite-nuxt/scripts/generate-favicons.test.ts +3 -3
  57. package/mdsite-nuxt/scripts/generate-favicons.ts +4 -4
  58. package/mdsite-nuxt/scripts/generate-indices.test.ts +221 -11
  59. package/mdsite-nuxt/scripts/generate-indices.ts +187 -28
  60. package/mdsite-nuxt/scripts/renderer-hooks.test.ts +0 -86
  61. package/mdsite-nuxt/scripts/renderer-hooks.ts +1 -48
  62. package/mdsite-nuxt/scripts/sync-content.ts +39 -1
  63. package/mdsite-nuxt/utils/mdsite-config.ts +86 -41
  64. package/package.json +1 -1
  65. 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<string[] | null> {
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
- if (parsed && Array.isArray(parsed.footer) && parsed.footer.length > 0) {
473
- return parsed.footer.filter((item): item is string => typeof item === 'string')
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<string[] | null> {
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 the
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
- * Process footer items into a flat list of { path, title } links.
521
- * Title falls back to the raw entry when the markdown file is missing or has no H1.
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: string[]): Promise<FooterLink[]> {
587
+ async function processFooterItems(items: MdsiteFooterItem[]): Promise<FooterLink[]> {
524
588
  const links: FooterLink[] = []
525
589
 
526
590
  for (const item of items) {
527
- const normalizedPath = resolveFooterPath(item)
528
- if (!normalizedPath) {
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
- const markdownPath = getMarkdownPath(normalizedPath)
533
- let title: string | null = null
534
-
535
- try {
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
- } catch (e) {
542
- // Ignore missing files
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: title || item
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
- * Build the set of normalized footer paths used to exclude entries from the nav tree.
556
- * Returns an empty set when no footer section is configured.
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 = resolveFooterPath(item)
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
- * Get all markdown files recursively
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
- let configPath = resolveMdsiteConfigPath({
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
- * Get all files with specific extension recursively
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}`)) {