@mandujs/core 0.54.18 → 0.54.20

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.
@@ -5,6 +5,7 @@ import {
5
5
  formatClientBoundaryDiagnostics,
6
6
  validateClientBoundaryServerOnlyImports,
7
7
  } from "../bundler/client-boundary-transform";
8
+ import { analyzeRouteSource, type RouteSourceImportRecord } from "./route-source-analyzer";
8
9
 
9
10
  export interface ClientComponentImport {
10
11
  module: string;
@@ -34,13 +35,13 @@ export function normalizeRouteModulePath(value: string | undefined): string {
34
35
  return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
35
36
  }
36
37
 
37
- export function hasUseClientDirective(source: string): boolean {
38
- return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use client["']\s*;?/.test(source);
39
- }
40
-
41
- export function hasUseServerDirective(source: string): boolean {
42
- return /^(?:\uFEFF)?\s*(?:(?:\/\/[^\r\n]*(?:\r?\n|$))|(?:\/\*[\s\S]*?\*\/\s*))*["']use server["']\s*;?/.test(source);
43
- }
38
+ export function hasUseClientDirective(source: string): boolean {
39
+ return analyzeRouteSource(source).directives.useClient;
40
+ }
41
+
42
+ export function hasUseServerDirective(source: string): boolean {
43
+ return analyzeRouteSource(source).directives.useServer;
44
+ }
44
45
 
45
46
  export function clientModuleIsRouteComponent(route: RouteSpec, clientModule = route.clientModule): boolean {
46
47
  if (route.kind !== "page" || !clientModule) return false;
@@ -75,77 +76,65 @@ function toPublicClientComponentImport(entry: ComponentImportRecord): ClientComp
75
76
  }
76
77
 
77
78
  function findComponentImportRecords(source: string): ComponentImportRecord[] {
78
- const imports: ComponentImportRecord[] = [];
79
- const importFromPattern = /import\s+(?!type\b)([\s\S]*?)\s+from\s+["']([^"']+)["']/g;
80
- const sideEffectPattern = /import\s+["']([^"']+)["']/g;
81
-
82
- for (const match of source.matchAll(importFromPattern)) {
83
- const clause = (match[1] ?? "").trim();
84
- const module = match[2] ?? "";
85
- const names: string[] = [];
86
- const specifiers: ComponentImportSpecifier[] = [];
87
- let hasDefault = false;
88
- let hasNamed = false;
89
- let hasNamespace = false;
90
-
91
- const namedMatch = clause.match(/\{([^}]*)\}/);
92
- if (namedMatch) {
93
- hasNamed = true;
94
- for (const rawName of namedMatch[1].split(",")) {
95
- const name = rawName.trim();
96
- if (!name) continue;
97
- const parts = name.split(/\s+as\s+/i).map((part) => part.trim()).filter(Boolean);
98
- const importedName = parts[0] ?? "";
99
- const localName = parts[1] ?? importedName;
100
- names.push(localName);
101
- specifiers.push({ importedName, localName });
102
- }
103
- }
79
+ return findComponentImportRecordsFromAnalysis(analyzeRouteSource(source).imports);
80
+ }
104
81
 
105
- if (/\*\s+as\s+/.test(clause)) {
106
- hasNamespace = true;
107
- const namespaceMatch = clause.match(/\*\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
108
- if (namespaceMatch?.[1]) {
109
- names.push(namespaceMatch[1]);
110
- specifiers.push({ importedName: "*", localName: namespaceMatch[1] });
111
- }
112
- }
82
+ function findComponentImportRecordsFromAnalysis(imports: RouteSourceImportRecord[]): ComponentImportRecord[] {
83
+ return imports.flatMap(toComponentImportRecord);
84
+ }
113
85
 
114
- const beforeNamed = clause.split("{")[0]?.replace(/,\s*$/, "").trim() ?? "";
115
- if (beforeNamed && !beforeNamed.startsWith("*")) {
116
- hasDefault = true;
117
- const localName = beforeNamed.split(",")[0].trim();
118
- names.push(localName);
119
- specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName });
120
- }
121
-
122
- const kind =
123
- (hasDefault && (hasNamed || hasNamespace))
124
- ? "mixed"
125
- : hasNamed
126
- ? "named"
127
- : hasNamespace
128
- ? "namespace"
129
- : "default";
130
-
131
- imports.push({ module, kind, names, specifiers });
86
+ function toComponentImportRecord(entry: RouteSourceImportRecord): ComponentImportRecord[] {
87
+ if (entry.isSideEffectOnly) {
88
+ return [{ module: entry.source, kind: "side-effect", names: [], specifiers: [] }];
132
89
  }
133
-
134
- for (const match of source.matchAll(sideEffectPattern)) {
135
- const module = match[1] ?? "";
136
- if (imports.some((entry) => entry.module === module)) continue;
137
- imports.push({ module, kind: "side-effect", names: [], specifiers: [] });
90
+ if (entry.isTypeOnly) return [];
91
+
92
+ const names: string[] = [];
93
+ const specifiers: ComponentImportSpecifier[] = [];
94
+ let hasDefault = false;
95
+ let hasNamed = false;
96
+ let hasNamespace = false;
97
+
98
+ if (entry.defaultName) {
99
+ hasDefault = true;
100
+ names.push(entry.defaultName);
101
+ specifiers.push({ importedName: DEFAULT_EXPORT_NAME, localName: entry.defaultName });
138
102
  }
139
103
 
140
- return imports;
141
- }
104
+ if (entry.namespaceName) {
105
+ hasNamespace = true;
106
+ names.push(entry.namespaceName);
107
+ specifiers.push({ importedName: "*", localName: entry.namespaceName });
108
+ }
109
+
110
+ for (const named of entry.named) {
111
+ if (named.isTypeOnly) continue;
112
+ hasNamed = true;
113
+ names.push(named.local);
114
+ specifiers.push({ importedName: named.imported, localName: named.local });
115
+ }
116
+
117
+ if (names.length === 0) return [];
118
+
119
+ const kind =
120
+ (hasDefault && (hasNamed || hasNamespace))
121
+ ? "mixed"
122
+ : hasNamed
123
+ ? "named"
124
+ : hasNamespace
125
+ ? "namespace"
126
+ : "default";
127
+
128
+ return [{ module: entry.source, kind, names, specifiers }];
129
+ }
142
130
 
143
131
  export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
144
132
  return findRouteLevelClientComponentImports(source)[0] ?? null;
145
133
  }
146
134
 
147
135
  export function findRouteLevelClientComponentImports(source: string): RouteLevelClientComponentImport[] {
148
- const candidates = findComponentImportRecords(source).flatMap((entry) =>
136
+ const analysis = analyzeRouteSource(source);
137
+ const candidates = findComponentImportRecordsFromAnalysis(analysis.imports).flatMap((entry) =>
149
138
  isRouteLevelClientEntrySpecifier(entry.module)
150
139
  ? entry.specifiers
151
140
  .filter((specifier) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(specifier.localName))
@@ -156,10 +145,10 @@ export function findRouteLevelClientComponentImports(source: string): RouteLevel
156
145
  }))
157
146
  : []
158
147
  );
