@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/router/fs-routes.ts
CHANGED
|
@@ -86,6 +86,20 @@ export function fsRouteToRouteSpec(fsRoute: FSRouteConfig): RouteSpec {
|
|
|
86
86
|
return pageRoute;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
// Issue #206: metadata 라우트 (sitemap / robots / llms-txt / manifest).
|
|
90
|
+
// metadataKind + contentType는 fs-scanner에서 채워진다. 방어적으로
|
|
91
|
+
// 없는 경우를 떨어뜨리지 않고 빈 값으로 통과시켜 RoutesManifest 검증
|
|
92
|
+
// 단계에서 명확한 에러가 나오게 한다.
|
|
93
|
+
if (fsRoute.kind === "metadata") {
|
|
94
|
+
const metadataRoute: RouteSpec = {
|
|
95
|
+
...base,
|
|
96
|
+
kind: "metadata" as const,
|
|
97
|
+
metadataKind: fsRoute.metadataKind ?? "sitemap",
|
|
98
|
+
contentType: fsRoute.contentType ?? "text/plain; charset=utf-8",
|
|
99
|
+
};
|
|
100
|
+
return metadataRoute;
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
// API 라우트
|
|
90
104
|
const apiRoute: RouteSpec = {
|
|
91
105
|
...base,
|
|
@@ -127,6 +141,12 @@ export async function resolveAutoLinks(
|
|
|
127
141
|
|
|
128
142
|
await Promise.all(
|
|
129
143
|
manifest.routes.map(async (route) => {
|
|
144
|
+
// Metadata routes (sitemap/robots/llms-txt/manifest) have a
|
|
145
|
+
// strict file-convention contract — no slots, no contracts.
|
|
146
|
+
// Skipping them here avoids fs.access chatter on every dev
|
|
147
|
+
// rescan.
|
|
148
|
+
if (route.kind === "metadata") return;
|
|
149
|
+
|
|
130
150
|
const contractPath = join(rootDir, "spec", "contracts", `${route.id}.contract.ts`);
|
|
131
151
|
|
|
132
152
|
// Check all extensions in parallel
|
|
@@ -393,7 +413,7 @@ export function formatRoutesForCLI(manifest: RoutesManifest): string {
|
|
|
393
413
|
lines.push("─".repeat(60));
|
|
394
414
|
|
|
395
415
|
for (const route of manifest.routes) {
|
|
396
|
-
const icon = route.kind === "page" ? "📄" : "📡";
|
|
416
|
+
const icon = route.kind === "page" ? "📄" : route.kind === "api" ? "📡" : "🗺️";
|
|
397
417
|
const hydration = route.clientModule ? " 🏝️" : "";
|
|
398
418
|
lines.push(`${icon} ${route.pattern.padEnd(30)} → ${route.id}${hydration}`);
|
|
399
419
|
}
|
package/src/router/fs-scanner.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
parseSegments,
|
|
22
22
|
segmentsToPattern,
|
|
23
23
|
detectFileType,
|
|
24
|
+
detectMetadataFileKind,
|
|
24
25
|
isPrivateFolder,
|
|
25
26
|
generateRouteId,
|
|
26
27
|
validateSegments,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
getPatternShape,
|
|
29
30
|
} from "./fs-patterns";
|
|
30
31
|
import { mark, measure } from "../perf";
|
|
32
|
+
import { METADATA_ROUTES } from "../routes/types";
|
|
31
33
|
|
|
32
34
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
33
35
|
// Scanner Class
|
|
@@ -166,6 +168,40 @@ export class FSScanner {
|
|
|
166
168
|
continue;
|
|
167
169
|
}
|
|
168
170
|
|
|
171
|
+
// Issue #206: metadata routes (sitemap/robots/llms.txt/manifest)
|
|
172
|
+
// must sit at the routesDir root — a nested
|
|
173
|
+
// `app/foo/sitemap.ts` would be ambiguous (group-scoped
|
|
174
|
+
// sitemap? route under /foo?) so we report it as an error
|
|
175
|
+
// instead of silently serving /sitemap.xml from the wrong file.
|
|
176
|
+
if (fileType === "metadata") {
|
|
177
|
+
if (relativePath.includes("/")) {
|
|
178
|
+
errors.push({
|
|
179
|
+
type: "invalid_segment",
|
|
180
|
+
message:
|
|
181
|
+
`Metadata routes must sit directly under "${this.config.routesDir}/". ` +
|
|
182
|
+
`Found "${relativePath}" — move it to "${this.config.routesDir}/${fileName}".`,
|
|
183
|
+
filePath: fullPath,
|
|
184
|
+
});
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const metadataKind = detectMetadataFileKind(fileName);
|
|
188
|
+
if (!metadataKind) {
|
|
189
|
+
// Defensive: detectFileType returned "metadata" but the
|
|
190
|
+
// narrower helper can't identify which one. Only reachable
|
|
191
|
+
// if the two detection paths drift.
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
files.push({
|
|
195
|
+
absolutePath: fullPath,
|
|
196
|
+
relativePath,
|
|
197
|
+
type: fileType,
|
|
198
|
+
segments: [],
|
|
199
|
+
extension: ext,
|
|
200
|
+
metadataKind,
|
|
201
|
+
});
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
169
205
|
const segments = parseSegments(relativePath);
|
|
170
206
|
const validation = validateSegments(segments);
|
|
171
207
|
if (!validation.valid) {
|
|
@@ -207,6 +243,7 @@ export class FSScanner {
|
|
|
207
243
|
const errorMap = new Map<string, ScannedFile>();
|
|
208
244
|
const islandMap = new Map<string, ScannedFile[]>();
|
|
209
245
|
const routeFiles: ScannedFile[] = [];
|
|
246
|
+
const metadataFiles: ScannedFile[] = [];
|
|
210
247
|
|
|
211
248
|
for (const file of files) {
|
|
212
249
|
const dirPath = this.getDirPath(file.relativePath);
|
|
@@ -234,11 +271,53 @@ export class FSScanner {
|
|
|
234
271
|
case "route":
|
|
235
272
|
routeFiles.push(file);
|
|
236
273
|
break;
|
|
274
|
+
case "metadata":
|
|
275
|
+
metadataFiles.push(file);
|
|
276
|
+
break;
|
|
237
277
|
default:
|
|
238
278
|
break;
|
|
239
279
|
}
|
|
240
280
|
}
|
|
241
281
|
|
|
282
|
+
// Issue #206: emit metadata-route entries first so the normal
|
|
283
|
+
// page/route loop below can surface "duplicate pattern" errors if
|
|
284
|
+
// a user ever creates e.g. `app/sitemap.xml/page.tsx` alongside
|
|
285
|
+
// `app/sitemap.ts` (both map to /sitemap.xml).
|
|
286
|
+
for (const file of metadataFiles) {
|
|
287
|
+
if (!file.metadataKind) continue;
|
|
288
|
+
const meta = METADATA_ROUTES[file.metadataKind];
|
|
289
|
+
const pattern = meta.pattern;
|
|
290
|
+
const routeId = `metadata-${file.metadataKind}`;
|
|
291
|
+
const modulePath = join(this.config.routesDir, file.relativePath).replace(/\\/g, "/");
|
|
292
|
+
|
|
293
|
+
const existing = patternMap.get(pattern);
|
|
294
|
+
if (existing) {
|
|
295
|
+
routeErrors.push({
|
|
296
|
+
type: "duplicate_route",
|
|
297
|
+
message: `Duplicate route pattern "${pattern}" — metadata route "${file.relativePath}" conflicts with "${existing.sourceFile}"`,
|
|
298
|
+
filePath: file.absolutePath,
|
|
299
|
+
conflictsWith: existing.sourceFile,
|
|
300
|
+
});
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const route: FSRouteConfig = {
|
|
305
|
+
id: routeId,
|
|
306
|
+
segments: [],
|
|
307
|
+
pattern,
|
|
308
|
+
kind: "metadata",
|
|
309
|
+
module: modulePath,
|
|
310
|
+
layoutChain: [],
|
|
311
|
+
sourceFile: file.absolutePath,
|
|
312
|
+
metadataKind: file.metadataKind,
|
|
313
|
+
contentType: meta.contentType,
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
routes.push(route);
|
|
317
|
+
patternMap.set(pattern, route);
|
|
318
|
+
shapeMap.set(pattern, route);
|
|
319
|
+
}
|
|
320
|
+
|
|
242
321
|
// 페이지 및 API 라우트 처리
|
|
243
322
|
for (const file of routeFiles) {
|
|
244
323
|
const pattern = segmentsToPattern(file.segments);
|
|
@@ -462,6 +541,7 @@ export class FSScanner {
|
|
|
462
541
|
apiCount: 0,
|
|
463
542
|
layoutCount: 0,
|
|
464
543
|
islandCount: 0,
|
|
544
|
+
metadataCount: 0,
|
|
465
545
|
scanTime,
|
|
466
546
|
},
|
|
467
547
|
};
|
|
@@ -481,6 +561,7 @@ export class FSScanner {
|
|
|
481
561
|
apiCount: routes.filter((r) => r.kind === "api").length,
|
|
482
562
|
layoutCount: files.filter((f) => f.type === "layout").length,
|
|
483
563
|
islandCount: files.filter((f) => f.type === "island").length,
|
|
564
|
+
metadataCount: routes.filter((r) => r.kind === "metadata").length,
|
|
484
565
|
scanTime,
|
|
485
566
|
};
|
|
486
567
|
}
|
package/src/router/fs-types.ts
CHANGED
|
@@ -51,7 +51,15 @@ export type ScannedFileType =
|
|
|
51
51
|
| "loading" // loading.tsx - 로딩 UI
|
|
52
52
|
| "error" // error.tsx - 에러 UI
|
|
53
53
|
| "not-found" // not-found.tsx - 404 UI
|
|
54
|
-
| "island"
|
|
54
|
+
| "island" // *.island.tsx - Island 컴포넌트
|
|
55
|
+
| "metadata"; // sitemap.ts / robots.ts / llms.txt.ts / manifest.ts — file-convention metadata routes
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Metadata 라우트 종류 (sitemap / robots / llms-txt / manifest)
|
|
59
|
+
* Duplicated in `@mandujs/core/routes` → `MetadataRouteKind`; kept
|
|
60
|
+
* inline here to avoid a cross-module import at the type layer.
|
|
61
|
+
*/
|
|
62
|
+
export type MetadataFileKind = "sitemap" | "robots" | "llms-txt" | "manifest";
|
|
55
63
|
|
|
56
64
|
/**
|
|
57
65
|
* 스캔된 파일 정보
|
|
@@ -71,6 +79,13 @@ export interface ScannedFile {
|
|
|
71
79
|
|
|
72
80
|
/** 파일 확장자 */
|
|
73
81
|
extension: string;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* When `type === "metadata"`, discriminates which file-convention
|
|
85
|
+
* metadata route (sitemap / robots / llms-txt / manifest) was
|
|
86
|
+
* matched. Undefined for all other types.
|
|
87
|
+
*/
|
|
88
|
+
metadataKind?: MetadataFileKind;
|
|
74
89
|
}
|
|
75
90
|
|
|
76
91
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -119,6 +134,18 @@ export interface FSRouteConfig {
|
|
|
119
134
|
|
|
120
135
|
/** 원본 파일 경로 */
|
|
121
136
|
sourceFile: string;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Metadata 라우트에만 존재하는 필드.
|
|
140
|
+
* `kind === "metadata"`일 때 어떤 종류의 metadata 파일인지 구분.
|
|
141
|
+
*/
|
|
142
|
+
metadataKind?: MetadataFileKind;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Metadata 라우트 응답 Content-Type.
|
|
146
|
+
* `kind === "metadata"`일 때만 의미가 있다.
|
|
147
|
+
*/
|
|
148
|
+
contentType?: string;
|
|
122
149
|
}
|
|
123
150
|
|
|
124
151
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -216,6 +243,9 @@ export interface ScanStats {
|
|
|
216
243
|
/** Island 수 */
|
|
217
244
|
islandCount: number;
|
|
218
245
|
|
|
246
|
+
/** Metadata 라우트 수 (sitemap/robots/llms-txt/manifest) */
|
|
247
|
+
metadataCount: number;
|
|
248
|
+
|
|
219
249
|
/** 스캔 소요 시간 (ms) */
|
|
220
250
|
scanTime: number;
|
|
221
251
|
}
|
|
@@ -248,6 +278,27 @@ export const FILE_PATTERNS = {
|
|
|
248
278
|
|
|
249
279
|
/** Island 파일 */
|
|
250
280
|
island: /\.island\.(tsx?|jsx?)$/,
|
|
281
|
+
|
|
282
|
+
// ── Metadata routes (Issue #206) ──
|
|
283
|
+
/** sitemap.ts → /sitemap.xml */
|
|
284
|
+
sitemap: /^sitemap\.(ts|js)$/,
|
|
285
|
+
|
|
286
|
+
/** robots.ts → /robots.txt */
|
|
287
|
+
robots: /^robots\.(ts|js)$/,
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* llms.txt.ts → /llms.txt
|
|
291
|
+
*
|
|
292
|
+
* Note the double-dot filename — we mirror the served path in the
|
|
293
|
+
* source filename so users can grep the filesystem for "llms.txt"
|
|
294
|
+
* and hit the right file. Must be matched BEFORE the generic
|
|
295
|
+
* extension stripper since "llms.txt" itself looks like a full
|
|
296
|
+
* filename with extension ".txt".
|
|
297
|
+
*/
|
|
298
|
+
llmsTxt: /^llms\.txt\.(ts|js)$/,
|
|
299
|
+
|
|
300
|
+
/** manifest.ts → /manifest.webmanifest */
|
|
301
|
+
manifest: /^manifest\.(ts|js)$/,
|
|
251
302
|
} as const;
|
|
252
303
|
|
|
253
304
|
/**
|
package/src/router/index.ts
CHANGED
|
@@ -34,6 +34,7 @@ export type {
|
|
|
34
34
|
// File types
|
|
35
35
|
ScannedFileType,
|
|
36
36
|
ScannedFile,
|
|
37
|
+
MetadataFileKind,
|
|
37
38
|
|
|
38
39
|
// Route config
|
|
39
40
|
FSRouteConfig,
|
|
@@ -56,6 +57,7 @@ export {
|
|
|
56
57
|
segmentsToPattern,
|
|
57
58
|
pathToPattern,
|
|
58
59
|
detectFileType,
|
|
60
|
+
detectMetadataFileKind,
|
|
59
61
|
isPrivateFolder,
|
|
60
62
|
isGroupFolder,
|
|
61
63
|
generateRouteId,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @mandujs/core/routes
|
|
3
|
+
*
|
|
4
|
+
* File-convention metadata routes: sitemap, robots, llms.txt, manifest.
|
|
5
|
+
*
|
|
6
|
+
* This subpath intentionally scopes only the Metadata Routes API so
|
|
7
|
+
* users can import types without pulling in the full `@mandujs/core`
|
|
8
|
+
* surface:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* import type { SitemapEntry, Robots, WebAppManifest } from "@mandujs/core/routes";
|
|
12
|
+
* ```
|
|
13
|
+
*
|
|
14
|
+
* Runtime helpers (`renderSitemap`, `renderRobots`, `renderManifest`,
|
|
15
|
+
* `renderLlmsTxt`, `handleMetadataRoute`) are also re-exported so
|
|
16
|
+
* advanced callers can bypass the auto-discovery pipeline and wire
|
|
17
|
+
* their own handlers.
|
|
18
|
+
*
|
|
19
|
+
* See `docs/architect/metadata-routes.md` for the file-convention
|
|
20
|
+
* contract.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// Types
|
|
24
|
+
export type {
|
|
25
|
+
// Sitemap
|
|
26
|
+
ChangeFrequency,
|
|
27
|
+
SitemapAlternates,
|
|
28
|
+
SitemapEntry,
|
|
29
|
+
Sitemap,
|
|
30
|
+
|
|
31
|
+
// Robots
|
|
32
|
+
RobotsRule,
|
|
33
|
+
Robots,
|
|
34
|
+
|
|
35
|
+
// Web App Manifest
|
|
36
|
+
DisplayMode,
|
|
37
|
+
Orientation,
|
|
38
|
+
WebAppManifestIcon,
|
|
39
|
+
WebAppManifestShortcut,
|
|
40
|
+
WebAppManifest,
|
|
41
|
+
|
|
42
|
+
// Default-export contracts
|
|
43
|
+
SitemapFn,
|
|
44
|
+
RobotsFn,
|
|
45
|
+
LlmsTxtFn,
|
|
46
|
+
ManifestFn,
|
|
47
|
+
|
|
48
|
+
// Manifest / fs-scanner discriminator
|
|
49
|
+
MetadataRouteKind,
|
|
50
|
+
} from "./types";
|
|
51
|
+
|
|
52
|
+
// Validation schemas + fixed route table (low-level — advanced)
|
|
53
|
+
export {
|
|
54
|
+
SitemapEntrySchema,
|
|
55
|
+
SitemapSchema,
|
|
56
|
+
RobotsRuleSchema,
|
|
57
|
+
RobotsSchema,
|
|
58
|
+
WebAppManifestIconSchema,
|
|
59
|
+
WebAppManifestSchema,
|
|
60
|
+
METADATA_ROUTES,
|
|
61
|
+
} from "./types";
|
|
62
|
+
|
|
63
|
+
// Runtime handlers
|
|
64
|
+
export {
|
|
65
|
+
renderSitemap,
|
|
66
|
+
renderRobots,
|
|
67
|
+
renderManifest,
|
|
68
|
+
renderLlmsTxt,
|
|
69
|
+
renderValidated,
|
|
70
|
+
handleMetadataRoute,
|
|
71
|
+
getMetadataRouteMeta,
|
|
72
|
+
MetadataRouteValidationError,
|
|
73
|
+
type MetadataRouteHandlerOptions,
|
|
74
|
+
} from "./metadata-routes";
|