@mandujs/core 0.25.3 → 0.26.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.25.3",
3
+ "version": "0.26.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -37,6 +37,7 @@
37
37
  "./observability": "./src/observability/index.ts",
38
38
  "./perf": "./src/perf/index.ts",
39
39
  "./perf/hmr-markers": "./src/perf/hmr-markers.ts",
40
+ "./routes": "./src/routes/index.ts",
40
41
  "./scheduler": "./src/scheduler/index.ts",
41
42
  "./storage/s3": "./src/storage/s3/index.ts",
42
43
  "./bundler/prerender": "./src/bundler/prerender.ts",
@@ -522,6 +522,14 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
522
522
  apiModuleSet.add(normalizeFsPath(absPath));
523
523
  watchDirs.add(path.dirname(absPath));
524
524
  }
525
+
526
+ // Track metadata-route modules so edits to `app/sitemap.ts`
527
+ // etc. trigger the same hot-reload pipeline as API routes.
528
+ if (route.kind === "metadata" && route.module) {
529
+ const absPath = path.resolve(rootDir, route.module);
530
+ apiModuleSet.add(normalizeFsPath(absPath));
531
+ watchDirs.add(path.dirname(absPath));
532
+ }
525
533
  }
526
534
 
527
535
  // spec/slots 디렉토리도 추가
@@ -70,6 +70,68 @@ export interface CompiledCollectionEntry<T = Record<string, unknown>>
70
70
  Component: () => unknown;
71
71
  /** Rendered HTML string (only populated when MDX tooling is available). */
72
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;
73
135
  }
74
136
 
75
137
  /**
@@ -147,16 +209,41 @@ const DEFAULT_EXTENSIONS = [".md", ".mdx", ".markdown"] as const;
147
209
  /**
148
210
  * Collection instance returned by `defineCollection({ path, ... })`.
149
211
  *
212
+ * # Caching
213
+ *
150
214
  * Load results are cached in-memory after the first `.load()` call.
151
215
  * 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).
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.
155
236
  */
