@mandujs/core 0.20.9 → 0.21.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.md +2 -1
- package/package.json +69 -69
- package/src/bundler/build.ts +185 -19
- package/src/bundler/dev.ts +60 -21
- package/src/bundler/types.ts +8 -0
- package/src/config/validate.ts +1 -0
- package/src/devtools/ai/mcp-connector.ts +18 -16
- package/src/devtools/client/components/mandu-character.tsx +4 -1
- package/src/devtools/client/components/panel/panel-container.tsx +20 -5
- package/src/runtime/server.ts +161 -3
- package/src/testing/index.ts +189 -189
|
@@ -119,13 +119,19 @@ const styles = {
|
|
|
119
119
|
fontWeight: typography.fontWeight.semibold,
|
|
120
120
|
letterSpacing: '0.08em',
|
|
121
121
|
textTransform: 'uppercase' as const,
|
|
122
|
-
|
|
122
|
+
// #183: borderColor longhand override 충돌 회피
|
|
123
|
+
borderWidth: '1px',
|
|
124
|
+
borderStyle: 'solid' as const,
|
|
125
|
+
borderColor: colors.background.light,
|
|
123
126
|
},
|
|
124
127
|
headerButton: {
|
|
125
128
|
padding: `${spacing.xs} ${spacing.sm}`,
|
|
126
129
|
borderRadius: borderRadius.full,
|
|
127
130
|
backgroundColor: colors.background.light,
|
|
128
|
-
|
|
131
|
+
// #183: borderColor longhand override 충돌 회피
|
|
132
|
+
borderWidth: '1px',
|
|
133
|
+
borderStyle: 'solid' as const,
|
|
134
|
+
borderColor: 'transparent',
|
|
129
135
|
color: colors.text.secondary,
|
|
130
136
|
fontSize: typography.fontSize.xs,
|
|
131
137
|
fontWeight: typography.fontWeight.medium,
|
|
@@ -134,7 +140,10 @@ const styles = {
|
|
|
134
140
|
},
|
|
135
141
|
restartButton: {
|
|
136
142
|
backgroundColor: colors.background.light,
|
|
137
|
-
|
|
143
|
+
// #183: borderColor longhand override 충돌 회피
|
|
144
|
+
borderWidth: '1px',
|
|
145
|
+
borderStyle: 'solid' as const,
|
|
146
|
+
borderColor: 'transparent',
|
|
138
147
|
color: colors.text.secondary,
|
|
139
148
|
fontSize: typography.fontSize.xs,
|
|
140
149
|
fontWeight: typography.fontWeight.medium,
|
|
@@ -146,7 +155,10 @@ const styles = {
|
|
|
146
155
|
},
|
|
147
156
|
closeButton: {
|
|
148
157
|
backgroundColor: 'transparent',
|
|
149
|
-
|
|
158
|
+
// #183: borderColor longhand override 충돌 회피
|
|
159
|
+
borderWidth: '1px',
|
|
160
|
+
borderStyle: 'solid' as const,
|
|
161
|
+
borderColor: 'transparent',
|
|
150
162
|
color: colors.text.secondary,
|
|
151
163
|
fontSize: typography.fontSize.md,
|
|
152
164
|
cursor: 'pointer',
|
|
@@ -190,7 +202,10 @@ const styles = {
|
|
|
190
202
|
gap: spacing.xs,
|
|
191
203
|
padding: `${spacing.sm} ${spacing.sm}`,
|
|
192
204
|
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
|
193
|
-
|
|
205
|
+
// #183: borderColor longhand override 충돌 회피
|
|
206
|
+
borderWidth: '1px',
|
|
207
|
+
borderStyle: 'solid' as const,
|
|
208
|
+
borderColor: 'rgba(255, 255, 255, 0.04)',
|
|
194
209
|
borderRadius: borderRadius.md,
|
|
195
210
|
color: colors.text.secondary,
|
|
196
211
|
fontSize: typography.fontSize.xs,
|
package/src/runtime/server.ts
CHANGED
|
@@ -5,6 +5,14 @@ import type { ManduFilling, RenderMode } from "../filling/filling";
|
|
|
5
5
|
import { ManduContext, type CookieManager } from "../filling/context";
|
|
6
6
|
import { Router } from "./router";
|
|
7
7
|
import { renderSSR, renderStreamingResponse } from "./ssr";
|
|
8
|
+
import {
|
|
9
|
+
resolveMetadata,
|
|
10
|
+
renderMetadata,
|
|
11
|
+
renderTitle,
|
|
12
|
+
type Metadata,
|
|
13
|
+
type MetadataItem,
|
|
14
|
+
type GenerateMetadata,
|
|
15
|
+
} from "../seo";
|
|
8
16
|
import { type ErrorFallbackProps } from "./boundary";
|
|
9
17
|
import React, { type ReactNode } from "react";
|
|
10
18
|
import path from "path";
|
|
@@ -390,6 +398,10 @@ export type ErrorLoader = () => Promise<{ default: ErrorComponent }>;
|
|
|
390
398
|
export interface PageRegistration {
|
|
391
399
|
component: React.ComponentType<{ params: Record<string, string>; loaderData?: unknown }>;
|
|
392
400
|
filling?: ManduFilling<unknown>;
|
|
401
|
+
/** #186: page 모듈의 static `metadata` export (선택) */
|
|
402
|
+
metadata?: Metadata;
|
|
403
|
+
/** #186: page 모듈의 `generateMetadata` 함수 export (선택) */
|
|
404
|
+
generateMetadata?: GenerateMetadata;
|
|
393
405
|
}
|
|
394
406
|
|
|
395
407
|
/**
|
|
@@ -466,6 +478,17 @@ export class ServerRegistry {
|
|
|
466
478
|
readonly layoutSlotPaths: Map<string, string | null> = new Map();
|
|
467
479
|
/** WebSocket 핸들러 (라우트 ID → WSHandlers) */
|
|
468
480
|
readonly wsHandlers: Map<string, import("../filling/ws").WSHandlers> = new Map();
|
|
481
|
+
/**
|
|
482
|
+
* Metadata API 캐시 (#186)
|
|
483
|
+
* - pageMetadata: routeId → page 모듈의 static `metadata` export
|
|
484
|
+
* - pageGenerateMetadata: routeId → `generateMetadata` 함수
|
|
485
|
+
* - layoutMetadata: layout 모듈 경로 → static `metadata` export (null = 시도했지만 없음)
|
|
486
|
+
* - layoutGenerateMetadata: layout 모듈 경로 → `generateMetadata` 함수
|
|
487
|
+
*/
|
|
488
|
+
readonly pageMetadata: Map<string, import("../seo").Metadata> = new Map();
|
|
489
|
+
readonly pageGenerateMetadata: Map<string, import("../seo").GenerateMetadata> = new Map();
|
|
490
|
+
readonly layoutMetadata: Map<string, import("../seo").Metadata | null> = new Map();
|
|
491
|
+
readonly layoutGenerateMetadata: Map<string, import("../seo").GenerateMetadata> = new Map();
|
|
469
492
|
settings: ServerRegistrySettings = {
|
|
470
493
|
isDev: false,
|
|
471
494
|
rootDir: process.cwd(),
|
|
@@ -537,6 +560,22 @@ export class ServerRegistry {
|
|
|
537
560
|
const cached = cacheMap.get(modulePath);
|
|
538
561
|
if (cached) return cached;
|
|
539
562
|
|
|
563
|
+
// #186: layout인 경우 metadata / generateMetadata export를 함께 캐싱
|
|
564
|
+
const cacheLayoutMetadata = (mod: unknown) => {
|
|
565
|
+
if (type !== "layout") return;
|
|
566
|
+
if (this.layoutMetadata.has(modulePath)) return;
|
|
567
|
+
const modObj = (mod && typeof mod === "object" ? (mod as Record<string, unknown>) : null);
|
|
568
|
+
const staticMeta = modObj?.metadata;
|
|
569
|
+
const generateFn = modObj?.generateMetadata;
|
|
570
|
+
this.layoutMetadata.set(
|
|
571
|
+
modulePath,
|
|
572
|
+
staticMeta && typeof staticMeta === "object" ? (staticMeta as Metadata) : null,
|
|
573
|
+
);
|
|
574
|
+
if (typeof generateFn === "function") {
|
|
575
|
+
this.layoutGenerateMetadata.set(modulePath, generateFn as GenerateMetadata);
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
540
579
|
// 2. 등록된 로더 시도
|
|
541
580
|
const loader = loaderMap.get(modulePath);
|
|
542
581
|
if (loader) {
|
|
@@ -544,6 +583,7 @@ export class ServerRegistry {
|
|
|
544
583
|
const module = await loader();
|
|
545
584
|
const component = module.default;
|
|
546
585
|
cacheMap.set(modulePath, component);
|
|
586
|
+
cacheLayoutMetadata(module);
|
|
547
587
|
return component;
|
|
548
588
|
} catch (error) {
|
|
549
589
|
console.error(`[Mandu] Failed to load ${type}: ${modulePath}`, error);
|
|
@@ -562,6 +602,7 @@ export class ServerRegistry {
|
|
|
562
602
|
const module = await import(validation.value);
|
|
563
603
|
const component = module.default;
|
|
564
604
|
cacheMap.set(modulePath, component);
|
|
605
|
+
cacheLayoutMetadata(module);
|
|
565
606
|
return component;
|
|
566
607
|
} catch (error) {
|
|
567
608
|
// layout은 에러 로깅, loading/error는 조용히 실패
|
|
@@ -611,6 +652,10 @@ export class ServerRegistry {
|
|
|
611
652
|
this.loadingLoaders.clear();
|
|
612
653
|
this.errorComponents.clear();
|
|
613
654
|
this.errorLoaders.clear();
|
|
655
|
+
this.pageMetadata.clear();
|
|
656
|
+
this.pageGenerateMetadata.clear();
|
|
657
|
+
this.layoutMetadata.clear();
|
|
658
|
+
this.layoutGenerateMetadata.clear();
|
|
614
659
|
this.createAppFn = null;
|
|
615
660
|
this.rateLimiter = null;
|
|
616
661
|
}
|
|
@@ -1270,6 +1315,18 @@ async function loadPageData(
|
|
|
1270
1315
|
: (exportedObj?.component ?? exported);
|
|
1271
1316
|
registry.registerRouteComponent(route.id, component as RouteComponent);
|
|
1272
1317
|
|
|
1318
|
+
// #186: page 모듈에서 metadata / generateMetadata export 캐싱
|
|
1319
|
+
const modObj = module as Record<string, unknown>;
|
|
1320
|
+
if (modObj.metadata && typeof modObj.metadata === "object") {
|
|
1321
|
+
registry.pageMetadata.set(route.id, modObj.metadata as Metadata);
|
|
1322
|
+
}
|
|
1323
|
+
if (typeof modObj.generateMetadata === "function") {
|
|
1324
|
+
registry.pageGenerateMetadata.set(
|
|
1325
|
+
route.id,
|
|
1326
|
+
modObj.generateMetadata as GenerateMetadata,
|
|
1327
|
+
);
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1273
1330
|
// filling이 있으면 캐시 옵션 등록 + loader 실행
|
|
1274
1331
|
let cookies: CookieManager | undefined;
|
|
1275
1332
|
const filling = typeof exported === "object" && exported !== null ? (exportedObj as Record<string, unknown>)?.filling as ManduFilling | null : null;
|
|
@@ -1385,6 +1442,92 @@ async function loadLayoutData(
|
|
|
1385
1442
|
|
|
1386
1443
|
// ---------- SSR Renderer ----------
|
|
1387
1444
|
|
|
1445
|
+
/**
|
|
1446
|
+
* #186: URL에서 searchParams를 Record<string, string>로 추출 (SEO 모듈 시그니처)
|
|
1447
|
+
*/
|
|
1448
|
+
function extractSearchParams(url: string): Record<string, string> {
|
|
1449
|
+
try {
|
|
1450
|
+
const u = new URL(url);
|
|
1451
|
+
const result: Record<string, string> = {};
|
|
1452
|
+
for (const [key, value] of u.searchParams.entries()) {
|
|
1453
|
+
if (!(key in result)) result[key] = value;
|
|
1454
|
+
}
|
|
1455
|
+
return result;
|
|
1456
|
+
} catch {
|
|
1457
|
+
return {};
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* #186: layout chain + page metadata를 순서대로 수집해 MetadataItem[] 구성
|
|
1463
|
+
* - 각 layout의 generateMetadata 우선, 없으면 static metadata
|
|
1464
|
+
* - page 모듈의 generateMetadata 우선, 없으면 static metadata
|
|
1465
|
+
* - 결과 배열을 SEO 모듈의 resolveMetadata에 전달
|
|
1466
|
+
*/
|
|
1467
|
+
async function collectMetadataItems(
|
|
1468
|
+
route: { id: string; layoutChain?: string[] },
|
|
1469
|
+
registry: ServerRegistry,
|
|
1470
|
+
): Promise<MetadataItem[]> {
|
|
1471
|
+
const items: MetadataItem[] = [];
|
|
1472
|
+
|
|
1473
|
+
if (route.layoutChain) {
|
|
1474
|
+
for (const layoutPath of route.layoutChain) {
|
|
1475
|
+
// Layout 모듈 로드 → metadata / generateMetadata 캐시 채움
|
|
1476
|
+
await registry.getLayoutComponent(layoutPath);
|
|
1477
|
+
const dyn = registry.layoutGenerateMetadata.get(layoutPath);
|
|
1478
|
+
if (dyn) {
|
|
1479
|
+
items.push(dyn);
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
const staticMeta = registry.layoutMetadata.get(layoutPath);
|
|
1483
|
+
if (staticMeta) items.push(staticMeta);
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const pageDyn = registry.pageGenerateMetadata.get(route.id);
|
|
1488
|
+
if (pageDyn) {
|
|
1489
|
+
items.push(pageDyn);
|
|
1490
|
+
} else {
|
|
1491
|
+
const pageStatic = registry.pageMetadata.get(route.id);
|
|
1492
|
+
if (pageStatic) items.push(pageStatic);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
return items;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* #186: 해석된 Metadata를 SSR 옵션(title + headTags)으로 변환
|
|
1500
|
+
*/
|
|
1501
|
+
async function buildSSRMetadata(
|
|
1502
|
+
route: { id: string; layoutChain?: string[] },
|
|
1503
|
+
params: Record<string, string>,
|
|
1504
|
+
url: string,
|
|
1505
|
+
registry: ServerRegistry,
|
|
1506
|
+
): Promise<{ title: string; headTags: string }> {
|
|
1507
|
+
try {
|
|
1508
|
+
const items = await collectMetadataItems(route, registry);
|
|
1509
|
+
if (items.length === 0) {
|
|
1510
|
+
return { title: "Mandu App", headTags: "" };
|
|
1511
|
+
}
|
|
1512
|
+
const resolved = await resolveMetadata(items, params, extractSearchParams(url));
|
|
1513
|
+
const titleHtml = renderTitle(resolved);
|
|
1514
|
+
const headTags = renderMetadata(resolved);
|
|
1515
|
+
// resolveMetadata는 <title>을 headTags 안에 이미 포함시키므로,
|
|
1516
|
+
// 중복 방지를 위해 title은 문자열만 뽑고 headTags에서 <title>을 제거
|
|
1517
|
+
const title = extractTitleText(titleHtml) ?? "Mandu App";
|
|
1518
|
+
const headWithoutTitle = headTags.replace(/<title>[^<]*<\/title>\n?/i, "");
|
|
1519
|
+
return { title, headTags: headWithoutTitle };
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
console.warn("[Mandu] metadata resolution failed:", error);
|
|
1522
|
+
return { title: "Mandu App", headTags: "" };
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
function extractTitleText(titleHtml: string): string | null {
|
|
1527
|
+
const match = /<title>([^<]*)<\/title>/i.exec(titleHtml);
|
|
1528
|
+
return match ? match[1] : null;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1388
1531
|
/**
|
|
1389
1532
|
* SSR 렌더링 (Streaming/Non-streaming)
|
|
1390
1533
|
*/
|
|
@@ -1437,6 +1580,9 @@ async function renderPageSSR(
|
|
|
1437
1580
|
? { [route.id]: { serverData: loaderData } }
|
|
1438
1581
|
: undefined;
|
|
1439
1582
|
|
|
1583
|
+
// #186: layout chain + page metadata 병합
|
|
1584
|
+
const builtMeta = await buildSSRMetadata(route, params, url, registry);
|
|
1585
|
+
|
|
1440
1586
|
// Streaming SSR 모드 결정
|
|
1441
1587
|
const useStreaming = route.streaming !== undefined
|
|
1442
1588
|
? route.streaming
|
|
@@ -1444,7 +1590,8 @@ async function renderPageSSR(
|
|
|
1444
1590
|
|
|
1445
1591
|
if (useStreaming) {
|
|
1446
1592
|
const streamingResponse = await renderStreamingResponse(app, {
|
|
1447
|
-
title:
|
|
1593
|
+
title: builtMeta.title,
|
|
1594
|
+
headTags: builtMeta.headTags,
|
|
1448
1595
|
isDev: settings.isDev,
|
|
1449
1596
|
hmrPort: settings.hmrPort,
|
|
1450
1597
|
routeId: route.id,
|
|
@@ -1477,7 +1624,8 @@ async function renderPageSSR(
|
|
|
1477
1624
|
// renderToHTML에서 중복 래핑하지 않도록 hydration을 전달하되 strategy를 "none"으로 설정
|
|
1478
1625
|
// 단, hydration 스크립트(importmap, runtime 등)는 여전히 필요하므로 bundleManifest는 유지
|
|
1479
1626
|
const ssrResponse = renderSSR(app, {
|
|
1480
|
-
title:
|
|
1627
|
+
title: builtMeta.title,
|
|
1628
|
+
headTags: builtMeta.headTags,
|
|
1481
1629
|
isDev: settings.isDev,
|
|
1482
1630
|
hmrPort: settings.hmrPort,
|
|
1483
1631
|
routeId: route.id,
|
|
@@ -1512,7 +1660,8 @@ async function renderPageSSR(
|
|
|
1512
1660
|
}
|
|
1513
1661
|
|
|
1514
1662
|
const errorHtml = renderSSR(errorApp, {
|
|
1515
|
-
|
|
1663
|
+
// 에러 상태에서는 resolveMetadata 결과를 신뢰할 수 없을 수 있으므로 리터럴 사용
|
|
1664
|
+
title: "Mandu App — Error",
|
|
1516
1665
|
isDev: settings.isDev,
|
|
1517
1666
|
cssPath: settings.cssPath,
|
|
1518
1667
|
});
|
|
@@ -1752,6 +1901,15 @@ async function ensurePageRouteMetadata(
|
|
|
1752
1901
|
registry.renderModes.set(routeId, registration.filling.getRenderMode());
|
|
1753
1902
|
}
|
|
1754
1903
|
|
|
1904
|
+
// #186: pageHandlers 경로에서도 metadata / generateMetadata 캐싱
|
|
1905
|
+
// (pageLoaders 경로는 loadPageData에서 이미 처리됨)
|
|
1906
|
+
if (registration.metadata && typeof registration.metadata === "object") {
|
|
1907
|
+
registry.pageMetadata.set(routeId, registration.metadata);
|
|
1908
|
+
}
|
|
1909
|
+
if (typeof registration.generateMetadata === "function") {
|
|
1910
|
+
registry.pageGenerateMetadata.set(routeId, registration.generateMetadata);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1755
1913
|
return registration;
|
|
1756
1914
|
}
|
|
1757
1915
|
|