@mandujs/core 0.54.21 → 0.54.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.21",
3
+ "version": "0.54.22",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -165,42 +165,42 @@ const patternCache = new LRUCache<string, CompiledPattern>(LIMITS.ROUTER_PATTERN
165
165
  // because `registerCacheSize` replaces any prior reporter under the same key.
166
166
  registerCacheSize("patternCache", () => patternCache.size);
167
167
 
168
- /**
169
- * 패턴을 정규식으로 컴파일
170
- */
171
- function compilePattern(pattern: string): CompiledPattern {
172
- const cached = patternCache.get(pattern);
173
- if (cached) return cached;
174
-
175
- const paramNames: string[] = [];
176
- const normalized = pattern === "/" ? "/" : pattern.replace(/\/+$/, "") || "/";
177
- const segments = normalized.split("/").filter(Boolean);
178
-
179
- const regexStr = segments.length === 0
180
- ? "/"
181
- : segments.map((segment) => {
182
- if (segment === "*") {
183
- return "/.+";
184
- }
185
-
186
- const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\*(\?)?$/);
187
- if (wildcardMatch) {
188
- paramNames.push(wildcardMatch[1]);
189
- return wildcardMatch[2] === "?" ? "(?:/(.*))?" : "/(.+)";
190
- }
191
-
192
- const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
193
- if (paramMatch) {
194
- paramNames.push(paramMatch[1]);
195
- return "/([^/]+)";
196
- }
197
-
198
- return `/${escapePatternSegment(segment)}`;
199
- }).join("");
200
-
201
- const compiled = {
202
- regex: new RegExp(`^${regexStr}$`),
203
- paramNames,
168
+ /**
169
+ * 패턴을 정규식으로 컴파일
170
+ */
171
+ function compilePattern(pattern: string): CompiledPattern {
172
+ const cached = patternCache.get(pattern);
173
+ if (cached) return cached;
174
+
175
+ const paramNames: string[] = [];
176
+ const normalized = pattern === "/" ? "/" : pattern.replace(/\/+$/, "") || "/";
177
+ const segments = normalized.split("/").filter(Boolean);
178
+
179
+ const regexStr = segments.length === 0
180
+ ? "/"
181
+ : segments.map((segment) => {
182
+ if (segment === "*") {
183
+ return "/.+";
184
+ }
185
+
186
+ const wildcardMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)\*(\?)?$/);
187
+ if (wildcardMatch) {
188
+ paramNames.push(wildcardMatch[1]);
189
+ return wildcardMatch[2] === "?" ? "(?:/(.*))?" : "/(.+)";
190
+ }
191
+
192
+ const paramMatch = segment.match(/^:([a-zA-Z_][a-zA-Z0-9_]*)$/);
193
+ if (paramMatch) {
194
+ paramNames.push(paramMatch[1]);
195
+ return "/([^/]+)";
196
+ }
197
+
198
+ return `/${escapePatternSegment(segment)}`;
199
+ }).join("");
200
+
201
+ const compiled = {
202
+ regex: new RegExp(`^${regexStr}$`),
203
+ paramNames,
204
204
  };
205
205
 
206
206
  patternCache.set(pattern, compiled);
@@ -219,17 +219,17 @@ function extractParamsFromPath(
219
219
 
220
220
  if (!match) return {};
221
221
 
222
- const params: Record<string, string> = {};
223
- compiled.paramNames.forEach((name, index) => {
224
- params[name] = match[index + 1] ?? "";
225
- });
226
-
227
- return params;
228
- }
229
-
230
- function escapePatternSegment(segment: string): string {
231
- return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
232
- }
222
+ const params: Record<string, string> = {};
223
+ compiled.paramNames.forEach((name, index) => {
224
+ params[name] = match[index + 1] ?? "";
225
+ });
226
+
227
+ return params;
228
+ }
229
+
230
+ function escapePatternSegment(segment: string): string {
231
+ return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
232
+ }
233
233
 
