@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.
@@ -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
+ }