@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
@@ -9,17 +9,17 @@ import { loadMdsiteConfigSync } from './utils/mdsite-config'
9
9
  const mdsite = loadMdsiteConfigSync()
10
10
  const siteConfig = mdsite.config
11
11
  const appBaseURL = process.env.NUXT_APP_BASE_URL || '/'
12
- // The git repo root, used by `useSourceEdit` to compute the
13
- // repo-relative path to a content file (so the Edit-on-GitHub URL
14
- // points at `…/blob/<branch>/<subdir>/<file>.md` rather than a
15
- // cwd-relative or absolute filesystem path). We default to the
16
- // directory containing `mdsite.yml` because that is the conventional
17
- // git root for an mdsite content dir; falling back to the content
18
- // dir itself covers the case where `mdsite.yml` is missing (legacy
19
- // `content.config.yml` layouts loaded through `renderer-hooks`).
20
- const contentGitPath = mdsite.configPath
21
- ? path.dirname(mdsite.configPath)
22
- : mdsite.contentDir
12
+
13
+ // mdsite is a static site generator. SSR is only needed at build time so
14
+ // `nuxi generate` can pre-render every route to HTML. In `dev` and
15
+ // `preview` mode SSR uses a per-request fork worker that pulls the full
16
+ // Nuxt + Vuetify + @nuxt/content + mermaid + sharp pipeline and OOMs
17
+ // (Worker terminated JS heap out of memory) on memory-constrained
18
+ // hosts. Keep SSR on for build/generate, off for dev/preview so the
19
+ // dev server renders client-side only and `mdsite generate` still
20
+ // produces per-route static HTML.
21
+ const isSsrNuxtCommand = process.argv.includes('build') || process.argv.includes('generate')
22
+ const ssrEnabled = isSsrNuxtCommand
23
23
 
