@mandujs/core 0.12.1 → 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 (177) hide show
  1. package/README.ko.md +304 -304
  2. package/README.md +653 -653
  3. package/package.json +8 -8
  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/healing.ts +2 -0
  106. package/src/guard/index.ts +2 -0
  107. package/src/guard/negotiation.ts +430 -4
  108. package/src/guard/presets/atomic.ts +70 -70
  109. package/src/guard/presets/clean.ts +77 -77
  110. package/src/guard/presets/cqrs.test.ts +175 -0
  111. package/src/guard/presets/cqrs.ts +107 -0
  112. package/src/guard/presets/fsd.ts +79 -79
  113. package/src/guard/presets/hexagonal.ts +68 -68
  114. package/src/guard/presets/index.ts +291 -288
  115. package/src/guard/reporter.ts +445 -445
  116. package/src/guard/rules.ts +12 -12
  117. package/src/guard/statistics.ts +578 -578
  118. package/src/guard/suggestions.ts +358 -352
  119. package/src/guard/types.ts +348 -347
  120. package/src/guard/validator.ts +834 -834
  121. package/src/guard/watcher.ts +404 -404
  122. package/src/index.ts +1 -0
  123. package/src/intent/index.ts +310 -310
  124. package/src/island/index.ts +304 -304
  125. package/src/logging/index.ts +22 -22
  126. package/src/logging/transports.ts +365 -365
  127. package/src/paths.test.ts +47 -0
  128. package/src/paths.ts +47 -0
  129. package/src/plugins/index.ts +38 -38
  130. package/src/plugins/registry.ts +377 -377
  131. package/src/plugins/types.ts +363 -363
  132. package/src/report/build.ts +1 -1
  133. package/src/report/index.ts +1 -1
  134. package/src/router/fs-patterns.ts +387 -387
  135. package/src/router/fs-routes.ts +344 -401
  136. package/src/router/fs-scanner.ts +497 -497
  137. package/src/router/fs-types.ts +270 -278
  138. package/src/router/index.ts +81 -81
  139. package/src/runtime/boundary.tsx +232 -232
  140. package/src/runtime/compose.ts +222 -222
  141. package/src/runtime/lifecycle.ts +381 -381
  142. package/src/runtime/logger.test.ts +345 -345
  143. package/src/runtime/logger.ts +677 -677
  144. package/src/runtime/router.test.ts +476 -476
  145. package/src/runtime/router.ts +105 -105
  146. package/src/runtime/security.ts +155 -155
  147. package/src/runtime/server.ts +24 -24
  148. package/src/runtime/session-key.ts +328 -328
  149. package/src/runtime/ssr.ts +367 -367
  150. package/src/runtime/streaming-ssr.ts +1245 -1245
  151. package/src/runtime/trace.ts +144 -144
  152. package/src/seo/index.ts +214 -214
  153. package/src/seo/integration/ssr.ts +307 -307
  154. package/src/seo/render/basic.ts +427 -427
  155. package/src/seo/render/index.ts +143 -143
  156. package/src/seo/render/jsonld.ts +539 -539
  157. package/src/seo/render/opengraph.ts +191 -191
  158. package/src/seo/render/robots.ts +116 -116
  159. package/src/seo/render/sitemap.ts +137 -137
  160. package/src/seo/render/twitter.ts +126 -126
  161. package/src/seo/resolve/index.ts +353 -353
  162. package/src/seo/resolve/opengraph.ts +143 -143
  163. package/src/seo/resolve/robots.ts +73 -73
  164. package/src/seo/resolve/title.ts +94 -94
  165. package/src/seo/resolve/twitter.ts +73 -73
  166. package/src/seo/resolve/url.ts +97 -97
  167. package/src/seo/routes/index.ts +290 -290
  168. package/src/seo/types.ts +575 -575
  169. package/src/slot/validator.ts +39 -39
  170. package/src/spec/index.ts +3 -3
  171. package/src/spec/load.ts +76 -76
  172. package/src/spec/lock.ts +56 -56
  173. package/src/utils/bun.ts +8 -8
  174. package/src/utils/lru-cache.ts +75 -75
  175. package/src/utils/safe-io.ts +188 -188
  176. package/src/utils/string-safe.ts +298 -298
  177. 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
+ }