@mandujs/core 0.12.2 → 0.13.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 (173) hide show
  1. package/README.ko.md +304 -304
  2. package/README.md +653 -653
  3. package/package.json +1 -1
  4. package/src/brain/architecture/analyzer.ts +28 -26
  5. package/src/brain/doctor/analyzer.ts +1 -1
  6. package/src/bundler/build.ts +91 -91
  7. package/src/bundler/css.ts +302 -302
  8. package/src/bundler/dev.ts +0 -1
  9. package/src/change/history.ts +3 -3
  10. package/src/change/snapshot.ts +10 -9
  11. package/src/change/transaction.ts +2 -2
  12. package/src/client/Link.tsx +227 -227
  13. package/src/client/globals.ts +44 -44
  14. package/src/client/hooks.ts +267 -267
  15. package/src/client/index.ts +5 -5
  16. package/src/client/island.ts +8 -8
  17. package/src/client/router.ts +435 -435
  18. package/src/client/runtime.ts +23 -23
  19. package/src/client/serialize.ts +404 -404
  20. package/src/client/window-state.ts +101 -101
  21. package/src/config/mandu.ts +94 -96
  22. package/src/config/validate.ts +213 -215
  23. package/src/config/watcher.ts +311 -311
  24. package/src/constants.ts +40 -40
  25. package/src/content/content-layer.ts +314 -314
  26. package/src/content/content.test.ts +433 -433
  27. package/src/content/data-store.ts +245 -245
  28. package/src/content/digest.ts +133 -133
  29. package/src/content/index.ts +164 -164
  30. package/src/content/loader-context.ts +172 -172
  31. package/src/content/loaders/api.ts +216 -216
  32. package/src/content/loaders/file.ts +169 -169
  33. package/src/content/loaders/glob.ts +252 -252
  34. package/src/content/loaders/index.ts +34 -34
  35. package/src/content/loaders/types.ts +137 -137
  36. package/src/content/meta-store.ts +209 -209
  37. package/src/content/types.ts +282 -282
  38. package/src/content/watcher.ts +135 -135
  39. package/src/contract/client-safe.test.ts +42 -42
  40. package/src/contract/client-safe.ts +114 -114
  41. package/src/contract/client.ts +16 -16
  42. package/src/contract/define.ts +459 -459
  43. package/src/contract/handler.ts +10 -10
  44. package/src/contract/normalize.test.ts +276 -276
  45. package/src/contract/normalize.ts +404 -404
  46. package/src/contract/registry.test.ts +206 -206
  47. package/src/contract/registry.ts +568 -568
  48. package/src/contract/schema.ts +48 -48
  49. package/src/contract/types.ts +58 -58
  50. package/src/contract/validator.ts +32 -32
  51. package/src/devtools/ai/context-builder.ts +375 -375
  52. package/src/devtools/ai/index.ts +25 -25
  53. package/src/devtools/ai/mcp-connector.ts +465 -465
  54. package/src/devtools/client/catchers/error-catcher.ts +327 -327
  55. package/src/devtools/client/catchers/index.ts +18 -18
  56. package/src/devtools/client/catchers/network-proxy.ts +363 -363
  57. package/src/devtools/client/components/index.ts +39 -39
  58. package/src/devtools/client/components/kitchen-root.tsx +362 -362
  59. package/src/devtools/client/components/mandu-character.tsx +241 -241
  60. package/src/devtools/client/components/overlay.tsx +368 -368
  61. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  62. package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
  63. package/src/devtools/client/components/panel/index.ts +32 -32
  64. package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
  65. package/src/devtools/client/components/panel/network-panel.tsx +292 -292
  66. package/src/devtools/client/components/panel/panel-container.tsx +259 -259
  67. package/src/devtools/client/filters/context-filters.ts +282 -282
  68. package/src/devtools/client/filters/index.ts +16 -16
  69. package/src/devtools/client/index.ts +63 -63
  70. package/src/devtools/client/persistence.ts +335 -335
  71. package/src/devtools/client/state-manager.ts +478 -478
  72. package/src/devtools/design-tokens.ts +263 -263
  73. package/src/devtools/hook/create-hook.ts +207 -207
  74. package/src/devtools/hook/index.ts +13 -13
  75. package/src/devtools/index.ts +439 -439
  76. package/src/devtools/init.ts +266 -266
  77. package/src/devtools/protocol.ts +237 -237
  78. package/src/devtools/server/index.ts +17 -17
  79. package/src/devtools/server/source-context.ts +444 -444
  80. package/src/devtools/types.ts +319 -319
  81. package/src/devtools/worker/index.ts +25 -25
  82. package/src/devtools/worker/redaction-worker.ts +222 -222
  83. package/src/devtools/worker/worker-manager.ts +409 -409
  84. package/src/error/classifier.ts +2 -2
  85. package/src/error/domains.ts +265 -265
  86. package/src/error/formatter.ts +32 -32
  87. package/src/error/result.ts +46 -46
  88. package/src/error/stack-analyzer.ts +5 -0
  89. package/src/error/types.ts +6 -6
  90. package/src/errors/extractor.ts +409 -409
  91. package/src/errors/index.ts +19 -19
  92. package/src/filling/auth.ts +308 -308
  93. package/src/filling/context.ts +569 -569
  94. package/src/filling/deps.ts +238 -238
  95. package/src/generator/contract-glue.ts +2 -1
  96. package/src/generator/generate.ts +12 -10
  97. package/src/generator/index.ts +3 -3
  98. package/src/generator/templates.ts +80 -79
  99. package/src/guard/analyzer.ts +360 -360
  100. package/src/guard/ast-analyzer.ts +806 -806
  101. package/src/guard/auto-correct.ts +1 -1
  102. package/src/guard/check.ts +128 -128
  103. package/src/guard/contract-guard.ts +9 -9
  104. package/src/guard/file-type.test.ts +24 -24
  105. package/src/guard/presets/atomic.ts +70 -70
  106. package/src/guard/presets/clean.ts +77 -77
  107. package/src/guard/presets/cqrs.test.ts +35 -14
  108. package/src/guard/presets/fsd.ts +79 -79
  109. package/src/guard/presets/hexagonal.ts +68 -68
  110. package/src/guard/presets/index.ts +291 -291
  111. package/src/guard/reporter.ts +445 -445
  112. package/src/guard/rules.ts +12 -12
  113. package/src/guard/statistics.ts +578 -578
  114. package/src/guard/suggestions.ts +358 -358
  115. package/src/guard/types.ts +348 -348
  116. package/src/guard/validator.ts +834 -834
  117. package/src/guard/watcher.ts +404 -404
  118. package/src/index.ts +1 -0
  119. package/src/intent/index.ts +310 -310
  120. package/src/island/index.ts +304 -304
  121. package/src/logging/index.ts +22 -22
  122. package/src/logging/transports.ts +365 -365
  123. package/src/paths.test.ts +47 -0
  124. package/src/paths.ts +47 -0
  125. package/src/plugins/index.ts +38 -38
  126. package/src/plugins/registry.ts +377 -377
  127. package/src/plugins/types.ts +363 -363
  128. package/src/report/build.ts +1 -1
  129. package/src/report/index.ts +1 -1
  130. package/src/router/fs-patterns.ts +387 -387
  131. package/src/router/fs-routes.ts +344 -401
  132. package/src/router/fs-scanner.ts +497 -497
  133. package/src/router/fs-types.ts +270 -278
  134. package/src/router/index.ts +81 -81
  135. package/src/runtime/boundary.tsx +232 -232
  136. package/src/runtime/compose.ts +222 -222
  137. package/src/runtime/lifecycle.ts +381 -381
  138. package/src/runtime/logger.test.ts +345 -345
  139. package/src/runtime/logger.ts +677 -677
  140. package/src/runtime/router.test.ts +476 -476
  141. package/src/runtime/router.ts +105 -105
  142. package/src/runtime/security.ts +155 -155
  143. package/src/runtime/server.ts +24 -24
  144. package/src/runtime/session-key.ts +328 -328
  145. package/src/runtime/ssr.ts +367 -367
  146. package/src/runtime/streaming-ssr.ts +1245 -1245
  147. package/src/runtime/trace.ts +144 -144
  148. package/src/seo/index.ts +214 -214
  149. package/src/seo/integration/ssr.ts +307 -307
  150. package/src/seo/render/basic.ts +427 -427
  151. package/src/seo/render/index.ts +143 -143
  152. package/src/seo/render/jsonld.ts +539 -539
  153. package/src/seo/render/opengraph.ts +191 -191
  154. package/src/seo/render/robots.ts +116 -116
  155. package/src/seo/render/sitemap.ts +137 -137
  156. package/src/seo/render/twitter.ts +126 -126
  157. package/src/seo/resolve/index.ts +353 -353
  158. package/src/seo/resolve/opengraph.ts +143 -143
  159. package/src/seo/resolve/robots.ts +73 -73
  160. package/src/seo/resolve/title.ts +94 -94
  161. package/src/seo/resolve/twitter.ts +73 -73
  162. package/src/seo/resolve/url.ts +97 -97
  163. package/src/seo/routes/index.ts +290 -290
  164. package/src/seo/types.ts +575 -575
  165. package/src/slot/validator.ts +39 -39
  166. package/src/spec/index.ts +3 -3
  167. package/src/spec/load.ts +76 -76
  168. package/src/spec/lock.ts +56 -56
  169. package/src/utils/bun.ts +8 -8
  170. package/src/utils/lru-cache.ts +75 -75
  171. package/src/utils/safe-io.ts +188 -188
  172. package/src/utils/string-safe.ts +298 -298
  173. package/src/watcher/rules.ts +5 -5
