@mandujs/core 0.13.0 → 0.13.1

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 (155) hide show
  1. package/README.ko.md +4 -4
  2. package/README.md +653 -653
  3. package/package.json +1 -1
  4. package/src/bundler/build.ts +91 -91
  5. package/src/bundler/css.ts +302 -302
  6. package/src/client/Link.tsx +227 -227
  7. package/src/client/globals.ts +44 -44
  8. package/src/client/hooks.ts +267 -267
  9. package/src/client/index.ts +5 -5
  10. package/src/client/island.ts +8 -8
  11. package/src/client/router.ts +435 -435
  12. package/src/client/runtime.ts +23 -23
  13. package/src/client/serialize.ts +404 -404
  14. package/src/client/window-state.ts +101 -101
  15. package/src/config/mandu.ts +9 -0
  16. package/src/config/validate.ts +12 -0
  17. package/src/config/watcher.ts +311 -311
  18. package/src/constants.ts +40 -40
  19. package/src/content/content-layer.ts +314 -314
  20. package/src/content/content.test.ts +433 -433
  21. package/src/content/data-store.ts +245 -245
  22. package/src/content/digest.ts +133 -133
  23. package/src/content/index.ts +164 -164
  24. package/src/content/loader-context.ts +172 -172
  25. package/src/content/loaders/api.ts +216 -216
  26. package/src/content/loaders/file.ts +169 -169
  27. package/src/content/loaders/glob.ts +252 -252
  28. package/src/content/loaders/index.ts +34 -34
  29. package/src/content/loaders/types.ts +137 -137
  30. package/src/content/meta-store.ts +209 -209
  31. package/src/content/types.ts +282 -282
  32. package/src/content/watcher.ts +135 -135
  33. package/src/contract/client-safe.test.ts +42 -42
  34. package/src/contract/client-safe.ts +114 -114
  35. package/src/contract/client.ts +16 -16
  36. package/src/contract/define.ts +459 -459
  37. package/src/contract/handler.ts +10 -10
  38. package/src/contract/normalize.test.ts +276 -276
  39. package/src/contract/normalize.ts +404 -404
  40. package/src/contract/registry.test.ts +206 -206
  41. package/src/contract/registry.ts +568 -568
  42. package/src/contract/schema.ts +48 -48
  43. package/src/contract/types.ts +58 -58
  44. package/src/contract/validator.ts +32 -32
  45. package/src/devtools/ai/context-builder.ts +375 -375
  46. package/src/devtools/ai/index.ts +25 -25
  47. package/src/devtools/ai/mcp-connector.ts +465 -465
  48. package/src/devtools/client/catchers/error-catcher.ts +327 -327
  49. package/src/devtools/client/catchers/index.ts +18 -18
  50. package/src/devtools/client/catchers/network-proxy.ts +363 -363
  51. package/src/devtools/client/components/index.ts +39 -39
  52. package/src/devtools/client/components/kitchen-root.tsx +362 -362
  53. package/src/devtools/client/components/mandu-character.tsx +241 -241
  54. package/src/devtools/client/components/overlay.tsx +368 -368
  55. package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
  56. package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
  57. package/src/devtools/client/components/panel/index.ts +32 -32
  58. package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
  59. package/src/devtools/client/components/panel/network-panel.tsx +292 -292
  60. package/src/devtools/client/components/panel/panel-container.tsx +259 -259
  61. package/src/devtools/client/filters/context-filters.ts +282 -282
  62. package/src/devtools/client/filters/index.ts +16 -16
  63. package/src/devtools/client/index.ts +63 -63
  64. package/src/devtools/client/persistence.ts +335 -335
  65. package/src/devtools/client/state-manager.ts +478 -478
  66. package/src/devtools/design-tokens.ts +263 -263
  67. package/src/devtools/hook/create-hook.ts +207 -207
  68. package/src/devtools/hook/index.ts +13 -13
  69. package/src/devtools/index.ts +439 -439
  70. package/src/devtools/init.ts +266 -266
  71. package/src/devtools/protocol.ts +237 -237
  72. package/src/devtools/server/index.ts +17 -17
  73. package/src/devtools/server/source-context.ts +444 -444
  74. package/src/devtools/types.ts +319 -319
  75. package/src/devtools/worker/index.ts +25 -25
  76. package/src/devtools/worker/redaction-worker.ts +222 -222
  77. package/src/devtools/worker/worker-manager.ts +409 -409
  78. package/src/error/domains.ts +265 -265
  79. package/src/error/result.ts +46 -46
  80. package/src/error/types.ts +6 -6
  81. package/src/errors/extractor.ts +409 -409
  82. package/src/errors/index.ts +19 -19
  83. package/src/filling/auth.ts +308 -308
  84. package/src/filling/context.ts +24 -1
  85. package/src/filling/deps.ts +238 -238
  86. package/src/filling/index.ts +2 -0
  87. package/src/filling/sse.test.ts +168 -0
  88. package/src/filling/sse.ts +162 -0
  89. package/src/generator/index.ts +3 -3
  90. package/src/guard/analyzer.ts +360 -360
  91. package/src/guard/ast-analyzer.ts +806 -806
  92. package/src/guard/contract-guard.ts +9 -9
  93. package/src/guard/file-type.test.ts +24 -24
  94. package/src/guard/presets/atomic.ts +70 -70
  95. package/src/guard/presets/clean.ts +77 -77
  96. package/src/guard/presets/fsd.ts +79 -79
  97. package/src/guard/presets/hexagonal.ts +68 -68
  98. package/src/guard/presets/index.ts +291 -291
  99. package/src/guard/reporter.ts +445 -445
  100. package/src/guard/rules.ts +12 -12
  101. package/src/guard/statistics.ts +578 -578
  102. package/src/guard/suggestions.ts +358 -358
  103. package/src/guard/types.ts +348 -348
  104. package/src/guard/validator.ts +834 -834
  105. package/src/guard/watcher.ts +404 -404
  106. package/src/index.ts +6 -1
  107. package/src/intent/index.ts +310 -310
  108. package/src/island/index.ts +304 -304
  109. package/src/logging/index.ts +22 -22
  110. package/src/logging/transports.ts +365 -365
  111. package/src/plugins/index.ts +38 -38
  112. package/src/plugins/registry.ts +377 -377
  113. package/src/plugins/types.ts +363 -363
  114. package/src/report/index.ts +1 -1
  115. package/src/router/fs-patterns.ts +387 -387
  116. package/src/router/fs-scanner.ts +497 -497
  117. package/src/runtime/boundary.tsx +232 -232
  118. package/src/runtime/compose.ts +222 -222
  119. package/src/runtime/escape.ts +44 -0
  120. package/src/runtime/lifecycle.ts +381 -381
  121. package/src/runtime/logger.test.ts +345 -345
  122. package/src/runtime/logger.ts +677 -677
  123. package/src/runtime/router.test.ts +476 -476
  124. package/src/runtime/router.ts +105 -105
  125. package/src/runtime/security.ts +155 -155
  126. package/src/runtime/server.ts +257 -0
  127. package/src/runtime/session-key.ts +328 -328
  128. package/src/runtime/ssr.ts +16 -21
  129. package/src/runtime/streaming-ssr.ts +24 -33
  130. package/src/runtime/trace.ts +144 -144
  131. package/src/seo/index.ts +214 -214
  132. package/src/seo/integration/ssr.ts +307 -307
  133. package/src/seo/render/basic.ts +427 -427
  134. package/src/seo/render/index.ts +143 -143
  135. package/src/seo/render/jsonld.ts +539 -539
  136. package/src/seo/render/opengraph.ts +191 -191
  137. package/src/seo/render/robots.ts +116 -116
  138. package/src/seo/render/sitemap.ts +137 -137
  139. package/src/seo/render/twitter.ts +126 -126
  140. package/src/seo/resolve/index.ts +353 -353
  141. package/src/seo/resolve/opengraph.ts +143 -143
  142. package/src/seo/resolve/robots.ts +73 -73
  143. package/src/seo/resolve/title.ts +94 -94
  144. package/src/seo/resolve/twitter.ts +73 -73
  145. package/src/seo/resolve/url.ts +97 -97
  146. package/src/seo/routes/index.ts +290 -290
  147. package/src/seo/types.ts +575 -575
  148. package/src/slot/validator.ts +39 -39
  149. package/src/spec/index.ts +3 -3
  150. package/src/spec/load.ts +76 -76
  151. package/src/spec/lock.ts +56 -56
  152. package/src/utils/bun.ts +8 -8
  153. package/src/utils/lru-cache.ts +75 -75
  154. package/src/utils/safe-io.ts +188 -188
  155. package/src/utils/string-safe.ts +298 -298
@@ -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
+ }