@mandujs/core 0.18.20 → 0.18.22
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/package.json +3 -1
- package/src/brain/architecture/analyzer.ts +3 -5
- package/src/brain/architecture/types.ts +4 -4
- package/src/brain/doctor/analyzer.ts +1 -0
- package/src/brain/doctor/index.ts +1 -1
- package/src/brain/doctor/patcher.ts +10 -6
- package/src/brain/doctor/reporter.ts +4 -4
- package/src/brain/types.ts +14 -10
- package/src/bundler/build.ts +17 -17
- package/src/bundler/css.ts +3 -2
- package/src/bundler/dev.ts +1 -1
- package/src/client/island.ts +10 -9
- package/src/client/router.ts +1 -1
- package/src/config/mcp-ref.ts +6 -6
- package/src/config/metadata.test.ts +1 -1
- package/src/config/metadata.ts +36 -16
- package/src/config/symbols.ts +1 -1
- package/src/config/validate.ts +17 -1
- package/src/content/content.test.ts +3 -3
- package/src/content/loaders/file.ts +3 -0
- package/src/content/loaders/glob.ts +1 -0
- package/src/contract/client-safe.test.ts +1 -1
- package/src/contract/client.test.ts +2 -1
- package/src/contract/client.ts +18 -18
- package/src/contract/define.ts +32 -17
- package/src/contract/handler.ts +11 -11
- package/src/contract/index.ts +2 -5
- package/src/contract/infer.test.ts +2 -1
- package/src/contract/normalize.test.ts +1 -1
- package/src/contract/normalize.ts +17 -11
- package/src/contract/registry.test.ts +1 -1
- package/src/contract/zod-utils.ts +155 -0
- package/src/devtools/client/catchers/error-catcher.ts +3 -3
- package/src/devtools/client/catchers/network-proxy.ts +5 -1
- package/src/devtools/client/components/kitchen-root.tsx +2 -2
- package/src/devtools/client/components/panel/guard-panel.tsx +3 -3
- package/src/devtools/client/state-manager.ts +9 -9
- package/src/devtools/index.ts +8 -8
- package/src/devtools/init.ts +2 -2
- package/src/devtools/protocol.ts +4 -4
- package/src/devtools/server/source-context.ts +9 -3
- package/src/devtools/types.ts +5 -5
- package/src/devtools/worker/redaction-worker.ts +12 -5
- package/src/error/index.ts +1 -1
- package/src/error/result.ts +14 -0
- package/src/filling/deps.ts +5 -2
- package/src/filling/filling.ts +1 -1
- package/src/generator/templates.ts +2 -2
- package/src/guard/contract-guard.test.ts +1 -0
- package/src/guard/file-type.test.ts +1 -1
- package/src/guard/index.ts +1 -1
- package/src/guard/negotiation.ts +29 -1
- package/src/guard/presets/index.ts +3 -0
- package/src/guard/semantic-slots.ts +4 -4
- package/src/index.ts +10 -1
- package/src/intent/index.ts +28 -17
- package/src/island/index.ts +8 -8
- package/src/openapi/generator.ts +49 -31
- package/src/plugins/index.ts +1 -1
- package/src/plugins/registry.ts +28 -18
- package/src/plugins/types.ts +2 -2
- package/src/resource/__tests__/backward-compat.test.ts +2 -2
- package/src/resource/__tests__/edge-cases.test.ts +14 -13
- package/src/resource/__tests__/fixtures.ts +2 -2
- package/src/resource/__tests__/generator.test.ts +1 -1
- package/src/resource/__tests__/performance.test.ts +8 -6
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-routes.ts +34 -40
- package/src/router/fs-types.ts +2 -2
- package/src/router/index.ts +1 -1
- package/src/runtime/boundary.tsx +4 -4
- package/src/runtime/logger.test.ts +3 -3
- package/src/runtime/logger.ts +1 -1
- package/src/runtime/server.ts +18 -16
- package/src/runtime/ssr.ts +1 -1
- package/src/runtime/stable-selector.ts +1 -2
- package/src/runtime/streaming-ssr.ts +15 -6
- package/src/seo/index.ts +5 -0
- package/src/seo/integration/ssr.ts +4 -4
- package/src/seo/render/basic.ts +12 -4
- package/src/seo/render/opengraph.ts +12 -6
- package/src/seo/render/twitter.ts +3 -2
- package/src/seo/resolve/url.ts +7 -0
- package/src/seo/types.ts +13 -0
- package/src/spec/schema.ts +89 -61
- package/src/types/branded.ts +56 -0
- package/src/types/index.ts +1 -0
- package/src/utils/hasher.test.ts +6 -6
- package/src/utils/hasher.ts +2 -2
- package/src/utils/index.ts +1 -1
- package/src/watcher/watcher.ts +2 -2
package/src/runtime/boundary.tsx
CHANGED
|
@@ -57,7 +57,7 @@ interface ErrorBoundaryState {
|
|
|
57
57
|
* </LoadingBoundary>
|
|
58
58
|
* ```
|
|
59
59
|
*/
|
|
60
|
-
export function LoadingBoundary({ fallback, children }: LoadingBoundaryProps):
|
|
60
|
+
export function LoadingBoundary({ fallback, children }: LoadingBoundaryProps): React.ReactElement {
|
|
61
61
|
return <Suspense fallback={fallback}>{children}</Suspense>;
|
|
62
62
|
}
|
|
63
63
|
|
|
@@ -127,7 +127,7 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
|
|
|
127
127
|
/**
|
|
128
128
|
* 기본 로딩 컴포넌트
|
|
129
129
|
*/
|
|
130
|
-
export function DefaultLoading():
|
|
130
|
+
export function DefaultLoading(): React.ReactElement {
|
|
131
131
|
return (
|
|
132
132
|
<div
|
|
133
133
|
style={{
|
|
@@ -146,7 +146,7 @@ export function DefaultLoading(): JSX.Element {
|
|
|
146
146
|
/**
|
|
147
147
|
* 기본 에러 컴포넌트
|
|
148
148
|
*/
|
|
149
|
-
export function DefaultError({ error, resetError }: ErrorFallbackProps):
|
|
149
|
+
export function DefaultError({ error, resetError }: ErrorFallbackProps): React.ReactElement {
|
|
150
150
|
return (
|
|
151
151
|
<div
|
|
152
152
|
style={{
|
|
@@ -220,7 +220,7 @@ export function PageBoundary({
|
|
|
220
220
|
errorComponent,
|
|
221
221
|
children,
|
|
222
222
|
onError,
|
|
223
|
-
}: PageBoundaryProps):
|
|
223
|
+
}: PageBoundaryProps): React.ReactElement {
|
|
224
224
|
const LoadingFallback = loadingComponent ?? <DefaultLoading />;
|
|
225
225
|
const ErrorFallback = errorComponent ?? DefaultError;
|
|
226
226
|
|
|
@@ -62,7 +62,7 @@ describe("logger", () => {
|
|
|
62
62
|
|
|
63
63
|
log.onError(ctx, error);
|
|
64
64
|
|
|
65
|
-
expect(ctx.get("__mandu_logger_error")).toBe(error);
|
|
65
|
+
expect(ctx.get<Error>("__mandu_logger_error")).toBe(error);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
test("afterHandle은 응답을 저장하고 반환", () => {
|
|
@@ -72,7 +72,7 @@ describe("logger", () => {
|
|
|
72
72
|
|
|
73
73
|
const result = log.afterHandle(ctx, response);
|
|
74
74
|
|
|
75
|
-
expect(ctx.get("__mandu_logger_response")).toBe(response);
|
|
75
|
+
expect(ctx.get<Response>("__mandu_logger_response")).toBe(response);
|
|
76
76
|
expect(result).toBe(response);
|
|
77
77
|
});
|
|
78
78
|
});
|
|
@@ -147,7 +147,7 @@ describe("logger", () => {
|
|
|
147
147
|
|
|
148
148
|
log.onRequest(ctx);
|
|
149
149
|
|
|
150
|
-
expect(ctx.get("__mandu_logger_request_id")).toBe("custom-POST-123");
|
|
150
|
+
expect(ctx.get<string>("__mandu_logger_request_id")).toBe("custom-POST-123");
|
|
151
151
|
});
|
|
152
152
|
});
|
|
153
153
|
|
package/src/runtime/logger.ts
CHANGED
|
@@ -639,7 +639,7 @@ export function applyLogger(
|
|
|
639
639
|
loggerInstance: ReturnType<typeof logger>
|
|
640
640
|
): void {
|
|
641
641
|
lifecycle.onRequest.push({ fn: loggerInstance.onRequest, scope: "global" });
|
|
642
|
-
lifecycle.onError.push({ fn: loggerInstance.onError as
|
|
642
|
+
lifecycle.onError.push({ fn: loggerInstance.onError as (ctx: ManduContext, error: Error) => void, scope: "global" });
|
|
643
643
|
lifecycle.afterHandle.push({ fn: loggerInstance.afterHandle, scope: "global" });
|
|
644
644
|
lifecycle.afterResponse.push({ fn: loggerInstance.afterResponse, scope: "global" });
|
|
645
645
|
}
|
package/src/runtime/server.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Server } from "bun";
|
|
2
|
-
import type { RoutesManifest } from "../spec/schema";
|
|
2
|
+
import type { RoutesManifest, RouteSpec, HydrationConfig } from "../spec/schema";
|
|
3
3
|
import type { BundleManifest } from "../bundler/types";
|
|
4
4
|
import type { ManduFilling } from "../filling/filling";
|
|
5
5
|
import { ManduContext } from "../filling/context";
|
|
@@ -304,7 +304,7 @@ export interface ServerOptions {
|
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
export interface ManduServer {
|
|
307
|
-
server: Server
|
|
307
|
+
server: Server<undefined>;
|
|
308
308
|
router: Router;
|
|
309
309
|
/** 이 서버 인스턴스의 레지스트리 */
|
|
310
310
|
registry: ServerRegistry;
|
|
@@ -659,7 +659,7 @@ async function wrapWithLayouts(
|
|
|
659
659
|
for (let i = layouts.length - 1; i >= 0; i--) {
|
|
660
660
|
const Layout = layouts[i];
|
|
661
661
|
if (Layout) {
|
|
662
|
-
wrapped = React.createElement(Layout, { params
|
|
662
|
+
wrapped = React.createElement(Layout, { params, children: wrapped });
|
|
663
663
|
}
|
|
664
664
|
}
|
|
665
665
|
|
|
@@ -916,14 +916,15 @@ async function loadPageData(
|
|
|
916
916
|
if (loader) {
|
|
917
917
|
try {
|
|
918
918
|
const module = await loader();
|
|
919
|
-
const exported = module.default;
|
|
919
|
+
const exported: unknown = module.default;
|
|
920
|
+
const exportedObj = exported as Record<string, unknown> | null;
|
|
920
921
|
const component = typeof exported === "function"
|
|
921
|
-
? exported
|
|
922
|
-
:
|
|
923
|
-
registry.registerRouteComponent(route.id, component);
|
|
922
|
+
? (exported as RouteComponent)
|
|
923
|
+
: (exportedObj?.component ?? exported);
|
|
924
|
+
registry.registerRouteComponent(route.id, component as RouteComponent);
|
|
924
925
|
|
|
925
926
|
// filling이 있으면 loader 실행
|
|
926
|
-
const filling = typeof exported === "object" ?
|
|
927
|
+
const filling = typeof exported === "object" && exported !== null ? (exportedObj as Record<string, unknown>)?.filling as ManduFilling | null : null;
|
|
927
928
|
if (filling?.hasLoader?.()) {
|
|
928
929
|
const ctx = new ManduContext(req, params);
|
|
929
930
|
loaderData = await filling.executeLoader(ctx);
|
|
@@ -948,7 +949,7 @@ async function loadPageData(
|
|
|
948
949
|
* SSR 렌더링 (Streaming/Non-streaming)
|
|
949
950
|
*/
|
|
950
951
|
async function renderPageSSR(
|
|
951
|
-
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?:
|
|
952
|
+
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
|
|
952
953
|
params: Record<string, string>,
|
|
953
954
|
loaderData: unknown,
|
|
954
955
|
url: string,
|
|
@@ -1041,7 +1042,7 @@ async function renderPageSSR(
|
|
|
1041
1042
|
async function handlePageRoute(
|
|
1042
1043
|
req: Request,
|
|
1043
1044
|
url: URL,
|
|
1044
|
-
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?:
|
|
1045
|
+
route: { id: string; pattern: string; layoutChain?: string[]; streaming?: boolean; hydration?: HydrationConfig },
|
|
1045
1046
|
params: Record<string, string>,
|
|
1046
1047
|
registry: ServerRegistry
|
|
1047
1048
|
): Promise<Result<Response>> {
|
|
@@ -1084,7 +1085,7 @@ async function handleRequestInternal(
|
|
|
1084
1085
|
|
|
1085
1086
|
// 0. CORS Preflight 요청 처리
|
|
1086
1087
|
if (settings.cors && isPreflightRequest(req)) {
|
|
1087
|
-
const corsOptions = settings.cors ===
|
|
1088
|
+
const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
|
|
1088
1089
|
return ok(handlePreflightRequest(req, corsOptions));
|
|
1089
1090
|
}
|
|
1090
1091
|
|
|
@@ -1092,7 +1093,7 @@ async function handleRequestInternal(
|
|
|
1092
1093
|
const staticResponse = await serveStaticFile(pathname, settings);
|
|
1093
1094
|
if (staticResponse) {
|
|
1094
1095
|
if (settings.cors && isCorsRequest(req)) {
|
|
1095
|
-
const corsOptions = settings.cors ===
|
|
1096
|
+
const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
|
|
1096
1097
|
return ok(applyCorsToResponse(staticResponse, req, corsOptions));
|
|
1097
1098
|
}
|
|
1098
1099
|
return ok(staticResponse);
|
|
@@ -1127,18 +1128,19 @@ async function handleRequestInternal(
|
|
|
1127
1128
|
return handlePageRoute(req, url, route, params, registry);
|
|
1128
1129
|
}
|
|
1129
1130
|
|
|
1130
|
-
// 4. 알 수 없는 라우트 종류
|
|
1131
|
+
// 4. 알 수 없는 라우트 종류 — exhaustiveness check
|
|
1132
|
+
const _exhaustive: never = route;
|
|
1131
1133
|
return err({
|
|
1132
1134
|
errorType: "FRAMEWORK_BUG",
|
|
1133
1135
|
code: "MANDU_F003",
|
|
1134
1136
|
httpStatus: 500,
|
|
1135
|
-
message: `Unknown route kind: ${
|
|
1137
|
+
message: `Unknown route kind: ${(_exhaustive as RouteSpec).kind}`,
|
|
1136
1138
|
summary: "알 수 없는 라우트 종류 - 프레임워크 버그",
|
|
1137
1139
|
fix: {
|
|
1138
1140
|
file: ".mandu/routes.manifest.json",
|
|
1139
1141
|
suggestion: "라우트의 kind는 'api' 또는 'page'여야 합니다",
|
|
1140
1142
|
},
|
|
1141
|
-
route: { id:
|
|
1143
|
+
route: { id: (_exhaustive as RouteSpec).id, pattern: (_exhaustive as RouteSpec).pattern },
|
|
1142
1144
|
timestamp: new Date().toISOString(),
|
|
1143
1145
|
});
|
|
1144
1146
|
}
|
|
@@ -1158,7 +1160,7 @@ function startBunServerWithFallback(options: {
|
|
|
1158
1160
|
port: number;
|
|
1159
1161
|
hostname?: string;
|
|
1160
1162
|
fetch: (req: Request) => Promise<Response>;
|
|
1161
|
-
}): { server: Server
|
|
1163
|
+
}): { server: Server<undefined>; port: number; attempts: number } {
|
|
1162
1164
|
const { port: startPort, hostname, fetch } = options;
|
|
1163
1165
|
let lastError: unknown = null;
|
|
1164
1166
|
|
package/src/runtime/ssr.ts
CHANGED
|
@@ -184,7 +184,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
|
|
|
184
184
|
// CSS 링크 태그 생성
|
|
185
185
|
// - cssPath가 string이면 해당 경로 사용
|
|
186
186
|
// - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
|
|
187
|
-
const cssLinkTag = cssPath
|
|
187
|
+
const cssLinkTag = cssPath
|
|
188
188
|
? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
|
|
189
189
|
: "";
|
|
190
190
|
|
|
@@ -11,8 +11,7 @@ function fnv1a64Hex(input: string): string {
|
|
|
11
11
|
|
|
12
12
|
function getBuildSaltFallback(): string {
|
|
13
13
|
try {
|
|
14
|
-
|
|
15
|
-
const salt = (typeof process !== "undefined" && (process as any)?.env?.MANDU_BUILD_SALT) as string | undefined;
|
|
14
|
+
const salt = (typeof process !== "undefined" && process.env?.MANDU_BUILD_SALT) as string | undefined;
|
|
16
15
|
return salt ?? "dev";
|
|
17
16
|
} catch {
|
|
18
17
|
return "dev";
|
|
@@ -244,6 +244,15 @@ function validateCriticalData(data: Record<string, unknown> | undefined, isDev:
|
|
|
244
244
|
|
|
245
245
|
// ========== Streaming Warnings ==========
|
|
246
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Streaming 경고 상태 (module-level, globalThis as any 제거)
|
|
249
|
+
*/
|
|
250
|
+
const streamingWarnings = {
|
|
251
|
+
_warned: false,
|
|
252
|
+
markWarned() { this._warned = true; },
|
|
253
|
+
hasWarned() { return this._warned; },
|
|
254
|
+
};
|
|
255
|
+
|
|
247
256
|
/**
|
|
248
257
|
* 프록시/버퍼링 관련 경고 (개발 모드)
|
|
249
258
|
*/
|
|
@@ -378,7 +387,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
|
|
|
378
387
|
// CSS 링크 태그 생성
|
|
379
388
|
// - cssPath가 string이면 해당 경로 사용
|
|
380
389
|
// - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
|
|
381
|
-
const cssLinkTag = cssPath
|
|
390
|
+
const cssLinkTag = cssPath
|
|
382
391
|
? `<link rel="stylesheet" href="${escapeHtmlAttr(`${cssPath}${isDev ? `?t=${Date.now()}` : ""}`)}">`
|
|
383
392
|
: "";
|
|
384
393
|
|
|
@@ -685,9 +694,9 @@ export async function renderToStream(
|
|
|
685
694
|
validateCriticalData(criticalData, isDev);
|
|
686
695
|
|
|
687
696
|
// 스트리밍 주의사항 경고 (첫 요청 시 1회만)
|
|
688
|
-
if (isDev && !(
|
|
697
|
+
if (isDev && !streamingWarnings.hasWarned()) {
|
|
689
698
|
warnStreamingCaveats(isDev);
|
|
690
|
-
(
|
|
699
|
+
streamingWarnings.markWarned();
|
|
691
700
|
}
|
|
692
701
|
|
|
693
702
|
const encoder = new TextEncoder();
|
|
@@ -748,7 +757,7 @@ export async function renderToStream(
|
|
|
748
757
|
|
|
749
758
|
async function readWithTimeout(): Promise<ReadableStreamReadResult<Uint8Array> | null> {
|
|
750
759
|
if (!deadline) {
|
|
751
|
-
return reader.read()
|
|
760
|
+
return reader.read() as Promise<ReadableStreamReadResult<Uint8Array>>;
|
|
752
761
|
}
|
|
753
762
|
|
|
754
763
|
const remaining = deadline - Date.now();
|
|
@@ -763,8 +772,8 @@ export async function renderToStream(
|
|
|
763
772
|
|
|
764
773
|
const readPromise = reader
|
|
765
774
|
.read()
|
|
766
|
-
.then((result) => ({ kind: "read" as const, result }))
|
|
767
|
-
.catch((error) => ({ kind: "error" as const, error }));
|
|
775
|
+
.then((result) => ({ kind: "read" as const, result: result as ReadableStreamReadResult<Uint8Array> }))
|
|
776
|
+
.catch((error: unknown) => ({ kind: "error" as const, error }));
|
|
768
777
|
|
|
769
778
|
const result = await Promise.race([readPromise, timeoutPromise]);
|
|
770
779
|
|
package/src/seo/index.ts
CHANGED
|
@@ -118,13 +118,13 @@ export function resolveSEOSync(staticMetadata: Metadata): SEOResult {
|
|
|
118
118
|
resolvedTitle = { absolute: staticMetadata.title, template: null }
|
|
119
119
|
} else if ('absolute' in staticMetadata.title) {
|
|
120
120
|
resolvedTitle = {
|
|
121
|
-
absolute: staticMetadata.title.absolute,
|
|
122
|
-
template: staticMetadata.title.template
|
|
121
|
+
absolute: staticMetadata.title.absolute ?? '',
|
|
122
|
+
template: staticMetadata.title.template ?? null,
|
|
123
123
|
}
|
|
124
124
|
} else if ('default' in staticMetadata.title) {
|
|
125
125
|
resolvedTitle = {
|
|
126
|
-
absolute: staticMetadata.title.default,
|
|
127
|
-
template: staticMetadata.title.template
|
|
126
|
+
absolute: staticMetadata.title.default ?? '',
|
|
127
|
+
template: staticMetadata.title.template ?? null,
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
}
|
package/src/seo/render/basic.ts
CHANGED
|
@@ -212,7 +212,9 @@ export function renderIcons(metadata: ResolvedMetadata): string {
|
|
|
212
212
|
|
|
213
213
|
// icon
|
|
214
214
|
for (const icon of icons.icon) {
|
|
215
|
-
const
|
|
215
|
+
const iconUrl = urlToString(icon.url)
|
|
216
|
+
if (!iconUrl) continue
|
|
217
|
+
const attrs = [`rel="icon"`, `href="${escapeHtml(iconUrl)}"`]
|
|
216
218
|
if (icon.type) attrs.push(`type="${escapeHtml(icon.type)}"`)
|
|
217
219
|
if (icon.sizes) attrs.push(`sizes="${escapeHtml(icon.sizes)}"`)
|
|
218
220
|
tags.push(`<link ${attrs.join(' ')} />`)
|
|
@@ -220,19 +222,25 @@ export function renderIcons(metadata: ResolvedMetadata): string {
|
|
|
220
222
|
|
|
221
223
|
// apple-touch-icon
|
|
222
224
|
for (const icon of icons.apple) {
|
|
223
|
-
const
|
|
225
|
+
const iconUrl = urlToString(icon.url)
|
|
226
|
+
if (!iconUrl) continue
|
|
227
|
+
const attrs = [`rel="apple-touch-icon"`, `href="${escapeHtml(iconUrl)}"`]
|
|
224
228
|
if (icon.sizes) attrs.push(`sizes="${escapeHtml(icon.sizes)}"`)
|
|
225
229
|
tags.push(`<link ${attrs.join(' ')} />`)
|
|
226
230
|
}
|
|
227
231
|
|
|
228
232
|
// shortcut icon
|
|
229
233
|
for (const icon of icons.shortcut) {
|
|
230
|
-
|
|
234
|
+
const iconUrl = urlToString(icon.url)
|
|
235
|
+
if (!iconUrl) continue
|
|
236
|
+
tags.push(`<link rel="shortcut icon" href="${escapeHtml(iconUrl)}" />`)
|
|
231
237
|
}
|
|
232
238
|
|
|
233
239
|
// other icons
|
|
234
240
|
for (const icon of icons.other) {
|
|
235
|
-
const
|
|
241
|
+
const iconUrl = urlToString(icon.url)
|
|
242
|
+
if (!iconUrl) continue
|
|
243
|
+
const attrs = [`href="${escapeHtml(iconUrl)}"`]
|
|
236
244
|
if (icon.rel) attrs.push(`rel="${escapeHtml(icon.rel)}"`)
|
|
237
245
|
if (icon.type) attrs.push(`type="${escapeHtml(icon.type)}"`)
|
|
238
246
|
if (icon.sizes) attrs.push(`sizes="${escapeHtml(icon.sizes)}"`)
|
|
@@ -69,9 +69,11 @@ export function renderOpenGraph(metadata: ResolvedMetadata): string {
|
|
|
69
69
|
// Images
|
|
70
70
|
if (openGraph.images) {
|
|
71
71
|
for (const image of openGraph.images) {
|
|
72
|
-
|
|
72
|
+
const imageUrl = urlToString(image.url)
|
|
73
|
+
if (imageUrl) tags.push(og('image', imageUrl))
|
|
73
74
|
if (image.secureUrl) {
|
|
74
|
-
|
|
75
|
+
const secureUrl = urlToString(image.secureUrl)
|
|
76
|
+
if (secureUrl) tags.push(og('image:secure_url', secureUrl))
|
|
75
77
|
}
|
|
76
78
|
if (image.type) {
|
|
77
79
|
tags.push(og('image:type', image.type))
|
|
@@ -91,9 +93,11 @@ export function renderOpenGraph(metadata: ResolvedMetadata): string {
|
|
|
91
93
|
// Videos
|
|
92
94
|
if (openGraph.videos) {
|
|
93
95
|
for (const video of openGraph.videos) {
|
|
94
|
-
|
|
96
|
+
const videoUrl = urlToString(video.url)
|
|
97
|
+
if (videoUrl) tags.push(og('video', videoUrl))
|
|
95
98
|
if (video.secureUrl) {
|
|
96
|
-
|
|
99
|
+
const secureUrl = urlToString(video.secureUrl)
|
|
100
|
+
if (secureUrl) tags.push(og('video:secure_url', secureUrl))
|
|
97
101
|
}
|
|
98
102
|
if (video.type) {
|
|
99
103
|
tags.push(og('video:type', video.type))
|
|
@@ -110,9 +114,11 @@ export function renderOpenGraph(metadata: ResolvedMetadata): string {
|
|
|
110
114
|
// Audio
|
|
111
115
|
if (openGraph.audio) {
|
|
112
116
|
for (const audio of openGraph.audio) {
|
|
113
|
-
|
|
117
|
+
const audioUrl = urlToString(audio.url)
|
|
118
|
+
if (audioUrl) tags.push(og('audio', audioUrl))
|
|
114
119
|
if (audio.secureUrl) {
|
|
115
|
-
|
|
120
|
+
const secureUrl = urlToString(audio.secureUrl)
|
|
121
|
+
if (secureUrl) tags.push(og('audio:secure_url', secureUrl))
|
|
116
122
|
}
|
|
117
123
|
if (audio.type) {
|
|
118
124
|
tags.push(og('audio:type', audio.type))
|
|
@@ -68,14 +68,15 @@ export function renderTwitter(metadata: ResolvedMetadata): string {
|
|
|
68
68
|
if (tw.images) {
|
|
69
69
|
for (let i = 0; i < tw.images.length; i++) {
|
|
70
70
|
const image = tw.images[i]
|
|
71
|
+
const imageUrl = urlToString(image.url)
|
|
71
72
|
if (i === 0) {
|
|
72
|
-
tags.push(twitter('image',
|
|
73
|
+
if (imageUrl) tags.push(twitter('image', imageUrl))
|
|
73
74
|
if (image.alt) {
|
|
74
75
|
tags.push(twitter('image:alt', image.alt))
|
|
75
76
|
}
|
|
76
77
|
} else {
|
|
77
78
|
// Multiple images (for galleries)
|
|
78
|
-
tags.push(twitter(`image${i}`,
|
|
79
|
+
if (imageUrl) tags.push(twitter(`image${i}`, imageUrl))
|
|
79
80
|
if (image.alt) {
|
|
80
81
|
tags.push(twitter(`image${i}:alt`, image.alt))
|
|
81
82
|
}
|
package/src/seo/resolve/url.ts
CHANGED
|
@@ -27,6 +27,9 @@ export function normalizeMetadataBase(
|
|
|
27
27
|
/**
|
|
28
28
|
* 상대 URL을 절대 URL로 변환
|
|
29
29
|
*/
|
|
30
|
+
export function resolveUrl(url: URL, metadataBase: URL | null): URL;
|
|
31
|
+
export function resolveUrl(url: string | null | undefined, metadataBase: URL | null): URL | null;
|
|
32
|
+
export function resolveUrl(url: string | URL | null | undefined, metadataBase: URL | null): URL | null;
|
|
30
33
|
export function resolveUrl(
|
|
31
34
|
url: string | URL | null | undefined,
|
|
32
35
|
metadataBase: URL | null
|
|
@@ -69,6 +72,10 @@ export function resolveUrl(
|
|
|
69
72
|
/**
|
|
70
73
|
* URL을 문자열로 변환 (렌더링용)
|
|
71
74
|
*/
|
|
75
|
+
export function urlToString(url: URL): string;
|
|
76
|
+
export function urlToString(url: string): string;
|
|
77
|
+
export function urlToString(url: null | undefined): null;
|
|
78
|
+
export function urlToString(url: URL | string | null | undefined): string | null;
|
|
72
79
|
export function urlToString(url: URL | string | null | undefined): string | null {
|
|
73
80
|
if (!url) return null
|
|
74
81
|
if (url instanceof URL) return url.href
|
package/src/seo/types.ts
CHANGED
|
@@ -223,6 +223,19 @@ export interface ResolvedVerification {
|
|
|
223
223
|
other: Record<string, string[]> | null
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Template Literal Types for Meta Properties
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
/** OpenGraph meta property name (og:title, og:description, etc.) */
|
|
231
|
+
export type OGProperty = `og:${string}`;
|
|
232
|
+
|
|
233
|
+
/** Twitter meta property name (twitter:card, twitter:site, etc.) */
|
|
234
|
+
export type TwitterProperty = `twitter:${string}`;
|
|
235
|
+
|
|
236
|
+
/** Union of well-known meta property prefixes plus arbitrary strings */
|
|
237
|
+
export type MetaProperty = OGProperty | TwitterProperty | string;
|
|
238
|
+
|
|
226
239
|
// ============================================================================
|
|
227
240
|
// Open Graph Types
|
|
228
241
|
// ============================================================================
|
package/src/spec/schema.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { z } from "zod";
|
|
|
2
2
|
|
|
3
3
|
// ========== Hydration 설정 ==========
|
|
4
4
|
|
|
5
|
-
export const
|
|
6
|
-
export type
|
|
5
|
+
export const SpecHydrationStrategy = z.enum(["none", "island", "full", "progressive"]);
|
|
6
|
+
export type SpecHydrationStrategy = z.infer<typeof SpecHydrationStrategy>;
|
|
7
7
|
|
|
8
8
|
export const HydrationPriority = z.enum(["immediate", "visible", "idle", "interaction"]);
|
|
9
9
|
export type HydrationPriority = z.infer<typeof HydrationPriority>;
|
|
@@ -16,7 +16,7 @@ export const HydrationConfig = z.object({
|
|
|
16
16
|
* - full: 전체 페이지 hydrate
|
|
17
17
|
* - progressive: 점진적 hydrate
|
|
18
18
|
*/
|
|
19
|
-
strategy:
|
|
19
|
+
strategy: SpecHydrationStrategy,
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
22
|
* Hydration 우선순위
|
|
@@ -56,71 +56,37 @@ export type LoaderConfig = z.infer<typeof LoaderConfig>;
|
|
|
56
56
|
export const RouteKind = z.enum(["page", "api"]);
|
|
57
57
|
export type RouteKind = z.infer<typeof RouteKind>;
|
|
58
58
|
|
|
59
|
-
export const
|
|
60
|
-
export type
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
export const SpecHttpMethod = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"]);
|
|
60
|
+
export type SpecHttpMethod = z.infer<typeof SpecHttpMethod>;
|
|
61
|
+
|
|
62
|
+
// ---- 공통 필드 ----
|
|
63
|
+
const RouteSpecBase = {
|
|
64
|
+
id: z.string().min(1, "id는 필수입니다"),
|
|
65
|
+
pattern: z.string().startsWith("/", "pattern은 /로 시작해야 합니다"),
|
|
66
|
+
module: z.string().min(1, "module 경로는 필수입니다"),
|
|
67
|
+
slotModule: z.string().optional(),
|
|
68
|
+
clientModule: z.string().optional(),
|
|
69
|
+
contractModule: z.string().optional(),
|
|
70
|
+
hydration: HydrationConfig.optional(),
|
|
71
|
+
loader: LoaderConfig.optional(),
|
|
72
|
+
streaming: z.boolean().optional(),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// ---- Page 라우트 ----
|
|
76
|
+
export const PageRouteSpec = z
|
|
63
77
|
.object({
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
methods: z.array(
|
|
70
|
-
|
|
71
|
-
// 서버 모듈 (generated route handler)
|
|
72
|
-
module: z.string().min(1, "module 경로는 필수입니다"),
|
|
73
|
-
|
|
74
|
-
// 페이지 컴포넌트 모듈 (generated)
|
|
75
|
-
componentModule: z.string().optional(),
|
|
76
|
-
|
|
77
|
-
// 서버 슬롯 (비즈니스 로직)
|
|
78
|
-
slotModule: z.string().optional(),
|
|
79
|
-
|
|
80
|
-
// 클라이언트 슬롯 (interactive 로직) [NEW]
|
|
81
|
-
clientModule: z.string().optional(),
|
|
82
|
-
|
|
83
|
-
// Contract 모듈 (API 스키마 정의) [NEW]
|
|
84
|
-
contractModule: z.string().optional(),
|
|
85
|
-
|
|
86
|
-
// Hydration 설정 [NEW]
|
|
87
|
-
hydration: HydrationConfig.optional(),
|
|
88
|
-
|
|
89
|
-
// Loader 설정 [NEW]
|
|
90
|
-
loader: LoaderConfig.optional(),
|
|
91
|
-
|
|
92
|
-
// Streaming SSR 설정 [NEW v0.9.18]
|
|
93
|
-
// - true: 이 라우트에 Streaming SSR 적용
|
|
94
|
-
// - false: 이 라우트에 전통적 SSR 적용
|
|
95
|
-
// - undefined: 서버 설정 따름
|
|
96
|
-
streaming: z.boolean().optional(),
|
|
97
|
-
|
|
98
|
-
// Layout 체인 [NEW v0.9.33]
|
|
99
|
-
// - 페이지에 적용할 레이아웃 모듈 경로 배열
|
|
100
|
-
// - 배열 순서: 외부 → 내부 (Root → Parent → Child)
|
|
78
|
+
...RouteSpecBase,
|
|
79
|
+
kind: z.literal("page"),
|
|
80
|
+
// page 필수
|
|
81
|
+
componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"),
|
|
82
|
+
// page 전용 optional
|
|
83
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
101
84
|
layoutChain: z.array(z.string()).optional(),
|
|
102
|
-
|
|
103
|
-
// Loading UI 모듈 경로 [NEW v0.9.33]
|
|
104
85
|
loadingModule: z.string().optional(),
|
|
105
|
-
|
|
106
|
-
// Error UI 모듈 경로 [NEW v0.9.33]
|
|
107
86
|
errorModule: z.string().optional(),
|
|
108
87
|
})
|
|
109
88
|
.refine(
|
|
110
89
|
(route) => {
|
|
111
|
-
if (route.kind === "page" && !route.componentModule) {
|
|
112
|
-
return false;
|
|
113
|
-
}
|
|
114
|
-
return true;
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
message: "kind가 'page'인 경우 componentModule은 필수입니다",
|
|
118
|
-
path: ["componentModule"],
|
|
119
|
-
}
|
|
120
|
-
)
|
|
121
|
-
.refine(
|
|
122
|
-
(route) => {
|
|
123
|
-
// clientModule이 있으면 hydration.strategy가 none이 아니어야 함
|
|
124
90
|
if (route.clientModule && route.hydration?.strategy === "none") {
|
|
125
91
|
return false;
|
|
126
92
|
}
|
|
@@ -132,6 +98,46 @@ export const RouteSpec = z
|
|
|
132
98
|
}
|
|
133
99
|
);
|
|
134
100
|
|
|
101
|
+
export type PageRouteSpec = z.infer<typeof PageRouteSpec>;
|
|
102
|
+
|
|
103
|
+
// ---- API 라우트 ----
|
|
104
|
+
export const ApiRouteSpec = z.object({
|
|
105
|
+
...RouteSpecBase,
|
|
106
|
+
kind: z.literal("api"),
|
|
107
|
+
// api 전용
|
|
108
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
109
|
+
// page 전용 필드도 optional로 허용 (호환성)
|
|
110
|
+
componentModule: z.string().optional(),
|
|
111
|
+
layoutChain: z.array(z.string()).optional(),
|
|
112
|
+
loadingModule: z.string().optional(),
|
|
113
|
+
errorModule: z.string().optional(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
export type ApiRouteSpec = z.infer<typeof ApiRouteSpec>;
|
|
117
|
+
|
|
118
|
+
// ---- discriminatedUnion ----
|
|
119
|
+
export const RouteSpec = z.discriminatedUnion("kind", [
|
|
120
|
+
// PageRouteSpec에 .refine()이 적용되어 있으므로 내부 shape를 직접 사용
|
|
121
|
+
z.object({
|
|
122
|
+
...RouteSpecBase,
|
|
123
|
+
kind: z.literal("page"),
|
|
124
|
+
componentModule: z.string().min(1, "kind가 'page'인 경우 componentModule은 필수입니다"),
|
|
125
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
126
|
+
layoutChain: z.array(z.string()).optional(),
|
|
127
|
+
loadingModule: z.string().optional(),
|
|
128
|
+
errorModule: z.string().optional(),
|
|
129
|
+
}),
|
|
130
|
+
z.object({
|
|
131
|
+
...RouteSpecBase,
|
|
132
|
+
kind: z.literal("api"),
|
|
133
|
+
methods: z.array(SpecHttpMethod).optional(),
|
|
134
|
+
componentModule: z.string().optional(),
|
|
135
|
+
layoutChain: z.array(z.string()).optional(),
|
|
136
|
+
loadingModule: z.string().optional(),
|
|
137
|
+
errorModule: z.string().optional(),
|
|
138
|
+
}),
|
|
139
|
+
]);
|
|
140
|
+
|
|
135
141
|
export type RouteSpec = z.infer<typeof RouteSpec>;
|
|
136
142
|
|
|
137
143
|
// ========== Manifest ==========
|
|
@@ -166,6 +172,28 @@ export const RoutesManifest = z
|
|
|
166
172
|
|
|
167
173
|
export type RoutesManifest = z.infer<typeof RoutesManifest>;
|
|
168
174
|
|
|
175
|
+
// ========== Assertion Functions ==========
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Asserts that the given route is a page route.
|
|
179
|
+
* After this call, TypeScript narrows the type to PageRouteSpec.
|
|
180
|
+
*/
|
|
181
|
+
export function assertPageRoute(route: RouteSpec): asserts route is PageRouteSpec {
|
|
182
|
+
if (route.kind !== "page") {
|
|
183
|
+
throw new Error(`Expected page route, got "${route.kind}" (id: ${route.id})`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Asserts that the given route is an API route.
|
|
189
|
+
* After this call, TypeScript narrows the type to ApiRouteSpec.
|
|
190
|
+
*/
|
|
191
|
+
export function assertApiRoute(route: RouteSpec): asserts route is ApiRouteSpec {
|
|
192
|
+
if (route.kind !== "api") {
|
|
193
|
+
throw new Error(`Expected API route, got "${route.kind}" (id: ${route.id})`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
169
197
|
// ========== 유틸리티 함수 ==========
|
|
170
198
|
|
|
171
199
|
/**
|