@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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- 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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
* #
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
if (
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
*
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
*
|
|
425
|
-
*
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
const
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
);
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
)
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
unsubscribe: () => {
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
this.watchHandles
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
527
|
-
*
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
*
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
const
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
chain =
|
|
666
|
-
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
*
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
+
}
|