24
24
  export default defineNuxtConfig({
25
25
  compatibilityDate: '2025-07-15',
@@ -29,7 +29,6 @@ export default defineNuxtConfig({
29
29
  public: {
30
30
  contentDomain: path.basename(mdsite.contentDir),
31
31
  contentPath: mdsite.contentDir,
32
- contentGitPath,
33
32
  // `mdsite.config` is a valid `MdsiteConfig` at runtime, but
34
33
  // Nuxt's runtime-config type generator collapses every complex
35
34
  // field of `siteConfig` to a degenerate shape — `menu` becomes
@@ -58,10 +57,18 @@ export default defineNuxtConfig({
58
57
  },
59
58
 
60
59
  nitro: {
61
- preset: 'static' // Pure static preset - no SPA fallbacks
60
+ preset: 'static', // Pure static preset - no SPA fallbacks
61
+ // The `mdsite` CLI sets `MDSITE_NITRO_OUTPUT_DIR` in dev mode so the
62
+ // build output lands in the content directory's `<paths.build>/.output/`
63
+ // rather than inside the renderer source (e.g. the `mdsite-nuxt/`
64
+ // submodule). The default `.output` is kept for direct use of the
65
+ // renderer (e.g. running `nuxt generate` by hand for renderer dev).
66
+ output: {
67
+ dir: process.env.MDSITE_NITRO_OUTPUT_DIR || '.output'
68
+ }
62
69
  },
63
70
 
64
- ssr: true,
71
+ ssr: ssrEnabled,
65
72
 
66
73
  css: [
67
74
  '~/assets/css/markdown.css',
@@ -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 config.favicon is empty', async () => {
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; site?: { name?: 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 = (config as { site?: { name?: string } }).site?.name ?? 'site'
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 (config.favicon empty or file not found). Using bundled default favicon.')
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}`)
@@ -2,7 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
 
5
- import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
6
6
 
7
7
  import { generateNavigationJson, generateSearchIndexJson, generateFooterJson } from './generate-indices.js'
8
8
 
@@ -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,37 @@ describe('generated content indices', () => {
253
253
  await fs.writeFile(mdsitePath, [
254
254
  'site:',
255
255
  ' name: Test Site',
256
- 'footer:',
257
- ' - about',
258
- ' - contacts',
256
+ 'features:',
257
+ ' footer:',
258
+ ' - about',
259
+ ' - contacts',
260
+ '',
261
+ ].join('\n'), 'utf8')
262
+ process.env.MDSITE_CONFIG_PATH = mdsitePath
263
+
264
+ await generateFooterJson()
265
+
266
+ const footer = await readFooter()
267
+ expect(footer).toEqual([
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',
259
287
  '',
260
288
  ].join('\n'), 'utf8')
261
289
  process.env.MDSITE_CONFIG_PATH = mdsitePath
@@ -264,8 +292,10 @@ describe('generated content indices', () => {
264
292
 
265
293
  const footer = await readFooter()
266
294
  expect(footer).toEqual([
267
- { path: '/about', title: 'About' },
268
- { path: '/contacts', title: 'Contacts' },
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
- 'footer:',
301
- ' - contacts',
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
- 'footer: [contacts]',
390
+ 'features:',
391
+ ' footer: [contacts]',
331
392
  '',
332
393
  ].join('\n'), 'utf8')
333
394
  delete process.env.MDSITE_CONFIG_PATH
@@ -341,4 +402,153 @@ describe('generated content indices', () => {
341
402
  expect(paths).not.toContain('/contacts')
342
403
  })
343
404
  })
405
+
406
+ describe('build/dependency directory exclusion', () => {
407
+ async function readSearchIndex() {
408
+ return JSON.parse(await fs.readFile(path.join(publicDir, '_search-index.json'), 'utf8'))
409
+ }
410
+
411
+ async function readNavigation() {
412
+ return JSON.parse(await fs.readFile(path.join(publicDir, '_navigation.json'), 'utf8'))
413
+ }
414
+
415
+ /**
416
+ * Build a fake content tree that mimics a project root used as the
417
+ * content directory: real user pages alongside build artifacts, hidden
418
+ * editor dirs, and a top-level `node_modules`/`dist`. Returns the set
419
+ * of "expected to be indexed" markdown paths.
420
+ */
421
+ async function plantMixedTree() {
422
+ // User-authored content at the content root
423
+ await fs.writeFile(path.join(contentDir, 'index.md'), '# Home\n\nWelcome home.', 'utf8')
424
+ await fs.writeFile(path.join(contentDir, 'guide.md'), '# Guide\n\nUseful guide.', 'utf8')
425
+ await fs.mkdir(path.join(contentDir, 'features'), { recursive: true })
426
+ await fs.writeFile(path.join(contentDir, 'features', 'theme.md'), '# Theme\n\nContent.', 'utf8')
427
+
428
+ // Renderer working dir with nested node_modules containing a README
429
+ // — mimics the layout the CLI materializes on first run.
430
+ await fs.mkdir(path.join(contentDir, '.mdsite', 'node_modules', 'some-pkg'), { recursive: true })
431
+ await fs.writeFile(
432
+ path.join(contentDir, '.mdsite', 'node_modules', 'some-pkg', 'README.md'),
433
+ '# some-pkg\n\nThis is a dependency README and should be ignored.'
434
+ )
435
+
436
+ // A top-level node_modules dir (e.g. when the content root IS the
437
+ // project root) should also be skipped.
438
+ await fs.mkdir(path.join(contentDir, 'node_modules', 'transitive'), { recursive: true })
439
+ await fs.writeFile(
440
+ path.join(contentDir, 'node_modules', 'transitive', 'README.md'),
441
+ '# transitive\n\nShould also be ignored.'
442
+ )
443
+
444
+ // A `dist` directory (top-level build output) should also be skipped.
445
+ await fs.mkdir(path.join(contentDir, 'dist', 'legacy'), { recursive: true })
446
+ await fs.writeFile(
447
+ path.join(contentDir, 'dist', 'legacy', 'stale.md'),
448
+ '# Stale\n\nShould be ignored.'
449
+ )
450
+
451
+ // Any hidden directory at any depth should be skipped — editor state,
452
+ // tooling caches, etc. `.vscode`, `.idea`, `.history`, `.cache` are
453
+ // all common offenders when the content root is the project root.
454
+ for (const hidden of ['.vscode', '.idea', '.history', '.cache']) {
455
+ await fs.mkdir(path.join(contentDir, hidden, 'notes'), { recursive: true })
456
+ await fs.writeFile(
457
+ path.join(contentDir, hidden, 'notes', 'scratch.md'),
458
+ `# ${hidden}\n\nShould be ignored.`
459
+ )
460
+ }
461
+
462
+ // Nuxt/Nitro/CMS output dirs that may sit next to the content.
463
+ for (const hidden of ['.nuxt', '.output', '.nitro', '.data', '.git']) {
464
+ await fs.mkdir(path.join(contentDir, hidden), { recursive: true })
465
+ await fs.writeFile(
466
+ path.join(contentDir, hidden, 'scratch.md'),
467
+ `# ${hidden}\n\nShould be ignored.`
468
+ )
469
+ }
470
+ }
471
+
472
+ it('does not index .md files under any hidden directory, node_modules, or dist', async () => {
473
+ await plantMixedTree()
474
+
475
+ // console.warn would log a "No H1 found" notice for any file the
476
+ // walker still visited — silence it so the test output stays clean.
477
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
478
+
479
+ try {
480
+ await generateSearchIndexJson()
481
+ } finally {
482
+ warnSpy.mockRestore()
483
+ }
484
+
485
+ const searchIndex = await readSearchIndex()
486
+ const paths = searchIndex.map((entry: { path: string }) => entry.path)
487
+ // User-authored pages are still indexed
488
+ expect(paths).toContain('/')
489
+ expect(paths).toContain('/guide')
490
+ expect(paths).toContain('/features/theme')
491
+ // No entry leaks from a build/dependency or hidden directory
492
+ for (const excluded of [
493
+ '.mdsite',
494
+ 'node_modules',
495
+ 'dist',
496
+ '.nuxt',
497
+ '.output',
498
+ '.nitro',
499
+ '.data',
500
+ '.git',
501
+ '.vscode',
502
+ '.idea',
503
+ '.history',
504
+ '.cache'
505
+ ]) {
506
+ expect(paths.some((p: string) => p.split('/').includes(excluded))).toBe(false)
507
+ }
508
+ const noH1Warnings = warnSpy.mock.calls
509
+ .map((call) => String(call[0] ?? ''))
510
+ .filter((line) => line.startsWith('⚠️'))
511
+ expect(noH1Warnings).toEqual([])
512
+ })
513
+
514
+ it('excludes build/dependency and hidden directories from the fallback navigation tree', async () => {
515
+ await plantMixedTree()
516
+
517
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
518
+
519
+ try {
520
+ await generateNavigationJson()
521
+ } finally {
522
+ warnSpy.mockRestore()
523
+ }
524
+
525
+ const navigation = await readNavigation()
526
+ const paths = navigation.map((n: { path: string }) => n.path)
527
+ expect(paths).toContain('/')
528
+ expect(paths).toContain('/guide')
529
+ expect(paths).toContain('/features/theme')
530
+ // No node should be derived from a build/dependency or hidden directory
531
+ for (const excluded of [
532
+ '.mdsite',
533
+ 'node_modules',
534
+ 'dist',
535
+ '.nuxt',
536
+ '.output',
537
+ '.nitro',
538
+ '.data',
539
+ '.git',
540
+ '.vscode',
541
+ '.idea',
542
+ '.history',
543
+ '.cache'
544
+ ]) {
545
+ expect(paths.some((p: string) => p.split('/').includes(excluded))).toBe(false)
546
+ }
547
+
548
+ const noH1Warnings = warnSpy.mock.calls
549
+ .map((call) => String(call[0] ?? ''))
550
+ .filter((line) => line.startsWith('⚠️'))
551
+ expect(noH1Warnings).toEqual([])
552
+ })
553
+ })
344
554
  })