@@ -1,387 +1,387 @@
1
- /**
2
- * FS Routes Patterns
3
- *
4
- * 파일 경로 → URL 패턴 변환 유틸리티
5
- *
6
- * @module router/fs-patterns
7
- */
8
-
9
- import type { RouteSegment, SegmentType, ScannedFileType } 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
- // Static segment
66
- return {
67
- raw: segment,
68
- type: "static",
69
- };
70
- }
71
-
72
- /**
73
- * 경로를 세그먼트 배열로 파싱
74
- *
75
- * @example
76
- * parseSegments("blog/[slug]/comments")
77
- * // [
78
- * // { raw: "blog", type: "static" },
79
- * // { raw: "[slug]", type: "dynamic", paramName: "slug" },
80
- * // { raw: "comments", type: "static" }
81
- * // ]
82
- */
83
- export function parseSegments(relativePath: string): RouteSegment[] {
84
- // Windows 경로 정규화
85
- const normalized = relativePath.replace(/\\/g, "/");
86
-
87
- // 경로에서 파일명 제거하고 디렉토리만 추출
88
- // 파일명 패턴: xxx.ext 또는 xxx.ext.ext (예: page.tsx, comments.island.tsx)
89
- const lastSlash = normalized.lastIndexOf("/");
90
-
91
- // 슬래시가 없으면 파일명만 있는 것 (루트)
92
- if (lastSlash === -1) {
93
- return [];
94
- }
95
-
96
- const pathWithoutFile = normalized.slice(0, lastSlash);
97
-
98
- if (!pathWithoutFile || pathWithoutFile === ".") {
99
- return [];
100
- }
101
-
102
- const parts = pathWithoutFile.split("/").filter(Boolean);
103
- return parts.map(parseSegment);
104
- }
105
-
106
- // ═══════════════════════════════════════════════════════════════════════════
107
- // Pattern Conversion
108
- // ═══════════════════════════════════════════════════════════════════════════
109
-
110
- /**
111
- * 세그먼트 배열을 URL 패턴으로 변환
112
- *
113
- * @example
114
- * segmentsToPattern([
115
- * { raw: "blog", type: "static" },
116
- * { raw: "[slug]", type: "dynamic", paramName: "slug" }
117
- * ])
118
- * // "/blog/:slug"
119
- */
120
- export function segmentsToPattern(segments: RouteSegment[]): string {
121
- if (segments.length === 0) {
122
- return "/";
123
- }
124
-
125
- const parts = segments
126
- .filter((seg) => seg.type !== "group") // 그룹은 URL에 포함 안 됨
127
- .map((seg) => segmentToPatternPart(seg));
128
-
129
- return "/" + parts.join("/");
130
- }
131
-
132
- /**
133
- * 단일 세그먼트를 URL 패턴 부분으로 변환
134
- */
135
- function segmentToPatternPart(segment: RouteSegment): string {
136
- switch (segment.type) {
137
- case "static":
138
- return segment.raw;
139
-
140
- case "dynamic":
141
- // [param] → :param
142
- return `:${segment.paramName}`;
143
-
144
- case "catchAll":
145
- // [...param] → :param* (Mandu 라우터 문법)
146
- return `:${segment.paramName}*`;
147
-
148
- case "optionalCatchAll":
149
- // [[...param]] → :param*? (optional catch-all)
150
- return `:${segment.paramName}*?`;
151
-
152
- case "group":
153
- // 그룹은 URL에 포함 안 됨
154
- return "";
155
-
156
- default:
157
- return segment.raw;
158
- }
159
- }
160
-
161
- /**
162
- * 파일 경로를 URL 패턴으로 변환
163
- *
164
- * @example
165
- * pathToPattern("blog/[slug]/page.tsx")
166
- * // "/blog/:slug"
167
- *
168
- * pathToPattern("(marketing)/pricing/page.tsx")
169
- * // "/pricing"
170
- */
171
- export function pathToPattern(relativePath: string): string {
172
- const segments = parseSegments(relativePath);
173
- return segmentsToPattern(segments);
174
- }
175
-
176
- // ═══════════════════════════════════════════════════════════════════════════
177
- // File Type Detection
178
- // ═══════════════════════════════════════════════════════════════════════════
179
-
180
- /**
181
- * 파일명으로 파일 타입 감지
182
- *
183
- * @example
184
- * detectFileType("page.tsx") // "page"
185
- * detectFileType("route.ts") // "route"
186
- * detectFileType("comments.island.tsx") // "island"
187
- */
188
- export function detectFileType(filename: string, islandSuffix: string = ".island"): ScannedFileType | null {
189
- // Island 파일 먼저 체크 (*.island.tsx)
190
- const islandPattern = new RegExp(`\\${islandSuffix}\\.(tsx?|jsx?)$`);
191
- if (islandPattern.test(filename)) {
192
- return "island";
193
- }
194
-
195
- if (FILE_PATTERNS.page.test(filename)) return "page";
196
- if (FILE_PATTERNS.layout.test(filename)) return "layout";
197
- if (FILE_PATTERNS.route.test(filename)) return "route";
198
- if (FILE_PATTERNS.loading.test(filename)) return "loading";
199
- if (FILE_PATTERNS.error.test(filename)) return "error";
200
- if (FILE_PATTERNS.notFound.test(filename)) return "not-found";
201
-
202
- return null;
203
- }
204
-
205
- /**
206
- * 비공개 폴더인지 확인
207
- *
208
- * @example
209
- * isPrivateFolder("_components") // true
210
- * isPrivateFolder("components") // false
211
- */
212
- export function isPrivateFolder(folderName: string): boolean {
213
- return SEGMENT_PATTERNS.private.test(folderName);
214
- }
215
-
216
- /**
217
- * 그룹 폴더인지 확인
218
- *
219
- * @example
220
- * isGroupFolder("(marketing)") // true
221
- * isGroupFolder("marketing") // false
222
- */
223
- export function isGroupFolder(folderName: string): boolean {
224
- return SEGMENT_PATTERNS.group.test(folderName);
225
- }
226
-
227
- // ═══════════════════════════════════════════════════════════════════════════
228
- // Route ID Generation
229
- // ═══════════════════════════════════════════════════════════════════════════
230
-
231
- /**
232
- * 파일 경로에서 라우트 ID 생성
233
- *
234
- * @example
235
- * generateRouteId("blog/[slug]/page.tsx")
236
- * // "blog-$slug"
237
- *
238
- * generateRouteId("api/users/route.ts")
239
- * // "api-users"
240
- */
241
- export function generateRouteId(relativePath: string): string {
242
- const segments = parseSegments(relativePath);
243
-
244
- const parts = segments
245
- .filter((seg) => seg.type !== "group")
246
- .map((seg) => {
247
- switch (seg.type) {
248
- case "dynamic":
249
- return `$${seg.paramName}`;
250
- case "catchAll":
251
- case "optionalCatchAll":
252
- return `$${seg.paramName}`;
253
- default:
254
- return seg.raw;
255
- }
256
- });
257
-
258
- if (parts.length === 0) {
259
- return "index";
260
- }
261
-
262
- return parts.join("-").toLowerCase();
263
- }
264
-
265
- // ═══════════════════════════════════════════════════════════════════════════
266
- // Priority Sorting
267
- // ═══════════════════════════════════════════════════════════════════════════
268
-
269
- /**
270
- * 세그먼트 타입별 우선순위 (낮을수록 높은 우선순위)
271
- */
272
- const SEGMENT_PRIORITY: Record<SegmentType, number> = {
273
- static: 0,
274
- group: 1, // 그룹은 URL에 영향 없으므로 static과 동일
275
- dynamic: 2,
276
- catchAll: 3,
277
- optionalCatchAll: 4,
278
- };
279
-
280
- /**
281
- * 라우트 우선순위 계산
282
- *
283
- * 정적 라우트가 동적 라우트보다 높은 우선순위
284
- * 더 구체적인 라우트가 높은 우선순위
285
- *
286
- * @returns 낮을수록 높은 우선순위
287
- */
288
- export function calculateRoutePriority(segments: RouteSegment[]): number {
289
- let priority = 0;
290
-
291
- for (let i = 0; i < segments.length; i++) {
292
- const seg = segments[i];
293
- // 깊이에 따른 가중치 적용
294
- priority += SEGMENT_PRIORITY[seg.type] * Math.pow(10, segments.length - i - 1);
295
- }
296
-
297
- return priority;
298
- }
299
-
300
- /**
301
- * 라우트 배열을 우선순위에 따라 정렬
302
- *
303
- * 정적 → 동적 → catch-all 순서
304
- */
305
- export function sortRoutesByPriority<T extends { segments: RouteSegment[] }>(routes: T[]): T[] {
306
- return [...routes].sort((a, b) => {
307
- const priorityA = calculateRoutePriority(a.segments);
308
- const priorityB = calculateRoutePriority(b.segments);
309
- return priorityA - priorityB;
310
- });
311
- }
312
-
313
- // ═══════════════════════════════════════════════════════════════════════════
314
- // Validation
315
- // ═══════════════════════════════════════════════════════════════════════════
316
-
317
- /**
318
- * 세그먼트 유효성 검사
319
- */
320
- export function validateSegments(segments: RouteSegment[]): { valid: boolean; error?: string } {
321
- for (let i = 0; i < segments.length; i++) {
322
- const seg = segments[i];
323
-
324
- // Catch-all은 마지막이어야 함
325
- if (seg.type === "catchAll" || seg.type === "optionalCatchAll") {
326
- if (i !== segments.length - 1) {
327
- return {
328
- valid: false,
329
- error: `Catch-all segment "${seg.raw}" must be the last segment`,
330
- };
331
- }
332
- }
333
- }
334
-
335
- return { valid: true };
336
- }
337
-
338
- /**
339
- * 패턴 충돌 확인
340
- *
341
- * 두 패턴이 동일한 URL을 매칭할 수 있는지 확인
342
- */
343
- export function patternsConflict(patternA: string, patternB: string): boolean {
344
- const shapeA = normalizePatternShape(patternA);
345
- const shapeB = normalizePatternShape(patternB);
346
-
347
- return shapeA === shapeB;
348
- }
349
-
350
- /**
351
- * 패턴 형태 반환 (파라미터 이름 무시)
352
- */
353
- export function getPatternShape(pattern: string): string {
354
- return normalizePatternShape(pattern);
355
- }
356
-
357
- /**
358
- * 패턴 형태 정규화 (파라미터 이름 무시)
359
- *
360
- * @example
361
- * /blog/:slug -> /blog/:PARAM
362
- * /docs/:path* -> /docs/*
363
- * /docs/:path*? -> /docs/*
364
- */
365
- function normalizePatternShape(pattern: string): string {
366
- const normalized = pattern.replace(/\/$/, "") || "/";
367
-
368
- if (normalized === "/") return "/";
369
-
370
- const segments = normalized.split("/").filter(Boolean);
371
- const parts = segments.map((seg) => {
372
- if (seg === "*") return "*";
373
-
374
- if (seg.startsWith(":")) {
375
- const wildcardMatch = seg.match(/^:([^*?]+)\*(\?)?$/);
376
- if (wildcardMatch) {
377
- // optional 여부는 충돌 판단에서 동일하게 취급
378
- return "*";
379
- }
380
- return ":PARAM";
381
- }
382
-
383
- return seg;
384
- });
385
-
386
- return "/" + parts.join("/");
387
- }
1
+ /**
2
+ * FS Routes Patterns
3
+ *
4
+ * 파일 경로 → URL 패턴 변환 유틸리티
5
+ *
6
+ * @module router/fs-patterns
7
+ */
8
+
9
+ import type { RouteSegment, SegmentType, ScannedFileType } 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
+ // Static segment
66
+ return {
67
+ raw: segment,
68
+ type: "static",
69
+ };
70
+ }
71
+
72
+ /**
73
+ * 경로를 세그먼트 배열로 파싱
74
+ *
75
+ * @example
76
+ * parseSegments("blog/[slug]/comments")
77
+ * // [
78
+ * // { raw: "blog", type: "static" },
79
+ * // { raw: "[slug]", type: "dynamic", paramName: "slug" },
80
+ * // { raw: "comments", type: "static" }
81
+ * // ]
82
+ */
83
+ export function parseSegments(relativePath: string): RouteSegment[] {
84
+ // Windows 경로 정규화
85
+ const normalized = relativePath.replace(/\\/g, "/");
86
+
87
+ // 경로에서 파일명 제거하고 디렉토리만 추출
88
+ // 파일명 패턴: xxx.ext 또는 xxx.ext.ext (예: page.tsx, comments.island.tsx)
89
+ const lastSlash = normalized.lastIndexOf("/");
90
+
91
+ // 슬래시가 없으면 파일명만 있는 것 (루트)
92
+ if (lastSlash === -1) {
93
+ return [];
94
+ }
95
+
96
+ const pathWithoutFile = normalized.slice(0, lastSlash);
97
+
98
+ if (!pathWithoutFile || pathWithoutFile === ".") {
99
+ return [];
100
+ }
101
+
102
+ const parts = pathWithoutFile.split("/").filter(Boolean);
103
+ return parts.map(parseSegment);
104
+ }
105
+
106
+ // ═══════════════════════════════════════════════════════════════════════════
107
+ // Pattern Conversion
108
+ // ═══════════════════════════════════════════════════════════════════════════
109
+
110
+ /**
111
+ * 세그먼트 배열을 URL 패턴으로 변환
112
+ *
113
+ * @example
114
+ * segmentsToPattern([
115
+ * { raw: "blog", type: "static" },
116
+ * { raw: "[slug]", type: "dynamic", paramName: "slug" }
117
+ * ])
118
+ * // "/blog/:slug"
119
+ */
120
+ export function segmentsToPattern(segments: RouteSegment[]): string {
121
+ if (segments.length === 0) {
122
+ return "/";
123
+ }
124
+
125
+ const parts = segments
126
+ .filter((seg) => seg.type !== "group") // 그룹은 URL에 포함 안 됨
127
+ .map((seg) => segmentToPatternPart(seg));
128
+
129
+ return "/" + parts.join("/");
130
+ }
131
+
132
+ /**
133
+ * 단일 세그먼트를 URL 패턴 부분으로 변환
134
+ */
135
+ function segmentToPatternPart(segment: RouteSegment): string {
136
+ switch (segment.type) {
137
+ case "static":
138
+ return segment.raw;
139
+
140
+ case "dynamic":
141
+ // [param] → :param
142
+ return `:${segment.paramName}`;
143
+
144
+ case "catchAll":
145
+ // [...param] → :param* (Mandu 라우터 문법)
146
+ return `:${segment.paramName}*`;
147
+
148
+ case "optionalCatchAll":
149
+ // [[...param]] → :param*? (optional catch-all)
150
+ return `:${segment.paramName}*?`;
151
+
152
+ case "group":
153
+ // 그룹은 URL에 포함 안 됨
154
+ return "";
155
+
156
+ default:
157
+ return segment.raw;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * 파일 경로를 URL 패턴으로 변환
163
+ *
164
+ * @example
165
+ * pathToPattern("blog/[slug]/page.tsx")
166
+ * // "/blog/:slug"
167
+ *
168
+ * pathToPattern("(marketing)/pricing/page.tsx")
169
+ * // "/pricing"
170
+ */
171
+ export function pathToPattern(relativePath: string): string {
172
+ const segments = parseSegments(relativePath);
173
+ return segmentsToPattern(segments);
174
+ }
175
+
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+ // File Type Detection
178
+ // ═══════════════════════════════════════════════════════════════════════════
179
+
180
+ /**
181
+ * 파일명으로 파일 타입 감지
182
+ *
183
+ * @example
184
+ * detectFileType("page.tsx") // "page"
185
+ * detectFileType("route.ts") // "route"
186
+ * detectFileType("comments.island.tsx") // "island"
187
+ */
188
+ export function detectFileType(filename: string, islandSuffix: string = ".island"): ScannedFileType | null {
189
+ // Island 파일 먼저 체크 (*.island.tsx)
190
+ const islandPattern = new RegExp(`\\${islandSuffix}\\.(tsx?|jsx?)$`);
191
+ if (islandPattern.test(filename)) {
192
+ return "island";
193
+ }
194
+
195
+ if (FILE_PATTERNS.page.test(filename)) return "page";
196
+ if (FILE_PATTERNS.layout.test(filename)) return "layout";
197
+ if (FILE_PATTERNS.route.test(filename)) return "route";
198
+ if (FILE_PATTERNS.loading.test(filename)) return "loading";
199
+ if (FILE_PATTERNS.error.test(filename)) return "error";
200
+ if (FILE_PATTERNS.notFound.test(filename)) return "not-found";
201
+
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * 비공개 폴더인지 확인
207
+ *
208
+ * @example
209
+ * isPrivateFolder("_components") // true
210
+ * isPrivateFolder("components") // false
211
+ */
212
+ export function isPrivateFolder(folderName: string): boolean {
213
+ return SEGMENT_PATTERNS.private.test(folderName);
214
+ }
215
+
216
+ /**
217
+ * 그룹 폴더인지 확인
218
+ *
219
+ * @example
220
+ * isGroupFolder("(marketing)") // true
221
+ * isGroupFolder("marketing") // false
222
+ */
223
+ export function isGroupFolder(folderName: string): boolean {
224
+ return SEGMENT_PATTERNS.group.test(folderName);
225
+ }
226
+
227
+ // ═══════════════════════════════════════════════════════════════════════════
228
+ // Route ID Generation
229
+ // ═══════════════════════════════════════════════════════════════════════════
230
+
231
+ /**
232
+ * 파일 경로에서 라우트 ID 생성
233
+ *
234
+ * @example
235
+ * generateRouteId("blog/[slug]/page.tsx")
236
+ * // "blog-$slug"
237
+ *
238
+ * generateRouteId("api/users/route.ts")
239
+ * // "api-users"
240
+ */
241
+ export function generateRouteId(relativePath: string): string {
242
+ const segments = parseSegments(relativePath);
243
+
244
+ const parts = segments
245
+ .filter((seg) => seg.type !== "group")
246
+ .map((seg) => {
247
+ switch (seg.type) {
248
+ case "dynamic":
249
+ return `$${seg.paramName}`;
250
+ case "catchAll":
251
+ case "optionalCatchAll":
252
+ return `$${seg.paramName}`;
253
+ default:
254
+ return seg.raw;
255
+ }
256
+ });
257
+
258
+ if (parts.length === 0) {
259
+ return "index";
260
+ }
261
+
262
+ return parts.join("-").toLowerCase();
263
+ }
264
+
265
+ // ═══════════════════════════════════════════════════════════════════════════
266
+ // Priority Sorting
267
+ // ═══════════════════════════════════════════════════════════════════════════
268
+
269
+ /**
270
+ * 세그먼트 타입별 우선순위 (낮을수록 높은 우선순위)
271
+ */
272
+ const SEGMENT_PRIORITY: Record<SegmentType, number> = {
273
+ static: 0,
274
+ group: 1, // 그룹은 URL에 영향 없으므로 static과 동일
275
+ dynamic: 2,
276
+ catchAll: 3,
277
+ optionalCatchAll: 4,
278
+ };
279
+
280
+ /**
281
+ * 라우트 우선순위 계산
282
+ *
283
+ * 정적 라우트가 동적 라우트보다 높은 우선순위
284
+ * 더 구체적인 라우트가 높은 우선순위
285
+ *
286
+ * @returns 낮을수록 높은 우선순위
287
+ */
288
+ export function calculateRoutePriority(segments: RouteSegment[]): number {
289
+ let priority = 0;
290
+
291
+ for (let i = 0; i < segments.length; i++) {
292
+ const seg = segments[i];
293
+ // 깊이에 따른 가중치 적용
294
+ priority += SEGMENT_PRIORITY[seg.type] * Math.pow(10, segments.length - i - 1);
295
+ }
296
+
297
+ return priority;
298
+ }
299
+
300
+ /**
301
+ * 라우트 배열을 우선순위에 따라 정렬
302
+ *
303
+ * 정적 → 동적 → catch-all 순서
304
+ */
305
+ export function sortRoutesByPriority<T extends { segments: RouteSegment[] }>(routes: T[]): T[] {
306
+ return [...routes].sort((a, b) => {
307
+ const priorityA = calculateRoutePriority(a.segments);
308
+ const priorityB = calculateRoutePriority(b.segments);
309
+ return priorityA - priorityB;
310
+ });
311
+ }
312
+
313
+ // ═══════════════════════════════════════════════════════════════════════════
314
+ // Validation
315
+ // ═══════════════════════════════════════════════════════════════════════════
316
+
317
+ /**
318
+ * 세그먼트 유효성 검사
319
+ */
320
+ export function validateSegments(segments: RouteSegment[]): { valid: boolean; error?: string } {
321
+ for (let i = 0; i < segments.length; i++) {
322
+ const seg = segments[i];
323
+
324
+ // Catch-all은 마지막이어야 함
325
+ if (seg.type === "catchAll" || seg.type === "optionalCatchAll") {
326
+ if (i !== segments.length - 1) {
327
+ return {
328
+ valid: false,
329
+ error: `Catch-all segment "${seg.raw}" must be the last segment`,
330
+ };
331
+ }
332
+ }
333
+ }
334
+
335
+ return { valid: true };
336
+ }
337
+
338
+ /**
339
+ * 패턴 충돌 확인
340
+ *
341
+ * 두 패턴이 동일한 URL을 매칭할 수 있는지 확인
342
+ */
343
+ export function patternsConflict(patternA: string, patternB: string): boolean {
344
+ const shapeA = normalizePatternShape(patternA);
345
+ const shapeB = normalizePatternShape(patternB);
346
+
347
+ return shapeA === shapeB;
348
+ }
349
+
350
+ /**
351
+ * 패턴 형태 반환 (파라미터 이름 무시)
352
+ */
353
+ export function getPatternShape(pattern: string): string {
354
+ return normalizePatternShape(pattern);
355
+ }
356
+
357
+ /**
358
+ * 패턴 형태 정규화 (파라미터 이름 무시)
359
+ *
360
+ * @example
361
+ * /blog/:slug -> /blog/:PARAM
362
+ * /docs/:path* -> /docs/*
363
+ * /docs/:path*? -> /docs/*
364
+ */
365
+ function normalizePatternShape(pattern: string): string {
366
+ const normalized = pattern.replace(/\/$/, "") || "/";
367
+
368
+ if (normalized === "/") return "/";
369
+
370
+ const segments = normalized.split("/").filter(Boolean);
371
+ const parts = segments.map((seg) => {
372
+ if (seg === "*") return "*";
373
+
374
+ if (seg.startsWith(":")) {
375
+ const wildcardMatch = seg.match(/^:([^*?]+)\*(\?)?$/);
376
+ if (wildcardMatch) {
377
+ // optional 여부는 충돌 판단에서 동일하게 취급
378
+ return "*";
379
+ }
380
+ return ":PARAM";
381
+ }
382
+
383
+ return seg;
384
+ });
385
+
386
+ return "/" + parts.join("/");
387
+ }