@stacksjs/bun-router 0.1.7 → 0.1.8

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.
Files changed (100) hide show
  1. package/dist/chunk-4wd1d6ay.js +416 -0
  2. package/dist/{chunk-nf3kv7kv.js → chunk-8rrs7556.js} +1 -1
  3. package/dist/{chunk-3y4gaz7v.js → chunk-m4aaj9ay.js} +1 -1
  4. package/dist/cli.js +30 -59
  5. package/dist/container/index.js +1 -1
  6. package/dist/index.js +2 -2
  7. package/package.json +9 -1
  8. package/dist/chunk-03m2j6h7.js +0 -2
  9. package/dist/chunk-08cw7ejr.js +0 -3
  10. package/dist/chunk-0e4g0fyv.js +0 -62
  11. package/dist/chunk-0mmn02yw.js +0 -2
  12. package/dist/chunk-126c9qm3.js +0 -2
  13. package/dist/chunk-12p13817.js +0 -2
  14. package/dist/chunk-14v65c37.js +0 -4
  15. package/dist/chunk-1b6a7t3v.js +0 -231
  16. package/dist/chunk-1wenkmk0.js +0 -2
  17. package/dist/chunk-2152npfd.js +0 -2
  18. package/dist/chunk-26w23krx.js +0 -2
  19. package/dist/chunk-2g8m379z.js +0 -10
  20. package/dist/chunk-2rr178et.js +0 -1654
  21. package/dist/chunk-2w8f3swz.js +0 -29
  22. package/dist/chunk-4r1x4afz.js +0 -12
  23. package/dist/chunk-4wyw66zk.js +0 -2
  24. package/dist/chunk-4z5sf8ag.js +0 -2
  25. package/dist/chunk-58tfvayh.js +0 -4
  26. package/dist/chunk-5be9x5cq.js +0 -3
  27. package/dist/chunk-5dp83qcx.js +0 -397
  28. package/dist/chunk-5f0d9j9a.js +0 -2
  29. package/dist/chunk-5z52b0fc.js +0 -28
  30. package/dist/chunk-5zrnk2kq.js +0 -4
  31. package/dist/chunk-6bbmkwng.js +0 -3
  32. package/dist/chunk-6dmqtdnq.js +0 -2
  33. package/dist/chunk-6e91956t.js +0 -3
  34. package/dist/chunk-6rv8w5jk.js +0 -2
  35. package/dist/chunk-6x1feysm.js +0 -3
  36. package/dist/chunk-7qnxdedn.js +0 -321
  37. package/dist/chunk-7y72hm1x.js +0 -2
  38. package/dist/chunk-8hbhfh6b.js +0 -194
  39. package/dist/chunk-8kfzygg5.js +0 -3
  40. package/dist/chunk-8wpj3fqe.js +0 -351
  41. package/dist/chunk-8wydx2we.js +0 -2
  42. package/dist/chunk-8zjnjvv7.js +0 -2
  43. package/dist/chunk-9805tt8n.js +0 -341
  44. package/dist/chunk-988h4bzr.js +0 -2
  45. package/dist/chunk-9b6a58ac.js +0 -2
  46. package/dist/chunk-a8fcaywb.js +0 -2
  47. package/dist/chunk-bfjwn7yg.js +0 -2
  48. package/dist/chunk-bp2522mm.js +0 -2
  49. package/dist/chunk-brpgjyqg.js +0 -3
  50. package/dist/chunk-bswe62q4.js +0 -48
  51. package/dist/chunk-c3v796c8.js +0 -943
  52. package/dist/chunk-ccqmwdry.js +0 -2
  53. package/dist/chunk-ck0crwnx.js +0 -2
  54. package/dist/chunk-cqgkxcb4.js +0 -2
  55. package/dist/chunk-dntnakjy.js +0 -2
  56. package/dist/chunk-dxg045wj.js +0 -5
  57. package/dist/chunk-e3qej0jr.js +0 -4
  58. package/dist/chunk-ettdpnrw.js +0 -60
  59. package/dist/chunk-ezqwq17j.js +0 -185
  60. package/dist/chunk-ezx3qsez.js +0 -2
  61. package/dist/chunk-f72nnjy4.js +0 -15413
  62. package/dist/chunk-fna5n0ey.js +0 -246
  63. package/dist/chunk-fqhckdt8.js +0 -2
  64. package/dist/chunk-fr6hbx25.js +0 -2
  65. package/dist/chunk-g9cwng6w.js +0 -3
  66. package/dist/chunk-gd3za5x0.js +0 -2
  67. package/dist/chunk-gf28608k.js +0 -2
  68. package/dist/chunk-h1dj6v8q.js +0 -3
  69. package/dist/chunk-hjxe7k2g.js +0 -2
  70. package/dist/chunk-hrkgznbn.js +0 -2
  71. package/dist/chunk-j6h5v0br.js +0 -2
  72. package/dist/chunk-kjtjbcq4.js +0 -71
  73. package/dist/chunk-mgb01qdv.js +0 -14
  74. package/dist/chunk-n24c9whp.js +0 -2
  75. package/dist/chunk-n2j24b0h.js +0 -4540
  76. package/dist/chunk-nhmtg01n.js +0 -84
  77. package/dist/chunk-p0qhpnqh.js +0 -70
  78. package/dist/chunk-p0w1x8y2.js +0 -3
  79. package/dist/chunk-p3dk1w2w.js +0 -216
  80. package/dist/chunk-q4e9pxs7.js +0 -3
  81. package/dist/chunk-q5dv1kyy.js +0 -2
  82. package/dist/chunk-qnqgf9tq.js +0 -2
  83. package/dist/chunk-qvwy1808.js +0 -898
  84. package/dist/chunk-r18f77vc.js +0 -12
  85. package/dist/chunk-r63za1mr.js +0 -2
  86. package/dist/chunk-rft8gw92.js +0 -7851
  87. package/dist/chunk-ryvmyskt.js +0 -4211
  88. package/dist/chunk-sa6mx308.js +0 -2
  89. package/dist/chunk-sw0chtw4.js +0 -4
  90. package/dist/chunk-va0pc7hx.js +0 -2
  91. package/dist/chunk-vcp8dnjq.js +0 -412
  92. package/dist/chunk-vg1erq1q.js +0 -2
  93. package/dist/chunk-vsh4390q.js +0 -358
  94. package/dist/chunk-w0jv49ch.js +0 -11
  95. package/dist/chunk-wm805tte.js +0 -35
  96. package/dist/chunk-xg9xscqz.js +0 -1
  97. package/dist/chunk-xj9h6mc9.js +0 -2
  98. package/dist/chunk-ytvzwrq3.js +0 -51
  99. package/dist/chunk-zde7ebc4.js +0 -288
  100. package/dist/chunk-zmqsv2xy.js +0 -2
