@mandujs/core 0.41.2 → 0.43.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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,809 +1,844 @@
1
- /**
2
- * Collection API (Issue #199)
3
- *
4
- * First-class content collection primitive inspired by Astro's
5
- * `astro:content` + Next.js `@next/mdx` + Fumadocs. Projects declare
6
- * collections once in `content.config.ts`, then read them anywhere
7
- * with full typed autocomplete:
8
- *
9
- * ```ts
10
- * // content.config.ts
11
- * import { defineCollection, z } from '@mandujs/core/content';
12
- *
13
- * export const docs = defineCollection({
14
- * path: 'content/docs',
15
- * schema: z.object({
16
- * title: z.string(),
17
- * order: z.number().optional(),
18
- * draft: z.boolean().default(false),
19
- * }),
20
- * });
21
- * ```
22
- *
23
- * ```ts
24
- * // anywhere in the app
25
- * import { docs } from './content.config';
26
- *
27
- * const entries = await docs.all();
28
- * const intro = await docs.get('intro');
29
- * ```
30
- *
31
- * # Overload compatibility
32
- *
33
- * The legacy `defineCollection({ loader, schema })` shape (used by the
34
- * existing ContentLayer in `content-layer.ts`) is still supported — we
35
- * detect the shape at runtime and pass config through unchanged. Only
36
- * the NEW `{ path, schema, ... }` shape returns a `Collection` instance
37
- * with `.load()/.all()/.get()/.getCompiled()`. This matters because the
38
- * CLI `mandu collection create` scaffolder already emits the legacy
39
- * shape, and breaking those projects on a minor version is not an
40
- * option for the MVP.
41
- */
42
-
43
- import * as fs from "fs";
44
- import * as path from "path";
45
- import type { ZodSchema } from "zod";
46
- import { parseFrontmatter } from "./frontmatter";
47
- import { slugFromPath, type SlugFromPathOptions } from "./slug";
48
-
49
- /** A single entry in a Collection after frontmatter + Zod validation. */
50
- export interface CollectionEntry<T = Record<string, unknown>> {
51
- /** URL-safe slug derived from the file path (see `slugFromPath`). */
52
- slug: string;
53
- /** Absolute filesystem path of the source file. */
54
- filePath: string;
55
- /** Validated frontmatter data. */
56
- data: T;
57
- /** Raw markdown/MDX body (everything after the closing `---`). */
58
- content: string;
59
- }
60
-
61
- /** A compiled MDX entry with a lazy-rendered React component. */
62
- export interface CompiledCollectionEntry<T = Record<string, unknown>>
63
- extends CollectionEntry<T> {
64
- /**
65
- * Rendered component. Falls back to a raw-markdown `<pre>` shell
66
- * when MDX-compiling tools (`unified`, `remark-*`, `rehype-*`) are
67
- * not installed — the wrapper always returns SOMETHING so callers
68
- * don't have to branch on "is MDX tooling present".
69
- */
70
- Component: () => unknown;
71
- /** Rendered HTML string (only populated when MDX tooling is available). */
72
- html?: string;
73
- /**
74
- * Diagnostic info describing which pipeline produced `Component`.
75
- *
76
- * - `"unified"` — the full `unified + remark + rehype` chain ran.
77
- * `html` is populated, `Component` wraps it via
78
- * `dangerouslySetInnerHTML`.
79
- * - `"fallback-missing-deps"` — one or more optional MDX deps were
80
- * absent. `Component` returns a `<pre>` shell.
81
- * - `"fallback-pipeline-error"` — deps loaded but the pipeline
82
- * threw; `Component` returns a `<pre>` shell with `error` set.
83
- */
84
- compilationMode: "unified" | "fallback-missing-deps" | "fallback-pipeline-error";
85
- /** The pipeline error when `compilationMode === "fallback-pipeline-error"`. */
86
- error?: Error;
87
- }
88
-
89
- /**
90
- * Compile-time options forwarded to `getCompiled()`. All fields are
91
- * optional; supplying any of the `*Plugins` arrays triggers the
92
- * `unified + remark-parse + remark-rehype + rehype-stringify` pipeline
93
- * (when the deps are installed).
94
- *
95
- * The plugins arrays are typed as `unknown[]` because the optional MDX
96
- * ecosystem does not carry types into `@mandujs/core`. Consumers pass
97
- * whatever their plugin entry exports — typically a function or a
98
- * `[plugin, options]` tuple.
99
- */
100
- export interface CompileOptions {
101
- /** Extra remark plugins applied BEFORE `remark-rehype`. */
102
- remarkPlugins?: unknown[];
103
- /** Extra rehype plugins applied AFTER `remark-rehype`. */
104
- rehypePlugins?: unknown[];
105
- /**
106
- * When true, suppress the warning emitted when optional MDX deps
107
- * are missing and the function falls back to a `<pre>` shell.
108
- * Use this in build scripts where you deliberately do not install
109
- * the MDX toolchain. Default: false.
110
- */
111
- silent?: boolean;
112
- }
113
-
114
- /**
115
- * Callback invoked by `Collection.watch()` on every filesystem event
116
- * that could affect the collection. The handler is called with a
117
- * shallow describe object so consumers can decide whether to
118
- * `collection.invalidate()` and re-render, or skip (e.g. when the
119
- * event targets a file outside the `extensions` allow-list).
120
- *
121
- * The watcher intentionally does NOT call `invalidate()` itself —
122
- * callers typically know when to force a reload (debounce, batch with
123
- * other changes) and the library staying hands-off matches how
124
- * `chokidar`-style APIs behave in the wild.
125
- */
126
- export type CollectionWatchHandler = (event: {
127
- type: "change" | "rename";
128
- filePath: string;
129
- }) => void;
130
-
131
- /** Control handle returned by `Collection.watch()`. */
132
- export interface CollectionWatchHandle {
133
- /** Stop listening to this watcher. Idempotent. */
134
- unsubscribe(): void;
135
- }
136
-
137
- /**
138
- * Compare function for sorting `CollectionEntry` instances. Matches the
139
- * TS `Array.sort` signature so users can compose their own comparators.
140
- */
141
- export type CollectionSort<T> = (
142
- a: CollectionEntry<T>,
143
- b: CollectionEntry<T>
144
- ) => number;
145
-
146
- /**
147
- * Options accepted by the MVP `defineCollection({ path, ... })` form.
148
- */
149
- export interface DefineCollectionOptions<T> {
150
- /**
151
- * Directory (relative to the project root, unless absolute) that
152
- * holds the collection's source files. Glob patterns are NOT
153
- * supported at the MVP — use one collection per directory. The
154
- * collection scans this directory recursively for markdown files.
155
- */
156
- path: string;
157
- /**
158
- * Zod schema for frontmatter validation. When omitted, entries are
159
- * returned with `data: Record<string, unknown>` and no type safety
160
- * — useful for prototypes before the shape stabilizes.
161
- */
162
- schema?: ZodSchema<T>;
163
- /**
164
- * File extensions to include (default: `.md`, `.mdx`, `.markdown`).
165
- * Caller values should include the leading dot.
166
- */
167
- extensions?: string[];
168
- /**
169
- * Override slug generation. Receives the collection-relative path
170
- * (forward slashes) and the parsed frontmatter so authors can force
171
- * a specific slug via `slug:` in frontmatter, fall back to `title`,
172
- * etc. Return the resolved slug string.
173
- */
174
- slug?: (entry: {
175
- path: string;
176
- data: Record<string, unknown>;
177
- }) => string;
178
- /**
179
- * Slug normalization options (forwarded to `slugFromPath`) for the
180
- * default slug generator. Ignored when a custom `slug` callback is
181
- * provided.
182
- */
183
- slugOptions?: SlugFromPathOptions;
184
- /**
185
- * Default sort applied by `.all()`. The framework applies a stable
186
- * fallback-by-slug tiebreaker on top of whatever the caller returns
187
- * so repeated loads always yield the same order. When absent, the
188
- * collection sorts by `data.order` ascending (missing = +Infinity),
189
- * then by slug alphabetical — matching the `generateSidebar`
190
- * helper's default.
191
- */
192
- sort?: CollectionSort<T>;
193
- /**
194
- * Project root override. Normally the Collection resolves `path`
195
- * against `process.cwd()` lazily at `.load()` time; passing this
196
- * pins the root for tests or tooling contexts where cwd is unstable.
197
- */
198
- root?: string;
199
- }
200
-
201
- /** Legacy config shape — preserved to avoid churn on existing projects. */
202
- interface LegacyCollectionConfig {
203
- loader: unknown;
204
- schema?: unknown;
205
- }
206
-
207
- const DEFAULT_EXTENSIONS = [".md", ".mdx", ".markdown"] as const;
208
-
209
- /**
210
- * Collection instance returned by `defineCollection({ path, ... })`.
211
- *
212
- * # Caching
213
- *
214
- * Load results are cached in-memory after the first `.load()` call.
215
- * This is intentional — at the MVP we treat collections as build-time
216
- * data that doesn't change within a process lifetime. Call
217
- * `.invalidate()` to force a rescan.
218
- *
219
- * # Watcher lifecycle (Issue #204 — critical)
220
- *
221
- * **Default: zero watchers.** `all()`, `get()`, and `getCompiled()`
222
- * do NOT open any `fs.watch` handle. Build scripts like
223
- * `scripts/prebuild-docs.ts` can run `await docs.all()` and the
224
- * process will exit cleanly — no active handles left to pin the
225
- * event loop.
226
- *
227
- * **Opt-in via `watch()`**: dev-mode tooling that wants change
228
- * notifications calls `const handle = collection.watch(cb)`. This
229
- * opens ONE `fs.watch` handle per collection (not per file), so the
230
- * cost is bounded regardless of collection size.
231
- *
232
- * **Cleanup**: user code either calls `handle.unsubscribe()` or
233
- * `await collection[Symbol.asyncDispose]()` (ES2023 `using`
234
- * semantics). Either path closes every open watcher so the process
235
- * exits.
236
- */
237
- export class Collection<T = Record<string, unknown>> {
238
- readonly options: DefineCollectionOptions<T>;
239
- private entries: CollectionEntry<T>[] | null = null;
240
- private loadPromise: Promise<CollectionEntry<T>[]> | null = null;
241
- /**
242
- * Active `fs.watch` handles — one per `watch()` call. Stored so
243
- * `dispose()` can close every handle regardless of whether the
244
- * user tracked the returned `unsubscribe` callback.
245
- */
246
- private watchHandles: Set<{ close: () => void }> = new Set();
247
-
248
- constructor(options: DefineCollectionOptions<T>) {
249
- this.options = options;
250
- }
251
-
252
- /** Resolve the collection root directory. */
253
- private resolveRoot(): string {
254
- const root = this.options.root ?? process.cwd();
255
- if (path.isAbsolute(this.options.path)) return this.options.path;
256
- return path.resolve(root, this.options.path);
257
- }
258
-
259
- /**
260
- * Scan the collection directory, parse frontmatter, validate with
261
- * the Zod schema, and cache entries. Safe to call repeatedly — the
262
- * first call's promise is reused by concurrent callers, so a burst
263
- * of `.all()` / `.get()` calls during initial render won't produce
264
- * redundant disk I/O.
265
- */
266
- async load(): Promise<CollectionEntry<T>[]> {
267
- if (this.entries) return this.entries;
268
- if (this.loadPromise) return this.loadPromise;
269
- this.loadPromise = this.doLoad();
270
- try {
271
- this.entries = await this.loadPromise;
272
- return this.entries;
273
- } finally {
274
- this.loadPromise = null;
275
- }
276
- }
277
-
278
- private async doLoad(): Promise<CollectionEntry<T>[]> {
279
- const root = this.resolveRoot();
280
- if (!fs.existsSync(root)) {
281
- // An empty collection is a valid state — authors might scaffold
282
- // the directory before adding entries. Returning `[]` here lets
283
- // pages render with "no content yet" messaging instead of 500.
284
- return [];
285
- }
286
- const extensions = this.options.extensions ?? [...DEFAULT_EXTENSIONS];
287
- const extSet = new Set(extensions.map((e) => e.toLowerCase()));
288
- const absPaths: string[] = [];
289
- walkDir(root, absPaths, extSet);
290
-
291
- const entries: CollectionEntry<T>[] = [];
292
- for (const absPath of absPaths) {
293
- const relPath = path
294
- .relative(root, absPath)
295
- .replace(/\\/g, "/");
296
- const src = fs.readFileSync(absPath, "utf8");
297
- let parsed;
298
- try {
299
- parsed = parseFrontmatter(src);
300
- } catch (err) {
301
- const msg = err instanceof Error ? err.message : String(err);
302
- throw new Error(
303
- `[content] failed to parse frontmatter in ${relPath}: ${msg}`
304
- );
305
- }
306
- // Derive slug via the user's override or the built-in kebab-case
307
- // generator. We pass the parsed frontmatter in so authors can
308
- // honor a `slug:` field without writing their own loader.
309
- const rawSlug = this.options.slug
310
- ? this.options.slug({ path: relPath, data: parsed.data })
311
- : typeof parsed.data.slug === "string" && parsed.data.slug.length > 0
312
- ? String(parsed.data.slug)
313
- : slugFromPath(relPath, this.options.slugOptions);
314
-
315
- let data = parsed.data as unknown as T;
316
- if (this.options.schema) {
317
- const result = this.options.schema.safeParse(parsed.data);
318
- if (!result.success) {
319
- throw new Error(
320
- `[content] schema validation failed for ${relPath}: ${formatZodError(
321
- result.error
322
- )}`
323
- );
324
- }
325
- data = result.data;
326
- }
327
- entries.push({
328
- slug: rawSlug,
329
- filePath: absPath,
330
- data,
331
- content: parsed.body,
332
- });
333
- }
334
-
335
- const sorter = this.options.sort ?? defaultSort<T>();
336
- // Stable sort by applying a slug tiebreaker AFTER the user sort.
337
- // Node's Array.sort is stable as of V8 7.0+ (Bun uses V8), but we
338
- // prefer not to rely on user comparators returning 0 for
339
- // equivalent entries, so we fold the tiebreaker into the key.
340
- entries.sort((a, b) => {
341
- const primary = sorter(a, b);
342
- if (primary !== 0) return primary;
343
- return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
344
- });
345
- return entries;
346
- }
347
-
348
- /** Return all entries (cached). */
349
- async all(): Promise<CollectionEntry<T>[]> {
350
- return this.load();
351
- }
352
-
353
- /** Retrieve a single entry by slug, or undefined. */
354
- async get(slug: string): Promise<CollectionEntry<T> | undefined> {
355
- const entries = await this.load();
356
- return entries.find((e) => e.slug === slug);
357
- }
358
-
359
- /**
360
- * Return an entry with a lazy-rendered React component.
361
- *
362
- * When optional MDX tooling (`unified`, `remark-parse`, `remark-rehype`,
363
- * `rehype-stringify`) is present in the user's deps, we pipe the body
364
- * through it and return both the raw HTML and a React component that
365
- * renders via `dangerouslySetInnerHTML` (safe because the source is
366
- * build-time content the project controls). When tooling is absent,
367
- * `Component` still returns a valid React element — a `<pre>` wrapper
368
- * around the raw markdown — so callers never have to branch on the
369
- * missing-dep case.
370
- *
371
- * # Plugin support (Issue #205)
372
- *
373
- * Pass `{ remarkPlugins, rehypePlugins }` to extend the pipeline
374
- * e.g. `rehype-slug`, `rehype-autolink-headings`, `shiki` for
375
- * syntax highlighting. Plugins are applied in array order, remark
376
- * plugins before `remark-rehype` and rehype plugins after.
377
- *
378
- * # Diagnostics (Issue #205)
379
- *
380
- * The returned entry includes a `compilationMode` discriminator
381
- * so callers can tell whether the full pipeline ran or the fallback
382
- * was used. When a dep is missing we emit a single-line warning
383
- * (unless `silent: true`) naming which module could not be
384
- * resolved — previously the fallback was silent, which made it
385
- * impossible to diagnose why a `<pre>` appeared.
386
- */
387
- async getCompiled(
388
- slug: string,
389
- compileOptions: CompileOptions = {}
390
- ): Promise<CompiledCollectionEntry<T> | undefined> {
391
- const entry = await this.get(slug);
392
- if (!entry) return undefined;
393
- const rendered = await renderMarkdownSafe(entry.content, compileOptions);
394
- const compiled: CompiledCollectionEntry<T> = {
395
- ...entry,
396
- Component: rendered.Component,
397
- compilationMode: rendered.mode,
398
- };
399
- if (rendered.html !== undefined) compiled.html = rendered.html;
400
- if (rendered.error !== undefined) compiled.error = rendered.error;
401
- return compiled;
402
- }
403
-
404
- /**
405
- * Reset the in-memory cache so the next `.load()` rescans disk.
406
- * Used by tests and (eventually) the dev-mode file watcher.
407
- */
408
- invalidate(): void {
409
- this.entries = null;
410
- }
411
-
412
- /**
413
- * Subscribe to filesystem events for this collection's root
414
- * directory. The watcher is lazy — created here, not in the
415
- * constructor — so callers who never call `watch()` pay no cost
416
- * and their process exits cleanly after `all()`.
417
- *
418
- * The callback is invoked with a `{ type, filePath }` object
419
- * where `filePath` is relative to the collection root. The
420
- * caller typically calls `collection.invalidate()` in response
421
- * and re-renders.
422
- *
423
- * Returns a handle with `unsubscribe()`. You can call that
424
- * directly, or call `collection.dispose()` / use `await using`
425
- * (ES2023) to close every watcher at once.
426
- */
427
- watch(handler: CollectionWatchHandler): CollectionWatchHandle {
428
- const root = this.resolveRoot();
429
- if (!fs.existsSync(root)) {
430
- // Nothing to watch return a no-op handle so callers don't
431
- // have to branch on "directory exists". If the directory
432
- // appears later, they can unsubscribe and re-watch.
433
- return { unsubscribe: () => {} };
434
- }
435
- const extensions = this.options.extensions ?? [...DEFAULT_EXTENSIONS];
436
- const extSet = new Set(extensions.map((e) => e.toLowerCase()));
437
-
438
- // Use node's `fs.watch` directly one handle per collection
439
- // root. `recursive: true` is the expensive bit; it is not
440
- // supported on Linux before kernel 5.0 but the dev-mode path
441
- // targets macOS/Windows/recent Linux where it works. If a
442
- // project needs stricter compatibility, they can wrap
443
- // `chokidar` in user code and call `invalidate()` themselves.
444
- let watcher: fs.FSWatcher;
445
- try {
446
- watcher = fs.watch(root, { recursive: true }, (eventType, filename) => {
447
- if (!filename) return;
448
- const normalized = String(filename).replace(/\\/g, "/");
449
- // Filter by extension so unrelated files don't fire the
450
- // callback. We still let the event propagate for rename
451
- // events on directories (no extension) — those can affect
452
- // slugs.
453
- const lastDot = normalized.lastIndexOf(".");
454
- const ext = lastDot >= 0 ? normalized.slice(lastDot).toLowerCase() : "";
455
- if (ext && !extSet.has(ext)) return;
456
- try {
457
- handler({
458
- type: eventType === "rename" ? "rename" : "change",
459
- filePath: normalized,
460
- });
461
- } catch (err) {
462
- // Swallow user-handler errors so one buggy consumer
463
- // does not tear down the watcher for everyone else.
464
- console.error(
465
- `[content] watch handler threw for ${normalized}:`,
466
- err instanceof Error ? err.message : err
467
- );
468
- }
469
- });
470
- } catch (err) {
471
- // Some platforms (notably older Linux) don't support
472
- // `recursive: true`. Log once and return a no-op handle so
473
- // the caller's code continues missing-reload is
474
- // degradation, not breakage.
475
- console.warn(
476
- `[content] fs.watch(${root}) failed — hot reload disabled for this collection:`,
477
- err instanceof Error ? err.message : err
478
- );
479
- return { unsubscribe: () => {} };
480
- }
481
-
482
- const entry = { close: () => watcher.close() };
483
- this.watchHandles.add(entry);
484
-
485
- return {
486
- unsubscribe: () => {
487
- if (!this.watchHandles.has(entry)) return;
488
- this.watchHandles.delete(entry);
489
- try {
490
- watcher.close();
491
- } catch {
492
- // Already closed — ignore.
493
- }
494
- },
495
- };
496
- }
497
-
498
- /**
499
- * Close every active watcher opened by `.watch()`. Safe to call
500
- * repeatedly. After `dispose()` the collection remains usable
501
- * — calling `watch()` again creates a fresh handle.
502
- */
503
- dispose(): void {
504
- for (const handle of this.watchHandles) {
505
- try {
506
- handle.close();
507
- } catch {
508
- // Handle already closed ignore.
509
- }
510
- }
511
- this.watchHandles.clear();
512
- }
513
-
514
- /**
515
- * ES2023 async-dispose support. Enables:
516
- *
517
- * ```ts
518
- * await using docs = defineCollection({ path: 'content/docs' });
519
- * const unsubscribe = docs.watch(onChange);
520
- * // ... work ...
521
- * // docs[Symbol.asyncDispose]() runs automatically at scope exit
522
- * ```
523
- *
524
- * The async variant is used (instead of sync `Symbol.dispose`)
525
- * because real watchers in the ecosystem close asynchronously
526
- * we keep the signature future-proof even though `fs.watch`
527
- * happens to close synchronously today.
528
- */
529
- async [Symbol.asyncDispose](): Promise<void> {
530
- this.dispose();
531
- }
532
- }
533
-
534
- /**
535
- * Default comparator: `data.order` ascending, with missing order
536
- * treated as infinity so numbered entries sink to the top.
537
- */
538
- function defaultSort<T>(): CollectionSort<T> {
539
- return (a, b) => {
540
- const oa = (a.data as { order?: unknown })?.order;
541
- const ob = (b.data as { order?: unknown })?.order;
542
- const na = typeof oa === "number" ? oa : Number.POSITIVE_INFINITY;
543
- const nb = typeof ob === "number" ? ob : Number.POSITIVE_INFINITY;
544
- if (na !== nb) return na - nb;
545
- return 0;
546
- };
547
- }
548
-
549
- /**
550
- * Compact a Zod error into a single line suitable for the error
551
- * messages surfaced by `Collection.load()`. We deliberately avoid
552
- * `z.prettifyError` (not present in Zod 3) and the flattener —
553
- * `issue.path` + `issue.message` is enough for docs authors to
554
- * locate the broken field fast.
555
- */
556
- function formatZodError(error: {
557
- issues: Array<{ path: (string | number)[]; message: string }>;
558
- }): string {
559
- return error.issues
560
- .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
561
- .join("; ");
562
- }
563
-
564
- /**
565
- * Synchronous recursive directory walker — the collection typically
566
- * has a bounded number of entries (dozens to low thousands) so the
567
- * sync cost is negligible, and using sync simplifies error paths
568
- * and the cache-hit fast-path in `load()`.
569
- */
570
- function walkDir(dir: string, out: string[], extSet: Set<string>): void {
571
- let entries: fs.Dirent[];
572
- try {
573
- entries = fs.readdirSync(dir, { withFileTypes: true });
574
- } catch {
575
- return;
576
- }
577
- for (const entry of entries) {
578
- const absPath = path.join(dir, entry.name);
579
- if (entry.isDirectory()) {
580
- walkDir(absPath, out, extSet);
581
- continue;
582
- }
583
- if (!entry.isFile()) continue;
584
- const ext = path.extname(entry.name).toLowerCase();
585
- if (!extSet.has(ext)) continue;
586
- out.push(absPath);
587
- }
588
- }
589
-
590
- /** Internal result type from `renderMarkdownSafe`. */
591
- interface RenderedMarkdown {
592
- html?: string;
593
- Component: () => unknown;
594
- mode: CompiledCollectionEntry<unknown>["compilationMode"];
595
- error?: Error;
596
- }
597
-
598
- /**
599
- * Lazy markdown renderer. Attempts to load `unified` + the standard
600
- * remark/rehype plugin chain; when any piece is missing, falls back
601
- * to returning a `<pre>` shell so the caller gets a stable API.
602
- *
603
- * We go through `Function("return import(...)")` instead of a direct
604
- * dynamic `import()` so TS doesn't resolve the optional modules
605
- * during typecheck — they are NOT in `@mandujs/core` deps by design.
606
- *
607
- * The `options.remarkPlugins` / `options.rehypePlugins` arrays let
608
- * docs sites add `rehype-slug`, `rehype-autolink-headings`, `shiki`,
609
- * etc. — the caller is responsible for installing those modules.
610
- */
611
- async function renderMarkdownSafe(
612
- body: string,
613
- options: CompileOptions = {}
614
- ): Promise<RenderedMarkdown> {
615
- const { remarkPlugins = [], rehypePlugins = [], silent = false } = options;
616
-
617
- // Passing the module specifier through a Function-wrapped dynamic
618
- // import keeps TypeScript from erroring on optional peer deps; if
619
- // any module is missing we fall through to the raw-markdown path.
620
- const tryImport = async (id: string): Promise<unknown> => {
621
- try {
622
- return await (Function("x", "return import(x)") as (x: string) => Promise<unknown>)(id);
623
- } catch {
624
- return null;
625
- }
626
- };
627
-
628
- const unified = (await tryImport("unified")) as
629
- | { unified: () => unknown }
630
- | null;
631
- const remarkParse = (await tryImport("remark-parse")) as
632
- | { default: unknown }
633
- | null;
634
- const remarkRehype = (await tryImport("remark-rehype")) as
635
- | { default: unknown }
636
- | null;
637
- const rehypeStringify = (await tryImport("rehype-stringify")) as
638
- | { default: unknown }
639
- | null;
640
-
641
- // Collect the names of missing modules so the warning is
642
- // actionable a generic "MDX tooling not installed" is hard to
643
- // act on when you DO have some of it installed.
644
- const missing: string[] = [];
645
- if (!unified) missing.push("unified");
646
- if (!remarkParse) missing.push("remark-parse");
647
- if (!remarkRehype) missing.push("remark-rehype");
648
- if (!rehypeStringify) missing.push("rehype-stringify");
649
-
650
- if (missing.length === 0 && unified && remarkParse && remarkRehype && rehypeStringify) {
651
- try {
652
- type Processor = {
653
- use: (plugin: unknown, options?: unknown) => Processor;
654
- process: (src: string) => Promise<{ toString: () => string }>;
655
- };
656
- // Type-punned unified chain — optional peer deps don't carry
657
- // their own types into our graph, so we route through `unknown`.
658
- let chain = unified.unified() as unknown as Processor;
659
- chain = chain.use(remarkParse.default);
660
- // User-supplied remark plugins run between `remark-parse`
661
- // and `remark-rehype` so they can transform the MDAST.
662
- for (const plugin of remarkPlugins) {
663
- chain = applyPlugin(chain, plugin);
664
- }
665
- chain = chain.use(remarkRehype.default);
666
- // Rehype plugins run AFTER `remark-rehype` so they see the
667
- // HAST this is the hook point for `rehype-slug` etc.
668
- for (const plugin of rehypePlugins) {
669
- chain = applyPlugin(chain, plugin);
670
- }
671
- chain = chain.use(rehypeStringify.default);
672
- const file = await chain.process(body);
673
- const html = file.toString();
674
- return {
675
- html,
676
- Component: () => createHtmlElement(html),
677
- mode: "unified",
678
- };
679
- } catch (err) {
680
- // Pipeline itself threw — report it so the caller sees the
681
- // underlying failure instead of a silent `<pre>`.
682
- if (!silent) {
683
- console.warn(
684
- "[content] MDX pipeline failed; falling back to <pre>. Underlying error:",
685
- err instanceof Error ? err.message : err
686
- );
687
- }
688
- return {
689
- Component: () => createPreElement(body),
690
- mode: "fallback-pipeline-error",
691
- error: err instanceof Error ? err : new Error(String(err)),
692
- };
693
- }
694
- }
695
-
696
- // Fallback: emit a simple React element wrapping the raw body in a
697
- // `<pre>` so pages don't 500. Pages that need real MDX should
698
- // install `unified` + remark/rehype in their project.
699
- if (!silent && missing.length > 0) {
700
- // One warning per call — if the project is rendering 500 pages
701
- // this will be noisy, but that noise is the feedback users
702
- // need to know WHY their markdown is not compiling. `silent:
703
- // true` mutes this for build scripts that deliberately opt out.
704
- console.warn(
705
- `[content] MDX tooling missing: ${missing.join(
706
- ", "
707
- )}. Install these peer deps to enable full rendering falling back to <pre> shell.`
708
- );
709
- }
710
- return {
711
- Component: () => createPreElement(body),
712
- mode: "fallback-missing-deps",
713
- };
714
- }
715
-
716
- /**
717
- * Apply a plugin spec to a unified chain. The unified plugin
718
- * ecosystem accepts either a bare function or a `[plugin, options]`
719
- * tuple, so we handle both without pulling the unified types in.
720
- */
721
- function applyPlugin<P extends { use: (plugin: unknown, options?: unknown) => P }>(
722
- chain: P,
723
- plugin: unknown
724
- ): P {
725
- if (Array.isArray(plugin)) {
726
- const [fn, ...rest] = plugin;
727
- return chain.use(fn, ...rest);
728
- }
729
- return chain.use(plugin);
730
- }
731
-
732
- /**
733
- * Build a lightweight React element carrying HTML content. We avoid
734
- * importing React directly so `@mandujs/core/content` stays
735
- * React-free at import time — the returned value is the plain React
736
- * element shape (`{ type, props, key }`) which matches what
737
- * `React.createElement('div', { dangerouslySetInnerHTML: ... })`
738
- * produces. If the project hosts a React version that uses a
739
- * different shape, users can swap to their own compiler.
740
- */
741
- function createHtmlElement(html: string): unknown {
742
- return {
743
- type: "div",
744
- props: {
745
- dangerouslySetInnerHTML: { __html: html },
746
- },
747
- key: null,
748
- // React 19 uses a $$typeof symbol to distinguish elements —
749
- // stamp it so React.isValidElement() accepts us.
750
- $$typeof: Symbol.for("react.element"),
751
- ref: null,
752
- };
753
- }
754
-
755
- function createPreElement(body: string): unknown {
756
- return {
757
- type: "pre",
758
- props: { children: body },
759
- key: null,
760
- $$typeof: Symbol.for("react.element"),
761
- ref: null,
762
- };
763
- }
764
-
765
- // ---------------------------------------------------------------------------
766
- // defineCollection overloads
767
- // ---------------------------------------------------------------------------
768
-
769
- /**
770
- * Legacy signature — pass-through for projects using the existing
771
- * ContentLayer (`{ loader, schema }`). We detect the shape at runtime
772
- * and return the config unchanged so downstream
773
- * `defineContentConfig({ collections: { ... } })` keeps working.
774
- */
775
- export function defineCollection<T extends LegacyCollectionConfig>(
776
- config: T
777
- ): T;
778
-
779
- /**
780
- * MVP signature create a typed `Collection` from a directory path
781
- * and optional Zod schema.
782
- */
783
- export function defineCollection<T>(
784
- options: DefineCollectionOptions<T>
785
- ): Collection<T>;
786
-
787
- export function defineCollection(
788
- config: LegacyCollectionConfig | DefineCollectionOptions<unknown>
789
- ): unknown {
790
- // Disambiguate by the presence of a `loader` property — the legacy
791
- // config always has one, the MVP config never does. Passing both
792
- // throws so the error points to the conflict rather than letting
793
- // one branch silently win.
794
- if (isLegacyConfig(config)) {
795
- if ("path" in config) {
796
- throw new Error(
797
- "[defineCollection] config has both `loader` and `path`; pick one — the legacy ContentLayer uses `loader`, the MVP Collection API uses `path`."
798
- );
799
- }
800
- return config;
801
- }
802
- return new Collection(config as DefineCollectionOptions<unknown>);
803
- }
804
-
805
- function isLegacyConfig(
806
- cfg: LegacyCollectionConfig | DefineCollectionOptions<unknown>
807
- ): cfg is LegacyCollectionConfig {
808
- return typeof cfg === "object" && cfg !== null && "loader" in cfg;
809
- }
1
+ /**
2
+ * Collection API (Issue #199)
3
+ *
4
+ * First-class content collection primitive inspired by Astro's
5
+ * `astro:content` + Next.js `@next/mdx` + Fumadocs. Projects declare
6
+ * collections once in `content.config.ts`, then read them anywhere
7
+ * with full typed autocomplete:
8
+ *
9
+ * ```ts
10
+ * // content.config.ts
11
+ * import { defineCollection, z } from '@mandujs/core/content';
12
+ *
13
+ * export const docs = defineCollection({
14
+ * path: 'content/docs',
15
+ * schema: z.object({
16
+ * title: z.string(),
17
+ * order: z.number().optional(),
18
+ * draft: z.boolean().default(false),
19
+ * }),
20
+ * });
21
+ * ```
22
+ *
23
+ * ```ts
24
+ * // anywhere in the app
25
+ * import { docs } from './content.config';
26
+ *
27
+ * const entries = await docs.all();
28
+ * const intro = await docs.get('intro');
29
+ * ```
30
+ *
31
+ * # Overload compatibility
32
+ *
33
+ * The legacy `defineCollection({ loader, schema })` shape (used by the
34
+ * existing ContentLayer in `content-layer.ts`) is still supported — we
35
+ * detect the shape at runtime and pass config through unchanged. Only
36
+ * the NEW `{ path, schema, ... }` shape returns a `Collection` instance
37
+ * with `.load()/.all()/.get()/.getCompiled()`. This matters because the
38
+ * CLI `mandu collection create` scaffolder already emits the legacy
39
+ * shape, and breaking those projects on a minor version is not an
40
+ * option for the MVP.
41
+ */
42
+
43
+ import * as fs from "fs";
44
+ import * as path from "path";
45
+ import type { ZodSchema } from "zod";
46
+ import { parseFrontmatter } from "./frontmatter";
47
+ import { slugFromPath, type SlugFromPathOptions } from "./slug";
48
+
49
+ /** A single entry in a Collection after frontmatter + Zod validation. */
50
+ export interface CollectionEntry<T = Record<string, unknown>> {
51
+ /** URL-safe slug derived from the file path (see `slugFromPath`). */
52
+ slug: string;
53
+ /** Absolute filesystem path of the source file. */
54
+ filePath: string;
55
+ /** Validated frontmatter data. */
56
+ data: T;
57
+ /** Raw markdown/MDX body (everything after the closing `---`). */
58
+ content: string;
59
+ }
60
+
61
+ /** A compiled MDX entry with a lazy-rendered React component. */
62
+ export interface CompiledCollectionEntry<T = Record<string, unknown>>
63
+ extends CollectionEntry<T> {
64
+ /**
65
+ * Rendered component. Falls back to a raw-markdown `<pre>` shell
66
+ * when MDX-compiling tools (`unified`, `remark-*`, `rehype-*`) are
67
+ * not installed — the wrapper always returns SOMETHING so callers
68
+ * don't have to branch on "is MDX tooling present".
69
+ */
70
+ Component: () => unknown;
71
+ /** Rendered HTML string (only populated when MDX tooling is available). */
72
+ html?: string;
73
+ /**
74
+ * Diagnostic info describing which pipeline produced `Component`.
75
+ *
76
+ * - `"unified"` — the full `unified + remark + rehype` chain ran.
77
+ * `html` is populated, `Component` wraps it via
78
+ * `dangerouslySetInnerHTML`.
79
+ * - `"fallback-missing-deps"` — one or more optional MDX deps were
80
+ * absent. `Component` returns a `<pre>` shell.
81
+ * - `"fallback-pipeline-error"` — deps loaded but the pipeline
82
+ * threw; `Component` returns a `<pre>` shell with `error` set.
83
+ */
84
+ compilationMode: "unified" | "fallback-missing-deps" | "fallback-pipeline-error";
85
+ /** The pipeline error when `compilationMode === "fallback-pipeline-error"`. */
86
+ error?: Error;
87
+ }
88
+
89
+ /**
90
+ * Compile-time options forwarded to `getCompiled()`. All fields are
91
+ * optional; supplying any of the `*Plugins` arrays triggers the
92
+ * `unified + remark-parse + remark-rehype + rehype-stringify` pipeline
93
+ * (when the deps are installed).
94
+ *
95
+ * The plugins arrays are typed as `unknown[]` because the optional MDX
96
+ * ecosystem does not carry types into `@mandujs/core`. Consumers pass
97
+ * whatever their plugin entry exports — typically a function or a
98
+ * `[plugin, options]` tuple.
99
+ */
100
+ export interface CompileOptions {
101
+ /** Extra remark plugins applied BEFORE `remark-rehype`. */
102
+ remarkPlugins?: unknown[];
103
+ /** Extra rehype plugins applied AFTER `remark-rehype`. */
104
+ rehypePlugins?: unknown[];
105
+ /**
106
+ * When true, suppress the warning emitted when optional MDX deps
107
+ * are missing and the function falls back to a `<pre>` shell.
108
+ * Use this in build scripts where you deliberately do not install
109
+ * the MDX toolchain. Default: false.
110
+ */
111
+ silent?: boolean;
112
+ }
113
+
114
+ /**
115
+ * Callback invoked by `Collection.watch()` on every filesystem event
116
+ * that could affect the collection. The handler is called with a
117
+ * shallow describe object so consumers can decide whether to
118
+ * `collection.invalidate()` and re-render, or skip (e.g. when the
119
+ * event targets a file outside the `extensions` allow-list).
120
+ *
121
+ * The watcher intentionally does NOT call `invalidate()` itself —
122
+ * callers typically know when to force a reload (debounce, batch with
123
+ * other changes) and the library staying hands-off matches how
124
+ * `chokidar`-style APIs behave in the wild.
125
+ */
126
+ export type CollectionWatchHandler = (event: {
127
+ type: "change" | "rename";
128
+ filePath: string;
129
+ }) => void;
130
+
131
+ /** Control handle returned by `Collection.watch()`. */
132
+ export interface CollectionWatchHandle {
133
+ /** Stop listening to this watcher. Idempotent. */
134
+ unsubscribe(): void;
135
+ }
136
+
137
+ /**
138
+ * Compare function for sorting `CollectionEntry` instances. Matches the
139
+ * TS `Array.sort` signature so users can compose their own comparators.
140
+ */
141
+ export type CollectionSort<T> = (
142
+ a: CollectionEntry<T>,
143
+ b: CollectionEntry<T>
144
+ ) => number;
145
+
146
+ /**
147
+ * Options accepted by the MVP `defineCollection({ path, ... })` form.
148
+ */
149
+ export interface DefineCollectionOptions<T> {
150
+ /**
151
+ * Directory (relative to the project root, unless absolute) that
152
+ * holds the collection's source files. Glob patterns are NOT
153
+ * supported at the MVP — use one collection per directory. The
154
+ * collection scans this directory recursively for markdown files.
155
+ */
156
+ path: string;
157
+ /**
158
+ * Zod schema for frontmatter validation. When omitted, entries are
159
+ * returned with `data: Record<string, unknown>` and no type safety
160
+ * — useful for prototypes before the shape stabilizes.
161
+ */
162
+ schema?: ZodSchema<T>;
163
+ /**
164
+ * File extensions to include (default: `.md`, `.mdx`, `.markdown`).
165
+ * Caller values should include the leading dot.
166
+ */
167
+ extensions?: string[];
168
+ /**
169
+ * Override slug generation. Receives the collection-relative path
170
+ * (forward slashes) and the parsed frontmatter so authors can force
171
+ * a specific slug via `slug:` in frontmatter, fall back to `title`,
172
+ * etc. Return the resolved slug string.
173
+ */
174
+ slug?: (entry: {
175
+ path: string;
176
+ data: Record<string, unknown>;
177
+ }) => string;
178
+ /**
179
+ * Slug normalization options (forwarded to `slugFromPath`) for the
180
+ * default slug generator. Ignored when a custom `slug` callback is
181
+ * provided.
182
+ */
183
+ slugOptions?: SlugFromPathOptions;
184
+ /**
185
+ * Default sort applied by `.all()`. The framework applies a stable
186
+ * fallback-by-slug tiebreaker on top of whatever the caller returns
187
+ * so repeated loads always yield the same order. When absent, the
188
+ * collection sorts by `data.order` ascending (missing = +Infinity),
189
+ * then by slug alphabetical — matching the `generateSidebar`
190
+ * helper's default.
191
+ */
192
+ sort?: CollectionSort<T>;
193
+ /**
194
+ * Project root override. Normally the Collection resolves `path`
195
+ * against `process.cwd()` lazily at `.load()` time; passing this
196
+ * pins the root for tests or tooling contexts where cwd is unstable.
197
+ */
198
+ root?: string;
199
+ }
200
+
201
+ /** Legacy config shape — preserved to avoid churn on existing projects. */
202
+ interface LegacyCollectionConfig {
203
+ loader: unknown;
204
+ schema?: unknown;
205
+ }
206
+
207
+ const DEFAULT_EXTENSIONS = [".md", ".mdx", ".markdown"] as const;
208
+
209
+ /**
210
+ * Collection instance returned by `defineCollection({ path, ... })`.
211
+ *
212
+ * # Caching
213
+ *
214
+ * Load results are cached in-memory after the first `.load()` call.
215
+ * This is intentional — at the MVP we treat collections as build-time
216
+ * data that doesn't change within a process lifetime. Call
217
+ * `.invalidate()` to force a rescan.
218
+ *
219
+ * # Watcher lifecycle (Issue #204 — critical)
220
+ *
221
+ * **Default: zero watchers.** `all()`, `get()`, and `getCompiled()`
222
+ * do NOT open any `fs.watch` handle. Build scripts like
223
+ * `scripts/prebuild-docs.ts` can run `await docs.all()` and the
224
+ * process will exit cleanly — no active handles left to pin the
225
+ * event loop.
226
+ *
227
+ * **Opt-in via `watch()`**: dev-mode tooling that wants change
228
+ * notifications calls `const handle = collection.watch(cb)`. This
229
+ * opens ONE `fs.watch` handle per collection (not per file), so the
230
+ * cost is bounded regardless of collection size.
231
+ *
232
+ * **Cleanup**: user code either calls `handle.unsubscribe()` or
233
+ * `await collection[Symbol.asyncDispose]()` (ES2023 `using`
234
+ * semantics). Either path closes every open watcher so the process
235
+ * exits.
236
+ */
237
+ export class Collection<T = Record<string, unknown>> {
238
+ readonly options: DefineCollectionOptions<T>;
239
+ private entries: CollectionEntry<T>[] | null = null;
240
+ private loadPromise: Promise<CollectionEntry<T>[]> | null = null;
241
+ /**
242
+ * Active `fs.watch` handles — one per `watch()` call. Stored so
243
+ * `dispose()` can close every handle regardless of whether the
244
+ * user tracked the returned `unsubscribe` callback.
245
+ */
246
+ private watchHandles: Set<{ close: () => void }> = new Set();
247
+
248
+ constructor(options: DefineCollectionOptions<T>) {
249
+ this.options = options;
250
+ // Register so dev-mode tooling (`getRegisteredCollections()` + the
251
+ // bundler's content watcher) can invalidate every collection on a
252
+ // filesystem add/unlink without each project wiring `.watch()` by
253
+ // hand. The set is module-scoped; every Collection constructed in
254
+ // this process joins automatically.
255
+ collectionRegistry.add(this as Collection<unknown>);
256
+ }
257
+
258
+ /** Resolve the collection root directory. */
259
+ private resolveRoot(): string {
260
+ const root = this.options.root ?? process.cwd();
261
+ if (path.isAbsolute(this.options.path)) return this.options.path;
262
+ return path.resolve(root, this.options.path);
263
+ }
264
+
265
+ /**
266
+ * Scan the collection directory, parse frontmatter, validate with
267
+ * the Zod schema, and cache entries. Safe to call repeatedly — the
268
+ * first call's promise is reused by concurrent callers, so a burst
269
+ * of `.all()` / `.get()` calls during initial render won't produce
270
+ * redundant disk I/O.
271
+ */
272
+ async load(): Promise<CollectionEntry<T>[]> {
273
+ if (this.entries) return this.entries;
274
+ if (this.loadPromise) return this.loadPromise;
275
+ this.loadPromise = this.doLoad();
276
+ try {
277
+ this.entries = await this.loadPromise;
278
+ return this.entries;
279
+ } finally {
280
+ this.loadPromise = null;
281
+ }
282
+ }
283
+
284
+ private async doLoad(): Promise<CollectionEntry<T>[]> {
285
+ const root = this.resolveRoot();
286
+ if (!fs.existsSync(root)) {
287
+ // An empty collection is a valid state — authors might scaffold
288
+ // the directory before adding entries. Returning `[]` here lets
289
+ // pages render with "no content yet" messaging instead of 500.
290
+ return [];
291
+ }
292
+ const extensions = this.options.extensions ?? [...DEFAULT_EXTENSIONS];
293
+ const extSet = new Set(extensions.map((e) => e.toLowerCase()));
294
+ const absPaths: string[] = [];
295
+ walkDir(root, absPaths, extSet);
296
+
297
+ const entries: CollectionEntry<T>[] = [];
298
+ for (const absPath of absPaths) {
299
+ const relPath = path
300
+ .relative(root, absPath)
301
+ .replace(/\\/g, "/");
302
+ const src = fs.readFileSync(absPath, "utf8");
303
+ let parsed;
304
+ try {
305
+ parsed = parseFrontmatter(src);
306
+ } catch (err) {
307
+ const msg = err instanceof Error ? err.message : String(err);
308
+ throw new Error(
309
+ `[content] failed to parse frontmatter in ${relPath}: ${msg}`,
310
+ { cause: err }
311
+ );
312
+ }
313
+ // Derive slug via the user's override or the built-in kebab-case
314
+ // generator. We pass the parsed frontmatter in so authors can
315
+ // honor a `slug:` field without writing their own loader.
316
+ const rawSlug = this.options.slug
317
+ ? this.options.slug({ path: relPath, data: parsed.data })
318
+ : typeof parsed.data.slug === "string" && parsed.data.slug.length > 0
319
+ ? String(parsed.data.slug)
320
+ : slugFromPath(relPath, this.options.slugOptions);
321
+
322
+ let data = parsed.data as unknown as T;
323
+ if (this.options.schema) {
324
+ const result = this.options.schema.safeParse(parsed.data);
325
+ if (!result.success) {
326
+ throw new Error(
327
+ `[content] schema validation failed for ${relPath}: ${formatZodError(
328
+ result.error
329
+ )}`
330
+ );
331
+ }
332
+ data = result.data;
333
+ }
334
+ entries.push({
335
+ slug: rawSlug,
336
+ filePath: absPath,
337
+ data,
338
+ content: parsed.body,
339
+ });
340
+ }
341
+
342
+ const sorter = this.options.sort ?? defaultSort<T>();
343
+ // Stable sort by applying a slug tiebreaker AFTER the user sort.
344
+ // Node's Array.sort is stable as of V8 7.0+ (Bun uses V8), but we
345
+ // prefer not to rely on user comparators returning 0 for
346
+ // equivalent entries, so we fold the tiebreaker into the key.
347
+ entries.sort((a, b) => {
348
+ const primary = sorter(a, b);
349
+ if (primary !== 0) return primary;
350
+ return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
351
+ });
352
+ return entries;
353
+ }
354
+
355
+ /** Return all entries (cached). */
356
+ async all(): Promise<CollectionEntry<T>[]> {
357
+ return this.load();
358
+ }
359
+
360
+ /** Retrieve a single entry by slug, or undefined. */
361
+ async get(slug: string): Promise<CollectionEntry<T> | undefined> {
362
+ const entries = await this.load();
363
+ return entries.find((e) => e.slug === slug);
364
+ }
365
+
366
+ /**
367
+ * Return an entry with a lazy-rendered React component.
368
+ *
369
+ * When optional MDX tooling (`unified`, `remark-parse`, `remark-rehype`,
370
+ * `rehype-stringify`) is present in the user's deps, we pipe the body
371
+ * through it and return both the raw HTML and a React component that
372
+ * renders via `dangerouslySetInnerHTML` (safe because the source is
373
+ * build-time content the project controls). When tooling is absent,
374
+ * `Component` still returns a valid React element — a `<pre>` wrapper
375
+ * around the raw markdown so callers never have to branch on the
376
+ * missing-dep case.
377
+ *
378
+ * # Plugin support (Issue #205)
379
+ *
380
+ * Pass `{ remarkPlugins, rehypePlugins }` to extend the pipeline
381
+ * e.g. `rehype-slug`, `rehype-autolink-headings`, `shiki` for
382
+ * syntax highlighting. Plugins are applied in array order, remark
383
+ * plugins before `remark-rehype` and rehype plugins after.
384
+ *
385
+ * # Diagnostics (Issue #205)
386
+ *
387
+ * The returned entry includes a `compilationMode` discriminator
388
+ * so callers can tell whether the full pipeline ran or the fallback
389
+ * was used. When a dep is missing we emit a single-line warning
390
+ * (unless `silent: true`) naming which module could not be
391
+ * resolved previously the fallback was silent, which made it
392
+ * impossible to diagnose why a `<pre>` appeared.
393
+ */
394
+ async getCompiled(
395
+ slug: string,
396
+ compileOptions: CompileOptions = {}
397
+ ): Promise<CompiledCollectionEntry<T> | undefined> {
398
+ const entry = await this.get(slug);
399
+ if (!entry) return undefined;
400
+ const rendered = await renderMarkdownSafe(entry.content, compileOptions);
401
+ const compiled: CompiledCollectionEntry<T> = {
402
+ ...entry,
403
+ Component: rendered.Component,
404
+ compilationMode: rendered.mode,
405
+ };
406
+ if (rendered.html !== undefined) compiled.html = rendered.html;
407
+ if (rendered.error !== undefined) compiled.error = rendered.error;
408
+ return compiled;
409
+ }
410
+
411
+ /**
412
+ * Reset the in-memory cache so the next `.load()` rescans disk.
413
+ * Used by tests and (eventually) the dev-mode file watcher.
414
+ */
415
+ invalidate(): void {
416
+ this.entries = null;
417
+ }
418
+
419
+ /**
420
+ * Subscribe to filesystem events for this collection's root
421
+ * directory. The watcher is lazy — created here, not in the
422
+ * constructor — so callers who never call `watch()` pay no cost
423
+ * and their process exits cleanly after `all()`.
424
+ *
425
+ * The callback is invoked with a `{ type, filePath }` object
426
+ * where `filePath` is relative to the collection root. The
427
+ * caller typically calls `collection.invalidate()` in response
428
+ * and re-renders.
429
+ *
430
+ * Returns a handle with `unsubscribe()`. You can call that
431
+ * directly, or call `collection.dispose()` / use `await using`
432
+ * (ES2023) to close every watcher at once.
433
+ */
434
+ watch(handler: CollectionWatchHandler): CollectionWatchHandle {
435
+ const root = this.resolveRoot();
436
+ if (!fs.existsSync(root)) {
437
+ // Nothing to watch — return a no-op handle so callers don't
438
+ // have to branch on "directory exists". If the directory
439
+ // appears later, they can unsubscribe and re-watch.
440
+ return { unsubscribe: () => {} };
441
+ }
442
+ const extensions = this.options.extensions ?? [...DEFAULT_EXTENSIONS];
443
+ const extSet = new Set(extensions.map((e) => e.toLowerCase()));
444
+
445
+ // Use node's `fs.watch` directly — one handle per collection
446
+ // root. `recursive: true` is the expensive bit; it is not
447
+ // supported on Linux before kernel 5.0 but the dev-mode path
448
+ // targets macOS/Windows/recent Linux where it works. If a
449
+ // project needs stricter compatibility, they can wrap
450
+ // `chokidar` in user code and call `invalidate()` themselves.
451
+ let watcher: fs.FSWatcher;
452
+ try {
453
+ watcher = fs.watch(root, { recursive: true }, (eventType, filename) => {
454
+ if (!filename) return;
455
+ const normalized = String(filename).replace(/\\/g, "/");
456
+ // Filter by extension so unrelated files don't fire the
457
+ // callback. We still let the event propagate for rename
458
+ // events on directories (no extension) those can affect
459
+ // slugs.
460
+ const lastDot = normalized.lastIndexOf(".");
461
+ const ext = lastDot >= 0 ? normalized.slice(lastDot).toLowerCase() : "";
462
+ if (ext && !extSet.has(ext)) return;
463
+ try {
464
+ handler({
465
+ type: eventType === "rename" ? "rename" : "change",
466
+ filePath: normalized,
467
+ });
468
+ } catch (err) {
469
+ // Swallow user-handler errors so one buggy consumer
470
+ // does not tear down the watcher for everyone else.
471
+ console.error(
472
+ `[content] watch handler threw for ${normalized}:`,
473
+ err instanceof Error ? err.message : err
474
+ );
475
+ }
476
+ });
477
+ } catch (err) {
478
+ // Some platforms (notably older Linux) don't support
479
+ // `recursive: true`. Log once and return a no-op handle so
480
+ // the caller's code continues — missing-reload is
481
+ // degradation, not breakage.
482
+ console.warn(
483
+ `[content] fs.watch(${root}) failed — hot reload disabled for this collection:`,
484
+ err instanceof Error ? err.message : err
485
+ );
486
+ return { unsubscribe: () => {} };
487
+ }
488
+
489
+ const entry = { close: () => watcher.close() };
490
+ this.watchHandles.add(entry);
491
+
492
+ return {
493
+ unsubscribe: () => {
494
+ if (!this.watchHandles.has(entry)) return;
495
+ this.watchHandles.delete(entry);
496
+ try {
497
+ watcher.close();
498
+ } catch {
499
+ // Already closed ignore.
500
+ }
501
+ },
502
+ };
503
+ }
504
+
505
+ /**
506
+ * Close every active watcher opened by `.watch()`. Safe to call
507
+ * repeatedly. After `dispose()` the collection remains usable
508
+ * calling `watch()` again creates a fresh handle.
509
+ */
510
+ dispose(): void {
511
+ for (const handle of this.watchHandles) {
512
+ try {
513
+ handle.close();
514
+ } catch {
515
+ // Handle already closed — ignore.
516
+ }
517
+ }
518
+ this.watchHandles.clear();
519
+ }
520
+
521
+ /**
522
+ * ES2023 async-dispose support. Enables:
523
+ *
524
+ * ```ts
525
+ * await using docs = defineCollection({ path: 'content/docs' });
526
+ * const unsubscribe = docs.watch(onChange);
527
+ * // ... work ...
528
+ * // docs[Symbol.asyncDispose]() runs automatically at scope exit
529
+ * ```
530
+ *
531
+ * The async variant is used (instead of sync `Symbol.dispose`)
532
+ * because real watchers in the ecosystem close asynchronously
533
+ * — we keep the signature future-proof even though `fs.watch`
534
+ * happens to close synchronously today.
535
+ */
536
+ async [Symbol.asyncDispose](): Promise<void> {
537
+ this.dispose();
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Default comparator: `data.order` ascending, with missing order
543
+ * treated as infinity so numbered entries sink to the top.
544
+ */
545
+ function defaultSort<T>(): CollectionSort<T> {
546
+ return (a, b) => {
547
+ const oa = (a.data as { order?: unknown })?.order;
548
+ const ob = (b.data as { order?: unknown })?.order;
549
+ const na = typeof oa === "number" ? oa : Number.POSITIVE_INFINITY;
550
+ const nb = typeof ob === "number" ? ob : Number.POSITIVE_INFINITY;
551
+ if (na !== nb) return na - nb;
552
+ return 0;
553
+ };
554
+ }
555
+
556
+ /**
557
+ * Compact a Zod error into a single line suitable for the error
558
+ * messages surfaced by `Collection.load()`. We deliberately avoid
559
+ * `z.prettifyError` (not present in Zod 3) and the flattener —
560
+ * `issue.path` + `issue.message` is enough for docs authors to
561
+ * locate the broken field fast.
562
+ */
563
+ function formatZodError(error: {
564
+ issues: Array<{ path: (string | number)[]; message: string }>;
565
+ }): string {
566
+ return error.issues
567
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
568
+ .join("; ");
569
+ }
570
+
571
+ /**
572
+ * Synchronous recursive directory walker — the collection typically
573
+ * has a bounded number of entries (dozens to low thousands) so the
574
+ * sync cost is negligible, and using sync simplifies error paths
575
+ * and the cache-hit fast-path in `load()`.
576
+ */
577
+ function walkDir(dir: string, out: string[], extSet: Set<string>): void {
578
+ let entries: fs.Dirent[];
579
+ try {
580
+ entries = fs.readdirSync(dir, { withFileTypes: true });
581
+ } catch {
582
+ return;
583
+ }
584
+ for (const entry of entries) {
585
+ const absPath = path.join(dir, entry.name);
586
+ if (entry.isDirectory()) {
587
+ walkDir(absPath, out, extSet);
588
+ continue;
589
+ }
590
+ if (!entry.isFile()) continue;
591
+ const ext = path.extname(entry.name).toLowerCase();
592
+ if (!extSet.has(ext)) continue;
593
+ out.push(absPath);
594
+ }
595
+ }
596
+
597
+ /** Internal result type from `renderMarkdownSafe`. */
598
+ interface RenderedMarkdown {
599
+ html?: string;
600
+ Component: () => unknown;
601
+ mode: CompiledCollectionEntry<unknown>["compilationMode"];
602
+ error?: Error;
603
+ }
604
+
605
+ /**
606
+ * Lazy markdown renderer. Attempts to load `unified` + the standard
607
+ * remark/rehype plugin chain; when any piece is missing, falls back
608
+ * to returning a `<pre>` shell so the caller gets a stable API.
609
+ *
610
+ * We go through `Function("return import(...)")` instead of a direct
611
+ * dynamic `import()` so TS doesn't resolve the optional modules
612
+ * during typecheck — they are NOT in `@mandujs/core` deps by design.
613
+ *
614
+ * The `options.remarkPlugins` / `options.rehypePlugins` arrays let
615
+ * docs sites add `rehype-slug`, `rehype-autolink-headings`, `shiki`,
616
+ * etc. — the caller is responsible for installing those modules.
617
+ */
618
+ async function renderMarkdownSafe(
619
+ body: string,
620
+ options: CompileOptions = {}
621
+ ): Promise<RenderedMarkdown> {
622
+ const { remarkPlugins = [], rehypePlugins = [], silent = false } = options;
623
+
624
+ // Passing the module specifier through a Function-wrapped dynamic
625
+ // import keeps TypeScript from erroring on optional peer deps; if
626
+ // any module is missing we fall through to the raw-markdown path.
627
+ const tryImport = async (id: string): Promise<unknown> => {
628
+ try {
629
+ return await (Function("x", "return import(x)") as (x: string) => Promise<unknown>)(id);
630
+ } catch {
631
+ return null;
632
+ }
633
+ };
634
+
635
+ const unified = (await tryImport("unified")) as
636
+ | { unified: () => unknown }
637
+ | null;
638
+ const remarkParse = (await tryImport("remark-parse")) as
639
+ | { default: unknown }
640
+ | null;
641
+ const remarkRehype = (await tryImport("remark-rehype")) as
642
+ | { default: unknown }
643
+ | null;
644
+ const rehypeStringify = (await tryImport("rehype-stringify")) as
645
+ | { default: unknown }
646
+ | null;
647
+
648
+ // Collect the names of missing modules so the warning is
649
+ // actionable — a generic "MDX tooling not installed" is hard to
650
+ // act on when you DO have some of it installed.
651
+ const missing: string[] = [];
652
+ if (!unified) missing.push("unified");
653
+ if (!remarkParse) missing.push("remark-parse");
654
+ if (!remarkRehype) missing.push("remark-rehype");
655
+ if (!rehypeStringify) missing.push("rehype-stringify");
656
+
657
+ if (missing.length === 0 && unified && remarkParse && remarkRehype && rehypeStringify) {
658
+ try {
659
+ type Processor = {
660
+ use: (plugin: unknown, options?: unknown) => Processor;
661
+ process: (src: string) => Promise<{ toString: () => string }>;
662
+ };
663
+ // Type-punned unified chain — optional peer deps don't carry
664
+ // their own types into our graph, so we route through `unknown`.
665
+ let chain = unified.unified() as unknown as Processor;
666
+ chain = chain.use(remarkParse.default);
667
+ // User-supplied remark plugins run between `remark-parse`
668
+ // and `remark-rehype` so they can transform the MDAST.
669
+ for (const plugin of remarkPlugins) {
670
+ chain = applyPlugin(chain, plugin);
671
+ }
672
+ chain = chain.use(remarkRehype.default);
673
+ // Rehype plugins run AFTER `remark-rehype` so they see the
674
+ // HAST — this is the hook point for `rehype-slug` etc.
675
+ for (const plugin of rehypePlugins) {
676
+ chain = applyPlugin(chain, plugin);
677
+ }
678
+ chain = chain.use(rehypeStringify.default);
679
+ const file = await chain.process(body);
680
+ const html = file.toString();
681
+ return {
682
+ html,
683
+ Component: () => createHtmlElement(html),
684
+ mode: "unified",
685
+ };
686
+ } catch (err) {
687
+ // Pipeline itself threw — report it so the caller sees the
688
+ // underlying failure instead of a silent `<pre>`.
689
+ if (!silent) {
690
+ console.warn(
691
+ "[content] MDX pipeline failed; falling back to <pre>. Underlying error:",
692
+ err instanceof Error ? err.message : err
693
+ );
694
+ }
695
+ return {
696
+ Component: () => createPreElement(body),
697
+ mode: "fallback-pipeline-error",
698
+ error: err instanceof Error ? err : new Error(String(err)),
699
+ };
700
+ }
701
+ }
702
+
703
+ // Fallback: emit a simple React element wrapping the raw body in a
704
+ // `<pre>` so pages don't 500. Pages that need real MDX should
705
+ // install `unified` + remark/rehype in their project.
706
+ if (!silent && missing.length > 0) {
707
+ // One warning per call if the project is rendering 500 pages
708
+ // this will be noisy, but that noise is the feedback users
709
+ // need to know WHY their markdown is not compiling. `silent:
710
+ // true` mutes this for build scripts that deliberately opt out.
711
+ console.warn(
712
+ `[content] MDX tooling missing: ${missing.join(
713
+ ", "
714
+ )}. Install these peer deps to enable full rendering — falling back to <pre> shell.`
715
+ );
716
+ }
717
+ return {
718
+ Component: () => createPreElement(body),
719
+ mode: "fallback-missing-deps",
720
+ };
721
+ }
722
+
723
+ /**
724
+ * Apply a plugin spec to a unified chain. The unified plugin
725
+ * ecosystem accepts either a bare function or a `[plugin, options]`
726
+ * tuple, so we handle both without pulling the unified types in.
727
+ */
728
+ function applyPlugin<P extends { use: (plugin: unknown, options?: unknown) => P }>(
729
+ chain: P,
730
+ plugin: unknown
731
+ ): P {
732
+ if (Array.isArray(plugin)) {
733
+ const [fn, ...rest] = plugin;
734
+ return chain.use(fn, ...rest);
735
+ }
736
+ return chain.use(plugin);
737
+ }
738
+
739
+ /**
740
+ * Build a lightweight React element carrying HTML content. We avoid
741
+ * importing React directly so `@mandujs/core/content` stays
742
+ * React-free at import time — the returned value is the plain React
743
+ * element shape (`{ type, props, key }`) which matches what
744
+ * `React.createElement('div', { dangerouslySetInnerHTML: ... })`
745
+ * produces. If the project hosts a React version that uses a
746
+ * different shape, users can swap to their own compiler.
747
+ */
748
+ function createHtmlElement(html: string): unknown {
749
+ return {
750
+ type: "div",
751
+ props: {
752
+ dangerouslySetInnerHTML: { __html: html },
753
+ },
754
+ key: null,
755
+ // React 19 uses a $$typeof symbol to distinguish elements —
756
+ // stamp it so React.isValidElement() accepts us.
757
+ $$typeof: Symbol.for("react.element"),
758
+ ref: null,
759
+ };
760
+ }
761
+
762
+ function createPreElement(body: string): unknown {
763
+ return {
764
+ type: "pre",
765
+ props: { children: body },
766
+ key: null,
767
+ $$typeof: Symbol.for("react.element"),
768
+ ref: null,
769
+ };
770
+ }
771
+
772
+ // ---------------------------------------------------------------------------
773
+ // defineCollection overloads
774
+ // ---------------------------------------------------------------------------
775
+
776
+ /**
777
+ * Legacy signature — pass-through for projects using the existing
778
+ * ContentLayer (`{ loader, schema }`). We detect the shape at runtime
779
+ * and return the config unchanged so downstream
780
+ * `defineContentConfig({ collections: { ... } })` keeps working.
781
+ */
782
+ export function defineCollection<T extends LegacyCollectionConfig>(
783
+ config: T
784
+ ): T;
785
+
786
+ /**
787
+ * MVP signature — create a typed `Collection` from a directory path
788
+ * and optional Zod schema.
789
+ */
790
+ export function defineCollection<T>(
791
+ options: DefineCollectionOptions<T>
792
+ ): Collection<T>;
793
+
794
+ export function defineCollection(
795
+ config: LegacyCollectionConfig | DefineCollectionOptions<unknown>
796
+ ): unknown {
797
+ // Disambiguate by the presence of a `loader` property — the legacy
798
+ // config always has one, the MVP config never does. Passing both
799
+ // throws so the error points to the conflict rather than letting
800
+ // one branch silently win.
801
+ if (isLegacyConfig(config)) {
802
+ if ("path" in config) {
803
+ throw new Error(
804
+ "[defineCollection] config has both `loader` and `path`; pick one — the legacy ContentLayer uses `loader`, the MVP Collection API uses `path`."
805
+ );
806
+ }
807
+ return config;
808
+ }
809
+ return new Collection(config as DefineCollectionOptions<unknown>);
810
+ }
811
+
812
+ function isLegacyConfig(
813
+ cfg: LegacyCollectionConfig | DefineCollectionOptions<unknown>
814
+ ): cfg is LegacyCollectionConfig {
815
+ return typeof cfg === "object" && cfg !== null && "loader" in cfg;
816
+ }
817
+
818
+ /**
819
+ * Module-scoped registry of every `Collection` instance ever constructed
820
+ * in this process. Consumed by the dev bundler's content-change handler
821
+ * to invalidate in-memory entries after a filesystem add/remove so the
822
+ * next SSR request rescans disk. Readers must treat the set as read-only.
823
+ */
824
+ const collectionRegistry = new Set<Collection<unknown>>();
825
+
826
+ /**
827
+ * Return the live registry of Collections. The returned set is live —
828
+ * a Collection constructed after the call still shows up on the next
829
+ * iteration. Intended for dev tooling; production code should go
830
+ * through the typed `getCollection()` / `defineCollection()` APIs.
831
+ */
832
+ export function getRegisteredCollections(): ReadonlySet<Collection<unknown>> {
833
+ return collectionRegistry;
834
+ }
835
+
836
+ /**
837
+ * Invalidate every registered collection's in-memory cache. Called by
838
+ * the dev bundler when a file under a collection root is added or
839
+ * removed. Cheap (walks the registry and nulls each `entries` cache);
840
+ * the next `.all()` / `.get()` call rescans disk.
841
+ */
842
+ export function invalidateAllCollections(): void {
843
+ for (const c of collectionRegistry) c.invalidate();
844
+ }