@mandujs/core 0.26.0 → 0.27.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Issue #208 — Minimal inline SPA-navigation helper.
3
+ *
4
+ * Self-contained IIFE injected into the SSR `<head>` that upgrades plain
5
+ * full-page navigations into client-side `history.pushState` +
6
+ * `fetch` + DOM-swap transitions, without loading any JS bundle.
7
+ *
8
+ * Motivating use case: docs / blog / marketing sites that build with
9
+ * `hydration: "none"` (no islands). Under Issue #193 the opt-out SPA
10
+ * router lives in `@mandujs/core/client` (`router.ts`), which only ships
11
+ * inside a hydration bundle. Zero-JS pages therefore lost the "feels
12
+ * like a SPA" behavior that `spa: true` (the framework default) promises.
13
+ *
14
+ * This helper fills the gap: ~1.6 KB of inline JavaScript that the
15
+ * browser parses and runs immediately, no module graph, no network
16
+ * round-trip. Paired with the `@view-transition { navigation: auto }`
17
+ * style block (#192) the result is a visually-animated pushState
18
+ * navigation on every internal link click.
19
+ *
20
+ * Design constraints (locked — changing any of these needs an explicit
21
+ * rationale in the PR):
22
+ *
23
+ * 1. **Exclusion parity with the full router** (`router.ts`
24
+ * `handleLinkClick`): every browser-owned escape hatch — modifier
25
+ * keys, non-left click, `target` other than `_self`, `download`,
26
+ * `mailto:` / `tel:` / `javascript:` / …, cross-origin, hash-only,
27
+ * no `href`, `data-no-spa`, and `event.defaultPrevented` — is
28
+ * checked here too. Regression matrix lives at
29
+ * `tests/client/spa-nav-helper-exclusions.test.ts`.
30
+ *
31
+ * 2. **Co-existence with the full router**: both handlers listen
32
+ * on `document` `click`. The helper bails out early when
33
+ * `window.__MANDU_ROUTER_STATE__` is present — that global is
34
+ * installed by `initializeRouter()` before it calls
35
+ * `addEventListener`, so on hydrated pages the full router wins.
36
+ * On pure-SSR pages the state global is missing and the helper
37
+ * is authoritative.
38
+ *
39
+ * 3. **View Transitions API** — we call
40
+ * `document.startViewTransition(cb)` when available, mirroring the
41
+ * `@view-transition` at-rule we already inject. Browsers without
42
+ * the API (Firefox, Safari < 18.2) execute the callback
43
+ * synchronously so the feature is a pure progressive enhancement.
44
+ *
45
+ * 4. **DOM swap strategy**: replace `document.body.innerHTML` using
46
+ * the parsed incoming document's `<body>`. This preserves the
47
+ * `<head>` across navigations (avoids re-running inline scripts
48
+ * like this helper) while still picking up `<title>` and
49
+ * `<meta>` changes via a selective head-element merge. We also
50
+ * reset `document.title`.
51
+ *
52
+ * 5. **Inline, not external**: same rationale as #192's prefetch
53
+ * helper — inline removes the extra round-trip on every SSR
54
+ * response, keeps the CSP posture simple (only two inline scripts:
55
+ * prefetch + spa-nav), and sidesteps the "zero-JS but loads one
56
+ * JS file anyway" awkwardness.
57
+ *
58
+ * 6. **Opt-out via `ssr.spa: false`**: the injection site
59
+ * (`ssr.ts::renderToHTML`, `streaming-ssr.ts::generateHTMLShell`)
60
+ * omits the `<script>` block entirely when the user's config sets
61
+ * `spa: false`. No runtime check needed inside the IIFE.
62
+ *
63
+ * The exported `SPA_NAV_HELPER_SCRIPT` wraps the IIFE in a
64
+ * `<script>` tag, ready to paste into `<head>` alongside the prefetch
65
+ * helper and `@view-transition` style block.
66
+ *
67
+ * Size target: ≤3 KB raw (currently ≈2.7 KB after the defensive
68
+ * hardNav / DOMParser-availability guards). If this grows past 3 KB we
69
+ * should revisit the inline-vs-external trade-off.
70
+ */
71
+
72
+ /**
73
+ * Inner IIFE — exposed for unit tests that want to parse the source.
74
+ *
75
+ * Byte-minified on purpose (no comments, short names). The high-level
76
+ * flow is documented in this file's JSDoc; anyone editing this string
77
+ * MUST update the exclusion-matrix test to match.
78
+ */
79
+ export const SPA_NAV_HELPER_BODY = `(function(){if(typeof document==="undefined"||typeof window==="undefined")return;var L=window.location;var H=window.history;function hardNav(u){try{L.href=u;}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||h.charAt(0)==="#")return null;var u;try{u=new URL(h,L.origin);}catch(_){return null;}if(u.origin!==L.origin)return null;if(u.protocol!=="http:"&&u.protocol!=="https:")return null;return u;}function swap(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){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));}}var nb=doc.body;if(nb)document.body.innerHTML=nb.innerHTML;try{window.scrollTo(0,0);}catch(_){}}catch(_){}}function nav(url,push){fetch(url,{credentials:"same-origin",headers:{"Accept":"text/html"}}).then(function(r){if(!r.ok||!r.headers.get("content-type")||r.headers.get("content-type").indexOf("text/html")<0){hardNav(url);return null;}return r.text();}).then(function(html){if(html==null)return;if(typeof DOMParser==="undefined"){hardNav(url);return;}var doc;try{doc=new DOMParser().parseFromString(html,"text/html");}catch(_){hardNav(url);return;}if(push){try{H.pushState({mandu:1},"",url);}catch(_){hardNav(url);return;}}var run=function(){swap(doc);try{window.dispatchEvent(new CustomEvent("mandu:spa-navigate",{detail:{url:url}}));}catch(_){}};if(typeof document.startViewTransition==="function"){try{document.startViewTransition(run);}catch(_){run();}}else{run();}}).catch(function(){hardNav(url);});}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();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;})();`;
80
+
81
+ /** Ready-to-inject `<script>` tag for SSR `<head>` injection. */
82
+ export const SPA_NAV_HELPER_SCRIPT = `<script>${SPA_NAV_HELPER_BODY}</script>`;
@@ -376,6 +376,17 @@ export interface ServerOptions {
376
376
  * also opt out via `data-no-prefetch`.
377
377
  */
378
378
  prefetch?: boolean;
379
+ /**
380
+ * Issue #193 / #208 — enable opt-out SPA navigation (default `true`).
381
+ * When `true`, every SSR response gets (a) the `window.__MANDU_SPA__`
382
+ * global elided (the router's default) and (b) the inline SPA-nav
383
+ * IIFE (~1.6 KB) that intercepts internal `<a>` clicks with pushState
384
+ * + fetch + View-Transitions DOM-swap, so `hydration: "none"` projects
385
+ * still feel like a SPA. `false` reverts to legacy full-reload (and
386
+ * the full router's opt-in `data-mandu-link` requirement). Wired from
387
+ * `ManduConfig.spa`; per-link opt-out lives on `data-no-spa`.
388
+ */
389
+ spa?: boolean;
379
390
  /**
380
391
  * Issue #191 — override dev-mode `_devtools.js` injection.
381
392
  * Wired from `ManduConfig.dev.devtools`.
@@ -527,6 +538,14 @@ export interface ServerRegistrySettings {
527
538
  * default); `false` suppresses the hover prefetch `<script>` injection.
528
539
  */
529
540
  prefetch?: boolean;
541
+ /**
542
+ * Issue #193 / #208 — threaded from `ServerOptions.spa`.
543
+ * `undefined` is treated as `true` at the SSR call-site (default SPA
544
+ * nav on, helper injected). `false` both disables the full client
545
+ * router (opt-in via `data-mandu-link` only) AND omits the inline
546
+ * SPA-nav IIFE from `<head>`.
547
+ */
548
+ spa?: boolean;
530
549
  /**
531
550
  * Issue #191 — threaded from `ServerOptions.devtools`. `undefined`
532
551
  * means "use default (islands → inject)"; `true` / `false` force the
@@ -2081,6 +2100,7 @@ async function renderPageSSR(
2081
2100
  cssPath: settings.cssPath,
2082
2101
  transitions: settings.transitions,
2083
2102
  prefetch: settings.prefetch,
2103
+ spa: settings.spa,
2084
2104
  devtools: settings.devtools,
2085
2105
  onShellReady: () => {
2086
2106
  if (settings.isDev) {
@@ -2119,6 +2139,7 @@ async function renderPageSSR(
2119
2139
  islandPreWrapped: !!needsIslandWrap,
2120
2140
  transitions: settings.transitions,
2121
2141
  prefetch: settings.prefetch,
2142
+ spa: settings.spa,
2122
2143
  devtools: settings.devtools,
2123
2144
  });
2124
2145
  return ok(cookies ? cookies.applyToResponse(ssrResponse) : ssrResponse);
@@ -2162,6 +2183,7 @@ async function renderPageSSR(
2162
2183
  cssPath: settings.cssPath,
2163
2184
  transitions: settings.transitions,
2164
2185
  prefetch: settings.prefetch,
2186
+ spa: settings.spa,
2165
2187
  devtools: settings.devtools,
2166
2188
  });
2167
2189
  return ok(cookies ? cookies.applyToResponse(errorHtml) : errorHtml);
@@ -2268,6 +2290,7 @@ async function renderNotFoundPage(
2268
2290
  cssPath: settings.cssPath,
2269
2291
  transitions: settings.transitions,
2270
2292
  prefetch: settings.prefetch,
2293
+ spa: settings.spa,
2271
2294
  devtools: settings.devtools,
2272
2295
  });
2273
2296
 
@@ -2749,6 +2772,7 @@ async function handleRequestInternal(
2749
2772
  cssPath: settings.cssPath,
2750
2773
  transitions: settings.transitions,
2751
2774
  prefetch: settings.prefetch,
2775
+ spa: settings.spa,
2752
2776
  devtools: settings.devtools,
2753
2777
  });
2754
2778
  const headers = new Headers(html.headers);
@@ -2907,6 +2931,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2907
2931
  managementToken,
2908
2932
  transitions,
2909
2933
  prefetch,
2934
+ spa,
2910
2935
  devtools,
2911
2936
  observability: observabilityOption,
2912
2937
  } = options;
@@ -2946,6 +2971,7 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
2946
2971
  managementToken,
2947
2972
  transitions,
2948
2973
  prefetch,
2974
+ spa,
2949
2975
  devtools,
2950
2976
  heapEndpoint: observabilityOption?.heapEndpoint,
2951
2977
  metricsEndpoint: observabilityOption?.metricsEndpoint,
@@ -10,6 +10,7 @@ import { escapeHtmlAttr, escapeHtmlText, escapeJsonForInlineScript } from "./esc
10
10
  import { REACT_INTERNALS_SHIM_SCRIPT } from "./shims";
11
11
  import { generateFastRefreshPreamble } from "../bundler/dev";
12
12
  import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
13
+ import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
13
14
 
14
15
  /**
15
16
  * Issue #192 — `@view-transition` at-rule block.
@@ -579,6 +580,16 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
579
580
  // or cancel with a later inline style. False disables each independently.
580
581
  const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
581
582
  const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
583
+ // Issue #208 — Inline SPA-nav IIFE. Pre-#208 Issue #193 wired the SPA
584
+ // click handler into the client bundle (`router.ts::handleLinkClick`),
585
+ // which never ships to `hydration: "none"` projects (docs / marketing
586
+ // sites). The inline helper closes that gap: ~1.6 KB of JS that runs
587
+ // parse-time on every SSR response and intercepts internal anchor
588
+ // clicks using the same 10 exclusion cases as the full router.
589
+ // Coexists safely with the full router via a `__MANDU_ROUTER_STATE__`
590
+ // early-exit. Emit when `spa !== false`; skip entirely when the user
591
+ // opts out via `ssr.spa: false` (same flag the big-router reads).
592
+ const spaNavHelperTag = spa !== false ? SPA_NAV_HELPER_SCRIPT : "";
582
593
 
583
594
  // useHead/useSeoMeta SSR 수집
584
595
  let collectedHeadTags = "";
@@ -709,6 +720,7 @@ export function renderToHTML(element: ReactElement, options: SSROptions = {}): s
709
720
  ${cssLinkTag}
710
721
  ${viewTransitionTag}
711
722
  ${prefetchScriptTag}
723
+ ${spaNavHelperTag}
712
724
  ${hoistedLinkTags}
713
725
  ${headTags}
714
726
  ${collectedHeadTags}
@@ -25,6 +25,7 @@ import { getRenderToString } from "./react-renderer";
25
25
  import { mark, measure } from "../perf";
26
26
  import { generateFastRefreshPreamble } from "../bundler/dev";
27
27
  import { PREFETCH_HELPER_SCRIPT } from "../client/prefetch-helper";
28
+ import { SPA_NAV_HELPER_SCRIPT } from "../client/spa-nav-helper";
28
29
 
29
30
  /**
30
31
  * Issue #192 — `@view-transition` at-rule, mirror of the constant in
@@ -176,6 +177,14 @@ export interface StreamingSSROptions {
176
177
  * Default: `true`.
177
178
  */
178
179
  prefetch?: boolean;
180
+ /**
181
+ * Issue #208 — emit the inline SPA-nav helper `<script>` (~1.6 KB)
182
+ * into the streaming shell `<head>`. Mirrors `SSROptions.spa`:
183
+ * `true` (default) injects so zero-JS / `hydration: "none"` projects
184
+ * still get pushState navigations + View Transitions API; `false`
185
+ * omits the block entirely, matching the legacy full-reload default.
186
+ */
187
+ spa?: boolean;
179
188
  /**
180
189
  * Issue #191 — control dev-mode injection of the `_devtools.js`
181
190
  * bundle. Mirrors `SSROptions.devtools`:
@@ -533,6 +542,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
533
542
  isDev = false,
534
543
  transitions = true,
535
544
  prefetch = true,
545
+ spa = true,
536
546
  } = options;
537
547
 
538
548
  // CSS 링크 태그 생성
@@ -549,6 +559,11 @@ function generateHTMLShell(options: StreamingSSROptions): string {
549
559
  // an inline style later in the document order.
550
560
  const viewTransitionTag = transitions !== false ? VIEW_TRANSITION_STYLE_TAG : "";
551
561
  const prefetchScriptTag = prefetch !== false ? PREFETCH_HELPER_SCRIPT : "";
562
+ // Issue #208 — Inline SPA-nav IIFE, mirror of `ssr.ts::renderToHTML`.
563
+ // See that call-site for the full rationale. Streaming SSR follows
564
+ // the same opt-out contract: `spa !== false` injects, `spa: false`
565
+ // omits the `<script>` block entirely.
566
+ const spaNavHelperTag = spa !== false ? SPA_NAV_HELPER_SCRIPT : "";
552
567
 
553
568
  // Island wrapper (hydration이 필요한 경우)
554
569
  const needsHydration = hydration && hydration.strategy !== "none" && routeId && bundleManifest;
@@ -630,6 +645,7 @@ function generateHTMLShell(options: StreamingSSROptions): string {
630
645
  ${cssLinkTag}
631
646
  ${viewTransitionTag}
632
647
  ${prefetchScriptTag}
648
+ ${spaNavHelperTag}
633
649
  ${loadingStyles}
634
650
  ${importMapScript}
635
651
  ${headTags}