@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.
@@ -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
  }
@@ -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
  }
@@ -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"; // *.island.tsx - 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
  /**
@@ -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";