@mandujs/core 0.41.2 → 0.43.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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,422 +1,422 @@
1
- /**
2
- * FS Routes Patterns
3
- *
4
- * 파일 경로 → URL 패턴 변환 유틸리티
5
- *
6
- * @module router/fs-patterns
7
- */
8
-
9
- import type { RouteSegment, SegmentType, ScannedFileType, MetadataFileKind } from "./fs-types";
10
- import { SEGMENT_PATTERNS, FILE_PATTERNS } from "./fs-types";
11
-
12
- // ═══════════════════════════════════════════════════════════════════════════
13
- // Segment Parsing
14
- // ═══════════════════════════════════════════════════════════════════════════
15
-
16
- /**
17
- * 세그먼트 문자열을 파싱하여 RouteSegment 반환
18
- *
19
- * @example
20
- * parseSegment("blog") // { raw: "blog", type: "static" }
21
- * parseSegment("[slug]") // { raw: "[slug]", type: "dynamic", paramName: "slug" }
22
- * parseSegment("[...path]") // { raw: "[...path]", type: "catchAll", paramName: "path" }
23
- * parseSegment("(marketing)") // { raw: "(marketing)", type: "group" }
24
- */
25
- export function parseSegment(segment: string): RouteSegment {
26
- // Optional catch-all: [[...param]]
27
- const optionalCatchAllMatch = segment.match(SEGMENT_PATTERNS.optionalCatchAll);
28
- if (optionalCatchAllMatch) {
29
- return {
30
- raw: segment,
31
- type: "optionalCatchAll",
32
- paramName: optionalCatchAllMatch[1],
33
- };
34
- }
35
-
36
- // Catch-all: [...param]
37
- const catchAllMatch = segment.match(SEGMENT_PATTERNS.catchAll);
38
- if (catchAllMatch) {
39
- return {
40
- raw: segment,
41
- type: "catchAll",
42
- paramName: catchAllMatch[1],
43
- };
44
- }
45
-
46
- // Dynamic: [param]
47
- const dynamicMatch = segment.match(SEGMENT_PATTERNS.dynamic);
48
- if (dynamicMatch) {
49
- return {
50
- raw: segment,
51
- type: "dynamic",
52
- paramName: dynamicMatch[1],
53
- };
54
- }
55
-
56
- // Group: (name)
57
- const groupMatch = segment.match(SEGMENT_PATTERNS.group);
58
- if (groupMatch) {
59
- return {
60
- raw: segment,
61
- type: "group",
62
- };
63
- }
64
-
65
- // Slot / Parallel route: @name
66
- if (segment.startsWith("@")) {
67
- return {
68
- raw: segment,
69
- type: "slot",
70
- paramName: segment.slice(1), // @modal → "modal"
71
- };
72
- }
73
-
74
- // Static segment
75
- return {
76
- raw: segment,
77
- type: "static",
78
- };
79
- }
80
-
81
- /**
82
- * 경로를 세그먼트 배열로 파싱
83
- *
84
- * @example
85
- * parseSegments("blog/[slug]/comments")
86
- * // [
87
- * // { raw: "blog", type: "static" },
88
- * // { raw: "[slug]", type: "dynamic", paramName: "slug" },
89
- * // { raw: "comments", type: "static" }
90
- * // ]
91
- */
92
- export function parseSegments(relativePath: string): RouteSegment[] {
93
- // Windows 경로 정규화
94
- const normalized = relativePath.replace(/\\/g, "/");
95
-
96
- // 경로에서 파일명 제거하고 디렉토리만 추출
97
- // 파일명 패턴: xxx.ext 또는 xxx.ext.ext (예: page.tsx, comments.island.tsx)
98
- const lastSlash = normalized.lastIndexOf("/");
99
-
100
- // 슬래시가 없으면 파일명만 있는 것 (루트)
101
- if (lastSlash === -1) {
102
- return [];
103
- }
104
-
105
- const pathWithoutFile = normalized.slice(0, lastSlash);
106
-
107
- if (!pathWithoutFile || pathWithoutFile === ".") {
108
- return [];
109
- }
110
-
111
- const parts = pathWithoutFile.split("/").filter(Boolean);
112
- return parts.map(parseSegment);
113
- }
114
-
115
- // ═══════════════════════════════════════════════════════════════════════════
116
- // Pattern Conversion
117
- // ═══════════════════════════════════════════════════════════════════════════
118
-
119
- /**
120
- * 세그먼트 배열을 URL 패턴으로 변환
121
- *
122
- * @example
123
- * segmentsToPattern([
124
- * { raw: "blog", type: "static" },
125
- * { raw: "[slug]", type: "dynamic", paramName: "slug" }
126
- * ])
127
- * // "/blog/:slug"
128
- */
129
- export function segmentsToPattern(segments: RouteSegment[]): string {
130
- if (segments.length === 0) {
131
- return "/";
132
- }
133
-
134
- const parts = segments
135
- .filter((seg) => seg.type !== "group" && seg.type !== "slot") // 그룹과 slot(@name)은 URL에 포함 안 됨
136
- .map((seg) => segmentToPatternPart(seg));
137
-
138
- return "/" + parts.join("/");
139
- }
140
-
141
- /**
142
- * 단일 세그먼트를 URL 패턴 부분으로 변환
143
- */
144
- function segmentToPatternPart(segment: RouteSegment): string {
145
- switch (segment.type) {
146
- case "static":
147
- return segment.raw;
148
-
149
- case "dynamic":
150
- // [param] → :param
151
- return `:${segment.paramName}`;
152
-
153
- case "catchAll":
154
- // [...param] → :param* (Mandu 라우터 문법)
155
- return `:${segment.paramName}*`;
156
-
157
- case "optionalCatchAll":
158
- // [[...param]] → :param*? (optional catch-all)
159
- return `:${segment.paramName}*?`;
160
-
161
- case "group":
162
- // 그룹은 URL에 포함 안 됨
163
- return "";
164
-
165
- default:
166
- return segment.raw;
167
- }
168
- }
169
-
170
- /**
171
- * 파일 경로를 URL 패턴으로 변환
172
- *
173
- * @example
174
- * pathToPattern("blog/[slug]/page.tsx")
175
- * // "/blog/:slug"
176
- *
177
- * pathToPattern("(marketing)/pricing/page.tsx")
178
- * // "/pricing"
179
- */
180
- export function pathToPattern(relativePath: string): string {
181
- const segments = parseSegments(relativePath);
182
- return segmentsToPattern(segments);
183
- }
184
-
185
- // ═══════════════════════════════════════════════════════════════════════════
186
- // File Type Detection
187
- // ═══════════════════════════════════════════════════════════════════════════
188
-
189
- /**
190
- * 파일명으로 파일 타입 감지
191
- *
192
- * @example
193
- * detectFileType("page.tsx") // "page"
194
- * detectFileType("route.ts") // "route"
195
- * detectFileType("comments.island.tsx") // "island"
196
- * detectFileType("sitemap.ts") // "metadata"
197
- */
198
- export function detectFileType(filename: string, islandSuffix: string = ".island"): ScannedFileType | null {
199
- // Island 파일 먼저 체크 (*.island.tsx)
200
- const islandPattern = new RegExp(`\\${islandSuffix}\\.(tsx?|jsx?)$`);
201
- if (islandPattern.test(filename)) {
202
- return "island";
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
-
213
- if (FILE_PATTERNS.page.test(filename)) return "page";
214
- if (FILE_PATTERNS.layout.test(filename)) return "layout";
215
- if (FILE_PATTERNS.route.test(filename)) return "route";
216
- if (FILE_PATTERNS.loading.test(filename)) return "loading";
217
- if (FILE_PATTERNS.error.test(filename)) return "error";
218
- if (FILE_PATTERNS.notFound.test(filename)) return "not-found";
219
-
220
- return null;
221
- }
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
-
239
- /**
240
- * 비공개 폴더인지 확인
241
- *
242
- * @example
243
- * isPrivateFolder("_components") // true
244
- * isPrivateFolder("components") // false
245
- */
246
- export function isPrivateFolder(folderName: string): boolean {
247
- return SEGMENT_PATTERNS.private.test(folderName);
248
- }
249
-
250
- /**
251
- * 그룹 폴더인지 확인
252
- *
253
- * @example
254
- * isGroupFolder("(marketing)") // true
255
- * isGroupFolder("marketing") // false
256
- */
257
- export function isGroupFolder(folderName: string): boolean {
258
- return SEGMENT_PATTERNS.group.test(folderName);
259
- }
260
-
261
- // ═══════════════════════════════════════════════════════════════════════════
262
- // Route ID Generation
263
- // ═══════════════════════════════════════════════════════════════════════════
264
-
265
- /**
266
- * 파일 경로에서 라우트 ID 생성
267
- *
268
- * @example
269
- * generateRouteId("blog/[slug]/page.tsx")
270
- * // "blog-$slug"
271
- *
272
- * generateRouteId("api/users/route.ts")
273
- * // "api-users"
274
- */
275
- export function generateRouteId(relativePath: string): string {
276
- const segments = parseSegments(relativePath);
277
-
278
- const parts = segments
279
- .filter((seg) => seg.type !== "group")
280
- .map((seg) => {
281
- switch (seg.type) {
282
- case "dynamic":
283
- return `$${seg.paramName}`;
284
- case "catchAll":
285
- case "optionalCatchAll":
286
- return `$${seg.paramName}`;
287
- default:
288
- return seg.raw;
289
- }
290
- });
291
-
292
- if (parts.length === 0) {
293
- return "index";
294
- }
295
-
296
- return parts.join("-").toLowerCase();
297
- }
298
-
299
- // ═══════════════════════════════════════════════════════════════════════════
300
- // Priority Sorting
301
- // ═══════════════════════════════════════════════════════════════════════════
302
-
303
- /**
304
- * 세그먼트 타입별 우선순위 (낮을수록 높은 우선순위)
305
- */
306
- const SEGMENT_PRIORITY: Record<SegmentType, number> = {
307
- static: 0,
308
- group: 1, // 그룹은 URL에 영향 없으므로 static과 동일
309
- slot: 1, // slot(@name)도 URL에 영향 없음 — layout named prop으로 전달
310
- dynamic: 2,
311
- catchAll: 3,
312
- optionalCatchAll: 4,
313
- };
314
-
315
- /**
316
- * 라우트 우선순위 계산
317
- *
318
- * 정적 라우트가 동적 라우트보다 높은 우선순위
319
- * 더 구체적인 라우트가 높은 우선순위
320
- *
321
- * @returns 낮을수록 높은 우선순위
322
- */
323
- export function calculateRoutePriority(segments: RouteSegment[]): number {
324
- let priority = 0;
325
-
326
- for (let i = 0; i < segments.length; i++) {
327
- const seg = segments[i];
328
- // 깊이에 따른 가중치 적용
329
- priority += SEGMENT_PRIORITY[seg.type] * Math.pow(10, segments.length - i - 1);
330
- }
331
-
332
- return priority;
333
- }
334
-
335
- /**
336
- * 라우트 배열을 우선순위에 따라 정렬
337
- *
338
- * 정적 → 동적 → catch-all 순서
339
- */
340
- export function sortRoutesByPriority<T extends { segments: RouteSegment[] }>(routes: T[]): T[] {
341
- return [...routes].sort((a, b) => {
342
- const priorityA = calculateRoutePriority(a.segments);
343
- const priorityB = calculateRoutePriority(b.segments);
344
- return priorityA - priorityB;
345
- });
346
- }
347
-
348
- // ═══════════════════════════════════════════════════════════════════════════
349
- // Validation
350
- // ═══════════════════════════════════════════════════════════════════════════
351
-
352
- /**
353
- * 세그먼트 유효성 검사
354
- */
355
- export function validateSegments(segments: RouteSegment[]): { valid: boolean; error?: string } {
356
- for (let i = 0; i < segments.length; i++) {
357
- const seg = segments[i];
358
-
359
- // Catch-all은 마지막이어야 함
360
- if (seg.type === "catchAll" || seg.type === "optionalCatchAll") {
361
- if (i !== segments.length - 1) {
362
- return {
363
- valid: false,
364
- error: `Catch-all segment "${seg.raw}" must be the last segment`,
365
- };
366
- }
367
- }
368
- }
369
-
370
- return { valid: true };
371
- }
372
-
373
- /**
374
- * 패턴 충돌 확인
375
- *
376
- * 두 패턴이 동일한 URL을 매칭할 수 있는지 확인
377
- */
378
- export function patternsConflict(patternA: string, patternB: string): boolean {
379
- const shapeA = normalizePatternShape(patternA);
380
- const shapeB = normalizePatternShape(patternB);
381
-
382
- return shapeA === shapeB;
383
- }
384
-
385
- /**
386
- * 패턴 형태 반환 (파라미터 이름 무시)
387
- */
388
- export function getPatternShape(pattern: string): string {
389
- return normalizePatternShape(pattern);
390
- }
391
-
392
- /**
393
- * 패턴 형태 정규화 (파라미터 이름 무시)
394
- *
395
- * @example
396
- * /blog/:slug -> /blog/:PARAM
397
- * /docs/:path* -> /docs/*
398
- * /docs/:path*? -> /docs/*
399
- */
400
- function normalizePatternShape(pattern: string): string {
401
- const normalized = pattern.replace(/\/$/, "") || "/";
402
-
403
- if (normalized === "/") return "/";
404
-
405
- const segments = normalized.split("/").filter(Boolean);
406
- const parts = segments.map((seg) => {
407
- if (seg === "*") return "*";
408
-
409
- if (seg.startsWith(":")) {
410
- const wildcardMatch = seg.match(/^:([^*?]+)\*(\?)?$/);
411
- if (wildcardMatch) {
412
- // optional 여부는 충돌 판단에서 동일하게 취급
413
- return "*";
414
- }
415
- return ":PARAM";
416
- }
417
-
418
- return seg;
419
- });
420
-
421
- return "/" + parts.join("/");
422
- }
1
+ /**
2
+ * FS Routes Patterns
3
+ *
4
+ * 파일 경로 → URL 패턴 변환 유틸리티
5
+ *
6
+ * @module router/fs-patterns
7
+ */
8
+
9
+ import type { RouteSegment, SegmentType, ScannedFileType, MetadataFileKind } from "./fs-types";
10
+ import { SEGMENT_PATTERNS, FILE_PATTERNS } from "./fs-types";
11
+
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+ // Segment Parsing
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ /**
17
+ * 세그먼트 문자열을 파싱하여 RouteSegment 반환
18
+ *
19
+ * @example
20
+ * parseSegment("blog") // { raw: "blog", type: "static" }
21
+ * parseSegment("[slug]") // { raw: "[slug]", type: "dynamic", paramName: "slug" }
22
+ * parseSegment("[...path]") // { raw: "[...path]", type: "catchAll", paramName: "path" }
23
+ * parseSegment("(marketing)") // { raw: "(marketing)", type: "group" }
24
+ */
25
+ export function parseSegment(segment: string): RouteSegment {
26
+ // Optional catch-all: [[...param]]
27
+ const optionalCatchAllMatch = segment.match(SEGMENT_PATTERNS.optionalCatchAll);
28
+ if (optionalCatchAllMatch) {
29
+ return {
30
+ raw: segment,
31
+ type: "optionalCatchAll",
32
+ paramName: optionalCatchAllMatch[1],
33
+ };
34
+ }
35
+
36
+ // Catch-all: [...param]
37
+ const catchAllMatch = segment.match(SEGMENT_PATTERNS.catchAll);
38
+ if (catchAllMatch) {
39
+ return {
40
+ raw: segment,
41
+ type: "catchAll",
42
+ paramName: catchAllMatch[1],
43
+ };
44
+ }
45
+
46
+ // Dynamic: [param]
47
+ const dynamicMatch = segment.match(SEGMENT_PATTERNS.dynamic);
48
+ if (dynamicMatch) {
49
+ return {
50
+ raw: segment,
51
+ type: "dynamic",
52
+ paramName: dynamicMatch[1],
53
+ };
54
+ }
55
+
56
+ // Group: (name)
57
+ const groupMatch = segment.match(SEGMENT_PATTERNS.group);
58
+ if (groupMatch) {
59
+ return {
60
+ raw: segment,
61
+ type: "group",
62
+ };
63
+ }
64
+
65
+ // Slot / Parallel route: @name
66
+ if (segment.startsWith("@")) {
67
+ return {
68
+ raw: segment,
69
+ type: "slot",
70
+ paramName: segment.slice(1), // @modal → "modal"
71
+ };
72
+ }
73
+
74
+ // Static segment
75
+ return {
76
+ raw: segment,
77
+ type: "static",
78
+ };
79
+ }
80
+
81
+ /**
82
+ * 경로를 세그먼트 배열로 파싱
83
+ *
84
+ * @example
85
+ * parseSegments("blog/[slug]/comments")
86
+ * // [
87
+ * // { raw: "blog", type: "static" },
88
+ * // { raw: "[slug]", type: "dynamic", paramName: "slug" },
89
+ * // { raw: "comments", type: "static" }
90
+ * // ]
91
+ */
92
+ export function parseSegments(relativePath: string): RouteSegment[] {
93
+ // Windows 경로 정규화
94
+ const normalized = relativePath.replace(/\\/g, "/");
95
+
96
+ // 경로에서 파일명 제거하고 디렉토리만 추출
97
+ // 파일명 패턴: xxx.ext 또는 xxx.ext.ext (예: page.tsx, comments.island.tsx)
98
+ const lastSlash = normalized.lastIndexOf("/");
99
+
100
+ // 슬래시가 없으면 파일명만 있는 것 (루트)
101
+ if (lastSlash === -1) {
102
+ return [];
103
+ }
104
+
105
+ const pathWithoutFile = normalized.slice(0, lastSlash);
106
+
107
+ if (!pathWithoutFile || pathWithoutFile === ".") {
108
+ return [];
109
+ }
110
+
111
+ const parts = pathWithoutFile.split("/").filter(Boolean);
112
+ return parts.map(parseSegment);
113
+ }
114
+
115
+ // ═══════════════════════════════════════════════════════════════════════════
116
+ // Pattern Conversion
117
+ // ═══════════════════════════════════════════════════════════════════════════
118
+
119
+ /**
120
+ * 세그먼트 배열을 URL 패턴으로 변환
121
+ *
122
+ * @example
123
+ * segmentsToPattern([
124
+ * { raw: "blog", type: "static" },
125
+ * { raw: "[slug]", type: "dynamic", paramName: "slug" }
126
+ * ])
127
+ * // "/blog/:slug"
128
+ */
129
+ export function segmentsToPattern(segments: RouteSegment[]): string {
130
+ if (segments.length === 0) {
131
+ return "/";
132
+ }
133
+
134
+ const parts = segments
135
+ .filter((seg) => seg.type !== "group" && seg.type !== "slot") // 그룹과 slot(@name)은 URL에 포함 안 됨
136
+ .map((seg) => segmentToPatternPart(seg));
137
+
138
+ return "/" + parts.join("/");
139
+ }
140
+
141
+ /**
142
+ * 단일 세그먼트를 URL 패턴 부분으로 변환
143
+ */
144
+ function segmentToPatternPart(segment: RouteSegment): string {
145
+ switch (segment.type) {
146
+ case "static":
147
+ return segment.raw;
148
+
149
+ case "dynamic":
150
+ // [param] → :param
151
+ return `:${segment.paramName}`;
152
+
153
+ case "catchAll":
154
+ // [...param] → :param* (Mandu 라우터 문법)
155
+ return `:${segment.paramName}*`;
156
+
157
+ case "optionalCatchAll":
158
+ // [[...param]] → :param*? (optional catch-all)
159
+ return `:${segment.paramName}*?`;
160
+
161
+ case "group":
162
+ // 그룹은 URL에 포함 안 됨
163
+ return "";
164
+
165
+ default:
166
+ return segment.raw;
167
+ }
168
+ }
169
+
170
+ /**
171
+ * 파일 경로를 URL 패턴으로 변환
172
+ *
173
+ * @example
174
+ * pathToPattern("blog/[slug]/page.tsx")
175
+ * // "/blog/:slug"
176
+ *
177
+ * pathToPattern("(marketing)/pricing/page.tsx")
178
+ * // "/pricing"
179
+ */
180
+ export function pathToPattern(relativePath: string): string {
181
+ const segments = parseSegments(relativePath);
182
+ return segmentsToPattern(segments);
183
+ }
184
+
185
+ // ═══════════════════════════════════════════════════════════════════════════
186
+ // File Type Detection
187
+ // ═══════════════════════════════════════════════════════════════════════════
188
+
189
+ /**
190
+ * 파일명으로 파일 타입 감지
191
+ *
192
+ * @example
193
+ * detectFileType("page.tsx") // "page"
194
+ * detectFileType("route.ts") // "route"
195
+ * detectFileType("comments.island.tsx") // "island"
196
+ * detectFileType("sitemap.ts") // "metadata"
197
+ */
198
+ export function detectFileType(filename: string, islandSuffix: string = ".island"): ScannedFileType | null {
199
+ // Island 파일 먼저 체크 (*.island.tsx)
200
+ const islandPattern = new RegExp(`\\${islandSuffix}\\.(tsx?|jsx?)$`);
201
+ if (islandPattern.test(filename)) {
202
+ return "island";
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
+
213
+ if (FILE_PATTERNS.page.test(filename)) return "page";
214
+ if (FILE_PATTERNS.layout.test(filename)) return "layout";
215
+ if (FILE_PATTERNS.route.test(filename)) return "route";
216
+ if (FILE_PATTERNS.loading.test(filename)) return "loading";
217
+ if (FILE_PATTERNS.error.test(filename)) return "error";
218
+ if (FILE_PATTERNS.notFound.test(filename)) return "not-found";
219
+
220
+ return null;
221
+ }
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
+
239
+ /**
240
+ * 비공개 폴더인지 확인
241
+ *
242
+ * @example
243
+ * isPrivateFolder("_components") // true
244
+ * isPrivateFolder("components") // false
245
+ */
246
+ export function isPrivateFolder(folderName: string): boolean {
247
+ return SEGMENT_PATTERNS.private.test(folderName);
248
+ }
249
+
250
+ /**
251
+ * 그룹 폴더인지 확인
252
+ *
253
+ * @example
254
+ * isGroupFolder("(marketing)") // true
255
+ * isGroupFolder("marketing") // false
256
+ */
257
+ export function isGroupFolder(folderName: string): boolean {
258
+ return SEGMENT_PATTERNS.group.test(folderName);
259
+ }
260
+
261
+ // ═══════════════════════════════════════════════════════════════════════════
262
+ // Route ID Generation
263
+ // ═══════════════════════════════════════════════════════════════════════════
264
+
265
+ /**
266
+ * 파일 경로에서 라우트 ID 생성
267
+ *
268
+ * @example
269
+ * generateRouteId("blog/[slug]/page.tsx")
270
+ * // "blog-$slug"
271
+ *
272
+ * generateRouteId("api/users/route.ts")
273
+ * // "api-users"
274
+ */
275
+ export function generateRouteId(relativePath: string): string {
276
+ const segments = parseSegments(relativePath);
277
+
278
+ const parts = segments
279
+ .filter((seg) => seg.type !== "group")
280
+ .map((seg) => {
281
+ switch (seg.type) {
282
+ case "dynamic":
283
+ return `$${seg.paramName}`;
284
+ case "catchAll":
285
+ case "optionalCatchAll":
286
+ return `$${seg.paramName}`;
287
+ default:
288
+ return seg.raw;
289
+ }
290
+ });
291
+
292
+ if (parts.length === 0) {
293
+ return "index";
294
+ }
295
+
296
+ return parts.join("-").toLowerCase();
297
+ }
298
+
299
+ // ═══════════════════════════════════════════════════════════════════════════
300
+ // Priority Sorting
301
+ // ═══════════════════════════════════════════════════════════════════════════
302
+
303
+ /**
304
+ * 세그먼트 타입별 우선순위 (낮을수록 높은 우선순위)
305
+ */
306
+ const SEGMENT_PRIORITY: Record<SegmentType, number> = {
307
+ static: 0,
308
+ group: 1, // 그룹은 URL에 영향 없으므로 static과 동일
309
+ slot: 1, // slot(@name)도 URL에 영향 없음 — layout named prop으로 전달
310
+ dynamic: 2,
311
+ catchAll: 3,
312
+ optionalCatchAll: 4,
313
+ };
314
+
315
+ /**
316
+ * 라우트 우선순위 계산
317
+ *
318
+ * 정적 라우트가 동적 라우트보다 높은 우선순위
319
+ * 더 구체적인 라우트가 높은 우선순위
320
+ *
321
+ * @returns 낮을수록 높은 우선순위
322
+ */
323
+ export function calculateRoutePriority(segments: RouteSegment[]): number {
324
+ let priority = 0;
325
+
326
+ for (let i = 0; i < segments.length; i++) {
327
+ const seg = segments[i];
328
+ // 깊이에 따른 가중치 적용
329
+ priority += SEGMENT_PRIORITY[seg.type] * Math.pow(10, segments.length - i - 1);
330
+ }
331
+
332
+ return priority;
333
+ }
334
+
335
+ /**
336
+ * 라우트 배열을 우선순위에 따라 정렬
337
+ *
338
+ * 정적 → 동적 → catch-all 순서
339
+ */
340
+ export function sortRoutesByPriority<T extends { segments: RouteSegment[] }>(routes: T[]): T[] {
341
+ return [...routes].sort((a, b) => {
342
+ const priorityA = calculateRoutePriority(a.segments);
343
+ const priorityB = calculateRoutePriority(b.segments);
344
+ return priorityA - priorityB;
345
+ });
346
+ }
347
+
348
+ // ═══════════════════════════════════════════════════════════════════════════
349
+ // Validation
350
+ // ═══════════════════════════════════════════════════════════════════════════
351
+
352
+ /**
353
+ * 세그먼트 유효성 검사
354
+ */
355
+ export function validateSegments(segments: RouteSegment[]): { valid: boolean; error?: string } {
356
+ for (let i = 0; i < segments.length; i++) {
357
+ const seg = segments[i];
358
+
359
+ // Catch-all은 마지막이어야 함
360
+ if (seg.type === "catchAll" || seg.type === "optionalCatchAll") {
361
+ if (i !== segments.length - 1) {
362
+ return {
363
+ valid: false,
364
+ error: `Catch-all segment "${seg.raw}" must be the last segment`,
365
+ };
366
+ }
367
+ }
368
+ }
369
+
370
+ return { valid: true };
371
+ }
372
+
373
+ /**
374
+ * 패턴 충돌 확인
375
+ *
376
+ * 두 패턴이 동일한 URL을 매칭할 수 있는지 확인
377
+ */
378
+ export function patternsConflict(patternA: string, patternB: string): boolean {
379
+ const shapeA = normalizePatternShape(patternA);
380
+ const shapeB = normalizePatternShape(patternB);
381
+
382
+ return shapeA === shapeB;
383
+ }
384
+
385
+ /**
386
+ * 패턴 형태 반환 (파라미터 이름 무시)
387
+ */
388
+ export function getPatternShape(pattern: string): string {
389
+ return normalizePatternShape(pattern);
390
+ }
391
+
392
+ /**
393
+ * 패턴 형태 정규화 (파라미터 이름 무시)
394
+ *
395
+ * @example
396
+ * /blog/:slug -> /blog/:PARAM
397
+ * /docs/:path* -> /docs/*
398
+ * /docs/:path*? -> /docs/*
399
+ */
400
+ function normalizePatternShape(pattern: string): string {
401
+ const normalized = pattern.replace(/\/$/, "") || "/";
402
+
403
+ if (normalized === "/") return "/";
404
+
405
+ const segments = normalized.split("/").filter(Boolean);
406
+ const parts = segments.map((seg) => {
407
+ if (seg === "*") return "*";
408
+
409
+ if (seg.startsWith(":")) {
410
+ const wildcardMatch = seg.match(/^:([^*?]+)\*(\?)?$/);
411
+ if (wildcardMatch) {
412
+ // optional 여부는 충돌 판단에서 동일하게 취급
413
+ return "*";
414
+ }
415
+ return ":PARAM";
416
+ }
417
+
418
+ return seg;
419
+ });
420
+
421
+ return "/" + parts.join("/");
422
+ }