@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 +2 -1
- package/src/bundler/dev.ts +8 -0
- package/src/content/collection.ts +322 -19
- package/src/content/index.ts +11 -2
- package/src/content/llms-txt.ts +98 -17
- package/src/content/sidebar.ts +446 -28
- package/src/generator/generate.ts +8 -0
- package/src/kitchen/api/routes-api.ts +8 -1
- package/src/router/fs-patterns.ts +26 -1
- package/src/router/fs-routes.ts +21 -1
- package/src/router/fs-scanner.ts +81 -0
- package/src/router/fs-types.ts +52 -1
- package/src/router/index.ts +2 -0
- package/src/routes/index.ts +74 -0
- package/src/routes/metadata-routes.ts +427 -0
- package/src/routes/types.ts +341 -0
- package/src/runtime/server.ts +87 -0
- package/src/spec/schema.ts +51 -1
package/src/content/sidebar.ts
CHANGED
|
@@ -1,32 +1,61 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sidebar Generator (
|
|
2
|
+
* Sidebar Generator (Issues #199, #205)
|
|
3
3
|
*
|
|
4
|
-
* Takes a loaded `Collection` and produces a nested
|
|
5
|
-
*
|
|
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
|
-
*
|
|
7
|
+
* Two output shapes are supported:
|
|
11
8
|
*
|
|
12
|
-
* 1.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
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-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
*
|