@@ -1,1654 +0,0 @@
1
- // @bun
2
- import E from"fs";import d from"path";import c from"fs";import f from"path";var g=["components","layouts","partials","emails"];function _(e){return e.split(/[\\/]/).filter(Boolean).slice(0,-1).some((t)=>g.includes(t))}function y(e){return _(e.replace(/^\//,""))}function k(e,t){let r=e.filter((o)=>!y(o.pattern)),a=["// Auto-generated by STX - do not edit"];if(r.length===0)a.push("// No page routes found \u2014 route paths stay typed as string.","export {}","");else{a.push("declare module '@stacksjs/stx' {"," interface KnownRoutes {");for(let o of r){let i=[];for(let u of o.params){let p=o.pattern.includes(`:${u}?`)?"?":"";i.push(`${u}${p}: string`)}let l=i.join("; ");a.push(` '${o.pattern}': { ${l} }`)}a.push(" }","}","","export {}","")}let n=a.join(`
3
- `),s=f.join(t,"route-types.d.ts");try{if(c.existsSync(s)&&c.readFileSync(s,"utf8")===n)return;c.mkdirSync(t,{recursive:!0}),c.writeFileSync(s,n)}catch{}}function C(e,t,r){let a=process.cwd(),n=f.relative(a,r),s=e.filter((i)=>!y(i.pattern)),o=["// Auto-generated by stx \u2014 do not edit",`// Source: ${n||"pages"}/`,"","export const routes = ["];for(let i of s){let l=f.relative(a,i.filePath).replace(/\\/g,"/"),u=JSON.stringify(i.params),p=i.layout?`, layout: '${f.relative(a,i.layout).replace(/\\/g,"/")}'`:"";o.push(` { pattern: '${i.pattern}', filePath: '${l}', isDynamic: ${i.isDynamic}, params: ${u}${p} },`)}o.push("] as const",""),o.push("export type RouteName = typeof routes[number]['pattern']",""),c.mkdirSync(t,{recursive:!0}),c.writeFileSync(f.join(t,"routes.ts"),o.join(`
4
- `))}import N from"path";function A(e,t){let r=N.relative(t,e).replace(/\.(stx|md)$/,"");if(r=r.split(N.sep).join("/"),r==="index")return"/";if(r.endsWith("/index"))r=r.slice(0,-6);return r=r.replace(/\[\[([^\]]+)\]\]/g,":$1?"),r=r.replace(/\[([^\]]+)\]/g,":$1"),r=r.replace(/:\.\.\.([^/]+)/g,":$1*"),`/${r}`}function v(e){let t=[],r="",a=0;while(a<e.length){let n=e.slice(a),s=/^\/:([^/?*]+)\?/.exec(n);if(s){t.push(s[1]),r+="(?:/([^/]+))?",a+=s[0].length;continue}let o=/^:([^/?*]+)\*/.exec(n);if(o){t.push(o[1]),r+="(.+)",a+=o[0].length;continue}let i=/^:([^/?*]+)/.exec(n);if(i){t.push(i[1]),r+="([^/]+)",a+=i[0].length;continue}r+=e[a].replace(/[.+^${}()|[\]\\*?]/g,"\\$&"),a+=1}return{regex:new RegExp(`^${r}$`),params:t}}function q(e,t){let r=e===""?"/":e;for(let a of t){let n=r.match(a.regex);if(n){let s={};return a.params.forEach((o,i)=>{s[o]=n[i+1]}),{route:a,params:s}}}return null}function H(e){let t=e.replace(/\[\[([^\]]+)\]\]/g,":$1?").replace(/\[([^\]]+)\]/g,":$1").replace(/:\.\.\.([^/]+)/g,":$1*");return v(t)}import W from"fs";import R from"path";function T(e,t){let r=[],a=R.dirname(e);while(a.startsWith(t)||a===t){let n=R.join(a,"_layout.stx");if(W.existsSync(n))r.unshift(n);if(a===t)break;a=R.dirname(a)}return r}function F(e,t){let r=[];if(!E.existsSync(e))return r;let a=E.readdirSync(e,{withFileTypes:!0});for(let n of a){let s=d.join(e,n.name);if(n.isDirectory()){if(n.name.startsWith("."))continue;if(g.includes(n.name))continue;r.push(...F(s,t))}else if(n.isFile()){let o=d.extname(n.name);if(t.includes(o)){if(n.name.startsWith("_")&&n.name!=="_layout.stx")continue;if(n.name==="_layout.stx")continue;r.push(s)}}}return r}class L{routes;pagesDir;pagesDirs;config;constructor(e,t={}){this.config=t;let r=t.pagesDirs&&t.pagesDirs.length>0?t.pagesDirs:[t.pagesDir||"pages"];this.pagesDirs=r.map((o)=>d.resolve(e,o)),this.pagesDir=this.pagesDirs[0];let a=t.extensions||[".stx"],n=new Set;this.routes=[];for(let o of this.pagesDirs){let i=F(o,a);for(let l of i){let u=A(l,o);if(n.has(u))continue;n.add(u);let{regex:p,params:O}=v(u),S=t.layouts!==!1?T(l,o):[];this.routes.push({pattern:u,regex:p,params:O,filePath:l,isDynamic:O.length>0,layout:S.length>0?S[S.length-1]:void 0})}}this.routes.sort((o,i)=>{if(!o.isDynamic&&i.isDynamic)return-1;if(o.isDynamic&&!i.isDynamic)return 1;let l=o.pattern.split("/").length,u=i.pattern.split("/").length;if(l!==u)return u-l;return o.pattern.localeCompare(i.pattern)});let s=d.resolve(e,t.stateDir||process.env.STX_DIR?.trim()||".stx");k(this.routes,s),C(this.routes,s,this.pagesDir)}match(e){let t=e===""?"/":e;for(let r of this.routes){let a=t.match(r.regex);if(a){let n={};return r.params.forEach((s,o)=>{n[s]=a[o+1]}),{route:r,params:n}}}return null}resolve(e,t){let r=this.routes.find((n)=>n.pattern===e||n.filePath.includes(e));if(!r)return"#not-found";let a=r.pattern;if(t)for(let[n,s]of Object.entries(t))a=a.replace(`:${n}`,encodeURIComponent(s));return a}addRoute(e){this.routes.push(e),this.routes.sort((t,r)=>{if(!t.isDynamic&&r.isDynamic)return-1;if(t.isDynamic&&!r.isDynamic)return 1;return t.pattern.localeCompare(r.pattern)})}getLayout(e){return e.layout??null}}function V(e,t){return new L(e,t)}function z(e,t){let r=[`${t}.stx`,"error.stx"];for(let a of r){let n=d.join(e,a);if(E.existsSync(n))return n}return null}function K(e,t){return e.map((r)=>{let a=d.relative(t,r.filePath),n=r.isDynamic?" (dynamic)":"";return`${r.pattern} \u2192 ${a}${n}`})}var h={},w="";function Y(e){w=e.replace(/\/$/,"")}function J(e,t,r={}){h[e]={path:t,name:e,params:r}}function Q(e){for(let[t,r]of Object.entries(e))if(typeof r==="string")h[t]={path:r,name:t};else h[t]={...r,name:t}}function Z(e,t={},r=!1){let a=h[e];if(!a)return console.warn(`Route [${e}] not defined.`),"#undefined-route";let n={...a.params,...t},s=a.path;s=s.replace(/:(\w+)/g,(i,l)=>{let u=n[l];if(u===void 0)return console.warn(`Missing parameter [${l}] for route [${e}].`),`:${l}`;return delete n[l],encodeURIComponent(String(u))});let o=Object.entries(n);if(o.length>0){let i=o.map(([l,u])=>`${encodeURIComponent(l)}=${encodeURIComponent(String(u))}`).join("&");s+=(s.includes("?")?"&":"?")+i}return r&&w?`${w}${s}`:s}function ee(){for(let e of Object.keys(h))delete h[e];w=""}import I from"fs";import D from"path";function te(e,t={}){return{type:"redirect",path:e,options:{replace:t.replace??!1,external:t.external??!1,redirectCode:t.redirectCode??302}}}function re(e){return{type:"abort",error:typeof e==="string"?{statusCode:500,message:e}:e}}function ae(e,t={}){return{handler:e,mode:t.mode??"universal"}}function P(e){let t={};if(!e)return t;let r=e.split(";");for(let a of r){let[n,...s]=a.split("=");if(n){let o=n.trim(),i=s.join("=").trim();if(o)t[o]=decodeURIComponent(i)}}return t}function x(e,t,r={}){let a=`${encodeURIComponent(e)}=${encodeURIComponent(t)}`;if(r.expires){let n=typeof r.expires==="number"?new Date(Date.now()+r.expires*1000):r.expires;a+=`; Expires=${n.toUTCString()}`}if(r.path)a+=`; Path=${r.path}`;if(r.domain)a+=`; Domain=${r.domain}`;if(r.secure)a+="; Secure";if(r.httpOnly)a+="; HttpOnly";if(r.sameSite)a+=`; SameSite=${r.sameSite}`;return a}function G(e,t){let r=e.headers.get("cookie")||"",a=P(r);return{get(n){return a[n]},set(n,s,o={}){a[n]=s,t.append("Set-Cookie",x(n,s,o))},delete(n){delete a[n],t.append("Set-Cookie",x(n,"",{expires:new Date(0),path:"/"}))},getAll(){return{...a}}}}function B(){return{get(e){if(typeof document>"u")return;return P(document.cookie)[e]},set(e,t,r={}){if(typeof document>"u")return;document.cookie=x(e,t,r)},delete(e){if(typeof document>"u")return;document.cookie=x(e,"",{expires:new Date(0),path:"/"})},getAll(){if(typeof document>"u")return{};return P(document.cookie)}}}function U(){let e=typeof window<"u"&&typeof window.localStorage<"u";return{get(t){if(!e)return null;try{return window.localStorage.getItem(t)}catch{return null}},set(t,r){if(!e)return;try{window.localStorage.setItem(t,r)}catch{}},remove(t){if(!e)return;try{window.localStorage.removeItem(t)}catch{}},isAvailable(){return e}}}var m=new Map;function M(e,t){m.set(e,t)}function X(e){return m.get(e)}function ne(e){return m.has(e)}function oe(){m.clear()}function se(){return Array.from(m.keys())}async function ie(e,t="middleware"){let r=D.resolve(e,t);if(!I.existsSync(r))return;let a=I.readdirSync(r);for(let n of a){if(!n.endsWith(".ts")&&!n.endsWith(".js"))continue;if(n.includes(".test.")||n.includes(".spec."))continue;let s=n.replace(/\.(ts|js)$/,""),o=D.join(r,n);try{let i=(await import(o)).default;if(i&&typeof i.handler==="function")M(s,i);else if(typeof i==="function")M(s,{handler:i,mode:"universal"})}catch(i){console.warn(`Failed to load middleware '${s}' from ${o}:`,i)}}}function le(e){let t={};if(!e)return t;let r=new URLSearchParams(e);for(let[a,n]of r)t[a]=n;return t}function ue(e,t,r,a=""){return{path:e,params:t,query:le(a),fullPath:a?`${e}?${a}`:e,meta:r}}function ce(e,t,r,a){let n=a?.isServer??typeof window>"u",s=!n,o=new Headers;return{to:e,from:t,isClient:s,isServer:n,cookies:n&&r?G(r,o):B(),storage:U(),state:{},request:r,responseHeaders:o}}async function de(e,t){let r=Array.isArray(e)?e:[e],a=t.isServer;for(let n of r){let s=X(n);if(!s)return console.error(`Route middleware '${n}' is not registered \u2014 failing the guard closed.`),{passed:!1,abort:{type:"abort",error:{statusCode:500,message:`Route middleware '${n}' is not registered`}},state:t.state,responseHeaders:t.responseHeaders};if(s.mode==="server"&&!a)continue;try{let o=await s.handler(t);if(o){if(o.type==="redirect")return{passed:!1,redirect:o,state:t.state,responseHeaders:t.responseHeaders};if(o.type==="abort")return{passed:!1,abort:o,state:t.state,responseHeaders:t.responseHeaders}}}catch(o){return console.error(`Error in middleware '${n}':`,o),{passed:!1,abort:{type:"abort",error:{statusCode:500,message:o instanceof Error?o.message:"Middleware error"}},state:t.state,responseHeaders:t.responseHeaders}}}return{passed:!0,state:t.state,responseHeaders:t.responseHeaders}}function j(e){let t=e.replace(/\\/g,"/").split("/").filter(Boolean),r=t.lastIndexOf("layouts"),a=r>=0?t[r+1]:t.at(-1);if(!a)return"app";return a.replace(/\.stx$/i,"")||"app"}function he(e){let t=e.match(/<meta\b[^>]*name=["']stx-layout["'][^>]*content=["']([^"']+)["'][^>]*>/i),r=e.match(/<meta\b[^>]*name=["']stx-layout-group["'][^>]*content=["']([^"']+)["'][^>]*>/i),a=e.match(/<!--\s*stx-layout:\s*([^ ]+)\s*-->/i),n=t?.[1]||a?.[1]||"";return{layout:n,group:r?.[1]||j(n)}}var b;function pe(e){try{return new Bun.Transpiler({loader:"js",minifyWhitespace:!0}).transformSync(e).replace(/\}(var |let |const |function )/g,"};$1").trim()}catch{return e}}function fe(){if(b)return b;return b=pe(`
5
- ;(function(){
6
- 'use strict';
7
- var ROUTER_REV=7;
8
- if(window.__stxRouter&&window.__stxRouter.__rev===ROUTER_REV)return;
9
-
10
- // \u2500\u2500 Configuration \u2500\u2500
11
- var defaults={container:'main',loadingClass:'stx-navigating',viewTransitions:true,cache:true,scrollToTop:true,prefetch:true,progress:true,progressColor:'#78dce8',progressHeight:'2px',interceptAllLinks:false,prefetchCacheMax:50,routeFocus:true,announceRoute:true,interceptForms:false};
12
- var o=Object.assign({},defaults,window.__stxRouterConfig||{},window.STX_ROUTER_OPTIONS||{});
13
- var containerSel=o.container;
14
- var debug=!!o.debug;
15
- function log(){if(debug&&typeof console!=='undefined'&&console.log)console.log.apply(console,arguments)}
16
-
17
- // \u2500\u2500 Build skew (stacksjs/stx#1772) \u2500\u2500
18
- // The build that rendered THIS document, and therefore the build the runtime
19
- // executing right now came from. Under bun --watch a save restarts the server,
20
- // so the next navigation can fetch a fragment produced by a newer build; when
21
- // the scoped-script or binding format drifted between the two, the old runtime
22
- // cannot hydrate the new fragment \u2014 literal moustaches, dead bindings, stale
23
- // canvas. Sporadic, and never on a clean boot.
24
- //
25
- // Conservative on purpose: act only when BOTH ids are known. A missing id is
26
- // "no information", never "mismatch", so statically hosted output (no headers)
27
- // and older servers behave exactly as before.
28
- var buildMeta=document.querySelector('meta[name="stx-build"]');
29
- var loadedBuild=buildMeta?(buildMeta.getAttribute('content')||''):'';
30
- window.__stxBuild=loadedBuild;
31
- function isBuildSkew(incoming){
32
- return !!(loadedBuild&&incoming&&incoming!==loadedBuild);
33
- }
34
- function reloadForSkew(url,incoming){
35
- log('[router] build skew: page is',loadedBuild,'server is',incoming,'\u2014 full navigation');
36
- location.href=url;
37
- }
38
- function runViewTransition(callback){
39
- if(!o.viewTransitions||!document.startViewTransition)return false;
40
- try{
41
- var transition=document.startViewTransition(callback);
42
- var onAbort=function(err){log('[router] view transition aborted:',err&&err.message?err.message:err)};
43
- if(transition&&transition.ready&&transition.ready.catch)transition.ready.catch(onAbort);
44
- if(transition&&transition.finished&&transition.finished.catch)transition.finished.catch(onAbort);
45
- if(transition&&transition.updateCallbackDone&&transition.updateCallbackDone.catch)transition.updateCallbackDone.catch(onAbort);
46
- return true;
47
- }catch(err){
48
- log('[router] view transition unavailable:',err&&err.message?err.message:err);
49
- return false;
50
- }
51
- }
52
-
53
- // \u2500\u2500 Progress bar (0\u2192100% at top of viewport) \u2500\u2500
54
- // Native loading indicator baked into the router. Starts when navigation
55
- // begins, trickles up to ~90% during fetch, snaps to 100% + fades on
56
- // completion. Opt-out via window.__stxRouterConfig={progress:false}.
57
- var progEl=null;
58
- var progVal=0;
59
- var progTimer=null;
60
- function setProgress(v){
61
- progVal=v<0?0:(v>100?100:v);
62
- if(!progEl)return;
63
- progEl.style.opacity=progVal>0?'1':'0';
64
- progEl.style.transform='scaleX('+(progVal/100)+')';
65
- }
66
- function startProgress(){
67
- if(!o.progress||!progEl)return;
68
- if(progTimer){clearInterval(progTimer);progTimer=null}
69
- setProgress(8);
70
- progTimer=setInterval(function(){
71
- if(progVal>=90)return;
72
- // Asymptotic trickle \u2014 fast at first, slower near 90%
73
- var inc=Math.max(0.4,(90-progVal)*0.08);
74
- setProgress(progVal+inc);
75
- },160);
76
- }
77
- function finishProgress(){
78
- if(!o.progress||!progEl)return;
79
- if(progTimer){clearInterval(progTimer);progTimer=null}
80
- setProgress(100);
81
- // Fade out, then reset. Delays chosen so the "full" state is briefly
82
- // visible before the bar disappears.
83
- setTimeout(function(){
84
- if(!progEl)return;
85
- progEl.style.opacity='0';
86
- setTimeout(function(){progVal=0;if(progEl)progEl.style.transform='scaleX(0)'},260);
87
- },180);
88
- }
89
-
90
- var cache={};
91
- var prefetching={};
92
- var isNavigating=false;
93
-
94
- // LRU bookkeeping for cache/layoutCache/layoutGroupCache. Pre-fix
95
- // (stacksjs/stx#1719) these were plain objects with no eviction, so
96
- // hover-prefetch on a sidebar of 200 internal links retained ~10MB
97
- // of HTML strings for the page's lifetime. cacheOrder is a most-
98
- // recently-used-last list of keys; setCache pushes new entries and
99
- // evicts the oldest when length exceeds o.prefetchCacheMax (default
100
- // 50). touchCache promotes a key on cache-hit. We use a plain array
101
- // rather than a Map to keep the diff localized and the runtime
102
- // hand-minified shape consistent.
103
- var cacheOrder=[];
104
- function setCache(key,html,layout,group,title,cattrs){
105
- if(!(key in cache))cacheOrder.push(key);
106
- cache[key]=html;
107
- layoutCache[key]=layout;
108
- layoutGroupCache[key]=group;
109
- titleCache[key]=title||'';
110
- attrsCache[key]=cattrs||'';
111
- while(cacheOrder.length>o.prefetchCacheMax){
112
- var oldest=cacheOrder.shift();
113
- delete cache[oldest];
114
- delete layoutCache[oldest];
115
- delete layoutGroupCache[oldest];
116
- delete titleCache[oldest];
117
- delete attrsCache[oldest];
118
- }
119
- }
120
- // Apply a page title captured from the fragment response's X-STX-Title
121
- // header (URI-encoded) so SPA swaps keep document.title in sync with the
122
- // page \u2014 full-document swaps set the title from the <title> tag directly.
123
- function applyTitle(t){if(t){try{document.title=decodeURIComponent(t)}catch(e){document.title=t}}}
124
- // \u2500\u2500 Container attributes \u2500\u2500
125
- // A fragment carries only the container's INNER content, so any layout that
126
- // lives on the destination's <main> itself (e.g. a centered auth page whose
127
- // <main> carries flex + min-h-[100dvh] + items-center) would be
128
- // dropped when injected into the persistent container. The server sends the
129
- // destination container's attributes in X-STX-Container-Attrs (URI-encoded);
130
- // we apply them here and track what we applied so navigating onward to a page
131
- // with a bare <main> clears them again. Attributes the router never applied
132
- // (the app's own id/data-stx-content hooks) are left untouched.
133
- var pendingContainerAttrs='';
134
- function parseContainerAttrs(attrStr){
135
- var map={};
136
- if(!attrStr)return map;
137
- var probe=document.createElement('div');
138
- probe.innerHTML='<i '+attrStr+'></i>';
139
- var src=probe.firstChild;
140
- if(src&&src.attributes){
141
- Array.prototype.forEach.call(src.attributes,function(a){map[a.name]=a.value});
142
- }
143
- return map;
144
- }
145
- function setContainerAttrs(el,map){
146
- if(!el)return;
147
- var prev=(el.getAttribute('data-stx-cattrs')||'').split(',');
148
- prev.forEach(function(n){if(n&&!(n in map))el.removeAttribute(n)});
149
- var names=[];
150
- for(var k in map){
151
- if(Object.prototype.hasOwnProperty.call(map,k)&&k!=='data-stx-cattrs'){
152
- el.setAttribute(k,map[k]);names.push(k);
153
- }
154
- }
155
- if(names.length)el.setAttribute('data-stx-cattrs',names.join(','));
156
- else el.removeAttribute('data-stx-cattrs');
157
- }
158
- function applyContainerAttrs(el,encoded){
159
- var attrStr='';
160
- if(encoded){try{attrStr=decodeURIComponent(encoded)}catch(e){attrStr=encoded}}
161
- setContainerAttrs(el,parseContainerAttrs(attrStr));
162
- }
163
- function touchCache(key){
164
- var i=cacheOrder.indexOf(key);
165
- if(i>=0){cacheOrder.splice(i,1);cacheOrder.push(key)}
166
- }
167
- function evictCache(key){
168
- var i=cacheOrder.indexOf(key);
169
- if(i>=0)cacheOrder.splice(i,1);
170
- delete cache[key];
171
- delete layoutCache[key];
172
- delete layoutGroupCache[key];
173
- delete titleCache[key];
174
- delete attrsCache[key];
175
- }
176
-
177
- // Extract top-level CSS blocks (rules AND @media blocks with nested braces).
178
- // The old regex ([^{}]+{[^{}]*}) silently dropped @media blocks because
179
- // it couldn't handle nested braces \u2014 causing responsive classes (lg:, md:)
180
- // to vanish after SPA navigation.
181
- function extractCssBlocks(css){
182
- var blocks=[];
183
- var i=0;
184
- var len=css.length;
185
- while(i<len){
186
- // Skip whitespace
187
- while(i<len&&(css[i]===' '||css[i]==='\\n'||css[i]==='\\r'||css[i]==='\\t'))i++;
188
- if(i>=len)break;
189
- // Find the start of a block (first {)
190
- var blockStart=i;
191
- var bracePos=css.indexOf('{',i);
192
- if(bracePos===-1)break;
193
- // Walk forward tracking brace depth to find the matching close
194
- var depth=0;
195
- var j=bracePos;
196
- while(j<len){
197
- if(css[j]==='{')depth++;
198
- else if(css[j]==='}'){depth--;if(depth===0){j++;break}}
199
- j++;
200
- }
201
- if(depth!==0)break;
202
- blocks.push(css.slice(blockStart,j).trim());
203
- i=j;
204
- }
205
- return blocks;
206
- }
207
-
208
- function mergeCrosswindCSS(existing,incoming){
209
- var blocks=extractCssBlocks(incoming);
210
- var newBlocks=[];
211
- for(var bi=0;bi<blocks.length;bi++){
212
- if(existing.indexOf(blocks[bi])===-1)newBlocks.push(blocks[bi]);
213
- }
214
- if(newBlocks.length>0)return existing+'\\n'+newBlocks.join('\\n');
215
- return null;
216
- }
217
-
218
- function getContainer(){
219
- return document.querySelector(containerSel)||document.querySelector('[data-stx-content]')||document.querySelector('main');
220
- }
221
-
222
- // \u2500\u2500 Navigation \u2500\u2500
223
- // Layout group detection \u2014 layouts in the same group share a <main> container.
224
- // Only truly different layout groups trigger a full page reload.
225
- var layoutCache={};
226
- var layoutGroupCache={};
227
- var titleCache={};
228
- var attrsCache={};
229
- // Track executed script hashes to prevent redeclaration errors on navigation.
230
- // Layout-level scripts (theme, nav setup) execute on initial page load and
231
- // should NOT re-execute when navigating to another page with the same layout.
232
- var executedScriptHashes={};
233
-
234
- // \u2500\u2500 Re-execution contract (stacksjs/stx#1773) \u2500\u2500
235
- // Whether a generated script may run again after an SPA swap used to be
236
- // SNIFFED from its source: does it start with '(' or ';', or mention
237
- // window.stx.mount. That is a property of the emitted shape, not of intent,
238
- // so every change to how scripts are wrapped was one edit away from silently
239
- // reclassifying them \u2014 and the symptom of getting it wrong is a component
240
- // that renders completely dead (literal moustaches, stuck :show) only on a
241
- // REVISIT, because nav-away already disposed its scope.
242
- //
243
- // Emitters now declare it: data-stx-run="always" on self-contained scope
244
- // IIFEs and mount() wrappers, which own their scope and whose registration
245
- // _cleanupContainer removed on the way out. Anything unstamped falls back to
246
- // the old sniff, so a page rendered by an older server behaves exactly as
247
- // before.
248
- //
249
- // The sniff itself now asks what the script REGISTERS rather than how it is
250
- // spelled (#1828). cleanupContainer deletes window.stx._scopes entries,
251
- // element disposers and destroy hooks on the way out, so the scripts that
252
- // must run again are exactly the ones that put a scope back. That is
253
- // detectable; a leading character is not.
254
- //
255
- // Deliberately NOT "strip leading comments, then test the first character",
256
- // which is the obvious repair and is wrong in the expensive direction. Three
257
- // emitters open with a comment and MUST NOT re-run: the animation scripts
258
- // register matchMedia and DOMContentLoaded listeners plus an
259
- // IntersectionObserver, and the STX lifecycle runtime owns a Map of live
260
- // instances. Comment-stripping would flip all three to re-running on every
261
- // navigation, leaking a listener and an observer each time and resetting the
262
- // instance registry \u2014 trading a silent missing component for a silent leak.
263
- var REGISTERS_SCOPE=/window\\.stx\\.mount|_scopes\\s*\\[|\\.initScope\\s*\\(|__stxComponentFactories\\s*\\[/;
264
- function runsAlways(declared, code){
265
- if(declared==='always')return true;
266
- if(declared==='once')return false;
267
- if(REGISTERS_SCOPE.test(code))return true;
268
- var head=code.trimStart();
269
- return head.charAt(0)==='('||head.charAt(0)===';';
270
- }
271
- function hashScript(code){
272
- var h=0;for(var i=0;i<code.length;i++){h=((h<<5)-h)+code.charCodeAt(i);h|=0}
273
- return h;
274
- }
275
- function hasStaticImport(code){
276
- return /(?:^|\\n)[ \\t]*import(?:[ \\t]+|[{*'"])/.test(code);
277
- }
278
- function generatedSetupName(code){
279
- var match=code.match(/function (__stx_setup_[A-Za-z0-9_]+)\\s*\\(/);
280
- return match?match[1]:'';
281
- }
282
- // Does this fetched DOCUMENT carry the signals runtime?
283
- //
284
- // Unlike a fragment, a document holds the answer directly, so there is
285
- // nothing to infer from markers or ask the server for: look for the script.
286
- // Both emitted shapes carry data-stx-runtime \u2014 inline, and the serve-mode
287
- // src="/_stx/runtime.js" \u2014 with the content sniff behind it for a document
288
- // rendered by a server old enough not to stamp the attribute.
289
- function documentShipsRuntime(doc,html){
290
- if(doc&&doc.querySelector&&doc.querySelector('script[data-stx-runtime]'))return true;
291
- return html.indexOf("'use strict';var cloakStyle")!==-1
292
- ||(html.indexOf('_cleanupContainer')!==-1&&html.indexOf('signals runtime loading')!==-1);
293
- }
294
- function isSignalsRuntimeScript(script,code){
295
- return !!(script&&script.hasAttribute&&script.hasAttribute('data-stx-runtime'))
296
- ||(code.indexOf('_cleanupContainer')!==-1&&code.indexOf('signals runtime loading')!==-1)
297
- ||code.indexOf("'use strict';var cloakStyle")!==-1;
298
- }
299
- // A fragment never carries the signals runtime: the server strips the runtime
300
- // IIFE out of it, and every re-execution path here filters it out again. So a
301
- // page that never needed a runtime cannot hydrate a fragment that does \u2014 the
302
- // setup function would be defined but never invoked, scope IIFEs throw while
303
- // destructuring window.stx, and every x-cloak element stays display:none
304
- // forever against the cloak style shipped on EVERY page. Hand those off to a
305
- // real navigation, which loads the destination's own runtime (#1809).
306
- //
307
- // The SERVER decides this now, and says so in X-STX-Runtime, which travels
308
- // into the fragment marker as rt=1 / rt=0 so it survives the prefetch cache.
309
- // It is answering a question it knows the answer to exactly \u2014 did I put a
310
- // runtime in this page \u2014 where the router could only guess from markup.
311
- //
312
- // Guessing missed a whole page shape (#1827). Every marker below originates in
313
- // a client SCRIPT, so a page with reactive syntax and no script anywhere \u2014 a
314
- // <script server>-only login form built from :if / x-model / :disabled \u2014 emits
315
- // none of them, while the server ships it the runtime. Whatever root marker it
316
- // does get lands on <body>, which a fragment excludes by construction. So
317
- // <p :if="error" x-cloak>Invalid credentials</p>
318
- // swapped into a runtime-less page keeps the x-cloak the server stamped on it,
319
- // and only the runtime ever removes that: server-rendered text, in the DOM, at
320
- // display:none forever.
321
- //
322
- // The same held for x-data on the routed container, whose scope attribute is
323
- // hoisted out of the fragment body into X-STX-Container-Attrs. Asking the
324
- // server covers both without either being enumerated.
325
- //
326
- // The sniff stays as the fallback for a server too old to declare it. Its
327
- // discriminators matter. x-cloak and data-stx-scoped are NOT usable: each
328
- // fragment inlines the head styles, cloak rule included, and the appearance
329
- // bootstrap emits a self-contained data-stx-scoped script on pages with no
330
- // reactivity at all. A bare window.stx is not usable either \u2014 the server
331
- // prepends a _latestSetup=null clear script to every fragment. Any of those
332
- // would make this always true and disable SPA navigation everywhere.
333
- function fragmentNeedsRuntime(frag,declared){
334
- if(declared==='1')return true;
335
- if(declared==='0')return false;
336
- if(frag.indexOf('__stx_setup_')!==-1
337
- ||frag.indexOf('data-stx-scope=')!==-1
338
- ||frag.indexOf('data-stx-reactive')!==-1
339
- ||/=\\s*window\\.stx\\s*;/.test(frag))return true;
340
- var content=stripInertRegions(frag);
341
- return BARE_DIRECTIVE.test(content)||content.indexOf('{{')!==-1;
342
- }
343
- // Documentation is both the largest population of runtime-less pages and the
344
- // likeliest to contain directive syntax as literal CONTENT, so the fallback
345
- // reads past the regions where that content lives. A page of stx examples must
346
- // not full-reload on every hop through the docs \u2014 that is the regression
347
- // #1809 existed to remove.
348
- function stripInertRegions(frag){
349
- return frag
350
- .replace(new RegExp('<scr'+'ipt\\\\b[\\\\s\\\\S]*?<\\\\/scr'+'ipt>','gi'),'')
351
- .replace(/<style\\b[\\s\\S]*?<\\/style>/gi,'')
352
- .replace(/<pre\\b[\\s\\S]*?<\\/pre>/gi,'')
353
- .replace(/<code\\b[\\s\\S]*?<\\/code>/gi,'')
354
- .replace(/<!--[\\s\\S]*?-->/g,'');
355
- }
356
- // Anchored on the whitespace before an attribute name and the = that follows,
357
- // so it reads attribute POSITIONS rather than characters. Bare ':' and '@' are
358
- // everywhere in ordinary content \u2014 'xlink:href', 'background: red', '10:00',
359
- // an email address \u2014 and none of those sit in that position.
360
- //
361
- // The x- arm is deliberately open rather than a list of known directives:
362
- // x-attr is a GENERIC binding prefix, so x-href / x-src / x-value / x-alt bind
363
- // exactly like x-text does. Measured over the 339 rendered docs pages, the
364
- // open form costs nothing over an allowlist (3 matches either way, all three
365
- // of them pages the CURRENT four-marker sniff already matches because they
366
- // document its marker names) while covering directives an allowlist forgets.
367
- var BARE_DIRECTIVE=/\\s(?::[a-z][\\w-]*|@[a-z][\\w.-]*|x-[a-z][\\w-]*)\\s*=\\s*["']/i;
368
- // The fragment marker doubles as the carrier for the server's declaration.
369
- // Encoding it in the HTML rather than threading a parameter keeps it inside
370
- // the one value that already reaches swap() through every path, the prefetch
371
- // cache included.
372
- function fragmentMarker(declared){
373
- return '<!--stx-fragment'+(declared==='true'?' rt=1':declared==='false'?' rt=0':'')+'-->';
374
- }
375
- // Record scripts from the initial page load
376
- document.querySelectorAll('script').forEach(function(s){
377
- var text=s.textContent||'';
378
- if(text.trim()&&!s.hasAttribute('src'))executedScriptHashes[hashScript(text)]=1;
379
- });
380
- function cacheKey(url){
381
- // Base is the CURRENT document, not the origin root: a relative key like
382
- // '?status=resolved' on /dashboard must cache as /dashboard?status=resolved,
383
- // not /?status=resolved (#1777).
384
- var u=new URL(url,location.href);
385
- return u.pathname+u.search;
386
- }
387
- // Keep SPA fetches inside the active locale (/en/... when viewing English).
388
- function withCurrentLocale(href){
389
- var d=window.__stxI18n;
390
- if(d&&typeof d.localizeHref==='function')return d.localizeHref(href);
391
- return href;
392
- }
393
- function defaultLayoutGroup(layout){
394
- if(!layout)return 'app';
395
- var clean=String(layout).replace(/\\\\/g,'/');
396
- var parts=clean.split('/').filter(Boolean);
397
- var idx=parts.lastIndexOf('layouts');
398
- var part=idx>=0?parts[idx+1]:parts[parts.length-1];
399
- return part?part.replace(/\\.stx$/i,''):'app';
400
- }
401
- function getCurrentLayoutGroup(){
402
- var meta=document.querySelector('meta[name="stx-layout-group"]');
403
- if(meta&&meta.getAttribute('content'))return meta.getAttribute('content');
404
- var layout=document.querySelector('meta[name="stx-layout"]');
405
- return defaultLayoutGroup(layout?layout.getAttribute('content'):'');
406
- }
407
- function getDocLayoutGroup(doc,layout){
408
- var meta=doc&&doc.querySelector?doc.querySelector('meta[name="stx-layout-group"]'):null;
409
- return meta&&meta.getAttribute('content')?meta.getAttribute('content'):defaultLayoutGroup(layout||'');
410
- }
411
- function checkLayoutChange(newLayout,targetUrl,newGroup){
412
- var currentLayout=document.querySelector('meta[name="stx-layout"]');
413
- var curLayoutName=currentLayout?currentLayout.getAttribute('content'):'';
414
- var curGroup=getCurrentLayoutGroup();
415
- var nextGroup=newGroup||defaultLayoutGroup(newLayout);
416
- log('[router] layout check: current='+curLayoutName+'('+curGroup+') new='+(newLayout||'')+'('+nextGroup+')');
417
- // Different layout GROUP (e.g. app \u2192 auth): full reload
418
- if(curGroup!==nextGroup){
419
- log('[router] layout group change:',curGroup,'\u2192',nextGroup,'\u2014 full reload to:',targetUrl);
420
- return true;
421
- }
422
- // Same group but different SPECIFIC layout (e.g. layouts/app \u2192 layouts/coach):
423
- // full body swap so nav, sidebar, and other layout-level elements update
424
- if(curLayoutName && newLayout && curLayoutName!==newLayout){
425
- log('[router] layout name change:',curLayoutName,'\u2192',newLayout,'\u2014 full body swap');
426
- return true;
427
- }
428
- return false;
429
- }
430
-
431
- function shouldUseFragmentResponse(){
432
- return containerSel==='main'||containerSel==='[data-stx-content]';
433
- }
434
-
435
- // Write the address bar. The mode travels in the same slot as the legacy
436
- // pushState boolean: false still means "do not touch history", true still
437
- // pushes, and the string 'replace' replaces. Threading a fourth argument
438
- // through swap() to each of the three history sites would have been the
439
- // alternative (#1807).
440
- function writeHistory(mode,href){
441
- if(mode==='replace')history.replaceState({},'',href);
442
- else history.pushState({},'',href);
443
- }
444
-
445
- // Second arg accepts the legacy pushState boolean OR an options object
446
- // { replace }. Callers inside this file still pass the boolean.
447
- function navigate(url,pushState,force){
448
- if(pushState&&typeof pushState==='object')pushState=pushState.replace?'replace':true;
449
- // Lang-picker passes force=true with an already-localized path (/en/...).
450
- // Re-localizing would map it back to the *current* locale and no-op.
451
- if(!force) url=withCurrentLocale(url);
452
- log('[router] navigate() called:',url,'isNavigating:',isNavigating);
453
- if(isNavigating)return Promise.resolve(false);
454
- // Resolve against the current document URL, matching native <a> semantics.
455
- // With location.origin as the base, any relative href ('?status=resolved',
456
- // '#anchor', './sibling') resolved to the SITE ROOT \u2014 so filter tabs,
457
- // pagination and sort links silently bounced users to the landing page once
458
- // the SPA interceptor was active (#1777).
459
- var t=new URL(url,location.href);
460
-
461
- if(t.origin!==location.origin){location.href=url;return Promise.resolve(false)}
462
-
463
- if(t.pathname===location.pathname&&t.hash){
464
- if(pushState!==false)writeHistory(pushState,t.href);
465
- var el=document.querySelector(t.hash);
466
- if(el)el.scrollIntoView({behavior:'smooth'});
467
- return Promise.resolve(true);
468
- }
469
-
470
- // Skip if user clicks a link to the page they're already on. We
471
- // deliberately allow popstate (pushState===false) through because
472
- // the browser has already updated location.href to the popped
473
- // entry \u2014 without this allowance, hitting back would early-return
474
- // and the visible page content would be left frozen on the
475
- // forward-navigation page.
476
- if(pushState!==false&&t.href===location.href&&!t.hash&&!force)return Promise.resolve(false);
477
-
478
- isNavigating=true;
479
- document.body.classList.add(o.loadingClass);
480
- startProgress();
481
-
482
- var targetHref=t.href;
483
- var targetPath=cacheKey(url);
484
- var targetHash=t.hash;
485
-
486
- function done(){isNavigating=false;document.body.classList.remove(o.loadingClass);finishProgress()}
487
-
488
- if(o.cache&&cache[targetPath]&&!force){
489
- // Cache hit \u2192 promote in LRU order so this entry survives eviction
490
- // longer than other entries that haven't been touched.
491
- touchCache(targetPath);
492
- if(checkLayoutChange(layoutCache[targetPath],url,layoutGroupCache[targetPath])){
493
- // Layout changed \u2014 fetch full page and do full document swap
494
- log('[router] cache hit but layout changed \u2014 fetching full page');
495
- return fetch(url,{headers:{'Accept':'text/html'}}).then(function(r){
496
- if(!r.ok)throw new Error(r.status);
497
- return r.text();
498
- }).then(function(html){
499
- log('[router] full page fetched from cache path, len:',html.length);
500
- pendingLayoutDecl={layout:layoutCache[targetPath]||'',group:layoutGroupCache[targetPath]||''};
501
- return swap(html,targetPath,pushState,targetHash);
502
- }).catch(function(err){
503
- console.error('[router] full page fetch error:',err);
504
- location.href=url;
505
- }).finally(done);
506
- }
507
- pendingContainerAttrs=attrsCache[targetPath]||'';
508
- pendingLayoutDecl=null;
509
- return Promise.resolve(swap(cache[targetPath],targetPath,pushState,targetHash)).then(function(){
510
- applyTitle(titleCache[targetPath]);
511
- return true;
512
- }).finally(done);
513
- }
514
- else {
515
- if(force&&cache[targetPath])evictCache(targetPath);
516
- // First fetch as fragment (SPA mode) when the configured container is
517
- // the page content. Custom app-shell containers need full documents so
518
- // the router does not inject a <main> fragment into the wrong element.
519
- var wantsFragment=shouldUseFragmentResponse();
520
- return fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
521
- if(!r.ok)throw new Error(r.status);
522
- // A route guard answered with a redirect and fetch followed it
523
- // transparently, so r.ok is the DESTINATION's 200 and this markup
524
- // belongs to r.url \u2014 not to the path that was requested. Without
525
- // retargeting, an auth guard's /login body is swapped in while the
526
- // address bar, the history entry and the cache key all still read
527
- // /dashboard: the user sees a login form at a URL that claims to be
528
- // the page they asked for, and reloading bounces them again (#1849).
529
- if(r.redirected&&r.url){
530
- var rd=new URL(r.url,location.href);
531
- // A guard that sends you off-origin (a hosted IdP) cannot be
532
- // resolved by swapping a fragment \u2014 hand it to the browser.
533
- if(rd.origin!==location.origin){location.href=r.url;return null}
534
- log('[router] guard redirected:',url,'->',rd.pathname);
535
- url=rd.pathname+rd.search+rd.hash;
536
- targetPath=cacheKey(url);
537
- targetHash=rd.hash;
538
- }
539
- // Before anything is parsed or swapped: a fragment from a different
540
- // build must not be handed to this page's runtime (#1772).
541
- var incomingBuild=r.headers.get('X-STX-Build')||'';
542
- if(isBuildSkew(incomingBuild)){reloadForSkew(url,incomingBuild);return null}
543
- var isFragment=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
544
- var newLayout=r.headers.get('X-STX-Layout')||'';
545
- var newGroup=r.headers.get('X-STX-Layout-Group')||'';
546
- var newTitle=r.headers.get('X-STX-Title')||'';
547
- var newCAttrs=r.headers.get('X-STX-Container-Attrs')||'';
548
- var newRuntime=r.headers.get('X-STX-Runtime')||'';
549
- // Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
550
- if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
551
- log('[router] layout change \u2014 fetching full page for document swap');
552
- return fetch(url,{headers:{'Accept':'text/html'}}).then(function(fullRes){
553
- log('[router] full page fetched:',fullRes.status,'ok:',fullRes.ok);
554
- if(!fullRes.ok)throw new Error(fullRes.status);
555
- return fullRes.text().then(function(html){
556
- log('[router] full page html length:',html.length);
557
- return{html:html,isFragment:false,layout:newLayout,layoutGroup:newGroup,title:newTitle};
558
- });
559
- });
560
- }
561
- return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle,containerAttrs:newCAttrs,runtime:newRuntime}});
562
- }).then(function(result){
563
- if(!result)return;
564
- if(result.isFragment)result.html=fragmentMarker(result.runtime)+result.html;
565
- if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
566
- pendingContainerAttrs=result.isFragment?(result.containerAttrs||''):'';
567
- pendingLayoutDecl=result.isFragment?null:{layout:result.layout||'',group:result.layoutGroup||''};
568
- return Promise.resolve(swap(result.html,targetPath,pushState,targetHash)).then(function(){
569
- // Fragment swaps carry no <head>; apply the title from the header.
570
- // Full-document swaps already set document.title from the <title> tag.
571
- if(result.isFragment)applyTitle(result.title);
572
- return true;
573
- });
574
- }).catch(function(err){
575
- console.error('[router] fetch error:',err);
576
- location.href=url;
577
- }).finally(done);
578
- }
579
- }
580
-
581
- // A full document is only safe to partial-swap into the stx shell if it is
582
- // itself stx-rendered. Pages served by another engine mounted on the same
583
- // origin (e.g. a docs/blog generator) carry none of these markers; swapping
584
- // their <main> + styles into the stx shell corrupts the layout. Detect them
585
- // and hand off to a native full navigation instead.
586
- function isStxDocument(html){
587
- return html.indexOf('name="stx-layout"')!==-1
588
- ||html.indexOf('__stxRouterConfig')!==-1
589
- ||html.indexOf('data-stx-content')!==-1;
590
- }
591
-
592
- // The layout and group the SERVER declared for the document about to be
593
- // swapped in. Set immediately before each swap() call by whichever path
594
- // fetched it, and consumed once.
595
- //
596
- // Threaded this way rather than as a swap() parameter, matching
597
- // pendingContainerAttrs \u2014 see the note above writeHistory about the cost of
598
- // adding an argument to swap() and its three call sites (#1807).
599
- var pendingLayoutDecl=null;
600
- // \u2500\u2500 Focus and route announcement \u2500\u2500
601
- // A fragment swap replaces content without a document load, so the browser
602
- // does none of what it normally does for a navigation. Focus stays on a link
603
- // that no longer exists, which the browser resets to <body> \u2014 a keyboard user
604
- // is silently returned to the top of the tab order \u2014 and a screen reader says
605
- // nothing at all, so there is no signal the page changed. Neither is fixable
606
- // from app code, because only the router knows a navigation happened (#1862).
607
- function routeAnnouncer(){
608
- var el=document.getElementById('stx-route-announcer');
609
- if(el)return el;
610
- el=document.createElement('div');
611
- el.id='stx-route-announcer';
612
- el.setAttribute('aria-live','polite');
613
- el.setAttribute('role','status');
614
- el.setAttribute('aria-atomic','true');
615
- // Visually hidden but still ANNOUNCED. display:none and visibility:hidden
616
- // are both skipped by screen readers, which would make this a silent no-op
617
- // that looks correct in the DOM.
618
- el.style.cssText='position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0';
619
- document.body.appendChild(el);
620
- return el;
621
- }
622
-
623
- function announceRoute(){
624
- if(o.announceRoute===false)return;
625
- var c=getContainer();
626
- var h=c&&c.querySelector?c.querySelector('h1'):null;
627
- // The heading names the destination better than the title, which often
628
- // carries a site-name suffix. Falls back to the title, then gives up
629
- // rather than announcing an empty string.
630
- var label=(h&&(h.textContent||'').trim())||document.title||'';
631
- if(!label)return;
632
- var el=routeAnnouncer();
633
- // Cleared first: several screen readers ignore a live-region update whose
634
- // text is identical to what is already there, so navigating between two
635
- // pages with the same heading would announce only the first.
636
- el.textContent='';
637
- el.textContent=label;
638
- }
639
-
640
- function focusAfterNavigation(hash){
641
- if(o.routeFocus===false)return;
642
- var target=null;
643
- if(hash){try{target=document.querySelector(hash)}catch(e){target=null}}
644
- if(!target)target=getContainer();
645
- if(!target||!target.focus)return;
646
- // The container is not naturally focusable. tabindex="-1" makes it
647
- // programmatically focusable without inserting it into the tab order.
648
- if(!target.hasAttribute('tabindex'))target.setAttribute('tabindex','-1');
649
- // preventScroll because scroll position is already decided just above \u2014
650
- // focusing would otherwise fight scrollToTop and the hash scrollIntoView.
651
- try{target.focus({preventScroll:true})}catch(e){try{target.focus()}catch(e2){}}
652
- }
653
-
654
- function swap(html,url,pushState,hash){
655
- var fragMark=/^<!--stx-fragment(?: rt=([01]))?-->/.exec(html);
656
- var isFragment=!!fragMark;
657
- var declaredRuntime=fragMark&&fragMark[1]?fragMark[1]:'';
658
- if(isFragment)html=html.slice(fragMark[0].length);
659
- if(!isFragment&&!isStxDocument(html)){log('[router] non-stx document \u2014 full navigation to:',url);location.href=url;return Promise.resolve(false)}
660
- var currentContent=getContainer();
661
- log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&&currentContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
662
- if(!currentContent){log('[router] no container \u2014 falling back');location.href=url;return Promise.resolve(false)}
663
-
664
- // Fragment mode: server returned just the page content (no document wrapper)
665
- if(isFragment){
666
- // Before _cleanupContainer, not after: handing off later would dispose
667
- // the OUTGOING page's scopes and then reload anyway. Inside doFragSwap
668
- // would be later still \u2014 innerHTML is already replaced by then.
669
- if(!window.stx&&fragmentNeedsRuntime(html,declaredRuntime)){
670
- log('[router] fragment needs the signals runtime and this page has none \u2014 full navigation to:',url);
671
- location.href=url;
672
- return Promise.resolve(false);
673
- }
674
- if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
675
- function doFragSwap(){
676
- // Extract scripts from fragment before injecting HTML
677
- var fragScripts=[];
678
- var fragScriptId=0;
679
- var fragStyles=[];
680
- var fragCrosswindHrefs=[];
681
- var fragCrosswindCSS=null;
682
- var cleanFrag=html.replace(new RegExp('<scr'+'ipt\\\\b([^>]*)>([\\\\s\\\\S]*?)<\\\\/scr'+'ipt>','gi'),function(m,attrs,code){
683
- // A data block is not code, and everything below this line assumes it
684
- // is: the body gets stashed in fragScripts, re-emitted as a pending
685
- // placeholder, then wrapped in braces and executed. Wrapping a JSON
686
- // object in braces reparses it as a labelled block, so its first ':'
687
- // is a SyntaxError on every single fragment swap
688
- // (stacksjs/stx#1801). @structuredData renders at its call site,
689
- // which inside a layout is @section('content') \u2014 i.e. in the routed
690
- // container \u2014 so the natural way to write it hit this.
691
- //
692
- // Left exactly as found rather than stripped: crawlers read
693
- // structured data wherever it sits, and the browser will not execute
694
- // a non-JS type anyway. Mirrors prepareRoutedBodyScripts' notion of
695
- // executable, which this path never had.
696
- var typeMatch=attrs.match(/\\btype\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
697
- var scriptType=((typeMatch&&(typeMatch[1]||typeMatch[2]||typeMatch[3]))||'').trim().toLowerCase();
698
- if(scriptType&&scriptType!=='text/javascript'&&scriptType!=='application/javascript'&&scriptType!=='module')return m;
699
- if(code&&code.trim()&&!isSignalsRuntimeScript({hasAttribute:function(name){return name==='data-stx-runtime'&&attrs.indexOf('data-stx-runtime')!==-1}},code)){
700
- var slot='fragment-'+(++fragScriptId);
701
- var scoped=/(?:^|\\s)data-stx-scoped(?:\\s|=|$)/i.test(attrs);
702
- var runDecl=(attrs.match(/data-stx-run\\s*=\\s*["']?(always|once)["']?/i)||[])[1];
703
- fragScripts.push({text:code,slot:slot,setupName:generatedSetupName(code),scoped:scoped,run:runDecl?runDecl.toLowerCase():''});
704
- // Retain scoped setup code in its inert placeholder. A placeholder
705
- // inside template.content is unreachable through document, so the
706
- // repeated component runtime must execute it for each clone.
707
- return '<scr'+'ipt type="application/stx-pending" data-stx-route-script="'+slot+'"'
708
- +(scoped?' data-stx-scoped':'')
709
- +(runDecl?' data-stx-run="'+runDecl.toLowerCase()+'"':'')
710
- +'>'+code+'<\\/scr'+'ipt>';
711
- }
712
- return '';
713
- });
714
- cleanFrag=cleanFrag.replace(new RegExp('<sty'+'le\\\\b([^>]*)>([\\\\s\\\\S]*?)<\\\\/sty'+'le>','gi'),function(m,attrs,css){
715
- if(attrs.indexOf('data-crosswind')!==-1){
716
- fragCrosswindCSS=css;
717
- }else{
718
- fragStyles.push({attrs:attrs,css:css});
719
- }
720
- return '';
721
- });
722
- cleanFrag=cleanFrag.replace(new RegExp('<link\\\\b([^>]*)>','gi'),function(m,attrs){
723
- if(attrs.indexOf('data-crosswind')===-1)return m;
724
- var hrefMatch=attrs.match(/\\bhref=(["'])(.*?)\\1/i);
725
- if(hrefMatch&&hrefMatch[2])fragCrosswindHrefs.push(hrefMatch[2]);
726
- return '';
727
- });
728
- // Remove old page styles (not crosswind \u2014 that gets merged)
729
- document.querySelectorAll('style[data-stx-page]').forEach(function(s){s.remove()});
730
- // Merge crosswind CSS from fragment into existing crosswind style
731
- if(fragCrosswindCSS){
732
- var curCrosswind=document.querySelector('head style[data-crosswind]');
733
- if(curCrosswind){
734
- var merged=mergeCrosswindCSS(curCrosswind.textContent||'',fragCrosswindCSS);
735
- if(merged)curCrosswind.textContent=merged;
736
- }else{
737
- var cw=document.createElement('style');
738
- cw.setAttribute('data-crosswind','generated');
739
- cw.textContent=fragCrosswindCSS;
740
- document.head.appendChild(cw);
741
- }
742
- }
743
- fragCrosswindHrefs.forEach(function(href){
744
- var absolute=new URL(href,location.href).href;
745
- var exists=false;
746
- document.querySelectorAll('head link[data-crosswind][href]').forEach(function(link){
747
- if(new URL(link.getAttribute('href'),location.href).href===absolute)exists=true;
748
- });
749
- if(exists)return;
750
- var link=document.createElement('link');
751
- link.setAttribute('data-crosswind','generated');
752
- link.setAttribute('rel','stylesheet');
753
- link.setAttribute('href',href);
754
- document.head.appendChild(link);
755
- });
756
- // Add new page styles
757
- fragStyles.forEach(function(s){
758
- var el=document.createElement('style');
759
- el.textContent=s.css;
760
- el.setAttribute('data-stx-page','');
761
- document.head.appendChild(el);
762
- });
763
- // Swap content \u2014 apply the destination container's own attributes first
764
- // so the incoming markup lands in a correctly-laid-out container instead
765
- // of flashing (or sticking) unstyled.
766
- applyContainerAttrs(currentContent,pendingContainerAttrs);
767
- currentContent.innerHTML=cleanFrag;
768
- // Remove old page scripts
769
- document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
770
- if(pushState!==false)writeHistory(pushState,url+(hash||''));
771
- updateNav(url);
772
- updateActiveLinks();
773
- if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
774
- else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
775
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
776
- // Before page scripts run, so a page that focuses its own control on
777
- // mount still wins \u2014 its script executes after this.
778
- focusAfterNavigation(hash);
779
- // Deferred a tick because the title is applied by the caller after this
780
- // swap resolves, and the announcement should read the new one.
781
- setTimeout(announceRoute,0);
782
- // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
783
- log('[router] frag scripts:', fragScripts.length);
784
- document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
785
- fragScripts.sort(function(a,b){return (a.setupName?1:0)-(b.setupName?1:0)});
786
- fragScripts.forEach(function(entry){
787
- var code=entry.text;
788
- log('[router] exec script len:', code.length, 'has __stx_setup:', code.indexOf('__stx_setup')>-1);
789
- var placeholder=document.querySelector('script[data-stx-route-script="'+entry.slot+'"]');
790
- var nestedScopedTemplate=false;
791
- var scopedLoopSetup=false;
792
- if(entry.scoped&&placeholder){
793
- document.querySelectorAll('template').forEach(function(template){
794
- if(template.contains(placeholder)||(template.content&&template.content.contains(placeholder)))
795
- nestedScopedTemplate=true;
796
- });
797
- var loopRoot=placeholder.previousSibling;
798
- while(loopRoot&&(
799
- (loopRoot.nodeType===Node.TEXT_NODE&&!(loopRoot.textContent||'').trim())
800
- ||loopRoot.nodeType===Node.COMMENT_NODE
801
- ))loopRoot=loopRoot.previousSibling;
802
- if(loopRoot&&loopRoot.nodeType===Node.ELEMENT_NODE&&loopRoot.hasAttribute('data-stx-scope')
803
- &&(loopRoot.hasAttribute(':for')||loopRoot.hasAttribute('@for')||loopRoot.hasAttribute('x-for'))){
804
- var loopScopeId=loopRoot.getAttribute('data-stx-scope');
805
- scopedLoopSetup=!!loopScopeId&&code.indexOf(loopScopeId)!==-1;
806
- }
807
- }
808
- if(entry.scoped&&(!placeholder||!placeholder.parentNode||nestedScopedTemplate||scopedLoopSetup)){
809
- log('[router] leaving loop-scoped script for component hydration');
810
- return;
811
- }
812
- // Skip scripts that were already executed (layout-level partials
813
- // like theme.stx, stores.stx, nav.stx). Their top-level const/let
814
- // declarations would throw "Identifier has already been declared"
815
- // on re-execution. Setup functions (__stx_setup_) must always
816
- // re-execute because each page has its own setup.
817
- var h=hashScript(code);
818
- var isSetup=code.indexOf('__stx_setup_')!==-1;
819
- // Self-contained scope scripts must re-execute on every navigation:
820
- // they own their scope, won't collide with prior runs, and
821
- // _cleanupContainer deleted their entry from window.stx._scopes on
822
- // the way out. Skipping one leaves the page leaf with no scope
823
- // registered \u2014 bindIf/@event never re-bind and every :if branch
824
- // stays visible at once. Declared by the emitter via data-stx-run,
825
- // sniffed only as a fallback (#1773).
826
- var isAlreadyScoped=runsAlways(entry.run,code);
827
- if(!isSetup&&!isAlreadyScoped&&executedScriptHashes[h]){
828
- log('[router] skipping already-executed script (hash dedup)');
829
- return;
830
- }
831
- executedScriptHashes[h]=1;
832
- var ns=document.createElement('script');
833
- var hasImport=hasStaticImport(code);
834
- if(hasImport){
835
- ns.type='module';
836
- }
837
- ns.textContent=(hasImport||isAlreadyScoped)?code:'{'+code+'}';
838
- ns.setAttribute('data-stx-page','');
839
- if(placeholder&&placeholder.parentNode){
840
- ns.setAttribute('data-stx-positioned','');
841
- placeholder.parentNode.replaceChild(ns,placeholder);
842
- }else{
843
- document.body.appendChild(ns);
844
- }
845
- });
846
- // Guarded: the router ships on every page, the signals runtime only on
847
- // pages that have a client script. log() is debug-gated but its
848
- // ARGUMENTS are evaluated at the call site regardless, so an unguarded
849
- // read here threw on every runtime-less page \u2014 aborting the swap and
850
- // falling back to a full document load (#1809).
851
- log('[router] scripts done. _latestSetup:', !!(window.stx&&window.stx._latestSetup));
852
- // THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
853
- window.dispatchEvent(new Event('stx:load'));
854
- }
855
- return new Promise(function(resolve,reject){
856
- function completeFragSwap(){
857
- try{
858
- doFragSwap();
859
- resolve(true);
860
- }catch(err){reject(err)}
861
- }
862
- if(runViewTransition(completeFragSwap)){}
863
- else{currentContent.style.transition='opacity 0.12s ease-out';currentContent.style.opacity='0';setTimeout(function(){completeFragSwap();currentContent.style.opacity='1';setTimeout(function(){currentContent.style.transition=''},150)},120)}
864
- });
865
- }
866
-
867
- // Full document mode: parse with DOMParser and extract container content
868
- var parser=new DOMParser();
869
- var doc=parser.parseFromString(html,'text/html');
870
- // Full documents carry the build id as a meta rather than a header, and
871
- // this path also serves cached HTML, so check here too (#1772).
872
- var docBuildMeta=doc.querySelector('meta[name="stx-build"]');
873
- var docBuild=docBuildMeta?(docBuildMeta.getAttribute('content')||''):'';
874
- if(isBuildSkew(docBuild)){reloadForSkew(url,docBuild);return Promise.resolve(false)}
875
- // Same hand-off as the fragment path, for the same reason (#1809, #1839).
876
- // A full document DOES carry the destination's runtime \u2014 and this path
877
- // throws it away: prepareRoutedBodyScripts drops every script[src] and
878
- // everything isSignalsRuntimeScript matches, on the assumption that the
879
- // current page already has one. When it does not, nothing puts a runtime
880
- // back, so the destination's client code dies on its first line with
881
- // "defineStore is not defined" (defineStore lives on window.stx) and the
882
- // page renders quietly inert \u2014 no error reaches the user.
883
- //
884
- // Before the swap and before _cleanupContainer, so we neither dispose the
885
- // outgoing page's scopes nor half-replace the document and then reload.
886
- if(!window.stx&&documentShipsRuntime(doc,html)){
887
- log('[router] document needs the signals runtime and this page has none \u2014 full navigation to:',url);
888
- location.href=url;
889
- return Promise.resolve(false);
890
- }
891
- var newContent=doc.querySelector(containerSel)||doc.querySelector('[data-stx-content]')||doc.querySelector('main');
892
- if(!newContent){location.href=url;return Promise.resolve(false)}
893
-
894
- // Clean up existing signals/effects
895
- if(window.stx&&window.stx._cleanupContainer){
896
- window.stx._cleanupContainer(currentContent);
897
- }
898
-
899
- function doSwap(){
900
- // \u2500\u2500 Swap <head> styles \u2500\u2500
901
- // Inject new styles FIRST, then remove old to prevent unstyled flash
902
- var keepIds={'stx-view-transitions':1,'stx-r-css':1};
903
- var curStyles=document.querySelectorAll('head style');
904
- var newStyles=doc.querySelectorAll('head style');
905
-
906
- // Merge crosswind styles instead of replacing \u2014 persistent elements
907
- // (nav, footer) outside <main> still need their utility classes
908
- var curCrosswind=document.querySelector('head style[data-crosswind]');
909
- var newCrosswind=null;
910
- newStyles.forEach(function(s){if(s.getAttribute('data-crosswind'))newCrosswind=s});
911
-
912
- if(curCrosswind&&newCrosswind){
913
- var merged=mergeCrosswindCSS(curCrosswind.textContent||'',newCrosswind.textContent||'');
914
- if(merged)curCrosswind.textContent=merged;
915
- }
916
-
917
- var incoming=[];
918
- newStyles.forEach(function(s){
919
- if(!keepIds[s.id]&&!s.getAttribute('data-crosswind')){
920
- var ns=document.createElement('style');
921
- ns.textContent=s.textContent;
922
- ns.setAttribute('data-stx-incoming','');
923
- document.head.appendChild(ns);
924
- incoming.push(ns);
925
- }
926
- });
927
-
928
- // If no existing crosswind but new page has one, add it
929
- if(!curCrosswind&&newCrosswind){
930
- var ns=document.createElement('style');
931
- ns.textContent=newCrosswind.textContent;
932
- ns.setAttribute('data-crosswind',newCrosswind.getAttribute('data-crosswind'));
933
- document.head.appendChild(ns);
934
- }
935
-
936
- // Remove old styles (except persistent ones, crosswind, and incoming)
937
- curStyles.forEach(function(s){
938
- if(!keepIds[s.id]&&!s.hasAttribute('data-stx-incoming')&&!s.hasAttribute('data-crosswind'))s.remove();
939
- });
940
-
941
- incoming.forEach(function(s){s.removeAttribute('data-stx-incoming')});
942
-
943
- // \u2500\u2500 Swap <head> stylesheet / font <link>s \u2500\u2500
944
- // Without this, an SPA navigation to a page whose <link rel="stylesheet">
945
- // (or font/preconnect <link>) the current page had not already loaded left
946
- // the swapped-in content completely unstyled (e.g. a page that pulls in its
947
- // own marketing.css, or Google Fonts). Additive and href-deduped, mirroring
948
- // the external <head script[src]> reconcile below \u2014 never removes existing
949
- // links, so stylesheets shared across pages simply persist.
950
- // Relative asset hrefs resolve against the document that declared them:
951
- // the CURRENT page for links already in <head>, and the page being
952
- // navigated TO for links from the fetched document. Against location.origin
953
- // both collapsed to the site root, so 'assets/app.css' on /docs/intro
954
- // deduped as /assets/app.css \u2014 the wrong file, and a false cache hit that
955
- // skipped the real stylesheet (#1777).
956
- var docBase;
957
- try{docBase=new URL(url,location.href).href}catch(e){docBase=location.href}
958
- var linkSel='link[rel="stylesheet"],link[rel="preconnect"]';
959
- var curLinks={};
960
- document.querySelectorAll('head '+linkSel).forEach(function(l){var h=l.getAttribute('href');if(h)curLinks[new URL(h,location.href).href]=1});
961
- doc.querySelectorAll('head '+linkSel).forEach(function(l){
962
- var href=l.getAttribute('href');
963
- if(!href)return;
964
- var abs=new URL(href,docBase).href;
965
- if(curLinks[abs])return;
966
- curLinks[abs]=1;
967
- var nl=document.createElement('link');
968
- Array.from(l.attributes).forEach(function(a){nl.setAttribute(a.name,a.value)});
969
- document.head.appendChild(nl);
970
- });
971
-
972
- // \u2500\u2500 Swap content \u2500\u2500
973
- // For layout changes: swap the entire <body> to replace layout chrome (nav, footer, etc.)
974
- // For same-layout: swap only the container (<main>)
975
- var newBody=doc.querySelector('body');
976
- var isLayoutChange=false;
977
- var declaredLayout=pendingLayoutDecl;
978
- pendingLayoutDecl=null;
979
- if(newBody){
980
- var newMeta=doc.querySelector('meta[name="stx-layout"]');
981
- var curMeta=document.querySelector('meta[name="stx-layout"]');
982
- var curLayout=curMeta?curMeta.getAttribute('content'):'';
983
- // The server's answer wins over the document's metas. We are only here
984
- // because checkLayoutChange already said the group changed, based on
985
- // exactly these headers \u2014 re-deriving from metas and disagreeing meant
986
- // paying for the full-document fetch and then discarding the reason we
987
- // made it, so the destination's <main> was swapped into the previous
988
- // page's chrome (#1833).
989
- //
990
- // They disagree more easily than they look. The bun-plugin serve path
991
- // calls a layout-less page 'default' while the client's own fallback
992
- // calls it 'app'; a <head> with attributes silently skips the meta
993
- // injection entirely; and the i18n path rewrites every page's group to
994
- // i18n:<locale>. Any one of those leaves the metas saying "unchanged"
995
- // about a document the headers said had changed.
996
- var newLayout=declaredLayout&&declaredLayout.layout
997
- ? declaredLayout.layout
998
- : (newMeta?newMeta.getAttribute('content'):'');
999
- var curGroup=getCurrentLayoutGroup();
1000
- var newGroup=declaredLayout&&declaredLayout.group
1001
- ? declaredLayout.group
1002
- : getDocLayoutGroup(doc,newLayout);
1003
- isLayoutChange=curGroup!==newGroup||!!(curLayout&&newLayout&&curLayout!==newLayout);
1004
- }
1005
- var incomingSetupName=newBody?(newBody.getAttribute('data-stx')||''):'';
1006
- // Scripts assigned through innerHTML are inert. Preserve executable
1007
- // scripts as placeholders so re-execution keeps document.currentScript
1008
- // beside the component template it owns.
1009
- var routedBodyScripts=[];
1010
- var routedBodyScriptId=0;
1011
- function prepareRoutedBodyScripts(root){
1012
- if(!root)return;
1013
- root.querySelectorAll('script').forEach(function(s){
1014
- var text=s.textContent||'';
1015
- if(s.hasAttribute('src')){s.remove();return}
1016
- var type=(s.getAttribute('type')||'').trim().toLowerCase();
1017
- var executable=!type||type==='text/javascript'||type==='application/javascript'||type==='module';
1018
- if(!executable||!text.trim())return;
1019
- if(isSignalsRuntimeScript(s,text)||text.indexOf('__stxRouter')!==-1){s.remove();return}
1020
- var slot='document-'+(++routedBodyScriptId);
1021
- var setupName=generatedSetupName(text);
1022
- if(setupName)incomingSetupName=setupName;
1023
- routedBodyScripts.push({text:text,runAlways:true,slot:slot,setupName:setupName,run:s.getAttribute('data-stx-run')||''});
1024
- s.textContent='';
1025
- s.setAttribute('type','application/stx-pending');
1026
- s.setAttribute('data-stx-route-script',slot);
1027
- });
1028
- }
1029
- prepareRoutedBodyScripts(isLayoutChange?newBody:newContent);
1030
- if(isLayoutChange&&newBody){
1031
- log('[router] full body swap for layout change');
1032
- // Replace entire body content \u2014 layout chrome and all
1033
- var bodyHTML=newBody.innerHTML;
1034
- document.body.innerHTML=bodyHTML;
1035
- // Copy body attributes (class, data-stx, etc.)
1036
- Array.from(newBody.attributes).forEach(function(attr){document.body.setAttribute(attr.name,attr.value)});
1037
- // Update layout meta tag
1038
- var oldMeta=document.querySelector('meta[name="stx-layout"]');
1039
- var freshMeta=doc.querySelector('meta[name="stx-layout"]');
1040
- if(oldMeta&&freshMeta)oldMeta.setAttribute('content',freshMeta.getAttribute('content')||'');
1041
- else if(freshMeta){var m=document.createElement('meta');m.name='stx-layout';m.content=freshMeta.getAttribute('content')||'';document.head.appendChild(m)}
1042
- var oldGroupMeta=document.querySelector('meta[name="stx-layout-group"]');
1043
- var freshGroupMeta=doc.querySelector('meta[name="stx-layout-group"]');
1044
- if(oldGroupMeta&&freshGroupMeta)oldGroupMeta.setAttribute('content',freshGroupMeta.getAttribute('content')||'');
1045
- else if(freshGroupMeta){var gm=document.createElement('meta');gm.name='stx-layout-group';gm.content=freshGroupMeta.getAttribute('content')||'';document.head.appendChild(gm)}
1046
- // Update container reference for script execution below
1047
- currentContent=document.querySelector(containerSel)||document.querySelector('main')||document.body;
1048
- } else {
1049
- // Same layout \u2014 swap only container content. Carry the destination
1050
- // container's attributes across too (same reason as the fragment path:
1051
- // pages whose layout lives on <main> itself would otherwise lose it).
1052
- var attrMap={};
1053
- Array.prototype.forEach.call(newContent.attributes,function(a){attrMap[a.name]=a.value});
1054
- setContainerAttrs(currentContent,attrMap);
1055
- var cleanHTML=newContent.innerHTML;
1056
- currentContent.innerHTML=cleanHTML;
1057
- if(incomingSetupName)document.body.setAttribute('data-stx',incomingSetupName);
1058
- }
1059
-
1060
- // \u2500\u2500 Load new external <head> scripts \u2500\u2500
1061
- var loadedSrcs={};
1062
- document.querySelectorAll('head script[src]').forEach(function(s){loadedSrcs[s.src]=1});
1063
- var extPromises=[];
1064
- doc.querySelectorAll('head script[src]').forEach(function(s){
1065
- // Fetched-document script: resolve against the page being navigated to
1066
- // (docBase), not the origin root \u2014 same rationale as the <link> reconcile (#1777).
1067
- var src=new URL(s.getAttribute('src'),docBase).href;
1068
- if(loadedSrcs[src])return;
1069
- loadedSrcs[src]=1;
1070
- extPromises.push(new Promise(function(resolve,reject){
1071
- var ns=document.createElement('script');
1072
- ns.src=src;
1073
- ns.onload=resolve;
1074
- ns.onerror=reject;
1075
- document.head.appendChild(ns);
1076
- }));
1077
- });
1078
-
1079
- // \u2500\u2500 Script re-execution \u2500\u2500
1080
- // Remove previously injected page scripts
1081
- document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
1082
-
1083
- var scripts=routedBodyScripts.slice();
1084
- function addScript(text, runAlways, slot){
1085
- scripts.push({text:text,runAlways:!!runAlways,slot:slot||''});
1086
- }
1087
- if(isLayoutChange){
1088
- // Also collect setup functions from <head>
1089
- doc.querySelectorAll('head script').forEach(function(s){
1090
- var text=s.textContent||'';
1091
- if(s.hasAttribute('src'))return;
1092
- if(!text.trim())return;
1093
- if(isSignalsRuntimeScript(s,text))return;
1094
- if(text.indexOf('__stx_setup_')!==-1)addScript(text,true);
1095
- });
1096
- } else {
1097
- // Collect page scripts from <head> AND <body> (outside container).
1098
- // SSG builds place the setup function in <body> before <main>, not
1099
- // in <head>. Without this, SPA navigation on static sites never
1100
- // runs the setup and reactive data stays empty.
1101
- var seenSetups={};
1102
- scripts.forEach(function(entry){var t=entry.text;if(t.indexOf('__stx_setup_')!==-1)seenSetups[t.substring(0,80)]=1});
1103
- doc.querySelectorAll('script').forEach(function(s){
1104
- var text=s.textContent||'';
1105
- if(s.hasAttribute('src'))return;
1106
- if(!text.trim())return;
1107
- // Skip signals runtime and router (they contain __stx_setup references but aren't setup functions)
1108
- if(isSignalsRuntimeScript(s,text))return;
1109
- if(text.indexOf('__stxRouter')!==-1)return;
1110
- if(newContent.contains(s))return;
1111
- if(text.indexOf('__stx_setup_')===-1&&!s.hasAttribute('data-stx-scoped')&&!s.hasAttribute('data-stx-page')&&!s.hasAttribute('data-stx-route-params'))return;
1112
- var key=text.substring(0,80);
1113
- if(seenSetups[key])return;
1114
- seenSetups[key]=1;
1115
- addScript(text,true);
1116
- });
1117
- }
1118
- // Component setup scripts also assign window.stx._latestSetup. Run the
1119
- // destination page setup last so stx:load hydrates with the page scope,
1120
- // not whichever nested component happened to render last.
1121
- if(incomingSetupName){
1122
- scripts.sort(function(a,b){
1123
- var aPage=(a.setupName||generatedSetupName(a.text))===incomingSetupName?1:0;
1124
- var bPage=(b.setupName||generatedSetupName(b.text))===incomingSetupName?1:0;
1125
- return aPage-bPage;
1126
- });
1127
- }
1128
-
1129
- // Push history state (before active link updates so location.pathname is current)
1130
- if(pushState!==false)writeHistory(pushState,url+(hash||''));
1131
-
1132
- // Update active nav links
1133
- updateNav(url);
1134
- updateActiveLinks();
1135
-
1136
- // Scroll
1137
- if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
1138
- else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
1139
-
1140
- // Update title
1141
- var newTitle=doc.querySelector('title');
1142
- if(newTitle)document.title=newTitle.textContent;
1143
-
1144
- // Update <html lang> from the destination doc so screen readers,
1145
- // CSS :lang() selectors, and any i18n picker that mirrors
1146
- // document.documentElement.lang stay accurate after SPA hops.
1147
- if(doc.documentElement&&doc.documentElement.lang){
1148
- document.documentElement.lang=doc.documentElement.lang;
1149
- }
1150
-
1151
- // \u2500\u2500 Reconcile <html> attributes (stacksjs/stx#1798) \u2500\u2500
1152
- // A layout that scopes its design tokens to the root element
1153
- // (html.marketing { --bg: \u2026 }) needs that class to LEAVE when you
1154
- // navigate to a layout that doesn't want it \u2014 otherwise both token sets
1155
- // match at once and the second layout paints with the first one's
1156
- // palette. Head stylesheets are additive across a swap, so the class is
1157
- // the only thing disambiguating them.
1158
- //
1159
- // Only what stx wrote is touched, per the markers emitted by
1160
- // document-shell.ts. Diffing the whole element against the incoming
1161
- // document would strip the color-mode boot's dark class and
1162
- // data-reduced-motion, which exist only on the live page.
1163
- if(doc.documentElement){
1164
- var curRoot=document.documentElement,incRoot=doc.documentElement;
1165
- var tokens=function(el,attr){var v=el.getAttribute(attr);return v?v.split(/\\s+/).filter(Boolean):[]};
1166
- var prevCls=tokens(curRoot,'data-stx-html-class'),nextCls=tokens(incRoot,'data-stx-html-class');
1167
- prevCls.forEach(function(c){if(nextCls.indexOf(c)===-1)curRoot.classList.remove(c)});
1168
- nextCls.forEach(function(c){curRoot.classList.add(c)});
1169
- if(nextCls.length)curRoot.setAttribute('data-stx-html-class',nextCls.join(' '));
1170
- else curRoot.removeAttribute('data-stx-html-class');
1171
-
1172
- var prevNames=tokens(curRoot,'data-stx-html-attrs'),nextNames=tokens(incRoot,'data-stx-html-attrs');
1173
- prevNames.forEach(function(n){if(nextNames.indexOf(n)===-1)curRoot.removeAttribute(n)});
1174
- nextNames.forEach(function(n){var v=incRoot.getAttribute(n);if(v!==null)curRoot.setAttribute(n,v)});
1175
- if(nextNames.length)curRoot.setAttribute('data-stx-html-attrs',nextNames.join(' '));
1176
- else curRoot.removeAttribute('data-stx-html-attrs');
1177
- }
1178
-
1179
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
1180
-
1181
- // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
1182
- function execScripts(){
1183
- scripts.forEach(function(entry){
1184
- var text=typeof entry==='string'?entry:entry.text;
1185
- var runAlways=typeof entry==='string'?false:entry.runAlways;
1186
- // Skip scripts that were already executed (layout-level partials
1187
- // like theme.stx). Their top-level const/function declarations
1188
- // would throw "Identifier has already been declared" on re-execution.
1189
- // Exception: setup functions (__stx_setup_) must always re-execute
1190
- // because each page has its own setup, and import statements
1191
- // need special handling.
1192
- var h=hashScript(text);
1193
- var isSetup=text.indexOf('__stx_setup_')!==-1;
1194
- var hasImport=hasStaticImport(text);
1195
- if(!runAlways&&!isSetup&&executedScriptHashes[h])return;
1196
- executedScriptHashes[h]=1;
1197
- // Wrap scripts with import statements as modules (Bug 3 fix)
1198
- var ns=document.createElement('script');
1199
- if(hasImport){
1200
- ns.type='module';
1201
- }
1202
- // Wrap in block scope to prevent const/let collisions on re-navigation.
1203
- // Top-level const/let in <script> tags are global-scoped in browsers
1204
- // and throw "Identifier has already been declared" on re-execution.
1205
- // Skip the wrap for module scripts \u2014 they get their own scope from
1206
- // ESM, and top-level 'import' is illegal inside a block, which
1207
- // would throw SyntaxError before the script ever runs.
1208
- var alreadyScoped=runsAlways(typeof entry==='string'?'':entry.run,text);
1209
- ns.textContent=(hasImport||alreadyScoped)?text:'{'+text+'}';
1210
- ns.setAttribute('data-stx-page','');
1211
- var placeholder=entry.slot?document.querySelector('script[data-stx-route-script="'+entry.slot+'"]'):null;
1212
- if(placeholder&&placeholder.parentNode){
1213
- ns.setAttribute('data-stx-positioned','');
1214
- placeholder.parentNode.replaceChild(ns,placeholder);
1215
- }else{
1216
- document.body.appendChild(ns);
1217
- }
1218
- });
1219
- // THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
1220
- window.dispatchEvent(new Event('stx:load'));
1221
- }
1222
-
1223
- if(extPromises.length>0){
1224
- Promise.all(extPromises).then(execScripts).catch(execScripts);
1225
- }
1226
- else {
1227
- execScripts();
1228
- }
1229
- }
1230
-
1231
- return new Promise(function(resolve,reject){
1232
- function completeSwap(){
1233
- try{
1234
- doSwap();
1235
- resolve(true);
1236
- }catch(err){reject(err)}
1237
- }
1238
- if(runViewTransition(completeSwap)){
1239
- }
1240
- else {
1241
- // Fallback fade for browsers without View Transitions API
1242
- currentContent.style.transition='opacity 0.12s ease-out';
1243
- currentContent.style.opacity='0';
1244
- setTimeout(function(){
1245
- completeSwap();
1246
- currentContent.style.opacity='1';
1247
- setTimeout(function(){currentContent.style.transition=''},150);
1248
- },120);
1249
- }
1250
- });
1251
- }
1252
-
1253
- // \u2500\u2500 Link interception \u2500\u2500
1254
- // Links the router must never touch, however it found them. This is the
1255
- // opt-out set, and it is deliberately separate from shouldIntercept's
1256
- // eligibility rules below: [data-stx-link] is an author saying "this is a
1257
- // router link", which answers eligibility but must NOT override an explicit
1258
- // opt-out or a non-navigational scheme.
1259
- function isRouterExcluded(link){
1260
- if(!link)return true;
1261
- var href=link.getAttribute('href');
1262
- if(!href)return true;
1263
- if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return true;
1264
- if(link.target==='_blank')return true;
1265
- if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return true;
1266
- return false;
1267
- }
1268
-
1269
- // Paths the router can actually render, compiled from the server's own route
1270
- // table and shipped as regex sources (#1864). Absent when discovery found
1271
- // nothing, which means UNKNOWN, not "owns nothing" \u2014 treating it as the
1272
- // latter would disable SPA navigation for a whole site.
1273
- var ownedRoutes=null;
1274
- (function(){
1275
- var raw=o.ownedRoutes;
1276
- if(!raw||!raw.length)return;
1277
- var compiled=[];
1278
- for(var i=0;i<raw.length;i++){
1279
- try{compiled.push(new RegExp(raw[i]))}
1280
- catch(e){}
1281
- }
1282
- if(compiled.length)ownedRoutes=compiled;
1283
- })();
1284
-
1285
- // Does the router own this path? Only meaningful when the route table made it
1286
- // to the client; otherwise every path is treated as owned, preserving the old
1287
- // behaviour.
1288
- function routerOwns(pathname){
1289
- if(!ownedRoutes)return true;
1290
- for(var i=0;i<ownedRoutes.length;i++){
1291
- if(ownedRoutes[i].test(pathname))return true;
1292
- }
1293
- return false;
1294
- }
1295
-
1296
- // Eligibility for auto-claiming an arbitrary same-origin anchor under
1297
- // interceptAllLinks. Only reached for anchors the author did not mark.
1298
- function shouldIntercept(link){
1299
- if(isRouterExcluded(link))return false;
1300
- var href=link.getAttribute('href');
1301
- if(href.startsWith('?'))return true;
1302
- if(href===location.pathname)return false;
1303
- if(!getContainer())return false;
1304
- // An endpoint the router cannot render must go to the browser. Claiming it
1305
- // meant a failed fragment fetch, a fallback navigation, and the endpoint
1306
- // hit TWICE \u2014 on OAuth redirects, that mints state twice.
1307
- var path=href;
1308
- var q=path.indexOf('?');if(q!==-1)path=path.slice(0,q);
1309
- var h=path.indexOf('#');if(h!==-1)path=path.slice(0,h);
1310
- if(path.charAt(0)==='/'&&!routerOwns(path)){
1311
- log('[router] not a known route, leaving to the browser:',href);
1312
- return false;
1313
- }
1314
- return true;
1315
- }
1316
-
1317
- // SPA click handling. By default only [data-stx-link] elements get
1318
- // SPA navigation; regular <a href> does native full page reload. Set
1319
- // interceptAllLinks:true (window.__stxRouterConfig or build-time
1320
- // config) to intercept any same-origin anchor \u2014 used by static-site
1321
- // mode where every page is part of the same SPA shell.
1322
- document.addEventListener('click',function(e){
1323
- if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0)return;
1324
- if(!e.target||!e.target.closest)return;
1325
- var link=e.target.closest('[data-stx-link]');
1326
- // A marked link still has to clear the opt-out set. It used to skip it
1327
- // entirely, so data-no-router on a [data-stx-link] anchor was silently
1328
- // ignored and an OAuth redirect got claimed by the router.
1329
- if(link&&isRouterExcluded(link)){log('[router] excluded:',link.getAttribute('href'));return}
1330
- if(!link&&o.interceptAllLinks){
1331
- var anchor=e.target.closest('a[href]');
1332
- if(anchor&&shouldIntercept(anchor))link=anchor;
1333
- }
1334
- if(!link){return}
1335
- var href=link.getAttribute('href');
1336
- log('[router] click intercepted:',href,'container:',!!getContainer(),'defaultPrevented:',e.defaultPrevented);
1337
- e.preventDefault();
1338
- e.stopPropagation();
1339
- log('[router] navigating to:',href);
1340
- navigate(withCurrentLocale(href));
1341
- },true);
1342
-
1343
- // \u2500\u2500 Form submission \u2500\u2500
1344
- // A submit IS a navigation, and the router only ever watched clicks. So any
1345
- // in-app form tore down the SPA with a full document load and discarded
1346
- // every signal on the page, and the only way out was a hand-written fetch in
1347
- // a @submit handler with its own loading and error state.
1348
- //
1349
- // Gated exactly like links: a form opts in with data-stx-form, or
1350
- // interceptForms claims them all \u2014 the same shape as [data-stx-link] /
1351
- // interceptAllLinks. Deliberately not on by default: a POST has side
1352
- // effects, so claiming one has to be a decision rather than something that
1353
- // starts happening on upgrade (#1863).
1354
- function formAttrOf(form,submitter,submitterAttr,formAttr){
1355
- // A submit button overrides the form: formaction / formmethod / formenctype.
1356
- var v=submitter&&submitter.getAttribute?submitter.getAttribute(submitterAttr):null;
1357
- return (v!==null&&v!==undefined)?v:form.getAttribute(formAttr);
1358
- }
1359
-
1360
- function formTargetUrl(form,submitter){
1361
- return formAttrOf(form,submitter,'formaction','action')||(location.pathname+location.search);
1362
- }
1363
-
1364
- function formMethodOf(form,submitter){
1365
- return String(formAttrOf(form,submitter,'formmethod','method')||'get').toLowerCase();
1366
- }
1367
-
1368
- function formExcluded(form,submitter){
1369
- if(!form||form.tagName!=='FORM')return true;
1370
- if(form.hasAttribute('data-stx-no-router')||form.hasAttribute('data-no-router'))return true;
1371
- // A form aimed at another browsing context is not this page's navigation.
1372
- if(form.target&&form.target!=='_self')return true;
1373
- try{
1374
- // Resolving against location handles relative, absolute and foreign
1375
- // schemes in one step: mailto: parses to a null origin and is excluded.
1376
- if(new URL(formTargetUrl(form,submitter),location.href).origin!==location.origin)return true;
1377
- }
1378
- catch(e){return true}
1379
- return false;
1380
- }
1381
-
1382
- function setFormPending(form,on){
1383
- if(on){form.classList.add('stx-submitting');form.setAttribute('aria-busy','true')}
1384
- else{form.classList.remove('stx-submitting');form.removeAttribute('aria-busy')}
1385
- }
1386
-
1387
- function submitForm(form,submitter){
1388
- var method=formMethodOf(form,submitter);
1389
- var action=formTargetUrl(form,submitter);
1390
- var fd=new FormData(form);
1391
- // A named submit button contributes its value, exactly as a native submit
1392
- // would. FormData does not include it, so multi-button forms (Save vs
1393
- // Delete) would otherwise lose the one piece of state that distinguishes them.
1394
- if(submitter&&submitter.name)fd.append(submitter.name,submitter.value||'');
1395
-
1396
- if(method==='get'){
1397
- // A GET form is a link whose href the user filled in. Serialise and hand
1398
- // it to the ordinary navigation path \u2014 no special swap handling, and it
1399
- // inherits caching, prefetch and the guard-redirect behaviour for free.
1400
- var qs=new URLSearchParams();
1401
- fd.forEach(function(v,k){if(typeof v==='string')qs.append(k,v)});
1402
- var gu=new URL(action,location.href);
1403
- gu.search=qs.toString();
1404
- return navigate(gu.pathname+gu.search+gu.hash);
1405
- }
1406
-
1407
- setFormPending(form,true);
1408
- form.dispatchEvent(new CustomEvent('stx:form-submit',{bubbles:true,detail:{action:action,method:method}}));
1409
-
1410
- var enctype=String(formAttrOf(form,submitter,'formenctype','enctype')||'').toLowerCase();
1411
- // Respect the declared encoding. Handing FormData to fetch always produces
1412
- // multipart, which a server expecting urlencoded will read as empty.
1413
- var body=enctype.indexOf('multipart')===0?fd:new URLSearchParams(fd);
1414
- var wantsFragment=shouldUseFragmentResponse();
1415
-
1416
- return fetch(action,{
1417
- method:method.toUpperCase(),
1418
- body:body,
1419
- headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'},
1420
- credentials:'same-origin',
1421
- }).then(function(r){
1422
- // POST/redirect/GET. The server said where the result lives, so go there
1423
- // through the normal path rather than swapping the POST's body under the
1424
- // form's own URL \u2014 otherwise reloading re-submits.
1425
- if(r.redirected&&r.url){
1426
- var rd=new URL(r.url,location.href);
1427
- if(rd.origin!==location.origin){location.href=r.url;return}
1428
- return navigate(rd.pathname+rd.search+rd.hash);
1429
- }
1430
- return r.text().then(function(html){
1431
- var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
1432
- pendingContainerAttrs=isFrag?(r.headers.get('X-STX-Container-Attrs')||''):'';
1433
- pendingLayoutDecl=isFrag?null:{layout:r.headers.get('X-STX-Layout')||'',group:r.headers.get('X-STX-Layout-Group')||''};
1434
- var marked=isFrag?fragmentMarker(r.headers.get('X-STX-Runtime')||'')+html:html;
1435
- // Deliberately NOT cached: this body is the answer to one POST, and
1436
- // serving it later for a GET of the same path would be a lie.
1437
- // 'replace' so re-rendered validation errors do not stack one history
1438
- // entry per attempt.
1439
- return Promise.resolve(swap(marked,cacheKey(action),'replace','')).then(function(){
1440
- applyTitle(r.headers.get('X-STX-Title')||'');
1441
- });
1442
- });
1443
- }).catch(function(err){
1444
- // No native re-submit on failure: that would send the POST a second time.
1445
- // The app is told instead, and the form is left intact for a retry.
1446
- console.error('[router] form submit failed:',err);
1447
- form.dispatchEvent(new CustomEvent('stx:form-error',{bubbles:true,detail:{error:err,action:action}}));
1448
- }).finally(function(){setFormPending(form,false)});
1449
- }
1450
-
1451
- document.addEventListener('submit',function(e){
1452
- // Bubble phase, and defaultPrevented is honoured, so a page's own @submit
1453
- // handler always wins. This is a fallback for forms nobody else claimed,
1454
- // never an override of one that is already handled.
1455
- if(e.defaultPrevented)return;
1456
- var form=e.target;
1457
- if(!form||form.tagName!=='FORM')return;
1458
- if(!form.hasAttribute('data-stx-form')&&!form.hasAttribute('data-stx-link')&&!o.interceptForms)return;
1459
- var submitter=e.submitter||null;
1460
- if(formExcluded(form,submitter))return;
1461
- // Without a container there is nothing to swap into, so a native submit is
1462
- // the only thing that can work.
1463
- if(!getContainer())return;
1464
- // Constraint validation has already run by the time submit fires, so an
1465
- // invalid form never reaches here.
1466
- e.preventDefault();
1467
- // A synchronous throw in here would be swallowed by dispatchEvent, and
1468
- // preventDefault has already run \u2014 so the form would be silently dead with
1469
- // nothing in the console. Report it and clear the pending state instead.
1470
- try{submitForm(form,submitter)}
1471
- catch(err){
1472
- console.error('[router] form submit failed:',err);
1473
- setFormPending(form,false);
1474
- form.dispatchEvent(new CustomEvent('stx:form-error',{bubbles:true,detail:{error:err}}));
1475
- }
1476
- });
1477
-
1478
- // \u2500\u2500 Back/forward \u2500\u2500
1479
- window.addEventListener('popstate',function(){
1480
- navigate(location.pathname+location.search+location.hash,false);
1481
- });
1482
-
1483
- // \u2500\u2500 Prefetch on hover \u2500\u2500
1484
- if(o.prefetch){
1485
- document.addEventListener('mouseover',function(e){
1486
- if(!e.target||!e.target.closest)return;
1487
- var link=e.target.closest('[data-stx-link]');
1488
- // Same opt-out set as the click path. Without it, hovering a link the
1489
- // router is not allowed to claim still fired a real GET at it \u2014 a
1490
- // logout or OAuth URL was requested on hover alone.
1491
- if(isRouterExcluded(link))return;
1492
- var href=withCurrentLocale(link.getAttribute('href'));
1493
- var key=cacheKey(href);
1494
- if(cache[key]||prefetching[key])return;
1495
- prefetching[key]=true;
1496
- var wantsFragment=shouldUseFragmentResponse();
1497
- fetch(href,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
1498
- // Never cache a body that came from somewhere else. A guarded route
1499
- // prefetched while logged out answers with /login's markup, and
1500
- // storing it under the guarded key poisons the cache: the later real
1501
- // navigation is a cache hit, so it swaps the login page in without
1502
- // ever hitting the network, where the redirect check above would
1503
- // have caught it (#1849).
1504
- if(r.redirected)return null;
1505
- var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
1506
- var pLayout=r.headers.get('X-STX-Layout')||'';
1507
- var pGroup=r.headers.get('X-STX-Layout-Group')||'';
1508
- var pTitle=r.headers.get('X-STX-Title')||'';
1509
- var pRuntime=r.headers.get('X-STX-Runtime')||'';
1510
- return r.text().then(function(html){return{html:isFrag?fragmentMarker(pRuntime)+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}});
1511
- }).then(function(result){
1512
- if(result&&o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
1513
- }).catch(function(){}).finally(function(){delete prefetching[key]});
1514
- },true);
1515
- }
1516
-
1517
- // \u2500\u2500 Active link management \u2500\u2500
1518
- function updateNav(url){
1519
- document.querySelectorAll('nav a[href], #mobileNav a[href], [data-stx-nav] a[href]').forEach(function(a){
1520
- var href=a.getAttribute('href');
1521
- if(!href||href.startsWith('#')||href.startsWith('http'))return;
1522
- var isActive=(href===url)||(href==='/'&&url==='/');
1523
- if(a.hasAttribute('data-stx-link')){
1524
- var ac=a.getAttribute('data-stx-active-class')||'active';
1525
- if(isActive)ac.split(' ').forEach(function(cls){if(cls)a.classList.add(cls)});else ac.split(' ').forEach(function(cls){if(cls)a.classList.remove(cls)});
1526
- }
1527
- });
1528
- }
1529
-
1530
- function updateActiveLinks(){
1531
- // Update active classes on <stx-link> elements (and legacy data-stx-link)
1532
- var links=document.querySelectorAll('[data-stx-link]');
1533
- var cur=location.pathname;
1534
- links.forEach(function(link){
1535
- var href=link.getAttribute('to')||link.getAttribute('href')||'';
1536
- var ac=link.getAttribute('active-class')||link.getAttribute('data-stx-active-class')||'active';
1537
- var eac=link.getAttribute('exact-active-class')||link.getAttribute('data-stx-exact-active-class')||'exact-active';
1538
- ac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
1539
- eac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
1540
- var isExact=cur===href;
1541
- var isActive=href!=='/'?cur.startsWith(href):cur==='/';
1542
- if(isExact)eac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
1543
- if(isActive)ac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
1544
- });
1545
- }
1546
-
1547
- // \u2500\u2500 Progress bar DOM + style \u2500\u2500
1548
- // Injects a fixed-position element at the top of the viewport plus the
1549
- // minimal CSS for its transform/opacity transitions. Kept idempotent so
1550
- // repeated init() calls (e.g. after full-body swaps) don't duplicate.
1551
- // Also ships the default View Transitions fade+slide CSS when
1552
- // viewTransitions is enabled and the browser supports it. Apps can
1553
- // override by defining more specific ::view-transition-* rules later in
1554
- // the cascade, or opt out entirely with viewTransitions:false.
1555
- function injectStyles(){
1556
- if(!document.getElementById('stx-r-css')){
1557
- var s=document.createElement('style');s.id='stx-r-css';
1558
- // Strip characters that could escape our CSS block and inject new
1559
- // declarations: ; { } ( ) " ' \\ < > plus whitespace control chars.
1560
- // This keeps the value safe to concat into a stylesheet even if a
1561
- // caller wires the config from an untrusted source.
1562
- var sanitize=function(v){return String(v).replace(/[;{}()"'\\\\<>\\n\\r\\t]/g,'')};
1563
- var pc=sanitize(o.progressColor);
1564
- var ph=sanitize(o.progressHeight);
1565
- var css='.stx-navigating{cursor:wait}.stx-navigating a,.stx-navigating button{pointer-events:none}#stx-router-progress{position:fixed;top:0;left:0;right:0;height:'+ph+';background:'+pc+';box-shadow:0 0 8px '+pc+',0 0 4px '+pc+';transform:scaleX(0);transform-origin:left;transition:transform .18s ease-out,opacity .26s ease;opacity:0;pointer-events:none;z-index:999999}';
1566
- if(o.viewTransitions&&'startViewTransition' in document){
1567
- var dur=(o.viewTransitionDuration||220)+'ms';
1568
- var ease=o.viewTransitionEasing||'cubic-bezier(0.16, 1, 0.3, 1)';
1569
- css+='::view-transition-old(root),::view-transition-new(root){animation-duration:'+dur+';animation-timing-function:'+ease+'}';
1570
- css+='::view-transition-old(root){animation-name:stx-r-fade-out}::view-transition-new(root){animation-name:stx-r-fade-in}';
1571
- css+='@keyframes stx-r-fade-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-4px)}}';
1572
- css+='@keyframes stx-r-fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}';
1573
- css+='@media (prefers-reduced-motion: reduce){::view-transition-old(root),::view-transition-new(root){animation-duration:0s;animation-name:none}}';
1574
- }
1575
- s.textContent=css;
1576
- document.head.appendChild(s);
1577
- }
1578
- if(o.progress&&!document.getElementById('stx-router-progress')){
1579
- var el=document.createElement('div');
1580
- el.id='stx-router-progress';
1581
- el.setAttribute('role','progressbar');
1582
- el.setAttribute('aria-hidden','true');
1583
- // Append to <html>, not <body>: when the router swaps the body
1584
- // (container:'body' for full-page static-site SPAs) the progress
1585
- // element gets wiped along with it. <html> stays put across
1586
- // swaps, so the bar survives multi-hop navigation.
1587
- document.documentElement.appendChild(el);
1588
- progEl=el;
1589
- } else if(o.progress){
1590
- progEl=document.getElementById('stx-router-progress');
1591
- }
1592
- }
1593
-
1594
- function injectViewTransitionCSS(){
1595
- if(document.getElementById('stx-view-transitions'))return;
1596
- var s=document.createElement('style');s.id='stx-view-transitions';
1597
- s.textContent='::view-transition-group(root){animation:none}::view-transition-old(root){animation:none}::view-transition-new(root){animation:none}main,#app-content,[data-stx-content]{view-transition-name:stx-content}::view-transition-old(stx-content){animation:stx-fade-out .15s ease-out both}::view-transition-new(stx-content){animation:stx-fade-in .15s ease-in .1s both}@keyframes stx-fade-out{from{opacity:1}to{opacity:0}}@keyframes stx-fade-in{from{opacity:0}to{opacity:1}}::view-transition{background:transparent}::view-transition-group(stx-content){background:inherit;overflow:hidden}';
1598
- (document.head||document.documentElement).appendChild(s);
1599
- }
1600
-
1601
- // \u2500\u2500 Public API \u2500\u2500
1602
- var router={
1603
- navigate:navigate,
1604
- navigateTo:navigate,
1605
- prefetch:function(url){
1606
- var key=cacheKey(url);
1607
- if(!cache[key]){
1608
- var wantsFragment=shouldUseFragmentResponse();
1609
- fetch(url,{headers:wantsFragment?{'X-STX-Router':'true'}:{'Accept':'text/html'}}).then(function(r){var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';var pLayout=r.headers.get('X-STX-Layout')||'';var pGroup=r.headers.get('X-STX-Layout-Group')||'';var pTitle=r.headers.get('X-STX-Title')||'';var pRuntime=r.headers.get('X-STX-Runtime')||'';return r.text().then(function(html){return{html:isFrag?fragmentMarker(pRuntime)+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}})}).then(function(result){setCache(key,result.html,result.layout,result.layoutGroup,result.title)}).catch(function(){});
1610
- }
1611
- },
1612
- // Re-run the CURRENT route against the server and swap the result.
1613
- //
1614
- // There was no way to do this. invalidate() on a query only re-runs client
1615
- // fetches, so anything the server rendered \u2014 a list the mutation just
1616
- // changed, a count in the layout \u2014 could only be refreshed with a full
1617
- // document load, which discards every signal on the page. That is the
1618
- // exact thing the SPA exists to avoid, so apps reached for
1619
- // location.reload() and lost their state (#1850, #1858).
1620
- //
1621
- // force:true both evicts the cache entry and defeats navigate's
1622
- // same-URL early return, so this is exposing behaviour the router already
1623
- // had rather than adding a second code path. 'replace' because a refresh
1624
- // is not a new place \u2014 it must not add a history entry you can go Back to.
1625
- refresh:function(){
1626
- return navigate(location.pathname+location.search+location.hash,'replace',true);
1627
- },
1628
- // Expire ONE entry, so a mutation can invalidate just the page it affected
1629
- // and let the next visit re-fetch, instead of throwing the whole cache away.
1630
- invalidate:function(url){
1631
- evictCache(cacheKey(url||(location.pathname+location.search)));
1632
- },
1633
- clearCache:function(){for(var k in cache)delete cache[k];for(var lk in layoutCache)delete layoutCache[lk];for(var gk in layoutGroupCache)delete layoutGroupCache[gk];cacheOrder.length=0},
1634
- cache:cache,
1635
- swap:swap,
1636
- updateNav:updateNav
1637
- };
1638
-
1639
- router.__rev=ROUTER_REV;
1640
- window.__stxRouter=router;
1641
- window.stxRouter=router;
1642
- if(window.stx)window.stx.router=router;
1643
-
1644
- // \u2500\u2500 Initialize \u2500\u2500
1645
- function init(){
1646
- injectStyles();
1647
- injectViewTransitionCSS();
1648
- updateActiveLinks();
1649
- updateNav(location.pathname);
1650
- }
1651
-
1652
- if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
1653
- })();
1654
- `),b}export{g as bo,_ as co,y as do,k as eo,C as fo,A as go,v as ho,q as io,H as jo,T as ko,L as lo,V as mo,z as no,K as oo,Y as po,J as qo,Q as ro,Z as so,ee as to,te as uo,re as vo,ae as wo,G as xo,B as yo,U as zo,M as Ao,X as Bo,ne as Co,oe as Do,se as Eo,ie as Fo,ue as Go,ce as Ho,de as Io,j as Jo,he as Ko,fe as Lo};