159
-
160
- if (candidates.length === 0) return [];
161
- return defaultExportRendersClientComponents(source, candidates);
162
- }
148
+
149
+ if (candidates.length === 0) return [];
150
+ return defaultExportRendersClientComponents(analysis.defaultExport.renderedJsxNames, candidates);
151
+ }
163
152
 
164
153
  function isRouteLevelClientEntrySpecifier(specifier: string): boolean {
165
154
  const normalized = specifier.replace(/\\/g, "/");
@@ -272,236 +261,24 @@ function clientSpecifierLooksBrowserOnly(specifier: string): boolean {
272
261
  return CLIENT_ENTRY_SPECIFIER_PATTERN.test(specifier.replace(/\\/g, "/"));
273
262
  }
274
263
 
275
- function defaultExportRendersClientComponents(
276
- source: string,
277
- candidates: RouteLevelClientComponentImport[],
278
- ): RouteLevelClientComponentImport[] {
279
- const functionBody = extractDefaultExportFunctionBody(source);
280
- if (functionBody !== null) {
281
- const returned = extractTopLevelReturnExpression(functionBody);
282
- return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
283
- }
284
-
285
- const arrowExpression = extractDefaultExportArrowExpression(source);
286
- if (arrowExpression !== null) {
287
- return jsxExpressionRendersClientComponents(arrowExpression, candidates);
288
- }
289
-
290
- const arrowBody = extractDefaultExportArrowFunctionBody(source);
291
- if (arrowBody !== null) {
292
- const returned = extractTopLevelReturnExpression(arrowBody);
293
- return returned !== null ? jsxExpressionRendersClientComponents(returned, candidates) : [];
294
- }
295
-
296
- return [];
297
- }
298
-
299
- function extractDefaultExportFunctionBody(source: string): string | null {
300
- const match = /export\s+default\s+(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{/m.exec(source);
301
- if (!match) return null;
302
-
303
- const openBrace = match.index + match[0].lastIndexOf("{");
304
- const closeBrace = findMatchingBrace(source, openBrace);
305
- if (closeBrace === -1) return null;
306
- return source.slice(openBrace + 1, closeBrace);
307
- }
308
-
309
- function extractDefaultExportArrowExpression(source: string): string | null {
310
- const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*/m.exec(source);
311
- if (!match) return null;
312
-
313
- const start = match.index + match[0].length;
314
- const rest = source.slice(start).trim();
315
- if (rest.startsWith("{")) return null;
316
-
317
- const semicolon = rest.indexOf(";");
318
- return semicolon === -1 ? rest : rest.slice(0, semicolon);
319
- }
320
-
321
- function extractDefaultExportArrowFunctionBody(source: string): string | null {
322
- const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*\{/m.exec(source);
323
- if (!match) return null;
324
-
325
- const openBrace = match.index + match[0].lastIndexOf("{");
326
- const closeBrace = findMatchingBrace(source, openBrace);
327
- if (closeBrace === -1) return null;
328
- return source.slice(openBrace + 1, closeBrace);
329
- }
330
-
331
- function extractTopLevelReturnExpression(body: string): string | null {
332
- let quote: '"' | "'" | "`" | null = null;
333
- let lineComment = false;
334
- let blockComment = false;
335
- let braceDepth = 0;
336
- let parenDepth = 0;
337
- let bracketDepth = 0;
338
-
339
- for (let i = 0; i < body.length; i++) {
340
- const char = body[i];
341
- const next = body[i + 1];
342
- const prev = body[i - 1];
343
-
344
- if (lineComment) {
345
- if (char === "\n" || char === "\r") lineComment = false;
346
- continue;
347
- }
348
-
349
- if (blockComment) {
350
- if (char === "*" && next === "/") {
351
- blockComment = false;
352
- i++;
353
- }
354
- continue;
355
- }
356
-
357
- if (quote) {
358
- if (char === quote && prev !== "\\") quote = null;
359
- continue;
360
- }
361
-
362
- if (char === "/" && next === "/") {
363
- lineComment = true;
364
- i++;
365
- continue;
366
- }
367
- if (char === "/" && next === "*") {
368
- blockComment = true;
369
- i++;
370
- continue;
371
- }
372
- if (char === '"' || char === "'" || char === "`") {
373
- quote = char;
374
- continue;
375
- }
376
-
377
- if (braceDepth === 0 && parenDepth === 0 && bracketDepth === 0 && body.startsWith("return", i)) {
378
- const before = body[i - 1] ?? "";
379
- const after = body[i + "return".length] ?? "";
380
- if (!isIdentifierChar(before) && !isIdentifierChar(after)) {
381
- const expr = body.slice(i + "return".length).trim();
382
- return trimTrailingSemicolon(expr);
383
- }
384
- }
385
-
386
- if (char === "{") braceDepth++;
387
- if (char === "}") braceDepth = Math.max(0, braceDepth - 1);
388
- if (char === "(") parenDepth++;
389
- if (char === ")") parenDepth = Math.max(0, parenDepth - 1);
390
- if (char === "[") bracketDepth++;
391
- if (char === "]") bracketDepth = Math.max(0, bracketDepth - 1);
392
- }
393
-
394
- return null;
395
- }
396
-
397
- function trimTrailingSemicolon(value: string): string {
398
- const trimmed = value.trim();
399
- return trimmed.endsWith(";") ? trimmed.slice(0, -1).trim() : trimmed;
400
- }
401
-
402
- function jsxExpressionRendersClientComponents(
403
- expression: string,
404
- candidates: RouteLevelClientComponentImport[],
405
- ): RouteLevelClientComponentImport[] {
406
- const expr = stripWrappingParentheses(expression.trim());
407
- const seen = new Set<string>();
408
- const rendered: RouteLevelClientComponentImport[] = [];
409
-
410
- for (const candidate of candidates) {
411
- const key = `${candidate.module}\0${candidate.localName}`;
412
- if (seen.has(key)) continue;
413
- if (!jsxExpressionContainsClientElement(expr, candidate.localName)) continue;
414
- seen.add(key);
415
- rendered.push(candidate);
416
- }
417
-
418
- return rendered;
419
- }
420
-
421
- function jsxExpressionContainsClientElement(expression: string, localName: string): boolean {
422
- const escaped = escapeRegExp(localName);
423
- return new RegExp(`<${escaped}(?:\\s|/|>)`).test(expression);
424
- }
425
-
426
- function stripWrappingParentheses(value: string): string {
427
- let current = value.trim();
428
- while (current.startsWith("(") && current.endsWith(")")) {
429
- const close = findMatchingParen(current, 0);
430
- if (close !== current.length - 1) break;
431
- current = current.slice(1, -1).trim();
432
- }
433
- return current;
434
- }
435
-
436
- function findMatchingBrace(source: string, openIndex: number): number {
437
- return findMatchingDelimiter(source, openIndex, "{", "}");
438
- }
439
-
440
- function findMatchingParen(source: string, openIndex: number): number {
441
- return findMatchingDelimiter(source, openIndex, "(", ")");
442
- }
443
-
444
- function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
445
- let depth = 0;
446
- let quote: '"' | "'" | "`" | null = null;
447
- let lineComment = false;
448
- let blockComment = false;
449
-
450
- for (let i = openIndex; i < source.length; i++) {
451
- const char = source[i];
452
- const next = source[i + 1];
453
- const prev = source[i - 1];
454
-
455
- if (lineComment) {
456
- if (char === "\n" || char === "\r") lineComment = false;
457
- continue;
458
- }
459
-
460
- if (blockComment) {
461
- if (char === "*" && next === "/") {
462
- blockComment = false;
463
- i++;
464
- }
465
- continue;
466
- }
467
-
468
- if (quote) {
469
- if (char === quote && prev !== "\\") quote = null;
470
- continue;
471
- }
472
-
473
- if (char === "/" && next === "/") {
474
- lineComment = true;
475
- i++;
476
- continue;
477
- }
478
- if (char === "/" && next === "*") {
479
- blockComment = true;
480
- i++;
481
- continue;
482
- }
483
- if (char === '"' || char === "'" || char === "`") {
484
- quote = char;
485
- continue;
486
- }
487
-
488
- if (char === open) depth++;
489
- if (char === close) {
490
- depth--;
491
- if (depth === 0) return i;
492
- }
493
- }
494
-
495
- return -1;
496
- }
497
-
498
- function escapeRegExp(value: string): string {
499
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
500
- }
501
-
502
- function isIdentifierChar(value: string): boolean {
503
- return /[A-Za-z0-9_$]/.test(value);
504
- }
264
+ function defaultExportRendersClientComponents(
265
+ renderedJsxNames: string[],
266
+ candidates: RouteLevelClientComponentImport[],
267
+ ): RouteLevelClientComponentImport[] {
268
+ const renderedNames = new Set(renderedJsxNames);
269
+ const seen = new Set<string>();
270
+ const rendered: RouteLevelClientComponentImport[] = [];
271
+
272
+ for (const candidate of candidates) {
273
+ const key = `${candidate.module}\0${candidate.localName}`;
274
+ if (seen.has(key)) continue;
275
+ if (!renderedNames.has(candidate.localName)) continue;
276
+ seen.add(key);
277
+ rendered.push(candidate);
278
+ }
279
+
280
+ return rendered;
281
+ }
505
282
 
506
283
  export async function validateClientModuleForBrowserBundle(
507
284
  route: RouteSpec,
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
2
2
  import { mkdir, mkdtemp, rm, writeFile } from "fs/promises";
3
3
  import path from "path";
4
4
  import { generateManifest } from "./fs-routes";
5
+ import { scanRoutes } from "./fs-scanner";
5
6
 
6
7
  const repoTempRoot = path.resolve(import.meta.dir, "../../../..", ".tmp-test-artifacts");
7
8
 
@@ -474,4 +475,58 @@ describe("generateManifest hydration config", () => {
474
475
  await rm(rootDir, { recursive: true, force: true });
475
476
  }
476
477
  });
478
+
479
+ it("ignores hydration exports inside comments and strings", async () => {
480
+ const rootDir = await mkRepoTempDir("routes-hydration-ast-");
481
+ try {
482
+ await mkdir(path.join(rootDir, "app", "comment"), { recursive: true });
483
+ await writeFile(
484
+ path.join(rootDir, "app", "comment", "page.tsx"),
485
+ `
486
+ // export const hydration = { strategy: "island", priority: "immediate", preload: true };
487
+ const example = "export const hydration = 'full'";
488
+
489
+ export default function Page() {
490
+ return <main>{example}</main>;
491
+ }
492
+ `,
493
+ "utf-8",
494
+ );
495
+
496
+ const result = await generateManifest(rootDir);
497
+ const route = result.manifest.routes.find((entry) => entry.id === "comment");
498
+
499
+ expect(route?.hydration).toBeUndefined();
500
+ } finally {
501
+ await rm(rootDir, { recursive: true, force: true });
502
+ }
503
+ });
504
+
505
+ it("reports analyzer diagnostics for unsupported hydration initializers", async () => {
506
+ const rootDir = await mkRepoTempDir("routes-hydration-diagnostic-");
507
+ try {
508
+ await mkdir(path.join(rootDir, "app", "diagnostic"), { recursive: true });
509
+ await writeFile(
510
+ path.join(rootDir, "app", "diagnostic", "page.tsx"),
511
+ `
512
+ const getHydration = () => ({ strategy: "island" });
513
+ export const hydration = getHydration();
514
+
515
+ export default function Page() {
516
+ return <main>Diagnostic</main>;
517
+ }
518
+ `,
519
+ "utf-8",
520
+ );
521
+
522
+ const result = await scanRoutes(rootDir);
523
+
524
+ expect(result.errors).toContainEqual(expect.objectContaining({
525
+ type: "route_source_diagnostic",
526
+ message: expect.stringContaining("MANDU_ROUTE_HYDRATION_UNSUPPORTED_INITIALIZER"),
527
+ }));
528
+ } finally {
529
+ await rm(rootDir, { recursive: true, force: true });
530
+ }
531
+ });
477
532
  });
@@ -36,6 +36,7 @@ import {
36
36
  resolveClientImportModulePath,
37
37
  resolveRouteLevelClientEntry,
38
38
  } from "./client-entry";
39
+ import { analyzeRouteSource } from "./route-source-analyzer";
39
40
  import {
40
41
  assertNoClientBoundaryDiagnostics,
41
42
  collectStaticImportSpecifiers,
@@ -45,44 +46,6 @@ import {
45
46
  validateClientBoundaryServerOnlyImports,
46
47
  } from "../bundler/client-boundary-transform";
47
48
 
48
- const HYDRATION_STRATEGIES = new Set(["none", "island", "full", "progressive"]);
49
- const HYDRATION_PRIORITIES = new Set(["immediate", "visible", "idle", "interaction"]);
50
-
51
- function parsePageHydrationConfig(source: string): HydrationConfig | undefined {
52
- const stringMatch = source.match(
53
- /export\s+const\s+hydration\s*(?::[^=]+)?=\s*["'](none|island|full|progressive)["']/m,
54
- );
55
- if (stringMatch?.[1]) {
56
- return {
57
- strategy: stringMatch[1] as HydrationConfig["strategy"],
58
- priority: "visible",
59
- preload: false,
60
- };
61
- }
62
-
63
- const objectMatch = source.match(
64
- /export\s+const\s+hydration\s*(?::[^=]+)?=\s*\{([\s\S]*?)\}\s*;?/m,
65
- );
66
- const body = objectMatch?.[1];
67
- if (!body) return undefined;
68
-
69
- const strategyMatch = body.match(/\bstrategy\s*:\s*["']([^"']+)["']/);
70
- const strategy = strategyMatch?.[1];
71
- if (!strategy || !HYDRATION_STRATEGIES.has(strategy)) return undefined;
72
-
73
- const priorityMatch = body.match(/\bpriority\s*:\s*["']([^"']+)["']/);
74
- const priority = priorityMatch?.[1];
75
- const preloadMatch = body.match(/\bpreload\s*:\s*(true|false)\b/);
76
-
77
- return {
78
- strategy: strategy as HydrationConfig["strategy"],
79
- priority: HYDRATION_PRIORITIES.has(priority ?? "")
80
- ? (priority as HydrationConfig["priority"])
81
- : "visible",
82
- preload: preloadMatch?.[1] === "true",
83
- };
84
- }
85
-
86
49
  // ═══════════════════════════════════════════════════════════════════════════
87
50
  // Scanner Class
88
51
  // ═══════════════════════════════════════════════════════════════════════════
@@ -427,8 +390,16 @@ export class FSScanner {
427
390
  pageFileContent = null;
428
391
  }
429
392
  if (pageFileContent) {
430
- hydration = parsePageHydrationConfig(pageFileContent);
431
- pageHasUseClient = hasUseClientDirective(pageFileContent);
393
+ const analysis = analyzeRouteSource(pageFileContent, modulePath);
394
+ hydration = analysis.hydrationConfig;
395
+ pageHasUseClient = analysis.directives.useClient;
396
+ for (const diagnostic of analysis.diagnostics) {
397
+ routeErrors.push({
398
+ type: "route_source_diagnostic",
399
+ message: `${diagnostic.code}: ${diagnostic.message}`,
400
+ filePath: file.absolutePath,
401
+ });
402
+ }
432
403
  if (!pageHasUseClient) {
433
404
  boundaries = await this.resolveClientBoundaries(
434
405
  rootDir,
@@ -226,7 +226,13 @@ export interface ScanResult {
226
226
  */
227
227
  export interface ScanError {
228
228
  /** 에러 타입 */
229
- type: "invalid_segment" | "duplicate_route" | "file_read_error" | "pattern_conflict" | "hydration_shell_mismatch_risk";
229
+ type:
230
+ | "invalid_segment"
231
+ | "duplicate_route"
232
+ | "file_read_error"
233
+ | "pattern_conflict"
234
+ | "hydration_shell_mismatch_risk"
235
+ | "route_source_diagnostic";
230
236
 
231
237
  /** 에러 메시지 */
232
238
  message: string;