@mandujs/core 0.54.6 → 0.54.7

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.
@@ -1,5 +1,8 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { findClientComponentImports } from "./client-entry";
2
+ import {
3
+ findClientComponentImports,
4
+ findRouteLevelClientComponentImport,
5
+ } from "./client-entry";
3
6
 
4
7
  describe("findClientComponentImports", () => {
5
8
  it("detects named .client imports for diagnostics", () => {
@@ -21,4 +24,48 @@ describe("findClientComponentImports", () => {
21
24
  },
22
25
  ]);
23
26
  });
27
+
28
+ it("detects a default-imported client component when the page returns only that component", () => {
29
+ const routeClient = findRouteLevelClientComponentImport(`
30
+ import LoginPage from "@/client/pages/login/LoginPage.client";
31
+
32
+ export const metadata = { title: "Login" };
33
+
34
+ export default function Page() {
35
+ return <LoginPage />;
36
+ }
37
+ `);
38
+
39
+ expect(routeClient).toEqual({
40
+ module: "@/client/pages/login/LoginPage.client",
41
+ localName: "LoginPage",
42
+ });
43
+ });
44
+
45
+ it("does not promote embedded client imports inside a larger server page", () => {
46
+ const routeClient = findRouteLevelClientComponentImport(`
47
+ import HomeApp from "@/client/pages/home/HomeApp.client";
48
+
49
+ export default function HomePage() {
50
+ return <>
51
+ <meta name="description" content="home" />
52
+ <HomeApp />
53
+ </>;
54
+ }
55
+ `);
56
+
57
+ expect(routeClient).toBeNull();
58
+ });
59
+
60
+ it("does not promote client imports when the page wrapper passes props", () => {
61
+ const routeClient = findRouteLevelClientComponentImport(`
62
+ import PledgePage from "@/client/pages/pledges/PledgePage.client";
63
+
64
+ export default function Page({ params }) {
65
+ return <PledgePage id={params.id} />;
66
+ }
67
+ `);
68
+
69
+ expect(routeClient).toBeNull();
70
+ });
24
71
  });
@@ -8,6 +8,11 @@ export interface ClientComponentImport {
8
8
  names: string[];
9
9
  }
10
10
 
11
+ export interface RouteLevelClientComponentImport {
12
+ module: string;
13
+ localName: string;
14
+ }
15
+
11
16
  export function normalizeRouteModulePath(value: string | undefined): string {
12
17
  return (value ?? "").replace(/\\/g, "/").replace(/^\.\//, "");
13
18
  }
@@ -94,6 +99,39 @@ export function findClientComponentImports(source: string): ClientComponentImpor
94
99
  return imports;
95
100
  }
96
101
 
