@life-and-dev/mdsite 0.7.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.
@@ -3,6 +3,26 @@ import { loadMdsiteConfigSync } from './utils/mdsite-config.js'
3
3
 
4
4
  const { contentDir } = loadMdsiteConfigSync()
5
5
 
6
+ /**
7
+ * Build/dependency directories that should never be crawled as content.
8
+ *
9
+ * @nuxt/content v3 does not exclude `node_modules` or hidden directories
10
+ * by default (it passes `dot: true` to all glob/match calls), so a
11
+ * content directory that happens to be the project root (i.e. `mdsite.yml`
12
+ * lives at the repo root and `paths.input` is unset) would otherwise
13
+ * walk into the renderer working dir (`.mdsite/`), its `node_modules`,
14
+ * and other build artifacts.
15
+ *
16
+ * The rule mirrors `isExcludedSourceDir` in `scripts/generate-indices.ts`
17
+ * and `scripts/sync-content.ts`: any hidden directory (name starts with
18
+ * `.`) plus `node_modules` and `dist`. Keep the three lists in sync.
19
+ */
20
+ const excludedSourcePatterns: readonly string[] = [
21
+ '**/node_modules/**',
22
+ '**/dist/**',
23
+ '**/.*/**'
24
+ ]
25
+
6
26
  export default defineContentConfig({
7
27
  collections: {
8
28
  content: defineCollection({
@@ -10,7 +30,7 @@ export default defineContentConfig({
10
30
  source: {
11
31
  cwd: contentDir,
12
32
  include: '**/*.md',
13
- exclude: ['**/*.draft.md'],
33
+ exclude: [...excludedSourcePatterns, '**/*.draft.md'],
14
34
  prefix: '/'
15
35
  }
16
36
  })
@@ -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
 
@@ -402,4 +402,153 @@ describe('generated content indices', () => {
402
402
  expect(paths).not.toContain('/contacts')
403
403
  })
404
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
+ })
405
554
  })
@@ -836,7 +836,29 @@ function filePathToUrlPath(filePath: string, sourceDir: string): string {
836
836
  }
837
837
 
838
838
  /**
839
- * 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`).
840
862
  */
841
863
  async function getAllMarkdownFiles(dir: string): Promise<string[]> {
842
864
  const files: string[] = []
@@ -852,6 +874,9 @@ async function getAllMarkdownFiles(dir: string): Promise<string[]> {
852
874
  const stat = await fs.stat(itemPath)
853
875
 
854
876
  if (stat.isDirectory()) {
877
+ if (isExcludedSourceDir(item)) {
878
+ continue
879
+ }
855
880
  const subFiles = await getAllMarkdownFiles(itemPath)
856
881
  files.push(...subFiles)
857
882
  } else if (item.endsWith('.md') && !item.endsWith('.draft.md')) {
@@ -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}`)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@life-and-dev/mdsite",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Local-first CLI that orchestrates mdsite-nuxt",