@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.
@@ -119,13 +119,19 @@ const styles = {
119
119
  fontWeight: typography.fontWeight.semibold,
120
120
  letterSpacing: '0.08em',
121
121
  textTransform: 'uppercase' as const,
122
- border: `1px solid ${colors.background.light}`,
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
- border: `1px solid transparent`,
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
- border: `1px solid transparent`,
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
- border: `1px solid transparent`,
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
- border: `1px solid rgba(255, 255, 255, 0.04)`,
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,
@@ -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: `${route.id} - Mandu`,
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: `${route.id} - Mandu`,
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
- title: `Error - ${route.id}`,
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