234
234
  // ========== Navigation ==========
235
235
 
@@ -324,6 +324,16 @@ export async function navigate(
324
324
  // json 파싱 사이에 새 네비게이션이 시작됐을 수 있음
325
325
  if (controller.signal.aborted) return;
326
326
 
327
+ // #316: server-only target (no client-renderable route component). A
328
+ // client-side state update would change the URL but not the content
329
+ // ("click does nothing / goes back"). Fall back to a full document
330
+ // navigation so the server SSRs the page. `=== false` is intentional:
331
+ // older servers omit the flag, and we must not regress those.
332
+ if (data.clientRenderable === false) {
333
+ window.location.href = url.href;
334
+ return;
335
+ }
336
+
327
337
  // 상태 + History + 스크롤을 한 번에 적용하는 함수
328
338
  const applyUpdate = () => {
329
339
  const historyState = { routeId: data.routeId, params: data.params };
@@ -2638,11 +2638,22 @@ async function handlePageRoute(
2638
2638
  // in prod is zero-value attack surface (request-triggered response
2639
2639
  // header reflection). Silently ignore the request header instead.
2640
2640
  const isHDR = settings.isDev && req.headers.get("x-mandu-hdr") === "1";
2641
+ // #316: tell the full SPA router whether it can render this route on the
2642
+ // client. Server-only pages (no route-level client hydration bundle) have
2643
+ // no client component, so a pushState + state update leaves the URL changed
2644
+ // but the content stale. When false, the router must fall back to a full
2645
+ // document navigation so the server re-renders the page.
2646
+ const clientRenderable = !!(
2647
+ route.hydration &&
2648
+ route.hydration.strategy !== "none" &&
2649
+ settings.bundleManifest?.bundles[route.id]?.js
2650
+ );
2641
2651
  const jsonResponse = Response.json({
2642
2652
  routeId: route.id,
2643
2653
  pattern: route.pattern,
2644
2654
  params,
2645
2655
  loaderData: loaderData ?? null,
2656
+ clientRenderable,
2646
2657
  timestamp: Date.now(),
2647
2658
  });
2648
2659
  if (isHDR) {
@@ -6,13 +6,13 @@ import type { BundleManifest } from "../bundler/types";
6
6
  import { isSafeManduUrl } from "../bundler/manifest-schema";
7
7
  import type { HydrationConfig, HydrationPriority } from "../spec/schema";
8
8
  import { PORTS, TIMEOUTS } from "../constants";
9
- import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
9
+ import { decodeHtmlText, escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./escape";
10
10
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
- import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
11
+ import { generateFastRefreshPreamble } from "../bundler/fast-refresh-preamble";
12
12
  import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
13
13
  import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
14
- import { maybeInjectDevOverlay } from "../dev-error-overlay";
15
- import { renderWithManduClientBoundaryManifest } from "../internal/client-boundary";
14
+ import { maybeInjectDevOverlay } from "../dev-error-overlay";
15
+ import { renderWithManduClientBoundaryManifest } from "../internal/client-boundary";
16
16
 
17
17
  /**
18
18
  * Issue #192 — `@view-transition` at-rule block.
@@ -47,7 +47,7 @@ export interface SSROptions {
47
47
  title?: string;
48
48
  lang?: string;
49
49
  /** 서버에서 로드한 데이터 (클라이언트로 전달) */
50
- serverData?: unknown;
50
+ serverData?: unknown;
51
51
  /** Hydration 설정 */
52
52
  hydration?: HydrationConfig;
53
53
  /** 번들 매니페스트 */
@@ -256,36 +256,36 @@ function generateHydrationScripts(
256
256
  ? Object.values(manifest.islands).filter((ib) => ib.route === routeId)
257
257
  : [];
258
258
 
259
- if (routeIslands.length > 0) {
260
- for (const ib of routeIslands) {
261
- const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
262
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
263
- }
259
+ if (routeIslands.length > 0) {
260
+ for (const ib of routeIslands) {
261
+ const cacheBust = `${ib.js}${ib.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
262
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
263
+ }
264
264
  } else {
265
265
  // Fallback: route-level bundle (backward compat)
266
266
  const bundle = manifest.bundles[routeId];
267
267
  if (bundle) {
268
268
  const cacheBust = `${bundle.js}${bundle.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
269
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
270
- }
271
- }
272
-
273
- if (manifest.partials) {
274
- for (const partial of Object.values(manifest.partials)) {
275
- const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
276
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
277
- }
278
- }
279
-
280
- if (manifest.boundaries) {
281
- for (const boundary of Object.values(manifest.boundaries)) {
282
- if (boundary.route !== routeId) continue;
283
- const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
284
- scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
285
- }
286
- }
287
-
288
- // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
269
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
270
+ }
271
+ }
272
+
273
+ if (manifest.partials) {
274
+ for (const partial of Object.values(manifest.partials)) {
275
+ const cacheBust = `${partial.js}${partial.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
276
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
277
+ }
278
+ }
279
+
280
+ if (manifest.boundaries) {
281
+ for (const boundary of Object.values(manifest.boundaries)) {
282
+ if (boundary.route !== routeId) continue;
283
+ const cacheBust = `${boundary.js}${boundary.js.includes('?') ? '&' : '?'}v=${Date.now()}`;
284
+ scripts.push(`<link rel="modulepreload" href="${escapeHtmlAttr(cacheBust)}">`);
285
+ }
286
+ }
287
+
288
+ // Runtime 로드 (hydrateIslands 실행 - dynamic import 사용)
289
289
  if (manifest.shared.runtime) {
290
290
  scripts.push(`<script type="module" src="${escapeHtmlAttr(manifest.shared.runtime)}"></script>`);
291
291
  }
@@ -628,9 +628,9 @@ export async function resolveAsyncElement(node: ReactNode): Promise<ReactNode> {
628
628
  return React.cloneElement(element, undefined, resolvedChildren);
629
629
  }
630
630
 
631
- export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
632
- const hasExplicitTitle = options.title !== undefined;
633
- const {
631
+ export function renderToHTML(element: ReactElement, options: SSROptions = {}): string {
632
+ const hasExplicitTitle = options.title !== undefined;
633
+ const {
634
634
  title = "Mandu App",
635
635
  lang = "ko",
636
636
  serverData,
@@ -712,9 +712,9 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
712
712
  } catch { /* client 모듈 로드 실패 시 무시 */ }
713
713
 
714
714
  const renderToString = getRenderToString();
715
- let content = renderWithManduClientBoundaryManifest(routeId, bundleManifest, () =>
716
- renderToString(element),
717
- );
715
+ let content = renderWithManduClientBoundaryManifest(routeId, bundleManifest, () =>
716
+ renderToString(element),
717
+ );
718
718
 
719
719
  // 렌더링 중 수집된 head 태그
720
720
  collectedHeadTags = headGet?.() ?? "";
@@ -724,14 +724,14 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
724
724
  const needsHydration =
725
725
  hydration && hydration.strategy !== "none" && routeId && bundleManifest;
726
726
 
727
- if (needsHydration && !islandPreWrapped) {
728
- // v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
729
- const bundle = bundleManifest.bundles[routeId];
730
- const bundleSrc = bundle?.js;
731
- if (bundleSrc) {
732
- content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
733
- }
734
- }
727
+ if (needsHydration && !islandPreWrapped) {
728
+ // v0.8.0: bundleSrc를 data-mandu-src 속성으로 전달 (Runtime이 dynamic import로 로드)
729
+ const bundle = bundleManifest.bundles[routeId];
730
+ const bundleSrc = bundle?.js;
731
+ if (bundleSrc) {
732
+ content = wrapWithIsland(content, routeId, hydration.priority, bundleSrc);
733
+ }
734
+ }
735
735
 
736
736
  // Zero-JS 모드: island이 없는 페이지에서는 클라이언트 JS 번들을 전송하지 않음
737
737
  // HMR/DevTools는 dev 환경에서만 유지 (CSS 핫리로드 등)
@@ -742,10 +742,10 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
742
742
 
743
743
  if (needsHydration) {
744
744
  // 서버 데이터 스크립트 (클라이언트 hydration에서 사용)
745
- if (serverData !== undefined && routeId) {
746
- const wrappedData = {
747
- [routeId]: {
748
- serverData,
745
+ if (serverData !== undefined && routeId) {
746
+ const wrappedData = {
747
+ [routeId]: {
748
+ serverData,
749
749
  timestamp: Date.now(),
750
750
  },
751
751
  };
@@ -814,28 +814,52 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
814
814
  ? `<script>window.__MANDU_SPA__=false;</script>`
815
815
  : "";
816
816
 
817
- // #179: body 내 <link> 태그를 <head>로 호이스팅
817
+ // #179: body 내 <link> 태그를 <head>로 호이스팅
818
818
  // React 컴포넌트(layout.tsx 등)에서 <link>를 렌더링하면 body 안에 위치하게 되는데,
819
819
  // 폰트/스타일시트는 <head>에 있어야 FOUT 없이 로드됨
820
820
  const linkTagPattern = /<link\s[^>]*(?:rel=["'](?:stylesheet|preconnect|preload|icon|dns-prefetch)["'][^>]*|href=["'][^"']+["'][^>]*)\/?\s*>/gi;
821
821
  const hoistedLinks: string[] = [];
822
- const bodyContent = content.replace(linkTagPattern, (match) => {
822
+ const bodyAfterLinks = content.replace(linkTagPattern, (match) => {
823
823
  hoistedLinks.push(match);
824
824
  return "";
825
825
  });
826
- const hoistedLinkTags = hoistedLinks.join("\n ");
827
-
828
- // #273 F15 React 19 renders document metadata such as <title> from a
829
- // page component into the body string in this SSR path. Hoist the first
830
- // body title into <head> when no metadata/generateMetadata title was
831
- // provided, and always strip body titles to avoid duplicate/invalid HTML.
832
- let effectiveTitle = title;
833
- const titleTagPattern = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i;
834
- const bodyTitleMatch = bodyContent.match(titleTagPattern);
835
- const bodyWithoutTitle = bodyContent.replace(/<title(?:\s[^>]*)?>[\s\S]*?<\/title>/gi, "");
836
- if (!hasExplicitTitle && bodyTitleMatch) {
837
- effectiveTitle = decodeHtmlText(bodyTitleMatch[1] ?? title);
838
- }
826
+ const hoistedLinkTags = hoistedLinks.join("\n ");
827
+
828
+ // #317: hoist body <meta> (og:*, twitter:*, description, …) and JSON-LD into
829
+ // <head>. The legacy renderToString path does not perform React 19's
830
+ // document-metadata hoisting, so without this og/twitter cards land in the
831
+ // body where crawlers ignore them. Mirrors React 19's rule: <meta> is
832
+ // hoistable document metadata EXCEPT microdata (`itemprop`), which is
833
+ // content and must stay where it was authored.
834
+ const metaTagPattern = /<meta\s[^>]*?\/?>/gi;
835
+ const hoistedMetas: string[] = [];
836
+ const bodyAfterMeta = bodyAfterLinks.replace(metaTagPattern, (match) => {
837
+ if (/\bitemprop[\s=]/i.test(match)) return match;
838
+ hoistedMetas.push(match);
839
+ return "";
840
+ });
841
+ const hoistedMetaTags = hoistedMetas.join("\n ");
842
+
843
+ const ldJsonPattern =
844
+ /<script\s[^>]*type=["']application\/ld\+json["'][^>]*>[\s\S]*?<\/script>/gi;
845
+ const hoistedLdJson: string[] = [];
846
+ const bodyContent = bodyAfterMeta.replace(ldJsonPattern, (match) => {
847
+ hoistedLdJson.push(match);
848
+ return "";
849
+ });
850
+ const hoistedLdJsonTags = hoistedLdJson.join("\n ");
851
+
852
+ // #273 F15 — React 19 renders document metadata such as <title> from a
853
+ // page component into the body string in this SSR path. Hoist the first
854
+ // body title into <head> when no metadata/generateMetadata title was
855
+ // provided, and always strip body titles to avoid duplicate/invalid HTML.
856
+ let effectiveTitle = title;
857
+ const titleTagPattern = /<title(?:\s[^>]*)?>([\s\S]*?)<\/title>/i;
858
+ const bodyTitleMatch = bodyContent.match(titleTagPattern);
859
+ const bodyWithoutTitle = bodyContent.replace(/<title(?:\s[^>]*)?>[\s\S]*?<\/title>/gi, "");
860
+ if (!hasExplicitTitle && bodyTitleMatch) {
861
+ effectiveTitle = decodeHtmlText(bodyTitleMatch[1] ?? title);
862
+ }
839
863
 
840
864
  // Phase 18.α — Dev Error Overlay injection.
841
865
  // Only emitted when `isDev` AND the user has not opted out (via
@@ -853,19 +877,21 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
853
877
  <head>
854
878
  <meta charset="UTF-8">
855
879
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
856
- <title>${escapeHtmlText(effectiveTitle)}</title>
880
+ <title>${escapeHtmlText(effectiveTitle)}</title>
857
881
  ${cssLinkTag}
858
882
  ${viewTransitionTag}
859
883
  ${prefetchScriptTag}
860
884
  ${spaNavHelperTag}
861
885
  ${hoistedLinkTags}
886
+ ${hoistedMetaTags}
887
+ ${hoistedLdJsonTags}
862
888
  ${headTags}
863
889
  ${collectedHeadTags}
864
890
  ${fastRefreshPreamble}
865
891
  ${devErrorOverlayTag}
866
892
  </head>
867
893
  <body>
868
- <div id="root"${rootAttrs}>${bodyWithoutTitle}</div>
894
+ <div id="root"${rootAttrs}>${bodyWithoutTitle}</div>
869
895
  ${dataScript}
870
896
  ${routeScript}
871
897
  ${hydrationScripts}
@@ -882,11 +908,11 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
882
908
  /**
883
909
  * Client-side Routing: 현재 라우트 정보 스크립트 생성
884
910
  */
885
- function generateRouteScript(
886
- routeId: string,
887
- pattern: string,
888
- _serverData?: unknown
889
- ): string {
911
+ function generateRouteScript(
912
+ routeId: string,
913
+ pattern: string,
914
+ _serverData?: unknown
915
+ ): string {
890
916
  const routeInfo = {
891
917
  id: routeId,
892
918
  pattern,
@@ -1187,12 +1213,12 @@ export function renderSSR(element: ReactElement, options: SSROptions = {}): Resp
1187
1213
  */
1188
1214
  export async function renderWithHydration(
1189
1215
  element: ReactElement,
1190
- options: SSROptions & {
1191
- routeId: string;
1192
- serverData: unknown;
1193
- hydration: HydrationConfig;
1194
- bundleManifest: BundleManifest;
1195
- }
1216
+ options: SSROptions & {
1217
+ routeId: string;
1218
+ serverData: unknown;
1219
+ hydration: HydrationConfig;
1220
+ bundleManifest: BundleManifest;
1221
+ }
1196
1222
  ): Promise<Response> {
1197
1223
  const html = renderToHTML(element, options);
1198
1224
  // Phase 7.2 R1 Agent C (H1) — same CSP header logic as renderSSR.