102
+ export function findRouteLevelClientComponentImport(source: string): RouteLevelClientComponentImport | null {
103
+ const defaultImports = findClientComponentImports(source).filter((entry) => {
104
+ const localName = entry.names[0] ?? "";
105
+ return entry.kind === "default" && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(localName);
106
+ });
107
+
108
+ if (defaultImports.length !== 1) return null;
109
+
110
+ const entry = defaultImports[0];
111
+ const localName = entry.names[0];
112
+ if (!entry.module || !localName) return null;
113
+ if (!defaultExportReturnsOnlyClientComponent(source, localName)) return null;
114
+
115
+ return { module: entry.module, localName };
116
+ }
117
+
118
+ export async function resolveClientImportModulePath(
119
+ rootDir: string,
120
+ importerModule: string,
121
+ specifier: string,
122
+ ): Promise<string | null> {
123
+ const base = resolveImportBasePath(rootDir, importerModule, specifier);
124
+ if (!base) return null;
125
+
126
+ for (const candidate of expandClientModuleCandidates(base)) {
127
+ if (await Bun.file(candidate).exists()) {
128
+ return path.relative(rootDir, candidate).replace(/\\/g, "/");
129
+ }
130
+ }
131
+
132
+ return null;
133
+ }
134
+
97
135
  export async function shouldPreserveExistingClientModule(
98
136
  route: RouteSpec,
99
137
  clientModule: string,
@@ -108,6 +146,147 @@ export async function shouldPreserveExistingClientModule(
108
146
  return true;
109
147
  }
110
148
 
149
+ function resolveImportBasePath(rootDir: string, importerModule: string, specifier: string): string | null {
150
+ const normalized = specifier.replace(/\\/g, "/");
151
+ if (normalized.startsWith("@/") || normalized.startsWith("~/")) {
152
+ return path.resolve(rootDir, "src", normalized.slice(2));
153
+ }
154
+ if (normalized.startsWith("./") || normalized.startsWith("../")) {
155
+ return path.resolve(rootDir, path.dirname(importerModule), normalized);
156
+ }
157
+ return null;
158
+ }
159
+
160
+ function expandClientModuleCandidates(basePath: string): string[] {
161
+ if (/\.[cm]?[jt]sx?$/.test(basePath)) return [basePath];
162
+ return [
163
+ `${basePath}.tsx`,
164
+ `${basePath}.ts`,
165
+ `${basePath}.jsx`,
166
+ `${basePath}.js`,
167
+ ];
168
+ }
169
+
170
+ function defaultExportReturnsOnlyClientComponent(source: string, localName: string): boolean {
171
+ const functionBody = extractDefaultExportFunctionBody(source);
172
+ if (functionBody !== null) {
173
+ const returned = extractOnlyReturnExpression(functionBody);
174
+ return returned !== null && isSelfClosingJsxElement(returned, localName);
175
+ }
176
+
177
+ const arrowExpression = extractDefaultExportArrowExpression(source);
178
+ return arrowExpression !== null && isSelfClosingJsxElement(arrowExpression, localName);
179
+ }
180
+
181
+ function extractDefaultExportFunctionBody(source: string): string | null {
182
+ const match = /export\s+default\s+(?:async\s+)?function(?:\s+[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\)\s*(?::\s*[^{=]+)?\{/m.exec(source);
183
+ if (!match) return null;
184
+
185
+ const openBrace = match.index + match[0].lastIndexOf("{");
186
+ const closeBrace = findMatchingBrace(source, openBrace);
187
+ if (closeBrace === -1) return null;
188
+ return source.slice(openBrace + 1, closeBrace);
189
+ }
190
+
191
+ function extractDefaultExportArrowExpression(source: string): string | null {
192
+ const match = /export\s+default\s+(?:async\s+)?(?:\([^)]*\)|[A-Za-z_$][A-Za-z0-9_$]*)\s*=>\s*/m.exec(source);
193
+ if (!match) return null;
194
+
195
+ const start = match.index + match[0].length;
196
+ const rest = source.slice(start).trim();
197
+ if (rest.startsWith("{")) return null;
198
+
199
+ const semicolon = rest.indexOf(";");
200
+ return semicolon === -1 ? rest : rest.slice(0, semicolon);
201
+ }
202
+
203
+ function extractOnlyReturnExpression(body: string): string | null {
204
+ const match = /^\s*return\s+([\s\S]*?)\s*;?\s*$/.exec(body);
205
+ return match?.[1]?.trim() ?? null;
206
+ }
207
+
208
+ function isSelfClosingJsxElement(expression: string, localName: string): boolean {
209
+ const expr = stripWrappingParentheses(expression.trim());
210
+ const escaped = escapeRegExp(localName);
211
+ return new RegExp(`^<${escaped}\\s*/>$`).test(expr);
212
+ }
213
+
214
+ function stripWrappingParentheses(value: string): string {
215
+ let current = value.trim();
216
+ while (current.startsWith("(") && current.endsWith(")")) {
217
+ const close = findMatchingParen(current, 0);
218
+ if (close !== current.length - 1) break;
219
+ current = current.slice(1, -1).trim();
220
+ }
221
+ return current;
222
+ }
223
+
224
+ function findMatchingBrace(source: string, openIndex: number): number {
225
+ return findMatchingDelimiter(source, openIndex, "{", "}");
226
+ }
227
+
228
+ function findMatchingParen(source: string, openIndex: number): number {
229
+ return findMatchingDelimiter(source, openIndex, "(", ")");
230
+ }
231
+
232
+ function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
233
+ let depth = 0;
234
+ let quote: '"' | "'" | "`" | null = null;
235
+ let lineComment = false;
236
+ let blockComment = false;
237
+
238
+ for (let i = openIndex; i < source.length; i++) {
239
+ const char = source[i];
240
+ const next = source[i + 1];
241
+ const prev = source[i - 1];
242
+
243
+ if (lineComment) {
244
+ if (char === "\n" || char === "\r") lineComment = false;
245
+ continue;
246
+ }
247
+
248
+ if (blockComment) {
249
+ if (char === "*" && next === "/") {
250
+ blockComment = false;
251
+ i++;
252
+ }
253
+ continue;
254
+ }
255
+
256
+ if (quote) {
257
+ if (char === quote && prev !== "\\") quote = null;
258
+ continue;
259
+ }
260
+
261
+ if (char === "/" && next === "/") {
262
+ lineComment = true;
263
+ i++;
264
+ continue;
265
+ }
266
+ if (char === "/" && next === "*") {
267
+ blockComment = true;
268
+ i++;
269
+ continue;
270
+ }
271
+ if (char === '"' || char === "'" || char === "`") {
272
+ quote = char;
273
+ continue;
274
+ }
275
+
276
+ if (char === open) depth++;
277
+ if (char === close) {
278
+ depth--;
279
+ if (depth === 0) return i;
280
+ }
281
+ }
282
+
283
+ return -1;
284
+ }
285
+
286
+ function escapeRegExp(value: string): string {
287
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
288
+ }
289
+
111
290
  export async function validateClientModuleForBrowserBundle(
112
291
  route: RouteSpec,
113
292
  rootDir: string,
@@ -30,7 +30,11 @@ import {
30
30
  } from "./fs-patterns";
31
31
  import { mark, measure } from "../perf";
32
32
  import { METADATA_ROUTES } from "../routes/types";
33
- import { hasUseClientDirective } from "./client-entry";
33
+ import {
34
+ findRouteLevelClientComponentImport,
35
+ hasUseClientDirective,
36
+ resolveClientImportModulePath,
37
+ } from "./client-entry";
34
38
 
35
39
  // ═══════════════════════════════════════════════════════════════════════════
36
40
  // Scanner Class
@@ -229,7 +233,7 @@ export class FSScanner {
229
233
  */
230
234
  private async createRouteConfigs(
231
235
  files: ScannedFile[],
232
- _rootDir: string
236
+ rootDir: string
233
237
  ): Promise<{ routes: FSRouteConfig[]; routeErrors: ScanError[] }> {
234
238
  const routes: FSRouteConfig[] = [];
235
239
  const routeErrors: ScanError[] = [];
@@ -389,13 +393,22 @@ export class FSScanner {
389
393
  conflictsWith: islands[0].absolutePath,
390
394
  });
391
395
  }
392
- } else if (file.type === "page" && pageFileContent) {
393
- // page 파일 자체에서 "use client" 확인
396
+ } else if (file.type === "page" && pageFileContent) {
397
+ // page 파일 자체에서 "use client" 확인
394
398
  const hasUseClient = hasUseClientDirective(pageFileContent);
395
- if (hasUseClient) {
396
- clientModule = modulePath;
397
- }
398
- }
399
+ if (hasUseClient) {
400
+ clientModule = modulePath;
401
+ } else {
402
+ const routeLevelClientImport = findRouteLevelClientComponentImport(pageFileContent);
403
+ if (routeLevelClientImport) {
404
+ clientModule = await resolveClientImportModulePath(
405
+ rootDir,
406
+ modulePath,
407
+ routeLevelClientImport.module,
408
+ ) ?? undefined;
409
+ }
410
+ }
411
+ }
399
412
 
400
413
  // 로딩/에러/404 모듈 찾기 — nearest-ancestor resolution.
401
414
  // Phase 18.β: `not-found.tsx` joins `loading.tsx`/`error.tsx` in