@mandujs/core 0.24.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -1
- package/src/bundler/build.test.ts +18 -8
- package/src/bundler/build.ts +26 -3
- package/src/bundler/dev.ts +18 -4
- package/src/bundler/safe-build.test.ts +22 -4
- package/src/client/globals.ts +61 -44
- package/src/client/router.ts +93 -15
- package/src/client/use-fetch.ts +243 -239
- package/src/config/mandu.ts +64 -0
- package/src/config/validate.ts +35 -0
- package/src/content/collection.ts +506 -0
- package/src/content/frontmatter.ts +189 -0
- package/src/content/generate-types.ts +168 -0
- package/src/content/index.ts +206 -168
- package/src/content/llms-txt.ts +196 -0
- package/src/content/prebuild.test.ts +249 -0
- package/src/content/prebuild.ts +400 -0
- package/src/content/schema.ts +20 -0
- package/src/content/sidebar.ts +212 -0
- package/src/content/slug.ts +110 -0
- package/src/guard/check.ts +5 -2
- package/src/observability/index.ts +37 -18
- package/src/observability/metrics.ts +334 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/registry.ts +171 -0
- package/src/runtime/server.ts +153 -6
- package/src/runtime/ssr.ts +118 -1
- package/src/runtime/streaming-ssr.ts +11 -1
- package/src/utils/__tests__/lru-cache.test.ts +186 -0
- package/src/utils/lru-cache.ts +172 -75
|
@@ -0,0 +1,506 @@
|
|
|
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
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Compare function for sorting `CollectionEntry` instances. Matches the
|
|
77
|
+
* TS `Array.sort` signature so users can compose their own comparators.
|
|
78
|
+
*/
|
|
79
|
+
export type CollectionSort<T> = (
|
|
80
|
+
a: CollectionEntry<T>,
|
|
81
|
+
b: CollectionEntry<T>
|
|
82
|
+
) => number;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Options accepted by the MVP `defineCollection({ path, ... })` form.
|
|
86
|
+
*/
|
|
87
|
+
export interface DefineCollectionOptions<T> {
|
|
88
|
+
/**
|
|
89
|
+
* Directory (relative to the project root, unless absolute) that
|
|
90
|
+
* holds the collection's source files. Glob patterns are NOT
|
|
91
|
+
* supported at the MVP — use one collection per directory. The
|
|
92
|
+
* collection scans this directory recursively for markdown files.
|
|
93
|
+
*/
|
|
94
|
+
path: string;
|
|
95
|
+
/**
|
|
96
|
+
* Zod schema for frontmatter validation. When omitted, entries are
|
|
97
|
+
* returned with `data: Record<string, unknown>` and no type safety
|
|
98
|
+
* — useful for prototypes before the shape stabilizes.
|
|
99
|
+
*/
|
|
100
|
+
schema?: ZodSchema<T>;
|
|
101
|
+
/**
|
|
102
|
+
* File extensions to include (default: `.md`, `.mdx`, `.markdown`).
|
|
103
|
+
* Caller values should include the leading dot.
|
|
104
|
+
*/
|
|
105
|
+
extensions?: string[];
|
|
106
|
+
/**
|
|
107
|
+
* Override slug generation. Receives the collection-relative path
|
|
108
|
+
* (forward slashes) and the parsed frontmatter so authors can force
|
|
109
|
+
* a specific slug via `slug:` in frontmatter, fall back to `title`,
|
|
110
|
+
* etc. Return the resolved slug string.
|
|
111
|
+
*/
|
|
112
|
+
slug?: (entry: {
|
|
113
|
+
path: string;
|
|
114
|
+
data: Record<string, unknown>;
|
|
115
|
+
}) => string;
|
|
116
|
+
/**
|
|
117
|
+
* Slug normalization options (forwarded to `slugFromPath`) for the
|
|
118
|
+
* default slug generator. Ignored when a custom `slug` callback is
|
|
119
|
+
* provided.
|
|
120
|
+
*/
|
|
121
|
+
slugOptions?: SlugFromPathOptions;
|
|
122
|
+
/**
|
|
123
|
+
* Default sort applied by `.all()`. The framework applies a stable
|
|
124
|
+
* fallback-by-slug tiebreaker on top of whatever the caller returns
|
|
125
|
+
* so repeated loads always yield the same order. When absent, the
|
|
126
|
+
* collection sorts by `data.order` ascending (missing = +Infinity),
|
|
127
|
+
* then by slug alphabetical — matching the `generateSidebar`
|
|
128
|
+
* helper's default.
|
|
129
|
+
*/
|
|
130
|
+
sort?: CollectionSort<T>;
|
|
131
|
+
/**
|
|
132
|
+
* Project root override. Normally the Collection resolves `path`
|
|
133
|
+
* against `process.cwd()` lazily at `.load()` time; passing this
|
|
134
|
+
* pins the root for tests or tooling contexts where cwd is unstable.
|
|
135
|
+
*/
|
|
136
|
+
root?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Legacy config shape — preserved to avoid churn on existing projects. */
|
|
140
|
+
interface LegacyCollectionConfig {
|
|
141
|
+
loader: unknown;
|
|
142
|
+
schema?: unknown;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const DEFAULT_EXTENSIONS = [".md", ".mdx", ".markdown"] as const;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Collection instance returned by `defineCollection({ path, ... })`.
|
|
149
|
+
*
|
|
150
|
+
* Load results are cached in-memory after the first `.load()` call.
|
|
151
|
+
* This is intentional — at the MVP we treat collections as build-time
|
|
152
|
+
* data that doesn't change within a process lifetime. Projects that
|
|
153
|
+
* need hot-reload during `mandu dev` will need to call `.invalidate()`
|
|
154
|
+
* explicitly (not yet implemented — tracked as a follow-up).
|
|
155
|
+
*/
|
|
156
|
+
export class Collection<T = Record<string, unknown>> {
|
|
157
|
+
readonly options: DefineCollectionOptions<T>;
|
|
158
|
+
private entries: CollectionEntry<T>[] | null = null;
|
|
159
|
+
private loadPromise: Promise<CollectionEntry<T>[]> | null = null;
|
|
160
|
+
|
|
161
|
+
constructor(options: DefineCollectionOptions<T>) {
|
|
162
|
+
this.options = options;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Resolve the collection root directory. */
|
|
166
|
+
private resolveRoot(): string {
|
|
167
|
+
const root = this.options.root ?? process.cwd();
|
|
168
|
+
if (path.isAbsolute(this.options.path)) return this.options.path;
|
|
169
|
+
return path.resolve(root, this.options.path);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Scan the collection directory, parse frontmatter, validate with
|
|
174
|
+
* the Zod schema, and cache entries. Safe to call repeatedly — the
|
|
175
|
+
* first call's promise is reused by concurrent callers, so a burst
|
|
176
|
+
* of `.all()` / `.get()` calls during initial render won't produce
|
|
177
|
+
* redundant disk I/O.
|
|
178
|
+
*/
|
|
179
|
+
async load(): Promise<CollectionEntry<T>[]> {
|
|
180
|
+
if (this.entries) return this.entries;
|
|
181
|
+
if (this.loadPromise) return this.loadPromise;
|
|
182
|
+
this.loadPromise = this.doLoad();
|
|
183
|
+
try {
|
|
184
|
+
this.entries = await this.loadPromise;
|
|
185
|
+
return this.entries;
|
|
186
|
+
} finally {
|
|
187
|
+
this.loadPromise = null;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private async doLoad(): Promise<CollectionEntry<T>[]> {
|
|
192
|
+
const root = this.resolveRoot();
|
|
193
|
+
if (!fs.existsSync(root)) {
|
|
194
|
+
// An empty collection is a valid state — authors might scaffold
|
|
195
|
+
// the directory before adding entries. Returning `[]` here lets
|
|
196
|
+
// pages render with "no content yet" messaging instead of 500.
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const extensions = this.options.extensions ?? [...DEFAULT_EXTENSIONS];
|
|
200
|
+
const extSet = new Set(extensions.map((e) => e.toLowerCase()));
|
|
201
|
+
const absPaths: string[] = [];
|
|
202
|
+
walkDir(root, absPaths, extSet);
|
|
203
|
+
|
|
204
|
+
const entries: CollectionEntry<T>[] = [];
|
|
205
|
+
for (const absPath of absPaths) {
|
|
206
|
+
const relPath = path
|
|
207
|
+
.relative(root, absPath)
|
|
208
|
+
.replace(/\\/g, "/");
|
|
209
|
+
const src = fs.readFileSync(absPath, "utf8");
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = parseFrontmatter(src);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
215
|
+
throw new Error(
|
|
216
|
+
`[content] failed to parse frontmatter in ${relPath}: ${msg}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
// Derive slug via the user's override or the built-in kebab-case
|
|
220
|
+
// generator. We pass the parsed frontmatter in so authors can
|
|
221
|
+
// honor a `slug:` field without writing their own loader.
|
|
222
|
+
const rawSlug = this.options.slug
|
|
223
|
+
? this.options.slug({ path: relPath, data: parsed.data })
|
|
224
|
+
: typeof parsed.data.slug === "string" && parsed.data.slug.length > 0
|
|
225
|
+
? String(parsed.data.slug)
|
|
226
|
+
: slugFromPath(relPath, this.options.slugOptions);
|
|
227
|
+
|
|
228
|
+
let data = parsed.data as unknown as T;
|
|
229
|
+
if (this.options.schema) {
|
|
230
|
+
const result = this.options.schema.safeParse(parsed.data);
|
|
231
|
+
if (!result.success) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`[content] schema validation failed for ${relPath}: ${formatZodError(
|
|
234
|
+
result.error
|
|
235
|
+
)}`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
data = result.data;
|
|
239
|
+
}
|
|
240
|
+
entries.push({
|
|
241
|
+
slug: rawSlug,
|
|
242
|
+
filePath: absPath,
|
|
243
|
+
data,
|
|
244
|
+
content: parsed.body,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const sorter = this.options.sort ?? defaultSort<T>();
|
|
249
|
+
// Stable sort by applying a slug tiebreaker AFTER the user sort.
|
|
250
|
+
// Node's Array.sort is stable as of V8 7.0+ (Bun uses V8), but we
|
|
251
|
+
// prefer not to rely on user comparators returning 0 for
|
|
252
|
+
// equivalent entries, so we fold the tiebreaker into the key.
|
|
253
|
+
entries.sort((a, b) => {
|
|
254
|
+
const primary = sorter(a, b);
|
|
255
|
+
if (primary !== 0) return primary;
|
|
256
|
+
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
|
|
257
|
+
});
|
|
258
|
+
return entries;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Return all entries (cached). */
|
|
262
|
+
async all(): Promise<CollectionEntry<T>[]> {
|
|
263
|
+
return this.load();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Retrieve a single entry by slug, or undefined. */
|
|
267
|
+
async get(slug: string): Promise<CollectionEntry<T> | undefined> {
|
|
268
|
+
const entries = await this.load();
|
|
269
|
+
return entries.find((e) => e.slug === slug);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Return an entry with a lazy-rendered React component.
|
|
274
|
+
*
|
|
275
|
+
* When optional MDX tooling (`unified`, `remark-parse`, `remark-rehype`,
|
|
276
|
+
* `rehype-stringify`) is present in the user's deps, we pipe the body
|
|
277
|
+
* through it and return both the raw HTML and a React component that
|
|
278
|
+
* renders via `dangerouslySetInnerHTML` (safe because the source is
|
|
279
|
+
* build-time content the project controls). When tooling is absent,
|
|
280
|
+
* `Component` still returns a valid React element — a `<pre>` wrapper
|
|
281
|
+
* around the raw markdown — so callers never have to branch on the
|
|
282
|
+
* missing-dep case.
|
|
283
|
+
*/
|
|
284
|
+
async getCompiled(
|
|
285
|
+
slug: string
|
|
286
|
+
): Promise<CompiledCollectionEntry<T> | undefined> {
|
|
287
|
+
const entry = await this.get(slug);
|
|
288
|
+
if (!entry) return undefined;
|
|
289
|
+
const rendered = await renderMarkdownSafe(entry.content);
|
|
290
|
+
return {
|
|
291
|
+
...entry,
|
|
292
|
+
html: rendered.html,
|
|
293
|
+
Component: rendered.Component,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Reset the in-memory cache so the next `.load()` rescans disk.
|
|
299
|
+
* Used by tests and (eventually) the dev-mode file watcher.
|
|
300
|
+
*/
|
|
301
|
+
invalidate(): void {
|
|
302
|
+
this.entries = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Default comparator: `data.order` ascending, with missing order
|
|
308
|
+
* treated as infinity so numbered entries sink to the top.
|
|
309
|
+
*/
|
|
310
|
+
function defaultSort<T>(): CollectionSort<T> {
|
|
311
|
+
return (a, b) => {
|
|
312
|
+
const oa = (a.data as { order?: unknown })?.order;
|
|
313
|
+
const ob = (b.data as { order?: unknown })?.order;
|
|
314
|
+
const na = typeof oa === "number" ? oa : Number.POSITIVE_INFINITY;
|
|
315
|
+
const nb = typeof ob === "number" ? ob : Number.POSITIVE_INFINITY;
|
|
316
|
+
if (na !== nb) return na - nb;
|
|
317
|
+
return 0;
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Compact a Zod error into a single line suitable for the error
|
|
323
|
+
* messages surfaced by `Collection.load()`. We deliberately avoid
|
|
324
|
+
* `z.prettifyError` (not present in Zod 3) and the flattener —
|
|
325
|
+
* `issue.path` + `issue.message` is enough for docs authors to
|
|
326
|
+
* locate the broken field fast.
|
|
327
|
+
*/
|
|
328
|
+
function formatZodError(error: {
|
|
329
|
+
issues: Array<{ path: (string | number)[]; message: string }>;
|
|
330
|
+
}): string {
|
|
331
|
+
return error.issues
|
|
332
|
+
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
333
|
+
.join("; ");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Synchronous recursive directory walker — the collection typically
|
|
338
|
+
* has a bounded number of entries (dozens to low thousands) so the
|
|
339
|
+
* sync cost is negligible, and using sync simplifies error paths
|
|
340
|
+
* and the cache-hit fast-path in `load()`.
|
|
341
|
+
*/
|
|
342
|
+
function walkDir(dir: string, out: string[], extSet: Set<string>): void {
|
|
343
|
+
let entries: fs.Dirent[];
|
|
344
|
+
try {
|
|
345
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
346
|
+
} catch {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
const absPath = path.join(dir, entry.name);
|
|
351
|
+
if (entry.isDirectory()) {
|
|
352
|
+
walkDir(absPath, out, extSet);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (!entry.isFile()) continue;
|
|
356
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
357
|
+
if (!extSet.has(ext)) continue;
|
|
358
|
+
out.push(absPath);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Lazy markdown renderer. Attempts to load `unified` + the standard
|
|
364
|
+
* remark/rehype plugin chain; when any piece is missing, falls back
|
|
365
|
+
* to returning a `<pre>` shell so the caller gets a stable API.
|
|
366
|
+
*
|
|
367
|
+
* We go through `Function("return import(...)")` instead of a direct
|
|
368
|
+
* dynamic `import()` so TS doesn't resolve the optional modules
|
|
369
|
+
* during typecheck — they are NOT in `@mandujs/core` deps by design.
|
|
370
|
+
*/
|
|
371
|
+
async function renderMarkdownSafe(
|
|
372
|
+
body: string
|
|
373
|
+
): Promise<{ html?: string; Component: () => unknown }> {
|
|
374
|
+
// Passing the module specifier through a Function-wrapped dynamic
|
|
375
|
+
// import keeps TypeScript from erroring on optional peer deps; if
|
|
376
|
+
// any module is missing we fall through to the raw-markdown path.
|
|
377
|
+
const tryImport = async (id: string): Promise<unknown> => {
|
|
378
|
+
try {
|
|
379
|
+
return await (Function("x", "return import(x)") as (x: string) => Promise<unknown>)(id);
|
|
380
|
+
} catch {
|
|
381
|
+
return null;
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
const unified = (await tryImport("unified")) as
|
|
385
|
+
| { unified: () => unknown }
|
|
386
|
+
| null;
|
|
387
|
+
const remarkParse = (await tryImport("remark-parse")) as
|
|
388
|
+
| { default: unknown }
|
|
389
|
+
| null;
|
|
390
|
+
const remarkRehype = (await tryImport("remark-rehype")) as
|
|
391
|
+
| { default: unknown }
|
|
392
|
+
| null;
|
|
393
|
+
const rehypeStringify = (await tryImport("rehype-stringify")) as
|
|
394
|
+
| { default: unknown }
|
|
395
|
+
| null;
|
|
396
|
+
|
|
397
|
+
if (unified && remarkParse && remarkRehype && rehypeStringify) {
|
|
398
|
+
try {
|
|
399
|
+
type Processor = {
|
|
400
|
+
use: (plugin: unknown) => Processor;
|
|
401
|
+
process: (src: string) => Promise<{ toString: () => string }>;
|
|
402
|
+
};
|
|
403
|
+
// Type-punned unified chain — optional peer deps don't carry
|
|
404
|
+
// their own types into our graph, so we route through `unknown`.
|
|
405
|
+
const chain = unified.unified() as unknown as Processor;
|
|
406
|
+
const file = await chain
|
|
407
|
+
.use(remarkParse.default)
|
|
408
|
+
.use(remarkRehype.default)
|
|
409
|
+
.use(rehypeStringify.default)
|
|
410
|
+
.process(body);
|
|
411
|
+
const html = file.toString();
|
|
412
|
+
return {
|
|
413
|
+
html,
|
|
414
|
+
Component: () => createHtmlElement(html),
|
|
415
|
+
};
|
|
416
|
+
} catch {
|
|
417
|
+
// Fall through to raw fallback
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Fallback: emit a simple React element wrapping the raw body in a
|
|
422
|
+
// `<pre>` so pages don't 500. Pages that need real MDX should
|
|
423
|
+
// install `unified` + remark/rehype in their project.
|
|
424
|
+
return {
|
|
425
|
+
Component: () => createPreElement(body),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build a lightweight React element carrying HTML content. We avoid
|
|
431
|
+
* importing React directly so `@mandujs/core/content` stays
|
|
432
|
+
* React-free at import time — the returned value is the plain React
|
|
433
|
+
* element shape (`{ type, props, key }`) which matches what
|
|
434
|
+
* `React.createElement('div', { dangerouslySetInnerHTML: ... })`
|
|
435
|
+
* produces. If the project hosts a React version that uses a
|
|
436
|
+
* different shape, users can swap to their own compiler.
|
|
437
|
+
*/
|
|
438
|
+
function createHtmlElement(html: string): unknown {
|
|
439
|
+
return {
|
|
440
|
+
type: "div",
|
|
441
|
+
props: {
|
|
442
|
+
dangerouslySetInnerHTML: { __html: html },
|
|
443
|
+
},
|
|
444
|
+
key: null,
|
|
445
|
+
// React 19 uses a $$typeof symbol to distinguish elements —
|
|
446
|
+
// stamp it so React.isValidElement() accepts us.
|
|
447
|
+
$$typeof: Symbol.for("react.element"),
|
|
448
|
+
ref: null,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function createPreElement(body: string): unknown {
|
|
453
|
+
return {
|
|
454
|
+
type: "pre",
|
|
455
|
+
props: { children: body },
|
|
456
|
+
key: null,
|
|
457
|
+
$$typeof: Symbol.for("react.element"),
|
|
458
|
+
ref: null,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// defineCollection overloads
|
|
464
|
+
// ---------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Legacy signature — pass-through for projects using the existing
|
|
468
|
+
* ContentLayer (`{ loader, schema }`). We detect the shape at runtime
|
|
469
|
+
* and return the config unchanged so downstream
|
|
470
|
+
* `defineContentConfig({ collections: { ... } })` keeps working.
|
|
471
|
+
*/
|
|
472
|
+
export function defineCollection<T extends LegacyCollectionConfig>(
|
|
473
|
+
config: T
|
|
474
|
+
): T;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* MVP signature — create a typed `Collection` from a directory path
|
|
478
|
+
* and optional Zod schema.
|
|
479
|
+
*/
|
|
480
|
+
export function defineCollection<T>(
|
|
481
|
+
options: DefineCollectionOptions<T>
|
|
482
|
+
): Collection<T>;
|
|
483
|
+
|
|
484
|
+
export function defineCollection(
|
|
485
|
+
config: LegacyCollectionConfig | DefineCollectionOptions<unknown>
|
|
486
|
+
): unknown {
|
|
487
|
+
// Disambiguate by the presence of a `loader` property — the legacy
|
|
488
|
+
// config always has one, the MVP config never does. Passing both
|
|
489
|
+
// throws so the error points to the conflict rather than letting
|
|
490
|
+
// one branch silently win.
|
|
491
|
+
if (isLegacyConfig(config)) {
|
|
492
|
+
if ("path" in config) {
|
|
493
|
+
throw new Error(
|
|
494
|
+
"[defineCollection] config has both `loader` and `path`; pick one — the legacy ContentLayer uses `loader`, the MVP Collection API uses `path`."
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
return config;
|
|
498
|
+
}
|
|
499
|
+
return new Collection(config as DefineCollectionOptions<unknown>);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function isLegacyConfig(
|
|
503
|
+
cfg: LegacyCollectionConfig | DefineCollectionOptions<unknown>
|
|
504
|
+
): cfg is LegacyCollectionConfig {
|
|
505
|
+
return typeof cfg === "object" && cfg !== null && "loader" in cfg;
|
|
506
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frontmatter Parser (Issue #199)
|
|
3
|
+
*
|
|
4
|
+
* Minimal, dependency-free YAML frontmatter parser for the MVP
|
|
5
|
+
* `defineCollection()` API. Handles the common subset used in docs
|
|
6
|
+
* collections — enough to cover the `title`/`order`/`draft`/`tags`
|
|
7
|
+
* patterns called out by the feature spec without pulling in
|
|
8
|
+
* `gray-matter` or `yaml` as a runtime dep.
|
|
9
|
+
*
|
|
10
|
+
* # Supported frontmatter syntax
|
|
11
|
+
*
|
|
12
|
+
* - Standard `---` fenced YAML at the top of the file
|
|
13
|
+
* - CRLF or LF line endings (so Windows authors do not have to
|
|
14
|
+
* convert before committing)
|
|
15
|
+
* - Scalar values: strings, numbers (int/float), booleans, null
|
|
16
|
+
* - Quoted strings: single + double quotes (with trailing content
|
|
17
|
+
* preservation — escape sequences NOT interpreted)
|
|
18
|
+
* - Simple inline arrays: `tags: [a, b, c]`
|
|
19
|
+
* - Block arrays:
|
|
20
|
+
* tags:
|
|
21
|
+
* - a
|
|
22
|
+
* - b
|
|
23
|
+
* - Comments (`#`) at end of line and on their own line
|
|
24
|
+
*
|
|
25
|
+
* # Explicitly unsupported (out of MVP scope)
|
|
26
|
+
*
|
|
27
|
+
* - Nested maps (`author: { name: ... }`) — use JSON-style inline
|
|
28
|
+
* or restructure with a Zod transform
|
|
29
|
+
* - Anchors, aliases, tags (`&anchor`, `*ref`, `!!tag`)
|
|
30
|
+
* - Multi-line scalars (`>` / `|`)
|
|
31
|
+
* - Complex quoted string escape sequences
|
|
32
|
+
*
|
|
33
|
+
* Projects that outgrow this parser can install `yaml` themselves and
|
|
34
|
+
* wrap their collection with a custom loader — the existing
|
|
35
|
+
* `glob()` loader in `./loaders/glob.ts` already dynamic-imports
|
|
36
|
+
* `yaml` when present.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** Result of parsing a Markdown file with optional frontmatter. */
|
|
40
|
+
export interface ParsedFrontmatter {
|
|
41
|
+
/** Parsed key/value pairs from the YAML block (empty if no frontmatter). */
|
|
42
|
+
data: Record<string, unknown>;
|
|
43
|
+
/** Body content following the closing `---` fence (or whole file if none). */
|
|
44
|
+
body: string;
|
|
45
|
+
/** Raw YAML text inside the fences — useful for diagnostics. */
|
|
46
|
+
raw?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse a Markdown-style source string into frontmatter data + body.
|
|
51
|
+
*
|
|
52
|
+
* When the source does NOT start with `---`, returns `{ data: {}, body: src }`
|
|
53
|
+
* verbatim — callers should treat that as "no frontmatter" rather than a
|
|
54
|
+
* parse error. When the source DOES open with `---` but the block is
|
|
55
|
+
* malformed (no closing fence), throws so the calling `Collection.load()`
|
|
56
|
+
* can attach the file path for reporting.
|
|
57
|
+
*/
|
|
58
|
+
export function parseFrontmatter(src: string): ParsedFrontmatter {
|
|
59
|
+
// Normalize line endings once up front so the fence regex and the
|
|
60
|
+
// per-line scanner agree on `\n` as the only delimiter. We preserve
|
|
61
|
+
// the body exactly as authored except for this normalization.
|
|
62
|
+
const normalized = src.replace(/\r\n/g, "\n");
|
|
63
|
+
const match = normalized.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
64
|
+
if (!match) {
|
|
65
|
+
// The spec's MVP never requires frontmatter — a .md file without
|
|
66
|
+
// it just becomes an entry with empty `data` and the whole file
|
|
67
|
+
// as `body`. Zod validation downstream will catch missing required
|
|
68
|
+
// fields and report them with a helpful collection/entry label.
|
|
69
|
+
return { data: {}, body: src };
|
|
70
|
+
}
|
|
71
|
+
const [, rawFront, body] = match;
|
|
72
|
+
const data = parseSimpleYaml(rawFront);
|
|
73
|
+
return { data, body: body.replace(/^\n/, ""), raw: rawFront };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse a small, opinionated subset of YAML. Not a general-purpose YAML
|
|
78
|
+
* parser — see the file header for the supported subset.
|
|
79
|
+
*/
|
|
80
|
+
export function parseSimpleYaml(text: string): Record<string, unknown> {
|
|
81
|
+
const out: Record<string, unknown> = {};
|
|
82
|
+
const lines = text.split("\n");
|
|
83
|
+
let i = 0;
|
|
84
|
+
while (i < lines.length) {
|
|
85
|
+
const rawLine = lines[i] ?? "";
|
|
86
|
+
const line = stripComment(rawLine);
|
|
87
|
+
if (line.trim() === "") {
|
|
88
|
+
i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// Top-level entries must be unindented — nested maps are out of
|
|
92
|
+
// scope, and treating indented lines as their own keys would mask
|
|
93
|
+
// real parse errors.
|
|
94
|
+
if (/^\s/.test(line) && !/^\s*-\s/.test(line)) {
|
|
95
|
+
// Unexpected indentation at top level — skip defensively rather
|
|
96
|
+
// than throw, since some authors add stray spaces.
|
|
97
|
+
i++;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const m = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
101
|
+
if (!m) {
|
|
102
|
+
i++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const [, key, rest] = m;
|
|
106
|
+
const value = rest.trim();
|
|
107
|
+
if (value === "") {
|
|
108
|
+
// Could be a block array — peek ahead for ` - item` lines.
|
|
109
|
+
const blockItems: unknown[] = [];
|
|
110
|
+
let j = i + 1;
|
|
111
|
+
while (j < lines.length) {
|
|
112
|
+
const next = stripComment(lines[j] ?? "");
|
|
113
|
+
const itemMatch = next.match(/^\s+-\s+(.+)$/);
|
|
114
|
+
if (!itemMatch) break;
|
|
115
|
+
blockItems.push(parseScalar(itemMatch[1].trim()));
|
|
116
|
+
j++;
|
|
117
|
+
}
|
|
118
|
+
if (blockItems.length > 0) {
|
|
119
|
+
out[key] = blockItems;
|
|
120
|
+
i = j;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Empty value with no following list — store as null rather than
|
|
124
|
+
// empty string so downstream Zod `.optional()` behaves correctly.
|
|
125
|
+
out[key] = null;
|
|
126
|
+
i++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
out[key] = parseScalar(value);
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Strip a trailing `# comment` from a line, respecting quoted strings
|
|
137
|
+
* so `title: "pricing # free"` does not lose the `# free` tail.
|
|
138
|
+
*/
|
|
139
|
+
function stripComment(line: string): string {
|
|
140
|
+
let inSingle = false;
|
|
141
|
+
let inDouble = false;
|
|
142
|
+
for (let k = 0; k < line.length; k++) {
|
|
143
|
+
const ch = line[k];
|
|
144
|
+
if (ch === "'" && !inDouble) inSingle = !inSingle;
|
|
145
|
+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
|
|
146
|
+
else if (ch === "#" && !inSingle && !inDouble) {
|
|
147
|
+
return line.slice(0, k);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return line;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Coerce a trimmed scalar token into the most appropriate JS primitive.
|
|
155
|
+
* The ordering here matters — booleans and null must come before the
|
|
156
|
+
* number check so `null`/`true` aren't accidentally parsed as NaN.
|
|
157
|
+
*/
|
|
158
|
+
export function parseScalar(v: string): unknown {
|
|
159
|
+
if (v === "") return null;
|
|
160
|
+
// Inline array: `[a, b, c]` — parse each element as a scalar. We
|
|
161
|
+
// intentionally use a non-quoted split because the spec's MVP only
|
|
162
|
+
// needs flat tag lists; nested arrays go via the block form.
|
|
163
|
+
if (v.startsWith("[") && v.endsWith("]")) {
|
|
164
|
+
const inner = v.slice(1, -1).trim();
|
|
165
|
+
if (inner === "") return [];
|
|
166
|
+
return inner.split(",").map((x) => parseScalar(x.trim()));
|
|
167
|
+
}
|
|
168
|
+
if (
|
|
169
|
+
(v.startsWith('"') && v.endsWith('"')) ||
|
|
170
|
+
(v.startsWith("'") && v.endsWith("'"))
|
|
171
|
+
) {
|
|
172
|
+
return v.slice(1, -1);
|
|
173
|
+
}
|
|
174
|
+
if (v === "true") return true;
|
|
175
|
+
if (v === "false") return false;
|
|
176
|
+
if (v === "null" || v === "~") return null;
|
|
177
|
+
// Number check: accept int + float, reject leading-zero-padded (e.g.
|
|
178
|
+
// `007`) to stay consistent with YAML 1.2 — treat those as strings
|
|
179
|
+
// so ID fields are preserved.
|
|
180
|
+
if (/^-?\d+$/.test(v) && !/^-?0\d/.test(v)) {
|
|
181
|
+
const n = Number(v);
|
|
182
|
+
if (Number.isFinite(n)) return n;
|
|
183
|
+
}
|
|
184
|
+
if (/^-?\d+\.\d+$/.test(v)) {
|
|
185
|
+
const n = Number(v);
|
|
186
|
+
if (Number.isFinite(n)) return n;
|
|
187
|
+
}
|
|
188
|
+
return v;
|
|
189
|
+
}
|