156
237
  export class Collection<T = Record<string, unknown>> {
157
238
  readonly options: DefineCollectionOptions<T>;
158
239
  private entries: CollectionEntry<T>[] | null = null;
159
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();
160
247
 
161
248
  constructor(options: DefineCollectionOptions<T>) {
162
249
  this.options = options;
@@ -280,18 +367,38 @@ export class Collection<T = Record<string, unknown>> {
280
367
  * `Component` still returns a valid React element — a `<pre>` wrapper
281
368
  * around the raw markdown — so callers never have to branch on the
282
369
  * missing-dep case.
370
+ *
371
+ * # Plugin support (Issue #205)
372
+ *
373
+ * Pass `{ remarkPlugins, rehypePlugins }` to extend the pipeline
374
+ * — e.g. `rehype-slug`, `rehype-autolink-headings`, `shiki` for
375
+ * syntax highlighting. Plugins are applied in array order, remark
376
+ * plugins before `remark-rehype` and rehype plugins after.
377
+ *
378
+ * # Diagnostics (Issue #205)
379
+ *
380
+ * The returned entry includes a `compilationMode` discriminator
381
+ * so callers can tell whether the full pipeline ran or the fallback
382
+ * was used. When a dep is missing we emit a single-line warning
383
+ * (unless `silent: true`) naming which module could not be
384
+ * resolved — previously the fallback was silent, which made it
385
+ * impossible to diagnose why a `<pre>` appeared.
283
386
  */
284
387
  async getCompiled(
285
- slug: string
388
+ slug: string,
389
+ compileOptions: CompileOptions = {}
286
390
  ): Promise<CompiledCollectionEntry<T> | undefined> {
287
391
  const entry = await this.get(slug);
288
392
  if (!entry) return undefined;
289
- const rendered = await renderMarkdownSafe(entry.content);
290
- return {
393
+ const rendered = await renderMarkdownSafe(entry.content, compileOptions);
394
+ const compiled: CompiledCollectionEntry<T> = {
291
395
  ...entry,
292
- html: rendered.html,
293
396
  Component: rendered.Component,
397
+ compilationMode: rendered.mode,
294
398
  };
399
+ if (rendered.html !== undefined) compiled.html = rendered.html;
400
+ if (rendered.error !== undefined) compiled.error = rendered.error;
401
+ return compiled;
295
402
  }
296
403
 
297
404
  /**
@@ -301,6 +408,127 @@ export class Collection<T = Record<string, unknown>> {
301
408
  invalidate(): void {
302
409
  this.entries = null;
303
410
  }
411
+
412
+ /**
413
+ * Subscribe to filesystem events for this collection's root
414
+ * directory. The watcher is lazy — created here, not in the
415
+ * constructor — so callers who never call `watch()` pay no cost
416
+ * and their process exits cleanly after `all()`.
417
+ *
418
+ * The callback is invoked with a `{ type, filePath }` object
419
+ * where `filePath` is relative to the collection root. The
420
+ * caller typically calls `collection.invalidate()` in response
421
+ * and re-renders.
422
+ *
423
+ * Returns a handle with `unsubscribe()`. You can call that
424
+ * directly, or call `collection.dispose()` / use `await using`
425
+ * (ES2023) to close every watcher at once.
426
+ */
427
+ watch(handler: CollectionWatchHandler): CollectionWatchHandle {
428
+ const root = this.resolveRoot();
429
+ if (!fs.existsSync(root)) {
430
+ // Nothing to watch — return a no-op handle so callers don't
431
+ // have to branch on "directory exists". If the directory
432
+ // appears later, they can unsubscribe and re-watch.
433
+ return { unsubscribe: () => {} };
434
+ }
435
+ const extensions = this.options.extensions ?? [...DEFAULT_EXTENSIONS];
436
+ const extSet = new Set(extensions.map((e) => e.toLowerCase()));
437
+
438
+ // Use node's `fs.watch` directly — one handle per collection
439
+ // root. `recursive: true` is the expensive bit; it is not
440
+ // supported on Linux before kernel 5.0 but the dev-mode path
441
+ // targets macOS/Windows/recent Linux where it works. If a
442
+ // project needs stricter compatibility, they can wrap
443
+ // `chokidar` in user code and call `invalidate()` themselves.
444
+ let watcher: fs.FSWatcher;
445
+ try {
446
+ watcher = fs.watch(root, { recursive: true }, (eventType, filename) => {
447
+ if (!filename) return;
448
+ const normalized = String(filename).replace(/\\/g, "/");
449
+ // Filter by extension so unrelated files don't fire the
450
+ // callback. We still let the event propagate for rename
451
+ // events on directories (no extension) — those can affect
452
+ // slugs.
453
+ const lastDot = normalized.lastIndexOf(".");
454
+ const ext = lastDot >= 0 ? normalized.slice(lastDot).toLowerCase() : "";
455
+ if (ext && !extSet.has(ext)) return;
456
+ try {
457
+ handler({
458
+ type: eventType === "rename" ? "rename" : "change",
459
+ filePath: normalized,
460
+ });
461
+ } catch (err) {
462
+ // Swallow user-handler errors so one buggy consumer
463
+ // does not tear down the watcher for everyone else.
464
+ console.error(
465
+ `[content] watch handler threw for ${normalized}:`,
466
+ err instanceof Error ? err.message : err
467
+ );
468
+ }
469
+ });
470
+ } catch (err) {
471
+ // Some platforms (notably older Linux) don't support
472
+ // `recursive: true`. Log once and return a no-op handle so
473
+ // the caller's code continues — missing-reload is
474
+ // degradation, not breakage.
475
+ console.warn(
476
+ `[content] fs.watch(${root}) failed — hot reload disabled for this collection:`,
477
+ err instanceof Error ? err.message : err
478
+ );
479
+ return { unsubscribe: () => {} };
480
+ }
481
+
482
+ const entry = { close: () => watcher.close() };
483
+ this.watchHandles.add(entry);
484
+
485
+ return {
486
+ unsubscribe: () => {
487
+ if (!this.watchHandles.has(entry)) return;
488
+ this.watchHandles.delete(entry);
489
+ try {
490
+ watcher.close();
491
+ } catch {
492
+ // Already closed — ignore.
493
+ }
494
+ },
495
+ };
496
+ }
497
+
498
+ /**
499
+ * Close every active watcher opened by `.watch()`. Safe to call
500
+ * repeatedly. After `dispose()` the collection remains usable
501
+ * — calling `watch()` again creates a fresh handle.
502
+ */
503
+ dispose(): void {
504
+ for (const handle of this.watchHandles) {
505
+ try {
506
+ handle.close();
507
+ } catch {
508
+ // Handle already closed — ignore.
509
+ }
510
+ }
511
+ this.watchHandles.clear();
512
+ }
513
+
514
+ /**
515
+ * ES2023 async-dispose support. Enables:
516
+ *
517
+ * ```ts
518
+ * await using docs = defineCollection({ path: 'content/docs' });
519
+ * const unsubscribe = docs.watch(onChange);
520
+ * // ... work ...
521
+ * // docs[Symbol.asyncDispose]() runs automatically at scope exit
522
+ * ```
523
+ *
524
+ * The async variant is used (instead of sync `Symbol.dispose`)
525
+ * because real watchers in the ecosystem close asynchronously
526
+ * — we keep the signature future-proof even though `fs.watch`
527
+ * happens to close synchronously today.
528
+ */
529
+ async [Symbol.asyncDispose](): Promise<void> {
530
+ this.dispose();
531
+ }
304
532
  }
305
533
 
306
534
  /**
@@ -359,6 +587,14 @@ function walkDir(dir: string, out: string[], extSet: Set<string>): void {
359
587
  }
360
588
  }
361
589
 
590
+ /** Internal result type from `renderMarkdownSafe`. */
591
+ interface RenderedMarkdown {
592
+ html?: string;
593
+ Component: () => unknown;
594
+ mode: CompiledCollectionEntry<unknown>["compilationMode"];
595
+ error?: Error;
596
+ }
597
+
362
598
  /**
363
599
  * Lazy markdown renderer. Attempts to load `unified` + the standard
364
600
  * remark/rehype plugin chain; when any piece is missing, falls back
@@ -367,10 +603,17 @@ function walkDir(dir: string, out: string[], extSet: Set<string>): void {
367
603
  * We go through `Function("return import(...)")` instead of a direct
368
604
  * dynamic `import()` so TS doesn't resolve the optional modules
369
605
  * during typecheck — they are NOT in `@mandujs/core` deps by design.
606
+ *
607
+ * The `options.remarkPlugins` / `options.rehypePlugins` arrays let
608
+ * docs sites add `rehype-slug`, `rehype-autolink-headings`, `shiki`,
609
+ * etc. — the caller is responsible for installing those modules.
370
610
  */
371
611
  async function renderMarkdownSafe(
372
- body: string
373
- ): Promise<{ html?: string; Component: () => unknown }> {
612
+ body: string,
613
+ options: CompileOptions = {}
614
+ ): Promise<RenderedMarkdown> {
615
+ const { remarkPlugins = [], rehypePlugins = [], silent = false } = options;
616
+
374
617
  // Passing the module specifier through a Function-wrapped dynamic
375
618
  // import keeps TypeScript from erroring on optional peer deps; if
376
619
  // any module is missing we fall through to the raw-markdown path.
@@ -381,6 +624,7 @@ async function renderMarkdownSafe(
381
624
  return null;
382
625
  }
383
626
  };
627
+
384
628
  const unified = (await tryImport("unified")) as
385
629
  | { unified: () => unknown }
386
630
  | null;
@@ -394,38 +638,97 @@ async function renderMarkdownSafe(
394
638
  | { default: unknown }
395
639
  | null;
396
640
 
397
- if (unified && remarkParse && remarkRehype && rehypeStringify) {
641
+ // Collect the names of missing modules so the warning is
642
+ // actionable — a generic "MDX tooling not installed" is hard to
643
+ // act on when you DO have some of it installed.
644
+ const missing: string[] = [];
645
+ if (!unified) missing.push("unified");
646
+ if (!remarkParse) missing.push("remark-parse");
647
+ if (!remarkRehype) missing.push("remark-rehype");
648
+ if (!rehypeStringify) missing.push("rehype-stringify");
649
+
650
+ if (missing.length === 0 && unified && remarkParse && remarkRehype && rehypeStringify) {
398
651
  try {
399
652
  type Processor = {
400
- use: (plugin: unknown) => Processor;
653
+ use: (plugin: unknown, options?: unknown) => Processor;
401
654
  process: (src: string) => Promise<{ toString: () => string }>;
402
655
  };
403
656
  // Type-punned unified chain — optional peer deps don't carry
404
657
  // 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);
658
+ let chain = unified.unified() as unknown as Processor;
659
+ chain = chain.use(remarkParse.default);
660
+ // User-supplied remark plugins run between `remark-parse`
661
+ // and `remark-rehype` so they can transform the MDAST.
662
+ for (const plugin of remarkPlugins) {
663
+ chain = applyPlugin(chain, plugin);
664
+ }
665
+ chain = chain.use(remarkRehype.default);
666
+ // Rehype plugins run AFTER `remark-rehype` so they see the
667
+ // HAST — this is the hook point for `rehype-slug` etc.
668
+ for (const plugin of rehypePlugins) {
669
+ chain = applyPlugin(chain, plugin);
670
+ }
671
+ chain = chain.use(rehypeStringify.default);
672
+ const file = await chain.process(body);
411
673
  const html = file.toString();
412
674
  return {
413
675
  html,
414
676
  Component: () => createHtmlElement(html),
677
+ mode: "unified",
678
+ };
679
+ } catch (err) {
680
+ // Pipeline itself threw — report it so the caller sees the
681
+ // underlying failure instead of a silent `<pre>`.
682
+ if (!silent) {
683
+ console.warn(
684
+ "[content] MDX pipeline failed; falling back to <pre>. Underlying error:",
685
+ err instanceof Error ? err.message : err
686
+ );
687
+ }
688
+ return {
689
+ Component: () => createPreElement(body),
690
+ mode: "fallback-pipeline-error",
691
+ error: err instanceof Error ? err : new Error(String(err)),
415
692
  };
416
- } catch {
417
- // Fall through to raw fallback
418
693
  }
419
694
  }
420
695
 
421
696
  // Fallback: emit a simple React element wrapping the raw body in a
422
697
  // `<pre>` so pages don't 500. Pages that need real MDX should
423
698
  // install `unified` + remark/rehype in their project.
699
+ if (!silent && missing.length > 0) {
700
+ // One warning per call — if the project is rendering 500 pages
701
+ // this will be noisy, but that noise is the feedback users
702
+ // need to know WHY their markdown is not compiling. `silent:
703
+ // true` mutes this for build scripts that deliberately opt out.
704
+ console.warn(
705
+ `[content] MDX tooling missing: ${missing.join(
706
+ ", "
707
+ )}. Install these peer deps to enable full rendering — falling back to <pre> shell.`
708
+ );
709
+ }
424
710
  return {
425
711
  Component: () => createPreElement(body),
712
+ mode: "fallback-missing-deps",
426
713
  };
427
714
  }
428
715
 
716
+ /**
717
+ * Apply a plugin spec to a unified chain. The unified plugin
718
+ * ecosystem accepts either a bare function or a `[plugin, options]`
719
+ * tuple, so we handle both without pulling the unified types in.
720
+ */
721
+ function applyPlugin<P extends { use: (plugin: unknown, options?: unknown) => P }>(
722
+ chain: P,
723
+ plugin: unknown
724
+ ): P {
725
+ if (Array.isArray(plugin)) {
726
+ const [fn, ...rest] = plugin;
727
+ return chain.use(fn, ...rest);
728
+ }
729
+ return chain.use(plugin);
730
+ }
731
+
429
732
  /**
430
733
  * Build a lightweight React element carrying HTML content. We avoid
431
734
  * importing React directly so `@mandujs/core/content` stays
@@ -185,6 +185,9 @@ export type {
185
185
  CompiledCollectionEntry,
186
186
  CollectionSort,
187
187
  DefineCollectionOptions,
188
+ CompileOptions,
189
+ CollectionWatchHandler,
190
+ CollectionWatchHandle,
188
191
  } from "./collection";
189
192
 
190
193
  export { z } from "./schema";
@@ -196,8 +199,14 @@ export type { ParsedFrontmatter } from "./frontmatter";
196
199
  export { slugFromPath } from "./slug";
197
200
  export type { SlugFromPathOptions } from "./slug";
198
201
 
199
- export { generateSidebar } from "./sidebar";
200
- export type { SidebarNode, GenerateSidebarOptions } from "./sidebar";
202
+ export { generateSidebar, generateCategoryTree } from "./sidebar";
203
+ export type {
204
+ SidebarNode,
205
+ GenerateSidebarOptions,
206
+ Category,
207
+ CategoryEntry,
208
+ DirMeta,
209
+ } from "./sidebar";
201
210
 
202
211
  export { generateLLMSTxt } from "./llms-txt";
203
212
  export type { LLMSTxtInput, GenerateLLMSTxtOptions } from "./llms-txt";
@@ -72,8 +72,19 @@ export interface GenerateLLMSTxtOptions {
72
72
  * an absolute origin (e.g. `https://example.com`) to produce an
73
73
  * outward-facing llms.txt that third-party crawlers can consume
74
74
  * without resolving against the host.
75
+ *
76
+ * Alias for `baseUrl` — accepts either name so the API reads
77
+ * naturally whether the caller thinks in "site base path" or
78
+ * "absolute URL".
75
79
  */
76
80
  basePath?: string;
81
+ /**
82
+ * Alias for `basePath`. When both are provided, `baseUrl` wins
83
+ * (callers explicitly typing an absolute URL typically mean
84
+ * "use this verbatim"). Useful in docs-site configs that already
85
+ * expose `baseUrl` for their router / canonical URL helpers.
86
+ */
87
+ baseUrl?: string;
77
88
  /**
78
89
  * When true, include each entry's body verbatim under its heading.
79
90
  * This produces the `llms-full.txt` variant — significantly larger
@@ -92,6 +103,18 @@ export interface GenerateLLMSTxtOptions {
92
103
  * omits the trailing `: {summary}` tail.
93
104
  */
94
105
  getSummary?: (entry: CollectionEntry<unknown>) => string;
106
+ /**
107
+ * When true, emit a nested heading structure that groups entries
108
+ * by their first slug segment (the "category"). With `full: true`
109
+ * this produces an `llms-full.txt` that mirrors the docs sidebar
110
+ * layout — useful for LLM crawlers that consume the
111
+ * category-section convention.
112
+ *
113
+ * The category headings use `###` so they nest cleanly under the
114
+ * `##` collection heading. Entries without a slash in their slug
115
+ * are placed under an implicit "root" section.
116
+ */
117
+ groupByCategory?: boolean;
95
118
  }
96
119
 
97
120
  /**
@@ -108,11 +131,13 @@ export async function generateLLMSTxt(
108
131
  const {
109
132
  siteName,
110
133
  description,
111
- basePath = "/",
112
134
  full = false,
113
135
  includeDrafts = false,
114
136
  getSummary,
137
+ groupByCategory = false,
115
138
  } = options;
139
+ // `baseUrl` wins when both are provided — see the option JSDoc.
140
+ const basePath = options.baseUrl ?? options.basePath ?? "/";
116
141
 
117
142
  const lines: string[] = [];
118
143
  if (siteName) {
@@ -140,23 +165,54 @@ export async function generateLLMSTxt(
140
165
 
141
166
  lines.push(`## ${input.name}`);
142
167
  lines.push("");
143
- for (const entry of sorted) {
144
- const title =
145
- typeof (entry.data as { title?: unknown })?.title === "string"
146
- ? String((entry.data as { title: string }).title)
147
- : entry.slug || "index";
148
- const href = joinHref(basePath, input.name, entry.slug);
149
- const summary = getSummary
150
- ? getSummary(entry)
151
- : typeof (entry.data as { description?: unknown })?.description === "string"
152
- ? String((entry.data as { description: string }).description)
153
- : "";
154
- const tail = summary ? `: ${summary}` : "";
155
- lines.push(`- [${title}](${href})${tail}`);
156
- if (full) {
157
- lines.push("");
158
- lines.push(entry.content);
168
+
169
+ if (groupByCategory) {
170
+ // Bucket entries by their first slug segment. Entries without
171
+ // a slash go under the "__root__" sentinel so we can render
172
+ // them above the categorized groups.
173
+ const groups = new Map<string, typeof sorted>();
174
+ for (const entry of sorted) {
175
+ const [head, ...rest] = entry.slug.split("/");
176
+ const key = rest.length > 0 ? head : "__root__";
177
+ const bucket = groups.get(key);
178
+ if (bucket) bucket.push(entry);
179
+ else groups.set(key, [entry]);
180
+ }
181
+ // Emit root-level entries first (if any), then categorized
182
+ // groups in alphabetical category order for determinism.
183
+ const rootEntries = groups.get("__root__") ?? [];
184
+ for (const entry of rootEntries) {
185
+ lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
186
+ if (full) {
187
+ lines.push("");
188
+ lines.push(entry.content);
189
+ lines.push("");
190
+ }
191
+ }
192
+ const categoryKeys = Array.from(groups.keys())
193
+ .filter((k) => k !== "__root__")
194
+ .sort();
195
+ for (const catKey of categoryKeys) {
196
+ if (rootEntries.length > 0) lines.push("");
197
+ lines.push(`### ${catKey}`);
159
198
  lines.push("");
199
+ for (const entry of groups.get(catKey) ?? []) {
200
+ lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
201
+ if (full) {
202
+ lines.push("");
203
+ lines.push(entry.content);
204
+ lines.push("");
205
+ }
206
+ }
207
+ }
208
+ } else {
209
+ for (const entry of sorted) {
210
+ lines.push(renderEntryLine(entry, input.name, basePath, getSummary));
211
+ if (full) {
212
+ lines.push("");
213
+ lines.push(entry.content);
214
+ lines.push("");
215
+ }
160
216
  }
161
217
  }
162
218
  lines.push("");
@@ -177,6 +233,31 @@ async function loadInput(input: LLMSTxtInput): Promise<CollectionEntry<unknown>[
177
233
  return entries as CollectionEntry<unknown>[];
178
234
  }
179
235
 
236
+ /**
237
+ * Format a single entry as a `- [Title](href): summary` line. Split
238
+ * out from the main loop so both the flat and the categorized
239
+ * rendering paths share the same output shape.
240
+ */
241
+ function renderEntryLine(
242
+ entry: CollectionEntry<unknown>,
243
+ collectionName: string,
244
+ basePath: string,
245
+ getSummary: ((entry: CollectionEntry<unknown>) => string) | undefined
246
+ ): string {
247
+ const title =
248
+ typeof (entry.data as { title?: unknown })?.title === "string"
249
+ ? String((entry.data as { title: string }).title)
250
+ : entry.slug || "index";
251
+ const href = joinHref(basePath, collectionName, entry.slug);
252
+ const summary = getSummary
253
+ ? getSummary(entry)
254
+ : typeof (entry.data as { description?: unknown })?.description === "string"
255
+ ? String((entry.data as { description: string }).description)
256
+ : "";
257
+ const tail = summary ? `: ${summary}` : "";
258
+ return `- [${title}](${href})${tail}`;
259
+ }
260
+
180
261
  function joinHref(base: string, collectionName: string, slug: string): string {
181
262
  const parts = [collectionName, slug].filter((x) => x !== "" && x !== "/");
182
263
  const tail = parts.join("/").replace(/\/+/g, "/");