@mandujs/core 0.39.2 → 0.39.3

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.39.2",
3
+ "version": "0.39.3",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -97,7 +97,7 @@
97
97
  * flow is documented in this file's JSDoc; anyone editing this string
98
98
  * MUST update the exclusion-matrix test and the body-swap test to match.
99
99
  */
100
- export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function esc(h){try{if(typeof CSS!=="undefined"&&CSS&&typeof CSS.escape==="function")return CSS.escape(h);}catch(_){}return String(h).replace(/([^a-zA-Z0-9_-])/g,"\\\\$1");}function extractHash(u){var i=u.indexOf("#");return i>=0?u.slice(i+1):"";}function scrollToHash(hash,url){if(!hash){try{window.scrollTo(0,0);}catch(_){}return;}var el=null;try{el=document.getElementById?document.getElementById(hash):null;}catch(_){}if(!el){try{el=document.querySelector?document.querySelector('[name="'+esc(hash)+'"]'):null;}catch(_){}}if(el&&typeof el.scrollIntoView==="function"){try{el.scrollIntoView({behavior:"instant",block:"start"});}catch(e1){try{el.scrollIntoView();}catch(_){}}try{if(L.hash!=="#"+hash)L.hash="#"+hash;}catch(_){}info("scrolled to #"+hash,url==null?"":url);}else{info("hash target #"+hash+" not found, scrolling to top",url==null?"":url);try{window.scrollTo(0,0);}catch(_){}}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h)return null;var u;try{u=new URL(h,L.href);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;if(u.pathname===L.pathname&&u.search===L.search&&!u.hash)return null;return u;}function pickContainer(doc){var main=doc.querySelector("main");if(main)return{src:main,dst:document.querySelector("main"),kind:"main"};var root=doc.getElementById&&doc.getElementById("root");if(root){var dstR=document.getElementById?document.getElementById("root"):null;if(dstR)return{src:root,dst:dstR,kind:"#root"};}if(doc.body)return{src:doc.body,dst:document.body,kind:"body"};return null;}function mergeHead(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(!nh||!ch)return;var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}catch(e){warn("head merge failed",e&&e.message||e);}}function runScripts(container){try{var scripts=container.querySelectorAll("script");for(var i=0;i<scripts.length;i++){var old=scripts[i];var s=document.createElement("script");for(var j=0;j<old.attributes.length;j++){var a=old.attributes[j];try{s.setAttribute(a.name,a.value);}catch(_){}}if(!old.src)s.text=old.textContent||"";old.parentNode&&old.parentNode.removeChild(old);(document.head||document.body||document.documentElement).appendChild(s);}}catch(e){warn("script re-exec failed",e&&e.message||e);}}function doSwap(doc,url,startedAt){var perr=doc.querySelector&&doc.querySelector("parsererror");if(perr){hardNav(url,"DOMParser returned parsererror");return false;}var pick=pickContainer(doc);if(!pick||!pick.dst){hardNav(url,"no swap container matched (main/#root/body)");return false;}info("swap target container: "+pick.kind);try{pick.dst.innerHTML=pick.src.innerHTML;}catch(e){hardNav(url,"innerHTML assignment threw: "+(e&&e.message||e));return false;}mergeHead(doc);runScripts(pick.dst);scrollToHash(extractHash(url),url);var dur=0;try{dur=Math.round((performance&&performance.now?performance.now():Date.now())-startedAt);}catch(_){}info("swapped to "+url+" in "+dur+"ms (container="+pick.kind+")");try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:dur,container:pick.kind}}));}catch(_){}try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}return true;}function nav(url,push){var startedAt=0;try{startedAt=performance&&performance.now?performance.now():Date.now();}catch(_){startedAt=Date.now();}fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok){hardNav(url,"fetch responded "+r.status);return null;}var ct=r.headers.get("content-type");if(!ct||ct.indexOf("text/html")<0){hardNav(url,"non-HTML response ("+(ct||"no content-type")+")");return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url,"DOMParser unavailable");return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(e){hardNav(url,"DOMParser threw: "+(e&&e.message||e));return;}if(push){try{H.pushState({mandu:1},"",url);}catch(e){hardNav(url,"pushState threw: "+(e&&e.message||e));return;}}var run=function(){doSwap(doc,url,startedAt);};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(e){warn("startViewTransition threw, running swap directly",e&&e.message||e);run();}}else{run();}}).catch(function(e){hardNav(url,"fetch rejected: "+(e&&e.message||e));});}function samePageHashNav(u,push){var url=u.pathname+u.search+u.hash;if(push){try{H.pushState({mandu:1},"",url);}catch(e){warn("pushState threw on same-page hash nav",e&&e.message||e);}}info("same-page hash nav "+url);scrollToHash(u.hash?u.hash.slice(1):"",url);try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:0,container:"hash"}}));}catch(_){}}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();if(url.pathname===L.pathname&&url.search===L.search&&url.hash){samePageHashNav(url,true);return;}nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
100
+ export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;var TAG="[mandu-spa-nav]";function warn(m,d){try{console.warn(TAG+" "+m,d==null?"":d);}catch(_){}}function info(m,d){try{console.debug(TAG+" "+m,d==null?"":d);}catch(_){}}function hardNav(u,why){warn("falling back to full navigation: "+why,u);try{L.href=u;}catch(_){}}function esc(h){try{if(typeof CSS!=="undefined"&&CSS&&typeof CSS.escape==="function")return CSS.escape(h);}catch(_){}return String(h).replace(/([^a-zA-Z0-9_-])/g,"\\\\$1");}function extractHash(u){var i=u.indexOf("#");return i>=0?u.slice(i+1):"";}function scrollToHash(hash,url){if(!hash){try{window.scrollTo(0,0);}catch(_){}return;}var el=null;try{el=document.getElementById?document.getElementById(hash):null;}catch(_){}if(!el){try{el=document.querySelector?document.querySelector('[name="'+esc(hash)+'"]'):null;}catch(_){}}if(el&&typeof el.scrollIntoView==="function"){try{el.scrollIntoView({behavior:"instant",block:"start"});}catch(e1){try{el.scrollIntoView();}catch(_){}}try{if(L.hash!=="#"+hash)L.hash="#"+hash;}catch(_){}info("scrolled to #"+hash,url==null?"":url);}else{info("hash target #"+hash+" not found, scrolling to top",url==null?"":url);try{window.scrollTo(0,0);}catch(_){}}}function okAnchor(a){if(!a||!a.getAttribute)return null;if(a.hasAttribute("data-no-spa"))return null;if(a.hasAttribute("download"))return null;var t=a.getAttribute("target");if(t&&t!=="_self")return null;var h=a.getAttribute("href");if(!h)return null;var u;try{u=new URL(h,L.href);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;if(u.pathname===L.pathname&&u.search===L.search&&!u.hash)return null;return u;}function pickContainer(doc){var main=doc.querySelector("main");if(main)return{src:main,dst:document.querySelector("main"),kind:"main"};var root=doc.getElementById&&doc.getElementById("root");if(root){var dstR=document.getElementById?document.getElementById("root"):null;if(dstR)return{src:root,dst:dstR,kind:"#root"};}if(doc.body)return{src:doc.body,dst:document.body,kind:"body"};return null;}function mergeHead(doc){try{var newTitle=doc.querySelector("title");if(newTitle)document.title=newTitle.textContent||document.title;var nh=doc.head,ch=document.head;if(!nh||!ch)return;var keep={};var metas=ch.querySelectorAll("meta[name=viewport],meta[charset]");for(var i=0;i<metas.length;i++)keep[metas[i].outerHTML]=true;var sel="meta,link[rel=icon],link[rel=shortcut icon],link[rel=canonical]";var oldMetas=ch.querySelectorAll(sel);for(var j=0;j<oldMetas.length;j++){if(!keep[oldMetas[j].outerHTML])oldMetas[j].parentNode.removeChild(oldMetas[j]);}var newMetas=nh.querySelectorAll(sel);for(var k=0;k<newMetas.length;k++){if(!keep[newMetas[k].outerHTML])ch.appendChild(newMetas[k].cloneNode(true));}}catch(e){warn("head merge failed",e&&e.message||e);}}function runScripts(container){try{var scripts=container.querySelectorAll("script");for(var i=0;i<scripts.length;i++){var old=scripts[i];var s=document.createElement("script");for(var j=0;j<old.attributes.length;j++){var a=old.attributes[j];try{s.setAttribute(a.name,a.value);}catch(_){}}if(!old.src)s.text=old.textContent||"";old.parentNode&&old.parentNode.removeChild(old);(document.head||document.body||document.documentElement).appendChild(s);}}catch(e){warn("script re-exec failed",e&&e.message||e);}}function doSwap(doc,url,startedAt){var perr=doc.querySelector&&doc.querySelector("parsererror");if(perr){hardNav(url,"DOMParser returned parsererror");return false;}try{var cRoot=document.getElementById?document.getElementById("root"):null;var nRoot=doc.getElementById?doc.getElementById("root"):null;var ck=cRoot&&cRoot.getAttribute?cRoot.getAttribute("data-mandu-layout"):"";var nk=nRoot&&nRoot.getAttribute?nRoot.getAttribute("data-mandu-layout"):"";if(ck&&nk&&ck!==nk){hardNav(url,"cross-layout transition ("+ck+" -> "+nk+")");return false;}}catch(_){}var pick=pickContainer(doc);if(!pick||!pick.dst){hardNav(url,"no swap container matched (main/#root/body)");return false;}info("swap target container: "+pick.kind);try{pick.dst.innerHTML=pick.src.innerHTML;}catch(e){hardNav(url,"innerHTML assignment threw: "+(e&&e.message||e));return false;}mergeHead(doc);runScripts(pick.dst);scrollToHash(extractHash(url),url);var dur=0;try{dur=Math.round((performance&&performance.now?performance.now():Date.now())-startedAt);}catch(_){}info("swapped to "+url+" in "+dur+"ms (container="+pick.kind+")");try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:dur,container:pick.kind}}));}catch(_){}try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}return true;}function nav(url,push){var startedAt=0;try{startedAt=performance&&performance.now?performance.now():Date.now();}catch(_){startedAt=Date.now();}fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok){hardNav(url,"fetch responded "+r.status);return null;}var ct=r.headers.get("content-type");if(!ct||ct.indexOf("text/html")<0){hardNav(url,"non-HTML response ("+(ct||"no content-type")+")");return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url,"DOMParser unavailable");return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(e){hardNav(url,"DOMParser threw: "+(e&&e.message||e));return;}if(push){try{H.pushState({mandu:1},"",url);}catch(e){hardNav(url,"pushState threw: "+(e&&e.message||e));return;}}var run=function(){doSwap(doc,url,startedAt);};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(e){warn("startViewTransition threw, running swap directly",e&&e.message||e);run();}}else{run();}}).catch(function(e){hardNav(url,"fetch rejected: "+(e&&e.message||e));});}function samePageHashNav(u,push){var url=u.pathname+u.search+u.hash;if(push){try{H.pushState({mandu:1},"",url);}catch(e){warn("pushState threw on same-page hash nav",e&&e.message||e);}}info("same-page hash nav "+url);scrollToHash(u.hash?u.hash.slice(1):"",url);try{window.dispatchEvent(new CustomEvent("__MANDU_SPA_NAV__",{detail:{url:url,durationMs:0,container:"hash"}}));}catch(_){}}document.addEventListener("click",function(e){if(e.defaultPrevented)return;if(e.button!==0||e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)return;if(window.__MANDU_ROUTER_STATE__)return;var tgt=e.target;var a=tgt&&typeof tgt.closest==="function"?tgt.closest("a"):null;if(!a)return;var url=okAnchor(a);if(!url)return;e.preventDefault();if(url.pathname===L.pathname&&url.search===L.search&&url.hash){samePageHashNav(url,true);return;}nav(url.pathname+url.search+url.hash,true);},false);window.addEventListener("popstate",function(){if(window.__MANDU_ROUTER_STATE__)return;nav(L.pathname+L.search+L.hash,false);});window.__MANDU_SPA_HELPER__=1;})();`;
101
101
 
102
102
  /** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
103
103
  export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
@@ -2848,6 +2848,9 @@ async function renderPageSSR(
2848
2848
  hmrPort: settings.hmrPort,
2849
2849
  routeId: route.id,
2850
2850
  routePattern: route.pattern,
2851
+ // Issue #233 — emit data-mandu-layout so SPA nav can detect
2852
+ // cross-layout transitions and hard-nav instead of half-swapping.
2853
+ layoutChain: route.layoutChain,
2851
2854
  hydration: route.hydration,
2852
2855
  bundleManifest: settings.bundleManifest,
2853
2856
  criticalData: loaderData as Record<string, unknown> | undefined,
@@ -2896,6 +2899,9 @@ async function renderPageSSR(
2896
2899
  prefetch: settings.prefetch,
2897
2900
  spa: settings.spa,
2898
2901
  devtools: settings.devtools,
2902
+ // Issue #233 — SPA nav uses this to detect cross-layout transitions
2903
+ // and fall back to a hard navigation.
2904
+ layoutChain: route.layoutChain,
2899
2905
  });
2900
2906
  return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
2901
2907
  } catch (error) {
@@ -53,6 +53,16 @@ export interface SSROptions {
53
53
  bundleManifest?: BundleManifest;
54
54
  /** 라우트 ID (island 식별용) */
55
55
  routeId?: string;
56
+ /**
57
+ * Issue #233 — layout chain applied to this render, ordered outer → inner.
58
+ * Serialized into `data-mandu-layout` on `<div id="root">` so the SPA
59
+ * navigation helper can detect cross-layout transitions and fall back
60
+ * to a hard navigation instead of a soft `<main>` swap (which would
61
+ * leave stale layout chrome like a sidebar from the source layout).
62
+ * When omitted or empty the attribute is dropped — same-layout nav
63
+ * remains cheap and visually crossfaded.
64
+ */
65
+ layoutChain?: string[];
56
66
  /** 추가 head 태그 */
57
67
  headTags?: string;
58
68
  /** 추가 body 끝 태그 */
@@ -623,8 +633,25 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
623
633
  spa,
624
634
  devtools,
625
635
  devErrorOverlay,
636
+ layoutChain,
626
637
  } = options;
627
638
 
639
+ // Issue #233 — derive a stable short key for the layout chain so the
640
+ // SPA nav helper can detect cross-layout transitions. Deterministic
641
+ // per server, stable across the same route/layout combo. Missing /
642
+ // empty chain → omit the attribute entirely (attribute absence acts
643
+ // like a wildcard match).
644
+ let layoutKey = "";
645
+ if (layoutChain && layoutChain.length > 0) {
646
+ let hash = 0;
647
+ const joined = layoutChain.join("|");
648
+ for (let i = 0; i < joined.length; i++) {
649
+ hash = ((hash << 5) - hash + joined.charCodeAt(i)) | 0;
650
+ }
651
+ layoutKey = (hash >>> 0).toString(16).padStart(8, "0");
652
+ }
653
+ const rootAttrs = layoutKey ? ` data-mandu-layout="${layoutKey}"` : "";
654
+
628
655
  // CSS 링크 태그 생성
629
656
  // - cssPath가 string이면 해당 경로 사용
630
657
  // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
@@ -804,7 +831,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
804
831
  ${devErrorOverlayTag}
805
832
  </head>
806
833
  <body>
807
- <div id="root">${bodyContent}</div>
834
+ <div id="root"${rootAttrs}>${bodyContent}</div>
808
835
  ${dataScript}
809
836
  ${routeScript}
810
837
  ${hydrationScripts}
@@ -99,6 +99,12 @@ export interface StreamingSSROptions {
99
99
  routeId?: string;
100
100
  /** 라우트 패턴 */
101
101
  routePattern?: string;
102
+ /**
103
+ * Issue #233 — layout chain (outer → inner). Emitted as
104
+ * `data-mandu-layout="<hash>"` on `<div id="root">` so the SPA
105
+ * navigation helper can detect cross-layout transitions.
106
+ */
107
+ layoutChain?: string[];
102
108
  /** Critical 데이터 (Shell과 함께 즉시 전송) - JSON-serializable object만 허용 */
103
109
  criticalData?: Record<string, unknown>;
104
110
  // Note: deferredData는 renderWithDeferredData의 deferredPromises로 대체됨
@@ -543,8 +549,24 @@ function generateHTMLShell(options: StreamingSSROptions): string {
543
549
  transitions = true,
544
550
  prefetch = true,
545
551
  spa = true,
552
+ layoutChain,
546
553
  } = options;
547
554
 
555
+ // Issue #233 — layout-key for SPA cross-layout detection. Mirror of the
556
+ // block in `ssr.ts::renderToHTML`; see that call-site for the full
557
+ // rationale. Streaming path must stamp the same attribute so the SPA
558
+ // helper's layout-mismatch heuristic is consistent across modes.
559
+ let layoutKey = "";
560
+ if (layoutChain && layoutChain.length > 0) {
561
+ let hash = 0;
562
+ const joined = layoutChain.join("|");
563
+ for (let i = 0; i < joined.length; i++) {
564
+ hash = ((hash << 5) - hash + joined.charCodeAt(i)) | 0;
565
+ }
566
+ layoutKey = (hash >>> 0).toString(16).padStart(8, "0");
567
+ }
568
+ const rootAttrs = layoutKey ? ` data-mandu-layout="${layoutKey}"` : "";
569
+
548
570
  // CSS 링크 태그 생성
549
571
  // - cssPath가 string이면 해당 경로 사용
550
572
  // - cssPath가 false 또는 undefined이면 링크 미삽입 (404 방지)
@@ -652,7 +674,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
652
674
  ${fastRefreshPreamble}
653
675
  </head>
654
676
  <body>
655
- <div id="root">${islandOpenTag}`;
677
+ <div id="root"${rootAttrs}>${islandOpenTag}`;
656
678
  }
657
679
 
658
680
  /**