@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.
- package/README.ko.md +304 -304
- package/README.md +653 -653
- package/package.json +1 -1
- package/src/brain/architecture/analyzer.ts +28 -26
- package/src/brain/doctor/analyzer.ts +1 -1
- package/src/bundler/build.ts +91 -91
- package/src/bundler/css.ts +302 -302
- package/src/bundler/dev.ts +0 -1
- package/src/change/history.ts +3 -3
- package/src/change/snapshot.ts +10 -9
- package/src/change/transaction.ts +2 -2
- package/src/client/Link.tsx +227 -227
- package/src/client/globals.ts +44 -44
- package/src/client/hooks.ts +267 -267
- package/src/client/index.ts +5 -5
- package/src/client/island.ts +8 -8
- package/src/client/router.ts +435 -435
- package/src/client/runtime.ts +23 -23
- package/src/client/serialize.ts +404 -404
- package/src/client/window-state.ts +101 -101
- package/src/config/mandu.ts +94 -96
- package/src/config/validate.ts +213 -215
- package/src/config/watcher.ts +311 -311
- package/src/constants.ts +40 -40
- package/src/content/content-layer.ts +314 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/data-store.ts +245 -245
- package/src/content/digest.ts +133 -133
- package/src/content/index.ts +164 -164
- package/src/content/loader-context.ts +172 -172
- package/src/content/loaders/api.ts +216 -216
- package/src/content/loaders/file.ts +169 -169
- package/src/content/loaders/glob.ts +252 -252
- package/src/content/loaders/index.ts +34 -34
- package/src/content/loaders/types.ts +137 -137
- package/src/content/meta-store.ts +209 -209
- package/src/content/types.ts +282 -282
- package/src/content/watcher.ts +135 -135
- package/src/contract/client-safe.test.ts +42 -42
- package/src/contract/client-safe.ts +114 -114
- package/src/contract/client.ts +16 -16
- package/src/contract/define.ts +459 -459
- package/src/contract/handler.ts +10 -10
- package/src/contract/normalize.test.ts +276 -276
- package/src/contract/normalize.ts +404 -404
- package/src/contract/registry.test.ts +206 -206
- package/src/contract/registry.ts +568 -568
- package/src/contract/schema.ts +48 -48
- package/src/contract/types.ts +58 -58
- package/src/contract/validator.ts +32 -32
- package/src/devtools/ai/context-builder.ts +375 -375
- package/src/devtools/ai/index.ts +25 -25
- package/src/devtools/ai/mcp-connector.ts +465 -465
- package/src/devtools/client/catchers/error-catcher.ts +327 -327
- package/src/devtools/client/catchers/index.ts +18 -18
- package/src/devtools/client/catchers/network-proxy.ts +363 -363
- package/src/devtools/client/components/index.ts +39 -39
- package/src/devtools/client/components/kitchen-root.tsx +362 -362
- package/src/devtools/client/components/mandu-character.tsx +241 -241
- package/src/devtools/client/components/overlay.tsx +368 -368
- package/src/devtools/client/components/panel/errors-panel.tsx +259 -259
- package/src/devtools/client/components/panel/guard-panel.tsx +244 -244
- package/src/devtools/client/components/panel/index.ts +32 -32
- package/src/devtools/client/components/panel/islands-panel.tsx +304 -304
- package/src/devtools/client/components/panel/network-panel.tsx +292 -292
- package/src/devtools/client/components/panel/panel-container.tsx +259 -259
- package/src/devtools/client/filters/context-filters.ts +282 -282
- package/src/devtools/client/filters/index.ts +16 -16
- package/src/devtools/client/index.ts +63 -63
- package/src/devtools/client/persistence.ts +335 -335
- package/src/devtools/client/state-manager.ts +478 -478
- package/src/devtools/design-tokens.ts +263 -263
- package/src/devtools/hook/create-hook.ts +207 -207
- package/src/devtools/hook/index.ts +13 -13
- package/src/devtools/index.ts +439 -439
- package/src/devtools/init.ts +266 -266
- package/src/devtools/protocol.ts +237 -237
- package/src/devtools/server/index.ts +17 -17
- package/src/devtools/server/source-context.ts +444 -444
- package/src/devtools/types.ts +319 -319
- package/src/devtools/worker/index.ts +25 -25
- package/src/devtools/worker/redaction-worker.ts +222 -222
- package/src/devtools/worker/worker-manager.ts +409 -409
- package/src/error/classifier.ts +2 -2
- package/src/error/domains.ts +265 -265
- package/src/error/formatter.ts +32 -32
- package/src/error/result.ts +46 -46
- package/src/error/stack-analyzer.ts +5 -0
- package/src/error/types.ts +6 -6
- package/src/errors/extractor.ts +409 -409
- package/src/errors/index.ts +19 -19
- package/src/filling/auth.ts +308 -308
- package/src/filling/context.ts +569 -569
- package/src/filling/deps.ts +238 -238
- package/src/generator/contract-glue.ts +2 -1
- package/src/generator/generate.ts +12 -10
- package/src/generator/index.ts +3 -3
- package/src/generator/templates.ts +80 -79
- package/src/guard/analyzer.ts +360 -360
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/auto-correct.ts +1 -1
- package/src/guard/check.ts +128 -128
- package/src/guard/contract-guard.ts +9 -9
- package/src/guard/file-type.test.ts +24 -24
- package/src/guard/presets/atomic.ts +70 -70
- package/src/guard/presets/clean.ts +77 -77
- package/src/guard/presets/cqrs.test.ts +35 -14
- package/src/guard/presets/fsd.ts +79 -79
- package/src/guard/presets/hexagonal.ts +68 -68
- package/src/guard/presets/index.ts +291 -291
- package/src/guard/reporter.ts +445 -445
- package/src/guard/rules.ts +12 -12
- package/src/guard/statistics.ts +578 -578
- package/src/guard/suggestions.ts +358 -358
- package/src/guard/types.ts +348 -348
- package/src/guard/validator.ts +834 -834
- package/src/guard/watcher.ts +404 -404
- package/src/index.ts +1 -0
- package/src/intent/index.ts +310 -310
- package/src/island/index.ts +304 -304
- package/src/logging/index.ts +22 -22
- package/src/logging/transports.ts +365 -365
- package/src/paths.test.ts +47 -0
- package/src/paths.ts +47 -0
- package/src/plugins/index.ts +38 -38
- package/src/plugins/registry.ts +377 -377
- package/src/plugins/types.ts +363 -363
- package/src/report/build.ts +1 -1
- package/src/report/index.ts +1 -1
- package/src/router/fs-patterns.ts +387 -387
- package/src/router/fs-routes.ts +344 -401
- package/src/router/fs-scanner.ts +497 -497
- package/src/router/fs-types.ts +270 -278
- package/src/router/index.ts +81 -81
- package/src/runtime/boundary.tsx +232 -232
- package/src/runtime/compose.ts +222 -222
- package/src/runtime/lifecycle.ts +381 -381
- package/src/runtime/logger.test.ts +345 -345
- package/src/runtime/logger.ts +677 -677
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/router.ts +105 -105
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +24 -24
- package/src/runtime/session-key.ts +328 -328
- package/src/runtime/ssr.ts +367 -367
- package/src/runtime/streaming-ssr.ts +1245 -1245
- package/src/runtime/trace.ts +144 -144
- package/src/seo/index.ts +214 -214
- package/src/seo/integration/ssr.ts +307 -307
- package/src/seo/render/basic.ts +427 -427
- package/src/seo/render/index.ts +143 -143
- package/src/seo/render/jsonld.ts +539 -539
- package/src/seo/render/opengraph.ts +191 -191
- package/src/seo/render/robots.ts +116 -116
- package/src/seo/render/sitemap.ts +137 -137
- package/src/seo/render/twitter.ts +126 -126
- package/src/seo/resolve/index.ts +353 -353
- package/src/seo/resolve/opengraph.ts +143 -143
- package/src/seo/resolve/robots.ts +73 -73
- package/src/seo/resolve/title.ts +94 -94
- package/src/seo/resolve/twitter.ts +73 -73
- package/src/seo/resolve/url.ts +97 -97
- package/src/seo/routes/index.ts +290 -290
- package/src/seo/types.ts +575 -575
- package/src/slot/validator.ts +39 -39
- package/src/spec/index.ts +3 -3
- package/src/spec/load.ts +76 -76
- package/src/spec/lock.ts +56 -56
- package/src/utils/bun.ts +8 -8
- package/src/utils/lru-cache.ts +75 -75
- package/src/utils/safe-io.ts +188 -188
- package/src/utils/string-safe.ts +298 -298
- 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
|
+
}
|