@mandujs/core 0.9.31 → 0.9.38

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.
@@ -18,12 +18,12 @@ export interface DevBundlerOptions {
18
18
  onRebuild?: (result: RebuildResult) => void;
19
19
  /** 에러 콜백 */
20
20
  onError?: (error: Error, routeId?: string) => void;
21
- /**
22
- * 추가 watch 디렉토리 (공통 컴포넌트 등)
23
- * 상대 경로 또는 절대 경로 모두 지원
24
- * 기본값: ["src/components", "components", "src/shared", "shared", "src/lib", "lib", "src/hooks", "hooks", "src/utils", "utils"]
25
- */
26
- watchDirs?: string[];
21
+ /**
22
+ * 추가 watch 디렉토리 (공통 컴포넌트 등)
23
+ * 상대 경로 또는 절대 경로 모두 지원
24
+ * 기본값: ["src/components", "components", "src/shared", "shared", "src/lib", "lib", "src/hooks", "hooks", "src/utils", "utils"]
25
+ */
26
+ watchDirs?: string[];
27
27
  /**
28
28
  * 기본 watch 디렉토리 비활성화
29
29
  * true로 설정하면 watchDirs만 감시
@@ -113,26 +113,26 @@ export async function startDevBundler(options: DevBundlerOptions): Promise<DevBu
113
113
  }
114
114
 
115
115
  // 공통 컴포넌트 디렉토리 추가 (기본 + 커스텀)
116
- const commonDirsToCheck = disableDefaultWatchDirs
117
- ? customWatchDirs
118
- : [...DEFAULT_COMMON_DIRS, ...customWatchDirs];
119
-
120
- const addCommonDir = async (dir: string): Promise<void> => {
121
- const absPath = path.isAbsolute(dir) ? dir : path.join(rootDir, dir);
122
- try {
123
- const stat = await fs.promises.stat(absPath);
124
- const watchPath = stat.isDirectory() ? absPath : path.dirname(absPath);
125
- await fs.promises.access(watchPath);
126
- commonWatchDirs.add(watchPath);
127
- watchDirs.add(watchPath);
128
- } catch {
129
- // 디렉토리 없으면 무시
130
- }
131
- };
132
-
133
- for (const dir of commonDirsToCheck) {
134
- await addCommonDir(dir);
135
- }
116
+ const commonDirsToCheck = disableDefaultWatchDirs
117
+ ? customWatchDirs
118
+ : [...DEFAULT_COMMON_DIRS, ...customWatchDirs];
119
+
120
+ const addCommonDir = async (dir: string): Promise<void> => {
121
+ const absPath = path.isAbsolute(dir) ? dir : path.join(rootDir, dir);
122
+ try {
123
+ const stat = await fs.promises.stat(absPath);
124
+ const watchPath = stat.isDirectory() ? absPath : path.dirname(absPath);
125
+ await fs.promises.access(watchPath);
126
+ commonWatchDirs.add(watchPath);
127
+ watchDirs.add(watchPath);
128
+ } catch {
129
+ // 디렉토리 없으면 무시
130
+ }
131
+ };
132
+
133
+ for (const dir of commonDirsToCheck) {
134
+ await addCommonDir(dir);
135
+ }
136
136
 
137
137
  // 파일 감시 설정
138
138
  const watchers: fs.FSWatcher[] = [];
@@ -312,9 +312,10 @@ export interface HMRServer {
312
312
  }
313
313
 
314
314
  export interface HMRMessage {
315
- type: "connected" | "reload" | "island-update" | "error" | "ping";
315
+ type: "connected" | "reload" | "island-update" | "layout-update" | "error" | "ping";
316
316
  data?: {
317
317
  routeId?: string;
318
+ layoutPath?: string;
318
319
  message?: string;
319
320
  timestamp?: number;
320
321
  };
@@ -482,6 +483,13 @@ export function generateHMRClientScript(port: number): string {
482
483
  }
483
484
  break;
484
485
 
486
+ case 'layout-update':
487
+ const layoutPath = message.data?.layoutPath;
488
+ console.log('[Mandu HMR] Layout updated:', layoutPath);
489
+ // Layout 변경은 항상 전체 리로드
490
+ location.reload();
491
+ break;
492
+
485
493
  case 'error':
486
494
  console.error('[Mandu HMR] Build error:', message.data?.message);
487
495
  showErrorOverlay(message.data?.message);
@@ -0,0 +1,350 @@
1
+ /**
2
+ * Mandu Guard Analyzer
3
+ *
4
+ * 파일 분석 및 Import 추출
5
+ */
6
+
7
+ import { readFile } from "fs/promises";
8
+ import { dirname, isAbsolute, relative, resolve } from "path";
9
+ import { minimatch } from "minimatch";
10
+ import type {
11
+ ImportInfo,
12
+ FileAnalysis,
13
+ LayerDefinition,
14
+ GuardConfig,
15
+ } from "./types";
16
+ import { WATCH_EXTENSIONS } from "./types";
17
+
18
+ // ═══════════════════════════════════════════════════════════════════════════
19
+ // Import Extraction
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+
22
+ /**
23
+ * Static import 패턴
24
+ *
25
+ * Examples:
26
+ * - import { X } from 'module'
27
+ * - import X from 'module'
28
+ * - import * as X from 'module'
29
+ * - import 'module'
30
+ */
31
+ const STATIC_IMPORT_PATTERN = /^import\s+(?:(?:(?:\{[^}]*\}|\*\s+as\s+\w+|\w+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+|\w+))*)\s+from\s+)?['"]([^'"]+)['"]/gm;
32
+
33
+ /**
34
+ * Dynamic import 패턴
35
+ *
36
+ * Examples:
37
+ * - import('module')
38
+ * - await import('module')
39
+ */
40
+ const DYNAMIC_IMPORT_PATTERN = /(?:await\s+)?import\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
41
+
42
+ /**
43
+ * CommonJS require 패턴
44
+ *
45
+ * Examples:
46
+ * - require('module')
47
+ * - const X = require('module')
48
+ */
49
+ const REQUIRE_PATTERN = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/gm;
50
+
51
+ /**
52
+ * Named import 추출 패턴
53
+ */
54
+ const NAMED_IMPORT_PATTERN = /\{\s*([^}]+)\s*\}/;
55
+
56
+ /**
57
+ * Default import 추출 패턴
58
+ */
59
+ const DEFAULT_IMPORT_PATTERN = /^import\s+(\w+)/;
60
+
61
+ /**
62
+ * 파일에서 import 문 추출
63
+ */
64
+ export function extractImports(content: string): ImportInfo[] {
65
+ const imports: ImportInfo[] = [];
66
+ const lines = content.split("\n");
67
+
68
+ // Static imports
69
+ let match: RegExpExecArray | null;
70
+ const staticPattern = new RegExp(STATIC_IMPORT_PATTERN.source, "gm");
71
+
72
+ while ((match = staticPattern.exec(content)) !== null) {
73
+ const statement = match[0];
74
+ const path = match[1];
75
+ const position = getLineAndColumn(content, match.index);
76
+
77
+ // Named imports 추출
78
+ const namedMatch = statement.match(NAMED_IMPORT_PATTERN);
79
+ const namedImports = namedMatch
80
+ ? namedMatch[1].split(",").map((s) => s.trim().split(" as ")[0].trim())
81
+ : undefined;
82
+
83
+ // Default import 추출
84
+ const defaultMatch = statement.match(DEFAULT_IMPORT_PATTERN);
85
+ const defaultImport =
86
+ defaultMatch && !statement.includes("{") && !statement.includes("*")
87
+ ? defaultMatch[1]
88
+ : undefined;
89
+
90
+ imports.push({
91
+ statement,
92
+ path,
93
+ line: position.line,
94
+ column: position.column,
95
+ type: "static",
96
+ namedImports,
97
+ defaultImport,
98
+ });
99
+ }
100
+
101
+ // Dynamic imports
102
+ const dynamicPattern = new RegExp(DYNAMIC_IMPORT_PATTERN.source, "gm");
103
+
104
+ while ((match = dynamicPattern.exec(content)) !== null) {
105
+ const position = getLineAndColumn(content, match.index);
106
+
107
+ imports.push({
108
+ statement: match[0],
109
+ path: match[1],
110
+ line: position.line,
111
+ column: position.column,
112
+ type: "dynamic",
113
+ });
114
+ }
115
+
116
+ // CommonJS requires
117
+ const requirePattern = new RegExp(REQUIRE_PATTERN.source, "gm");
118
+
119
+ while ((match = requirePattern.exec(content)) !== null) {
120
+ const position = getLineAndColumn(content, match.index);
121
+
122
+ imports.push({
123
+ statement: match[0],
124
+ path: match[1],
125
+ line: position.line,
126
+ column: position.column,
127
+ type: "require",
128
+ });
129
+ }
130
+
131
+ return imports;
132
+ }
133
+
134
+ /**
135
+ * 인덱스에서 라인과 컬럼 위치 계산
136
+ */
137
+ function getLineAndColumn(content: string, index: number): { line: number; column: number } {
138
+ const lines = content.slice(0, index).split("\n");
139
+ return {
140
+ line: lines.length,
141
+ column: lines[lines.length - 1].length + 1,
142
+ };
143
+ }
144
+
145
+ // ═══════════════════════════════════════════════════════════════════════════
146
+ // Layer Resolution
147
+ // ═══════════════════════════════════════════════════════════════════════════
148
+
149
+ /**
150
+ * 파일 경로에서 레이어 결정
151
+ */
152
+ export function resolveFileLayer(
153
+ filePath: string,
154
+ layers: LayerDefinition[],
155
+ rootDir: string
156
+ ): string | null {
157
+ const relativePath = relative(rootDir, filePath).replace(/\\/g, "/");
158
+
159
+ for (const layer of layers) {
160
+ if (minimatch(relativePath, layer.pattern)) {
161
+ return layer.name;
162
+ }
163
+ }
164
+
165
+ return null;
166
+ }
167
+
168
+ /**
169
+ * Import 경로에서 타겟 레이어 결정
170
+ */
171
+ export function resolveImportLayer(
172
+ importPath: string,
173
+ layers: LayerDefinition[],
174
+ srcDir: string,
175
+ fromFile?: string,
176
+ rootDir?: string
177
+ ): string | null {
178
+ const normalizedImportPath = importPath.replace(/\\/g, "/");
179
+ const normalizedSrcDir = srcDir.replace(/\\/g, "/").replace(/\/$/, "");
180
+
181
+ const isAlias = normalizedImportPath.startsWith("@/") || normalizedImportPath.startsWith("~/");
182
+ const isRelative = normalizedImportPath.startsWith(".");
183
+ const isSrcAbsolute = normalizedSrcDir.length > 0 && normalizedSrcDir !== "." &&
184
+ (normalizedImportPath === normalizedSrcDir || normalizedImportPath.startsWith(`${normalizedSrcDir}/`));
185
+
186
+ // 상대/alias/src 경로가 아닌 경우 (node_modules 등)
187
+ if (!isAlias && !isRelative && !isSrcAbsolute) {
188
+ return null;
189
+ }
190
+
191
+ const candidates: string[] = [];
192
+
193
+ if (isAlias) {
194
+ const aliasPath = normalizedImportPath.slice(2);
195
+ const withSrc = normalizedSrcDir.length > 0 ? `${normalizedSrcDir}/${aliasPath}` : aliasPath;
196
+ candidates.push(withSrc, aliasPath);
197
+ } else if (isSrcAbsolute) {
198
+ const trimmed = normalizedSrcDir.length > 0 && normalizedImportPath.startsWith(`${normalizedSrcDir}/`)
199
+ ? normalizedImportPath.slice(normalizedSrcDir.length + 1)
200
+ : normalizedImportPath;
201
+ candidates.push(normalizedImportPath, trimmed);
202
+ } else if (isRelative) {
203
+ if (!fromFile || !rootDir) {
204
+ return null;
205
+ }
206
+
207
+ const absoluteFromFile = isAbsolute(fromFile) ? fromFile : resolve(rootDir, fromFile);
208
+ const resolvedPath = resolve(dirname(absoluteFromFile), normalizedImportPath);
209
+ const relativeToRoot = relative(rootDir, resolvedPath).replace(/\\/g, "/");
210
+
211
+ // 루트 밖이면 무시
212
+ if (relativeToRoot.startsWith("..") || relativeToRoot.startsWith("../")) {
213
+ return null;
214
+ }
215
+
216
+ candidates.push(relativeToRoot);
217
+
218
+ if (normalizedSrcDir.length > 0 && relativeToRoot.startsWith(`${normalizedSrcDir}/`)) {
219
+ candidates.push(relativeToRoot.slice(normalizedSrcDir.length + 1));
220
+ }
221
+ }
222
+
223
+ for (const candidate of candidates) {
224
+ const normalizedCandidate = candidate.replace(/\\/g, "/");
225
+
226
+ for (const layer of layers) {
227
+ if (minimatch(normalizedCandidate, layer.pattern)) {
228
+ return layer.name;
229
+ }
230
+ }
231
+ }
232
+
233
+ return null;
234
+ }
235
+
236
+ /**
237
+ * FSD 슬라이스 이름 추출
238
+ */
239
+ export function extractSlice(filePath: string, layer: string): string | undefined {
240
+ const relativePath = filePath.replace(/\\/g, "/");
241
+
242
+ // layer/slice/... 형식에서 slice 추출
243
+ const pattern = new RegExp(`${layer}/([^/]+)`);
244
+ const match = relativePath.match(pattern);
245
+
246
+ return match?.[1];
247
+ }
248
+
249
+ // ═══════════════════════════════════════════════════════════════════════════
250
+ // File Analysis
251
+ // ═══════════════════════════════════════════════════════════════════════════
252
+
253
+ /**
254
+ * 파일 분석
255
+ */
256
+ export async function analyzeFile(
257
+ filePath: string,
258
+ layers: LayerDefinition[],
259
+ rootDir: string
260
+ ): Promise<FileAnalysis> {
261
+ const content = await readFile(filePath, "utf-8");
262
+ const imports = extractImports(content);
263
+ const layer = resolveFileLayer(filePath, layers, rootDir);
264
+ const slice = layer ? extractSlice(filePath, layer) : undefined;
265
+
266
+ return {
267
+ filePath,
268
+ rootDir,
269
+ layer,
270
+ slice,
271
+ imports,
272
+ analyzedAt: Date.now(),
273
+ };
274
+ }
275
+
276
+ /**
277
+ * 파일이 분석 대상인지 확인
278
+ */
279
+ export function shouldAnalyzeFile(
280
+ filePath: string,
281
+ config: GuardConfig,
282
+ rootDir?: string
283
+ ): boolean {
284
+ const ext = filePath.slice(filePath.lastIndexOf("."));
285
+
286
+ // 확장자 체크
287
+ if (!WATCH_EXTENSIONS.includes(ext)) {
288
+ return false;
289
+ }
290
+
291
+ // 제외 패턴 체크
292
+ if (config.exclude) {
293
+ const normalizedPath = filePath.replace(/\\/g, "/");
294
+ const candidates = new Set<string>([normalizedPath]);
295
+
296
+ if (rootDir) {
297
+ const relativeToRoot = relative(rootDir, filePath).replace(/\\/g, "/");
298
+ candidates.add(relativeToRoot);
299
+
300
+ const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, "");
301
+ if (srcDir && relativeToRoot.startsWith(`${srcDir}/`)) {
302
+ candidates.add(relativeToRoot.slice(srcDir.length + 1));
303
+ }
304
+ }
305
+
306
+ for (const pattern of config.exclude) {
307
+ for (const candidate of candidates) {
308
+ if (minimatch(candidate, pattern)) {
309
+ return false;
310
+ }
311
+ }
312
+ }
313
+ }
314
+
315
+ return true;
316
+ }
317
+
318
+ /**
319
+ * Import가 무시 대상인지 확인
320
+ */
321
+ export function shouldIgnoreImport(
322
+ importPath: string,
323
+ config: GuardConfig
324
+ ): boolean {
325
+ const normalizedImportPath = importPath.replace(/\\/g, "/");
326
+ const srcDir = (config.srcDir ?? "src").replace(/\\/g, "/").replace(/\/$/, "");
327
+ const isSrcAbsolute = srcDir.length > 0 && srcDir !== "." &&
328
+ (normalizedImportPath === srcDir || normalizedImportPath.startsWith(`${srcDir}/`));
329
+
330
+ // 외부 모듈 (node_modules)
331
+ if (
332
+ !normalizedImportPath.startsWith(".") &&
333
+ !normalizedImportPath.startsWith("@/") &&
334
+ !normalizedImportPath.startsWith("~/") &&
335
+ !isSrcAbsolute
336
+ ) {
337
+ return true;
338
+ }
339
+
340
+ // 무시 패턴 체크
341
+ if (config.ignoreImports) {
342
+ for (const pattern of config.ignoreImports) {
343
+ if (minimatch(normalizedImportPath, pattern)) {
344
+ return true;
345
+ }
346
+ }
347
+ }
348
+
349
+ return false;
350
+ }