@mandujs/core 0.25.2 → 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.
@@ -1,32 +1,61 @@
1
1
  /**
2
- * Sidebar Generator (Issue #199)
2
+ * Sidebar Generator (Issues #199, #205)
3
3
  *
4
- * Takes a loaded `Collection` and produces a nested `{ title, href,
5
- * children }` tree suitable for rendering a docs sidebar. The goal is
6
- * to make the common case (slash-delimited slugs → hierarchical nav)
7
- * work with zero config, while leaving escape hatches for projects
8
- * with bespoke ordering.
4
+ * Takes a loaded `Collection` and produces a nested navigation tree
5
+ * suitable for rendering a docs sidebar.
9
6
  *
10
- * # Ordering rules
7
+ * Two output shapes are supported:
11
8
  *
12
- * 1. `order` field from frontmatter (ascending, missing = +Infinity)
13
- * 2. Fallback: slug alphabetical
14
- * 3. Category-level ordering: a `_category` meta file with an
15
- * `order` field controls sibling order at the parent level —
16
- * deliberately NOT implemented at the MVP; callers that need it
17
- * today can pass a custom `sortNodes` comparator.
9
+ * 1. **`SidebarNode[]`** (default) a lightweight
10
+ * `{ title, href, children }` tree. Backward-compatible with the
11
+ * Wave D MVP signature: `await generateSidebar(collection)`.
12
+ *
13
+ * 2. **`Category[]`** (Issue #205) a richer tree with `slug`,
14
+ * `icon`, `order`, and `items` so docs sites can render
15
+ * sectioned navigation with icons and explicit ordering. Enable
16
+ * via `generateCategoryTree(collection, options)`.
17
+ *
18
+ * # `_meta.json` support (Issue #205)
19
+ *
20
+ * When a directory contains a `_meta.json` file alongside its
21
+ * markdown entries, the generator reads it for per-directory metadata
22
+ * and ordering hints:
23
+ *
24
+ * ```json
25
+ * {
26
+ * "title": "Getting Started",
27
+ * "icon": "rocket",
28
+ * "order": 1,
29
+ * "pages": ["intro", "install", "quickstart"]
30
+ * }
31
+ * ```
32
+ *
33
+ * The `pages` field takes precedence over frontmatter `order` and
34
+ * filename alphabetical — it is the explicit escape hatch when the
35
+ * author wants a specific nav order that does not match any
36
+ * mechanical rule. Missing `pages` entries fall back to frontmatter
37
+ * `order` ascending, then filename alphabetical (numeric-aware so
38
+ * `10-foo` sorts after `2-foo`).
39
+ *
40
+ * # Ordering precedence (siblings at the same depth)
41
+ *
42
+ * 1. Explicit index in parent's `_meta.json` `pages: []`.
43
+ * 2. `order` field — directory `_meta.json` for categories,
44
+ * frontmatter `order` for leaf entries. Lower values first.
45
+ * 3. Numeric-aware filename comparison (title or slug).
18
46
  *
19
47
  * # Draft entries
20
48
  *
21
- * When `collection.all()` yields entries with `data.draft === true`,
22
- * they are **filtered out by default**. Callers can disable this by
23
- * passing `includeDrafts: true` (useful during preview builds).
49
+ * Entries with `data.draft === true` are **filtered out by default**.
50
+ * Callers can surface them with `includeDrafts: true` (preview builds).
24
51
  */
25
52
 
53
+ import * as fs from "fs";
54
+ import * as path from "path";
26
55
  import type { Collection, CollectionEntry } from "./collection";
27
56
  export type { Collection };
28
57
 
29
- /** A node in the sidebar tree. */
58
+ /** A node in the sidebar tree (legacy + MVP shape). */
30
59
  export interface SidebarNode {
31
60
  title: string;
32
61
  href: string;
@@ -36,6 +65,66 @@ export interface SidebarNode {
36
65
  draft?: boolean;
37
66
  }
38
67
 
68
+ /**
69
+ * Rich category node produced by `generateCategoryTree` (Issue #205).
70
+ * `items` mixes child categories and leaf entries — siblings at any
71
+ * depth are ordered consistently under the same precedence rules.
72
+ */
73
+ export interface Category {
74
+ /** Directory-relative slug. For nested categories this includes
75
+ * parent slugs (`getting-started/install`). For the root-level
76
+ * synthetic category, this is `""`. */
77
+ slug: string;
78
+ /** Resolved title — `_meta.json.title` > directory name > slug. */
79
+ title: string;
80
+ /** Optional icon identifier from `_meta.json.icon`. */
81
+ icon?: string;
82
+ /** Ordering key (`_meta.json.order`). Missing = Infinity. */
83
+ order?: number;
84
+ /** Mixed children: `Category` branches or leaf `CategoryEntry`s. */
85
+ items: Array<Category | CategoryEntry>;
86
+ /** Absolute href for the category landing, when an `index` entry exists. */
87
+ href?: string;
88
+ /** Tag so callers can discriminate without `'items' in x`. */
89
+ kind: "category";
90
+ }
91
+
92
+ /** Leaf entry emitted into a `Category.items` array. */
93
+ export interface CategoryEntry {
94
+ /** Absolute href (prefixed by `basePath`). */
95
+ href: string;
96
+ /** Resolved title — frontmatter `title` > slug. */
97
+ title: string;
98
+ /** Optional per-entry icon from frontmatter. */
99
+ icon?: string;
100
+ /** Frontmatter `order`, when present. */
101
+ order?: number;
102
+ /** The original slug (directory-relative). */
103
+ slug: string;
104
+ /** `data.draft === true` — surfaced only when `includeDrafts` is on. */
105
+ draft?: boolean;
106
+ /** Tag so callers can discriminate without `'items' in x`. */
107
+ kind: "entry";
108
+ }
109
+
110
+ /** Shape of a `_meta.json` file as parsed by the sidebar generator. */
111
+ export interface DirMeta {
112
+ /** Override for the category title. */
113
+ title?: string;
114
+ /** Icon identifier (consumer-defined, e.g. a lucide name). */
115
+ icon?: string;
116
+ /** Ordering key among siblings — ascending. */
117
+ order?: number;
118
+ /**
119
+ * Explicit list of child slugs (directory-relative, extension-free)
120
+ * that controls the order and membership of `items`. Entries not
121
+ * listed here are appended in the default order after the listed
122
+ * entries — authors get a "pin these at top" ergonomic without
123
+ * having to list every file.
124
+ */
125
+ pages?: string[];
126
+ }
127
+
39
128
  /** Options controlling sidebar shape and filtering. */
40
129
  export interface GenerateSidebarOptions<T> {
41
130
  /**
@@ -59,7 +148,7 @@ export interface GenerateSidebarOptions<T> {
59
148
  includeDrafts?: boolean;
60
149
  /**
61
150
  * Custom comparator applied to siblings at every level. When
62
- * omitted, the default (order-then-slug) comparator is used —
151
+ * omitted, the default (order-then-title) comparator is used —
63
152
  * consistent with `Collection.all()`'s default sort so the
64
153
  * sidebar order matches the `.all()` order.
65
154
  */
@@ -70,6 +159,14 @@ export interface GenerateSidebarOptions<T> {
70
159
  * project prefers flat nav with all leaves at root.
71
160
  */
72
161
  synthesizeGroups?: boolean;
162
+ /**
163
+ * When true, read `_meta.json` from each directory for titles,
164
+ * icons, explicit `pages` ordering, and numeric `order`. Default:
165
+ * true. Set to false for projects that don't use the convention —
166
+ * the generator gracefully skips missing files regardless, so
167
+ * disabling is purely a performance knob for very large trees.
168
+ */
169
+ useDirMeta?: boolean;
73
170
  }
74
171
 
75
172
  interface InternalNode {
@@ -81,6 +178,10 @@ interface InternalNode {
81
178
  children: Map<string, InternalNode>;
82
179
  /** Source entry if this node corresponds to a real file. */
83
180
  entry?: CollectionEntry<unknown>;
181
+ /** Directory-level metadata (from `_meta.json`, when present). */
182
+ dirMeta?: DirMeta;
183
+ /** Icon resolved from dir meta OR entry frontmatter. */
184
+ icon?: string;
84
185
  }
85
186
 
86
187
  /**
@@ -97,6 +198,7 @@ export async function generateSidebar<T>(
97
198
  includeDrafts = false,
98
199
  sortNodes,
99
200
  synthesizeGroups = true,
201
+ useDirMeta = true,
100
202
  } = options;
101
203
  const entries = await collection.all();
102
204
  const visible = includeDrafts
@@ -108,11 +210,62 @@ export async function generateSidebar<T>(
108
210
  insertEntry(root, entry, getTitle, basePath, synthesizeGroups);
109
211
  }
110
212
 
213
+ // Apply `_meta.json` metadata to the tree — only when the caller
214
+ // opts in (default) AND the collection has a resolvable root.
215
+ // Missing directories are a no-op so this is safe for synthetic
216
+ // or virtual collections.
217
+ if (useDirMeta) {
218
+ const collectionRoot = resolveCollectionRoot(collection);
219
+ if (collectionRoot) {
220
+ applyDirMeta(root, collectionRoot, []);
221
+ }
222
+ }
223
+
111
224
  const tree = toSidebarNodes(root, basePath, synthesizeGroups);
112
- sortTree(tree, 0, sortNodes);
225
+ sortTreeWithMeta(tree, root, 0, sortNodes);
113
226
  return tree;
114
227
  }
115
228
 
229
+ /**
230
+ * Build a `Category[]` tree with rich metadata (slug, icon, order,
231
+ * items). The `Category` shape is the preferred output for new docs
232
+ * sites — legacy callers can keep using `generateSidebar`.
233
+ *
234
+ * Uses the same `_meta.json` conventions as `generateSidebar`. The
235
+ * synthetic root category is flattened — the return value is the
236
+ * array of top-level categories / entries, not a single wrapping
237
+ * root (consistent with how `generateSidebar` returns `SidebarNode[]`).
238
+ */
239
+ export async function generateCategoryTree<T>(
240
+ collection: Collection<T>,
241
+ options: GenerateSidebarOptions<T> = {}
242
+ ): Promise<Array<Category | CategoryEntry>> {
243
+ const {
244
+ basePath = "/",
245
+ getTitle,
246
+ includeDrafts = false,
247
+ synthesizeGroups = true,
248
+ useDirMeta = true,
249
+ } = options;
250
+ const entries = await collection.all();
251
+ const visible = includeDrafts
252
+ ? entries
253
+ : entries.filter((e) => !(e.data as { draft?: unknown })?.draft);
254
+
255
+ const root = new Map<string, InternalNode>();
256
+ for (const entry of visible) {
257
+ insertEntry(root, entry, getTitle, basePath, synthesizeGroups);
258
+ }
259
+ if (useDirMeta) {
260
+ const collectionRoot = resolveCollectionRoot(collection);
261
+ if (collectionRoot) {
262
+ applyDirMeta(root, collectionRoot, []);
263
+ }
264
+ }
265
+
266
+ return buildCategoryArray(root, []);
267
+ }
268
+
116
269
  function insertEntry<T>(
117
270
  root: Map<string, InternalNode>,
118
271
  entry: CollectionEntry<T>,
@@ -148,6 +301,8 @@ function insertEntry<T>(
148
301
  const rawOrder = (entry.data as { order?: unknown })?.order;
149
302
  if (typeof rawOrder === "number") node.order = rawOrder;
150
303
  node.draft = Boolean((entry.data as { draft?: unknown })?.draft);
304
+ const rawIcon = (entry.data as { icon?: unknown })?.icon;
305
+ if (typeof rawIcon === "string") node.icon = rawIcon;
151
306
  node.href = joinHref(basePath, entry.slug);
152
307
  } else if (!synthesizeGroups) {
153
308
  // When groups are disabled, promote deeply-nested leaves to
@@ -165,7 +320,10 @@ function toSidebarNodes(
165
320
  _synthesizeGroups: boolean
166
321
  ): SidebarNode[] {
167
322
  const out: SidebarNode[] = [];
168
- for (const node of map.values()) {
323
+ for (const [key, node] of map) {
324
+ // `__dir__` is a synthetic meta-only key created by
325
+ // `applyDirMeta` — never emit it as a real sidebar node.
326
+ if (key === "__dir__") continue;
169
327
  const children =
170
328
  node.children.size > 0
171
329
  ? toSidebarNodes(node.children, _basePath, _synthesizeGroups)
@@ -181,32 +339,292 @@ function toSidebarNodes(
181
339
  return out;
182
340
  }
183
341
 
184
- function sortTree(
342
+ /**
343
+ * Sort the sidebar tree using the internal metadata (dir meta's
344
+ * `pages` / `order` and leaf `order`). We route through the
345
+ * InternalNode map to access `pages` ordering, then fall back to
346
+ * the public `sortNodes` comparator for anything left over.
347
+ */
348
+ function sortTreeWithMeta(
185
349
  nodes: SidebarNode[],
350
+ internalMap: Map<string, InternalNode>,
186
351
  depth: number,
187
352
  custom: ((a: SidebarNode, b: SidebarNode, depth: number) => number) | undefined
188
353
  ): void {
189
- // Fallback comparator: order field via per-node metadata is lost in
190
- // the conversion to SidebarNode (intentionally the public surface
191
- // is title/href), so sort by title with a numeric-aware collator so
192
- // "10-foo" sorts after "2-foo" for authors prefixing filenames.
193
- const fallback = (a: SidebarNode, b: SidebarNode): number =>
354
+ // Build an index from SidebarNode.href -> InternalNode so we can
355
+ // resolve meta for each public node without another pass.
356
+ const byHref = new Map<string, InternalNode>();
357
+ for (const node of internalMap.values()) {
358
+ byHref.set(node.href, node);
359
+ }
360
+
361
+ // `pages` list from the parent dir meta — precomputed by the
362
+ // caller when we recurse into a specific subtree. At the top
363
+ // level we look at the root-level `_meta.json` (stored under the
364
+ // synthetic `""` key when present).
365
+ const rootMeta = internalMap.get("__dir__")?.dirMeta;
366
+ const explicitPages = rootMeta?.pages ?? [];
367
+ const pagesIndex = new Map<string, number>();
368
+ explicitPages.forEach((slug, idx) => {
369
+ // `pages` entries are relative slugs (just the last segment).
370
+ pagesIndex.set(slug, idx);
371
+ });
372
+
373
+ const fallbackNumeric = (a: SidebarNode, b: SidebarNode): number =>
194
374
  a.title.localeCompare(b.title, undefined, { numeric: true });
375
+
195
376
  nodes.sort((a, b) => {
377
+ const aInternal = byHref.get(a.href);
378
+ const bInternal = byHref.get(b.href);
379
+
380
+ // 1. Explicit `pages` position wins absolutely.
381
+ const aLastSeg = lastSlugSegment(aInternal);
382
+ const bLastSeg = lastSlugSegment(bInternal);
383
+ const aIdx = aLastSeg !== undefined ? pagesIndex.get(aLastSeg) : undefined;
384
+ const bIdx = bLastSeg !== undefined ? pagesIndex.get(bLastSeg) : undefined;
385
+ if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
386
+ if (aIdx !== undefined) return -1;
387
+ if (bIdx !== undefined) return 1;
388
+
389
+ // 2. Caller-supplied comparator.
196
390
  if (custom) {
197
391
  const c = custom(a, b, depth);
198
392
  if (c !== 0) return c;
199
393
  }
200
- return fallback(a, b);
394
+
395
+ // 3. Numeric `order` from frontmatter / dir meta.
396
+ const aOrder = categoryOrder(aInternal);
397
+ const bOrder = categoryOrder(bInternal);
398
+ if (aOrder !== bOrder) return aOrder - bOrder;
399
+
400
+ // 4. Numeric-aware title collation.
401
+ return fallbackNumeric(a, b);
201
402
  });
403
+
202
404
  for (const node of nodes) {
203
- if (node.children) sortTree(node.children, depth + 1, custom);
405
+ if (node.children) {
406
+ // Recurse into the child InternalNode map so nested pages/order
407
+ // metadata applies at each level.
408
+ const internal = byHref.get(node.href);
409
+ if (internal) {
410
+ const childrenMap = internal.children;
411
+ // Seed the child map's `__dir__` proxy with the parent's
412
+ // dir meta for recursion — we previously stored dir meta on
413
+ // the parent node itself.
414
+ if (internal.dirMeta) {
415
+ childrenMap.set("__dir__", {
416
+ ...internal,
417
+ dirMeta: internal.dirMeta,
418
+ children: new Map(),
419
+ });
420
+ }
421
+ sortTreeWithMeta(node.children, childrenMap, depth + 1, custom);
422
+ childrenMap.delete("__dir__");
423
+ } else {
424
+ node.children.sort(fallbackNumeric);
425
+ for (const child of node.children) {
426
+ if (child.children) sortTreeWithMeta(child.children, new Map(), depth + 1, custom);
427
+ }
428
+ }
429
+ }
204
430
  }
205
431
  }
206
432
 
433
+ function lastSlugSegment(node: InternalNode | undefined): string | undefined {
434
+ if (!node) return undefined;
435
+ return node.slugSegments[node.slugSegments.length - 1];
436
+ }
437
+
438
+ function categoryOrder(node: InternalNode | undefined): number {
439
+ if (!node) return Number.POSITIVE_INFINITY;
440
+ if (typeof node.dirMeta?.order === "number") return node.dirMeta.order;
441
+ return node.order;
442
+ }
443
+
207
444
  function joinHref(base: string, slug: string): string {
208
445
  const normalizedBase = base.endsWith("/") ? base.slice(0, -1) : base;
209
446
  if (!slug) return normalizedBase || "/";
210
447
  const normalizedSlug = slug.startsWith("/") ? slug.slice(1) : slug;
211
448
  return `${normalizedBase}/${normalizedSlug}`.replace(/\/+/g, "/");
212
449
  }
450
+
451
+ /**
452
+ * Resolve the filesystem root for a collection without reaching into
453
+ * its private state. We rely on the public `options.path` + optional
454
+ * `options.root` to recreate the path; if either is missing (e.g. a
455
+ * synthetic collection built from `new Collection({ path: "" })`),
456
+ * we return `null` and skip dir-meta loading.
457
+ */
458
+ function resolveCollectionRoot<T>(collection: Collection<T>): string | null {
459
+ const opts = collection.options;
460
+ if (!opts.path) return null;
461
+ const root = opts.root ?? process.cwd();
462
+ const resolved = path.isAbsolute(opts.path)
463
+ ? opts.path
464
+ : path.resolve(root, opts.path);
465
+ // Skip dir-meta entirely if the directory doesn't exist on disk —
466
+ // synthetic collections shouldn't crash on first load.
467
+ if (!fs.existsSync(resolved)) return null;
468
+ return resolved;
469
+ }
470
+
471
+ /**
472
+ * Walk the internal tree, loading `_meta.json` at each directory
473
+ * level when present. Non-existent files are silently skipped;
474
+ * malformed JSON emits a one-line warning (so authors can diagnose
475
+ * typos) and continues with empty meta.
476
+ */
477
+ function applyDirMeta(
478
+ map: Map<string, InternalNode>,
479
+ currentDir: string,
480
+ pathSoFar: string[]
481
+ ): void {
482
+ // Look for a `_meta.json` at this directory level and attach it
483
+ // to every child node so sorting/category generation can consult
484
+ // the parent's metadata.
485
+ const metaPath = path.join(currentDir, "_meta.json");
486
+ let dirMeta: DirMeta | undefined;
487
+ if (fs.existsSync(metaPath)) {
488
+ try {
489
+ const raw = fs.readFileSync(metaPath, "utf8");
490
+ dirMeta = JSON.parse(raw) as DirMeta;
491
+ } catch (err) {
492
+ // A broken `_meta.json` should not crash the sidebar —
493
+ // authors will see the warning and fix it. We fall back to
494
+ // the same defaults as if the file were absent.
495
+ console.warn(
496
+ `[content] failed to parse ${metaPath}:`,
497
+ err instanceof Error ? err.message : err
498
+ );
499
+ }
500
+ }
501
+
502
+ // Attach the dir meta to each node at this level so sorting /
503
+ // category generation can pick it up. A `_meta.json` in docs/
504
+ // describes its children — so we stash it under the SYNTHETIC
505
+ // `__dir__` key of the parent map, which `sortTreeWithMeta` and
506
+ // `buildCategoryArray` both look up.
507
+ if (dirMeta) {
508
+ // Pin the parent's dir meta onto the map itself via the
509
+ // `__dir__` synthetic key. The key never collides with a
510
+ // real slug segment because slug segments are URL-safe and
511
+ // never start with `__`.
512
+ map.set("__dir__", {
513
+ title: dirMeta.title ?? path.basename(currentDir) ?? "",
514
+ href: "",
515
+ order: typeof dirMeta.order === "number" ? dirMeta.order : Number.POSITIVE_INFINITY,
516
+ draft: false,
517
+ slugSegments: pathSoFar,
518
+ children: new Map(),
519
+ dirMeta,
520
+ icon: dirMeta.icon,
521
+ });
522
+ }
523
+
524
+ // Recurse into every child directory.
525
+ for (const [seg, node] of map) {
526
+ if (seg === "__dir__") continue;
527
+ // A child is a directory iff it has children OR the segment
528
+ // maps to an actual directory on disk (the node may be a file
529
+ // entry with no children but a dir-level sibling).
530
+ const childDir = path.join(currentDir, seg);
531
+ if (fs.existsSync(childDir) && fs.statSync(childDir).isDirectory()) {
532
+ // Copy parent's dir meta's icon/order onto this branch
533
+ // node so category output has the right values even if the
534
+ // child directory has no _meta.json of its own.
535
+ if (dirMeta) {
536
+ // Record on the branch itself when it's a grouping
537
+ // category (has children).
538
+ if (node.children.size > 0) {
539
+ // No-op: we'll fetch dir meta for this branch from its
540
+ // own _meta.json during recursion.
541
+ }
542
+ }
543
+ applyDirMeta(node.children, childDir, [...pathSoFar, seg]);
544
+ }
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Build the `Category | CategoryEntry` array from the internal
550
+ * tree. Siblings at each depth are sorted under the same precedence
551
+ * as `sortTreeWithMeta`: explicit `pages` > `order` > numeric title.
552
+ */
553
+ function buildCategoryArray(
554
+ map: Map<string, InternalNode>,
555
+ pathSoFar: string[]
556
+ ): Array<Category | CategoryEntry> {
557
+ const dirMetaNode = map.get("__dir__");
558
+ const dirMeta = dirMetaNode?.dirMeta;
559
+ const pagesIndex = new Map<string, number>();
560
+ if (dirMeta?.pages) {
561
+ dirMeta.pages.forEach((slug, idx) => pagesIndex.set(slug, idx));
562
+ }
563
+
564
+ const out: Array<Category | CategoryEntry> = [];
565
+ for (const [seg, node] of map) {
566
+ if (seg === "__dir__") continue;
567
+ const segmentSlug = pathSoFar.concat(seg).join("/");
568
+ if (node.children.size > 0) {
569
+ // Branch — emit a Category.
570
+ const childDirMetaNode = node.children.get("__dir__");
571
+ const childDirMeta = childDirMetaNode?.dirMeta;
572
+ const category: Category = {
573
+ kind: "category",
574
+ slug: segmentSlug,
575
+ title:
576
+ childDirMeta?.title ??
577
+ (node.entry
578
+ ? node.title
579
+ : seg || "index"),
580
+ items: buildCategoryArray(node.children, pathSoFar.concat(seg)),
581
+ };
582
+ if (typeof childDirMeta?.order === "number") {
583
+ category.order = childDirMeta.order;
584
+ } else if (node.order !== Number.POSITIVE_INFINITY) {
585
+ category.order = node.order;
586
+ }
587
+ if (childDirMeta?.icon) category.icon = childDirMeta.icon;
588
+ else if (node.icon) category.icon = node.icon;
589
+ // If the branch has an entry attached (e.g. `docs/guide.md`
590
+ // AND `docs/guide/` both exist), expose the href so the
591
+ // category can also be clickable.
592
+ if (node.entry) category.href = node.href;
593
+ out.push(category);
594
+ } else {
595
+ // Leaf — emit a CategoryEntry.
596
+ const entry: CategoryEntry = {
597
+ kind: "entry",
598
+ slug: segmentSlug,
599
+ title: node.title,
600
+ href: node.href,
601
+ };
602
+ if (node.order !== Number.POSITIVE_INFINITY) entry.order = node.order;
603
+ if (node.icon) entry.icon = node.icon;
604
+ if (node.draft) entry.draft = true;
605
+ out.push(entry);
606
+ }
607
+ }
608
+
609
+ out.sort((a, b) => {
610
+ const aKey = categorySlugSegment(a);
611
+ const bKey = categorySlugSegment(b);
612
+ const aIdx = pagesIndex.get(aKey);
613
+ const bIdx = pagesIndex.get(bKey);
614
+ if (aIdx !== undefined && bIdx !== undefined) return aIdx - bIdx;
615
+ if (aIdx !== undefined) return -1;
616
+ if (bIdx !== undefined) return 1;
617
+
618
+ const aOrder = a.order ?? Number.POSITIVE_INFINITY;
619
+ const bOrder = b.order ?? Number.POSITIVE_INFINITY;
620
+ if (aOrder !== bOrder) return aOrder - bOrder;
621
+ return a.title.localeCompare(b.title, undefined, { numeric: true });
622
+ });
623
+
624
+ return out;
625
+ }
626
+
627
+ function categorySlugSegment(node: Category | CategoryEntry): string {
628
+ const parts = node.slug.split("/");
629
+ return parts[parts.length - 1] ?? node.slug;
630
+ }
@@ -176,6 +176,14 @@ export async function generateRoutes(
176
176
  for (let routeIndex = 0; routeIndex < manifest.routes.length; routeIndex++) {
177
177
  const route = manifest.routes[routeIndex];
178
178
 
179
+ // Issue #206: metadata routes (sitemap/robots/llms.txt/manifest)
180
+ // are served directly from the user's `app/*.ts` file — no
181
+ // scaffold files to emit in `.mandu/generated/`, no slot/contract
182
+ // mapping. Skip them so we don't create ghost server handlers.
183
+ if (route.kind === "metadata") {
184
+ continue;
185
+ }
186
+
179
187
  try {
180
188
  // Spec 위치 정보
181
189
  const specLocation: SpecLocation = {
@@ -9,7 +9,7 @@ import type { RoutesManifest, RouteSpec } from "../../spec/schema";
9
9
  export interface RouteInfo {
10
10
  id: string;
11
11
  pattern: string;
12
- kind: "page" | "api";
12
+ kind: "page" | "api" | "metadata";
13
13
  module: string;
14
14
  methods?: string[];
15
15
  hasSlot: boolean;
@@ -17,6 +17,10 @@ export interface RouteInfo {
17
17
  hasClient: boolean;
18
18
  hasLayout: boolean;
19
19
  hydration?: string;
20
+ /** Metadata route sub-kind (issue #206) */
21
+ metadataKind?: "sitemap" | "robots" | "llms-txt" | "manifest";
22
+ /** Metadata route response Content-Type (issue #206) */
23
+ contentType?: string;
20
24
  }
21
25
 
22
26
  function toRouteInfo(route: RouteSpec): RouteInfo {
@@ -31,6 +35,8 @@ function toRouteInfo(route: RouteSpec): RouteInfo {
31
35
  hasClient: !!route.clientModule,
32
36
  hasLayout: !!(route.kind === "page" && route.layoutChain?.length),
33
37
  hydration: route.kind === "page" ? route.hydration?.strategy : undefined,
38
+ metadataKind: route.kind === "metadata" ? route.metadataKind : undefined,
39
+ contentType: route.kind === "metadata" ? route.contentType : undefined,
34
40
  };
35
41
  }
36
42
 
@@ -41,6 +47,7 @@ export function handleRoutesRequest(manifest: RoutesManifest): Response {
41
47
  total: routes.length,
42
48
  pages: routes.filter((r) => r.kind === "page").length,
43
49
  apis: routes.filter((r) => r.kind === "api").length,
50
+ metadata: routes.filter((r) => r.kind === "metadata").length,
44
51
  withSlots: routes.filter((r) => r.hasSlot).length,
45
52
  withContracts: routes.filter((r) => r.hasContract).length,
46
53
  withIslands: routes.filter((r) => r.hasClient).length,
@@ -6,7 +6,7 @@
6
6
  * @module router/fs-patterns
7
7
  */
8
8
 
9
- import type { RouteSegment, SegmentType, ScannedFileType } from "./fs-types";
9
+ import type { RouteSegment, SegmentType, ScannedFileType, MetadataFileKind } from "./fs-types";
10
10
  import { SEGMENT_PATTERNS, FILE_PATTERNS } from "./fs-types";
11
11
 
12
12
  // ═══════════════════════════════════════════════════════════════════════════
@@ -193,6 +193,7 @@ export function pathToPattern(relativePath: string): string {
193
193
  * detectFileType("page.tsx") // "page"
194
194
  * detectFileType("route.ts") // "route"
195
195
  * detectFileType("comments.island.tsx") // "island"
196
+ * detectFileType("sitemap.ts") // "metadata"
196
197
  */
197
198
  export function detectFileType(filename: string, islandSuffix: string = ".island"): ScannedFileType | null {
198
199
  // Island 파일 먼저 체크 (*.island.tsx)
@@ -201,6 +202,14 @@ export function detectFileType(filename: string, islandSuffix: string = ".island
201
202
  return "island";
202
203
  }
203
204
 
205
+ // Metadata routes (Issue #206) — matched before `page`/`route` so
206
+ // a hypothetical `app/manifest/page.tsx` still routes as a page,
207
+ // while `app/manifest.ts` routes as metadata. Dot-in-filename
208
+ // (`llms.txt.ts`) is matched by its dedicated regex.
209
+ if (detectMetadataFileKind(filename)) {
210
+ return "metadata";
211
+ }
212
+
204
213
  if (FILE_PATTERNS.page.test(filename)) return "page";
205
214
  if (FILE_PATTERNS.layout.test(filename)) return "layout";
206
215
  if (FILE_PATTERNS.route.test(filename)) return "route";
@@ -211,6 +220,22 @@ export function detectFileType(filename: string, islandSuffix: string = ".island
211
220
  return null;
212
221
  }
213
222
 
223
+ /**
224
+ * If `filename` matches one of the metadata-route file conventions,
225
+ * return its kind; otherwise return `null`. Exposed so the scanner
226
+ * can both detect the type AND store the specific kind without
227
+ * re-matching the regex.
228
+ */
229
+ export function detectMetadataFileKind(filename: string): MetadataFileKind | null {
230
+ // llmsTxt MUST come first — its prefix `llms.txt.` also technically
231
+ // matches nothing else, but the explicit ordering documents intent.
232
+ if (FILE_PATTERNS.llmsTxt.test(filename)) return "llms-txt";
233
+ if (FILE_PATTERNS.sitemap.test(filename)) return "sitemap";
234
+ if (FILE_PATTERNS.robots.test(filename)) return "robots";
235
+ if (FILE_PATTERNS.manifest.test(filename)) return "manifest";
236
+ return null;
237
+ }
238
+
214
239
  /**
215
240
  * 비공개 폴더인지 확인
216
241
  *