what-router 0.11.6 → 0.11.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.
package/README.md CHANGED
@@ -134,6 +134,27 @@ const requireRole = asyncGuard(
134
134
  }
135
135
  ```
136
136
 
137
+ `redirect()` throws a navigation signal. Two places catch it: route middleware,
138
+ and a component body.
139
+
140
+ ```js
141
+ {
142
+ path: '/admin',
143
+ component: AdminPanel,
144
+ middleware: [() => isLoggedIn() || redirect('/login')],
145
+ }
146
+
147
+ function Private() {
148
+ if (!isLoggedIn()) redirect('/login');
149
+ return h(Secret, {});
150
+ }
151
+ ```
152
+
153
+ From an event handler, a promise callback or a timer, nothing catches the
154
+ signal and it surfaces as an uncaught error carrying `ERR_REDIRECT_NOT_CAUGHT`.
155
+ Call `navigate(to)` there instead. A `try/catch` around a `redirect()` call
156
+ also swallows the signal, so rethrow anything whose `name` is `RouterRedirect`.
157
+
137
158
  ## View Transitions
138
159
 
139
160
  Navigation uses the View Transitions API by default when available. Use helpers to customize:
@@ -165,6 +186,13 @@ enableScrollRestoration(); // call once at app entry
165
186
  | `navigate(to, opts?)` | Programmatic navigation |
166
187
  | `route` | Reactive route state object |
167
188
  | `useRoute()` | Hook returning computed route properties |
189
+ | `useParams()` | Current route params |
190
+ | `useSearch()` | Parsed query string of the last matched route (stale on a 404, like `route.query`) |
191
+ | `useNavigate()` | Returns `navigate` |
192
+ | `redirect(to, opts?)` | Abort route matching and navigate, **from route middleware** (throws, never returns) |
193
+ | `prefetchRoute(href)` | Prefetch a route's assets |
194
+ | `beforeNavigate(fn)` | Guard run before each route navigation (not hash links); return `false` to cancel |
195
+ | `afterNavigate(fn)` | Callback run after each committed navigation |
168
196
  | `defineRoutes(config)` | Create routes from flat object |
169
197
  | `nestedRoutes(base, children, opts?)` | Nested route helper |
170
198
  | `routeGroup(name, routes, opts?)` | Group routes without affecting URL |
package/dist/index.min.js CHANGED
@@ -1 +1,15 @@
1
- import{signal as x,effect as U,computed as S,batch as I,h as a,ErrorBoundary as V}from"what-core";function L(e){let t=e.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(r,c)=>`*:${c}`).replace(/\[(\w+)\]/g,":$1"),n=[],s=null,o=t.split("/").map(r=>r.startsWith("*:")?(s=r.slice(2),n.push(s),"(.+)"):r==="*"?(s="rest",n.push("rest"),"(.+)"):r.startsWith(":")?(n.push(r.slice(1)),"([^/]+)"):r.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:n,catchAll:s}}function A(e,t){let s=t.filter(o=>o.path).sort((o,i)=>{let r=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0),c=(i.path.match(/:/g)||[]).length+(i.path.includes("*")?100:0);return r-c});for(let o of s){let{regex:i,paramNames:r}=L(o.path),c=e.match(i);if(c){let d={};return r.forEach((u,l)=>{d[u]=decodeURIComponent(c[l+1])}),{route:o,params:d}}}return null}function q(e){let t={};if(!e)return t;let n=e.startsWith("?")?e.slice(1):e;for(let s of n.split("&")){let[o,i]=s.split("=");if(!o)continue;let r=decodeURIComponent(o),c=i?decodeURIComponent(i):"";r in t?Array.isArray(t[r])?t[r].push(c):t[r]=[t[r],c]:t[r]=c}return t}function C(e){if(typeof e!="string")return!1;let n=e.trim().replace(/[\s\x00-\x1f]/g,"").toLowerCase();return!(n.startsWith("javascript:")||n.startsWith("data:")||n.startsWith("vbscript:"))}var p=x(typeof location<"u"?location.pathname+location.search+location.hash:"/"),T=x({}),$=x({}),w=x(!1),W=x(null),h={get url(){return p()},get path(){return p().split("?")[0].split("#")[0]},get params(){return T()},get query(){return $()},get hash(){let e=p().split("#")[1];return e?"#"+e:""},get isNavigating(){return w()},get error(){return W()}};async function v(e,t={}){let{replace:n=!1,state:s=null,transition:o=!0,_fromPopstate:i=!1}=t;if(!C(e)){typeof console<"u"&&console.warn(`[what-router] Blocked navigation to unsafe URL: ${e}`);return}if(typeof window<"u"&&e.startsWith("#")){let u=p().split("#")[0]+e;history.replaceState(s,"",u),p.set(u);let l=document.querySelector(e);l&&l.scrollIntoView({behavior:"smooth"});return}if(e===p()||w.peek())return;w.set(!0),W.set(null);let r=()=>{i||(typeof window<"u"&&k.set(p(),{x:scrollX,y:scrollY}),n?history.replaceState(s,"",e):history.pushState(s,"",e)),p.set(e),w.set(!1)};if(o&&typeof document<"u"&&document.startViewTransition)try{await document.startViewTransition(r).finished}catch{}else r()}typeof window<"u"&&window.addEventListener("popstate",()=>{k.set(p(),{x:scrollX,y:scrollY});let e=location.pathname+location.search+location.hash;v(e,{replace:!0,_fromPopstate:!0,transition:!1}).then(()=>{let t=k.get(e);t&&requestAnimationFrame(()=>window.scrollTo(t.x,t.y))})});function K(e,t){let n=[];if(!e.path)return n;let s=e.path.split("/").filter(Boolean),o="";for(let i of s){o+="/"+i;let r=t.find(c=>c.layout&&c.path===o+"/_layout");r&&n.push(r.layout)}return e.layout&&n.push(e.layout),n}var g=[],B=10;function G({routes:e,fallback:t,globalLayout:n}){let s=()=>{let o=p(),i=o.split("?")[0].split("#")[0],r=o.split("?")[1]?.split("#")[0]||"",c=w(),d=A(i,e);if(d){I(()=>{T.set(d.params),$.set(q(r))});let{route:u,params:l}=d,m=q(r);if(u.middleware&&u.middleware.length>0)for(let b of u.middleware){let P=b({path:i,params:l,query:m,route:u});if(P===!1)return t?a(t,{}):a("div",{class:"what-403"},a("h1",null,"403"),a("p",null,"Access denied"));if(typeof P=="string"){if(g.push(P),g.length>B){let R=g.slice(-5).join(" \u2192 ");return g.length=0,console.error(`[what-router] Redirect loop detected: ${R}`),w.set(!1),a("div",{class:"what-redirect-loop"},a("h1",null,"Redirect Loop"),a("p",null,"Too many redirects. Check your middleware configuration."))}let E=new Set,_=!1;for(let R of g){if(E.has(R)){_=!0;break}E.add(R)}if(_){let R=g.join(" \u2192 ");return g.length=0,console.error(`[what-router] Redirect cycle detected: ${R}`),w.set(!1),a("div",{class:"what-redirect-loop"},a("h1",null,"Redirect Loop"),a("p",null,"Circular redirect detected. Check your middleware configuration."))}return v(P,{replace:!0}),null}}g.length=0;let y;u.loading&&c?y=a(u.loading,{}):y=a(u.component,{params:l,query:m,route:u}),u.error&&(y=a(V,{fallback:u.error},y));let f=K(u,e);for(let b of f.reverse())y=a(b,{params:l,query:m},y);return y}return t?a(t,{}):a("div",{class:"what-404"},a("h1",null,"404"),a("p",null,"Page not found"))};return n?a(n,{},s):s}function z({href:e,class:t,className:n,children:s,replace:o,prefetch:i=!0,activeClass:r="active",exactActiveClass:c="exact-active",transition:d=!0,...u}){let l=C(e)?e:"about:blank";!C(e)&&typeof console<"u"&&console.warn(`[what-router] Link blocked unsafe href: ${e}`);let m=l.split("?")[0].split("#")[0];return a("a",{href:l,class:()=>{let f=h.path,b=m==="/"?f==="/":f===m||f.startsWith(m+"/");return[t||n,b&&r,f===m&&c].filter(Boolean).join(" ")||void 0},onclick:f=>{f.ctrlKey||f.metaKey||f.shiftKey||f.altKey||f.button!==0||(f.preventDefault(),v(l,{replace:o,transition:d}))},onmouseenter:i?()=>j(l):void 0,...u},...Array.isArray(s)?s:[s])}function Y(e){return z(e)}function H(e){return Object.entries(e).map(([t,n])=>typeof n=="function"?{path:t,component:n}:{path:t,...n})}function M(e,t,n={}){let{layout:s,loading:o,error:i}=n;return t.map(r=>({...r,path:e+r.path,layout:r.layout||s,loading:r.loading||o,error:r.error||i}))}function J(e,t,n={}){let{layout:s,middleware:o}=n;return t.map(i=>({...i,_group:e,layout:i.layout||s,middleware:[...i.middleware||[],...o||[]]}))}function Z({to:e}){return v(e,{replace:!0}),null}function ee(e,t){return n=>function(o){let i=e(o);return i instanceof Promise?a("div",{class:"what-guard-loading"},"Loading..."):i?a(n,o):typeof t=="string"?(v(t,{replace:!0}),null):a(t,o)}}function te(e,t={}){let{fallback:n="/login",loading:s=null}=t;return o=>function(r){let c=x("pending"),d=x(null),u=!1;return U(()=>(u=!1,Promise.resolve(e(r)).then(l=>{u||(d.set(l),c.set(l?"allowed":"denied"))}).catch(()=>{u||c.set("denied")}),()=>{u=!0})),()=>{let l=c();return l==="pending"?s?a(s,{}):null:l==="allowed"?a(o,r):typeof n=="string"?(v(n,{replace:!0}),null):a(n,r)}}}var N=new Set;function j(e){if(typeof document>"u"||N.has(e))return;N.add(e);let t=document.createElement("link");t.rel="prefetch",t.href=e,document.head.appendChild(t)}var k=new Map;function ne(){typeof window>"u"||(window.addEventListener("beforeunload",()=>{k.set(location.pathname,window.scrollY)}),U(()=>{let e=h.path,t=k.get(e);requestAnimationFrame(()=>{t!==void 0?window.scrollTo(0,t):h.hash?document.querySelector(h.hash)?.scrollIntoView():window.scrollTo(0,0)})}))}function re(e){return{style:{viewTransitionName:e}}}function oe(e){typeof document>"u"||(document.documentElement.dataset.transition=e)}function ie(){return{path:S(()=>h.path),params:S(()=>h.params),query:S(()=>h.query),hash:S(()=>h.hash),isNavigating:S(()=>h.isNavigating),navigate:v,prefetch:j}}function se({children:e}){return e||null}function ae({routes:e,layout:t,fallback:n,error:s}){let o=e.map(i=>({path:i.path,component:i.component,layout:i.layout||void 0,_mode:i.mode||"client"}));return G({routes:o,globalLayout:t,fallback:n||D})}function D(){return a("div",{style:"text-align:center;padding:60px 20px"},a("h1",{style:"font-size:48px;margin-bottom:8px"},"404"),a("p",{style:"color:#64748b"},"Page not found"))}export{ae as FileRouter,z as Link,Y as NavLink,se as Outlet,Z as Redirect,G as Router,te as asyncGuard,L as compilePath,H as defineRoutes,ne as enableScrollRestoration,ee as guard,C as isSafeUrl,A as matchRoute,v as navigate,M as nestedRoutes,q as parseQuery,j as prefetch,h as route,J as routeGroup,oe as setViewTransition,ie as useRoute,re as viewTransitionName};
1
+ import{signal as v,effect as I,computed as R,batch as H,h as c,ErrorBoundary as K}from"what-core";function U(e){let t=e.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(a,u)=>`*:${u}`).replace(/\[(\w+)\]/g,":$1"),r=[],n=null,o=t.split("/").map(a=>a.startsWith("*:")?(n=a.slice(2),r.push(n),"(.+)"):a==="*"?(n="rest",r.push("rest"),"(.+)"):a.startsWith(":")?(r.push(a.slice(1)),"([^/]+)"):a.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:r,catchAll:n}}var S=new Map,z=1e3;function B(e){let t=S.get(e);return t||(t=U(e),S.size>=z&&S.clear(),S.set(e,t)),t}function F(e,t){let r;try{r=decodeURIComponent(e)}catch{return null}let n=r.split(/[/\\]/);return!t&&n.length>1||n.includes("..")?null:r}function k(e,t){let r=t.filter(n=>n.path);r.sort((n,o)=>{let i=(n.path.match(/:/g)||[]).length+(n.path.includes("*")?100:0),a=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0);return i-a});for(let n of r){let{regex:o,paramNames:i,catchAll:a}=B(n.path),u=e.match(o);if(u){let p={},l=!1;if(i.forEach((s,d)=>{let g=F(u[d+1],s===a);if(g===null){l=!0;return}p[s]=g}),l)continue;return{route:n,params:p}}}return null}function _(e){let t=Object.create(null);if(!e)return t;let r=e.startsWith("?")?e.slice(1):e;for(let n of r.split("&")){let[o,i]=n.split("=");if(!o)continue;let a=decodeURIComponent(o),u=i?decodeURIComponent(i):"";Object.prototype.hasOwnProperty.call(t,a)?Array.isArray(t[a])?t[a].push(u):t[a]=[t[a],u]:t[a]=u}return t}var M=new Set(["http:","https:","mailto:","tel:"]);function A(e){if(typeof e!="string")return!1;let r=e.trim().replace(/[\s\x00-\x1f]/g,"").toLowerCase();if(r.startsWith("javascript:")||r.startsWith("data:")||r.startsWith("vbscript:")||/^[/\\]{2}/.test(r))return!1;let n=r.match(/^([a-z][a-z0-9+.-]*:)/);return n?M.has(n[1]):!r.includes("\\")}var h=v(typeof location<"u"?location.pathname+location.search+location.hash:"/"),$=v({}),G=v({}),y=v(!1),W=v(null),m={get url(){return h()},get path(){return h().split("?")[0].split("#")[0]},get params(){return $()},get query(){return G()},get hash(){let e=h().split("#")[1];return e?"#"+e:""},get isNavigating(){return y()},get error(){return W()}},N=[],T=[];function D(e,t){return e.push(t),()=>{let r=e.indexOf(t);r!==-1&&e.splice(r,1)}}function ae(e){return D(N,e)}function ie(e){return D(T,e)}async function x(e,t={}){let{replace:r=!1,state:n=null,transition:o=!0,_fromPopstate:i=!1,_redirectChain:a=!1}=t;if(a||(w.length=0),!A(e)){typeof console<"u"&&console.warn("[what-router] Blocked navigation to unsafe URL:",e);return}if(typeof window<"u"&&e.startsWith("#")){let d=h().split("#")[0]+e;history.replaceState(n,"",d),h.set(d);let g=document.querySelector(e);g&&g.scrollIntoView({behavior:"smooth"});return}if(e===h()||y.peek())return;y.set(!0);let u=h();if(N.length){let l=!1;try{for(let s of N.slice())if(await s(e,u)===!1){l=!0;break}}catch(s){throw y.set(!1),s}if(l){y.set(!1),i&&typeof history<"u"&&history.pushState(null,"",u);return}}W.set(null);let p=()=>{i||(typeof window<"u"&&b.set(h(),{x:scrollX,y:scrollY}),r?history.replaceState(n,"",e):history.pushState(n,"",e)),h.set(e),y.set(!1)};if(o&&typeof document<"u"&&document.startViewTransition)try{await document.startViewTransition(p).finished}catch{}else p();if(T.length)for(let l of T.slice())l(e,u)}var V=Symbol.for("what.router.redirect"),X=Symbol.for("what.navigation.signal");function se(e,t={}){if(!A(e)){let n=typeof e=="string"?e:Object.prototype.toString.call(e),o=new Error(`[what-router] redirect() refused an unsafe target: ${n}`);throw o.code="ERR_UNSAFE_REDIRECT",o.suggestion='redirect() accepts same-origin paths and http:, https:, mailto: or tel: URLs only. Protocol-relative ("//host"), backslash-smuggled and javascript:/data: targets are open-redirect vectors. Check a user-supplied target against an allowlist first.',o.codeExample=`// Bad - a user-controlled target can leave your origin:
2
+ redirect(query.next);
3
+
4
+ // Good - allowlist the target first:
5
+ redirect(ALLOWED.has(query.next) ? query.next : '/');`,o}let r=new Error(`[what-router] redirect to ${e}`);throw r.name="RouterRedirect",r.code="ERR_REDIRECT_NOT_CAUGHT",r.suggestion="Seeing this signal in your console means nothing caught it. redirect() works from route middleware and from a component body, where the Router catches it. From an event handler, a promise callback or a timer, call navigate(to) instead. A try/catch around the redirect() call also swallows it.",r.codeExample=`// Bad - an event handler runs long after the render the Router caught:
6
+ <button onclick={() => redirect('/login')}>Sign in</button>
7
+
8
+ // Good - navigate() from a handler:
9
+ <button onclick={() => navigate('/login')}>Sign in</button>
10
+
11
+ // Good - redirect() from a component body, which the Router catches:
12
+ function Private() {
13
+ if (!user()) redirect('/login');
14
+ return <Secret />;
15
+ }`,r[V]=!0,r[X]=()=>{q(e,t)},r.to=e,r.options=t,r}typeof window<"u"&&window.addEventListener("popstate",()=>{b.set(h(),{x:scrollX,y:scrollY});let e=location.pathname+location.search+location.hash;x(e,{replace:!0,_fromPopstate:!0,transition:!1}).then(()=>{let t=b.get(e);t&&requestAnimationFrame(()=>window.scrollTo(t.x,t.y))})});function Q(e,t){let r=[];if(!e.path)return r;let n=e.path.split("/").filter(Boolean),o="";for(let i of n){o+="/"+i;let a=t.find(u=>u.layout&&u.path===o+"/_layout");a&&r.push(a.layout)}return e.layout&&r.push(e.layout),r}var w=[],Y=10;function O(e){return c("div",{class:"what-redirect-loop"},c("h1",null,"Redirect Loop"),c("p",null,e))}function q(e,t){if(w.push(e),w.length>Y){let o=w.slice(-5).join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect loop detected: ${o}`),y.set(!1),O("Too many redirects. Check your middleware configuration.")}let r=new Set,n=!1;for(let o of w){if(r.has(o)){n=!0;break}r.add(o)}if(n){let o=w.join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect cycle detected: ${o}`),y.set(!1),O("Circular redirect detected. Check your middleware configuration.")}return x(e,{replace:!0,...t,_redirectChain:!0}),null}function J({routes:e,fallback:t,globalLayout:r}){let n=()=>{let i=h(),a=i.split("?")[0].split("#")[0],u=i.split("?")[1]?.split("#")[0]||"",p=y(),l=k(a,e);if(l){H(()=>{$.set(l.params),G.set(_(u))});let{route:s,params:d}=l,g=_(u);if(s.middleware&&s.middleware.length>0)for(let E of s.middleware){let P=E({path:a,params:d,query:g,route:s});if(P===!1)return t?c(t,{}):c("div",{class:"what-403"},c("h1",null,"403"),c("p",null,"Access denied"));if(typeof P=="string")return q(P)}let f;s.loading&&p?f=c(s.loading,{}):f=c(s.component,{params:d,query:g,route:s}),s.error&&(f=c(K,{fallback:s.error},f));let C=Q(s,e);for(let E of C.reverse())f=c(E,{params:d,query:g},f);return f}return t?c(t,{}):c("div",{class:"what-404"},c("h1",null,"404"),c("p",null,"Page not found"))},o=()=>{let i=null;try{return n()}catch(a){if(!a||!a[V])throw a;i=a}return q(i.to,i.options)};return r?c(r,{},o):o}function Z({href:e,class:t,className:r,children:n,replace:o,prefetch:i=!0,activeClass:a="active",exactActiveClass:u="exact-active",transition:p=!0,...l}){let s=A(e)?e:"about:blank";!A(e)&&typeof console<"u"&&console.warn("[what-router] Link blocked unsafe href:",e);let d=s.split("?")[0].split("#")[0];return c("a",{href:s,class:()=>{let f=m.path,C=d==="/"?f==="/":f===d||f.startsWith(d+"/");return[t||r,C&&a,f===d&&u].filter(Boolean).join(" ")||void 0},onclick:f=>{f.ctrlKey||f.metaKey||f.shiftKey||f.altKey||f.button!==0||(f.preventDefault(),x(s,{replace:o,transition:p}))},onmouseenter:i?()=>L(s):void 0,...l},...Array.isArray(n)?n:[n])}function ce(e){return Z(e)}function ue(e){return Object.entries(e).map(([t,r])=>typeof r=="function"?{path:t,component:r}:{path:t,...r})}function le(e,t,r={}){let{layout:n,loading:o,error:i}=r;return t.map(a=>({...a,path:e+a.path,layout:a.layout||n,loading:a.loading||o,error:a.error||i}))}function fe(e,t,r={}){let{layout:n,middleware:o}=r;return t.map(i=>({...i,_group:e,layout:i.layout||n,middleware:[...i.middleware||[],...o||[]]}))}function de({to:e}){return x(e,{replace:!0}),null}function pe(e,t){return r=>function(o){let i=e(o);return i instanceof Promise?c("div",{class:"what-guard-loading"},"Loading..."):i?c(r,o):typeof t=="string"?(x(t,{replace:!0}),null):c(t,o)}}function he(e,t={}){let{fallback:r="/login",loading:n=null}=t;return o=>function(a){let u=v("pending"),p=v(null),l=!1;return I(()=>(l=!1,Promise.resolve(e(a)).then(s=>{l||(p.set(s),u.set(s?"allowed":"denied"))}).catch(()=>{l||u.set("denied")}),()=>{l=!0})),()=>{let s=u();return s==="pending"?n?c(n,{}):null:s==="allowed"?c(o,a):typeof r=="string"?(x(r,{replace:!0}),null):c(r,a)}}}var j=new Set;function L(e){if(typeof document>"u"||j.has(e))return;j.add(e);let t=document.createElement("link");t.rel="prefetch",t.href=e,document.head.appendChild(t)}var b=new Map;function me(){typeof window>"u"||(window.addEventListener("beforeunload",()=>{b.set(location.pathname,window.scrollY)}),I(()=>{let e=m.path,t=b.get(e);requestAnimationFrame(()=>{t!==void 0?window.scrollTo(0,t):m.hash?document.querySelector(m.hash)?.scrollIntoView():window.scrollTo(0,0)})}))}function ge(e){return{style:{viewTransitionName:e}}}function ye(e){typeof document>"u"||(document.documentElement.dataset.transition=e)}function we(){return{path:R(()=>m.path),params:R(()=>m.params),query:R(()=>m.query),hash:R(()=>m.hash),isNavigating:R(()=>m.isNavigating),navigate:x,prefetch:L}}function xe(){return m.params}function ve(){return m.query}function Re(){return x}function be(e){L(e)}function Ee({children:e}){return e||null}function Se({routes:e,layout:t,fallback:r,error:n}){let o=e.map(i=>({path:i.path,component:i.component,layout:i.layout||void 0,_mode:i.mode||"client"}));return J({routes:o,globalLayout:t,fallback:r||ee})}function ee(){return c("div",{style:"text-align:center;padding:60px 20px"},c("h1",{style:"font-size:48px;margin-bottom:8px"},"404"),c("p",{style:"color:#64748b"},"Page not found"))}export{Se as FileRouter,Z as Link,ce as NavLink,Ee as Outlet,de as Redirect,J as Router,ie as afterNavigate,he as asyncGuard,ae as beforeNavigate,U as compilePath,ue as defineRoutes,me as enableScrollRestoration,pe as guard,A as isSafeUrl,k as matchRoute,x as navigate,le as nestedRoutes,_ as parseQuery,L as prefetch,be as prefetchRoute,se as redirect,m as route,fe as routeGroup,ye as setViewTransition,Re as useNavigate,xe as useParams,we as useRoute,ve as useSearch,ge as viewTransitionName};
package/dist/match.min.js CHANGED
@@ -1 +1 @@
1
- function u(n){let e=n.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(t,o)=>`*:${o}`).replace(/\[(\w+)\]/g,":$1"),a=[],c=null,r=e.split("/").map(t=>t.startsWith("*:")?(c=t.slice(2),a.push(c),"(.+)"):t==="*"?(c="rest",a.push("rest"),"(.+)"):t.startsWith(":")?(a.push(t.slice(1)),"([^/]+)"):t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${r}$`),paramNames:a,catchAll:c}}function h(n,e){let c=e.filter(r=>r.path).sort((r,s)=>{let t=(r.path.match(/:/g)||[]).length+(r.path.includes("*")?100:0),o=(s.path.match(/:/g)||[]).length+(s.path.includes("*")?100:0);return t-o});for(let r of c){let{regex:s,paramNames:t}=u(r.path),o=n.match(s);if(o){let i={};return t.forEach((p,l)=>{i[p]=decodeURIComponent(o[l+1])}),{route:r,params:i}}}return null}function f(n){let e={};if(!n)return e;let a=n.startsWith("?")?n.slice(1):n;for(let c of a.split("&")){let[r,s]=c.split("=");if(!r)continue;let t=decodeURIComponent(r),o=s?decodeURIComponent(s):"";t in e?Array.isArray(e[t])?e[t].push(o):e[t]=[e[t],o]:e[t]=o}return e}export{u as compilePath,h as matchRoute,f as parseQuery};
1
+ function h(n){let t=n.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(e,s)=>`*:${s}`).replace(/\[(\w+)\]/g,":$1"),c=[],r=null,o=t.split("/").map(e=>e.startsWith("*:")?(r=e.slice(2),c.push(r),"(.+)"):e==="*"?(r="rest",c.push("rest"),"(.+)"):e.startsWith(":")?(c.push(e.slice(1)),"([^/]+)"):e.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:c,catchAll:r}}var a=new Map,m=1e3;function g(n){let t=a.get(n);return t||(t=h(n),a.size>=m&&a.clear(),a.set(n,t)),t}function x(n,t){let c;try{c=decodeURIComponent(n)}catch{return null}let r=c.split(/[/\\]/);return!t&&r.length>1||r.includes("..")?null:c}function y(n,t){let c=t.filter(r=>r.path);c.sort((r,o)=>{let l=(r.path.match(/:/g)||[]).length+(r.path.includes("*")?100:0),e=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0);return l-e});for(let r of c){let{regex:o,paramNames:l,catchAll:e}=g(r.path),s=n.match(o);if(s){let i={},u=!1;if(l.forEach((p,d)=>{let f=x(s[d+1],p===e);if(f===null){u=!0;return}i[p]=f}),u)continue;return{route:r,params:i}}}return null}function A(n){let t=Object.create(null);if(!n)return t;let c=n.startsWith("?")?n.slice(1):n;for(let r of c.split("&")){let[o,l]=r.split("=");if(!o)continue;let e=decodeURIComponent(o),s=l?decodeURIComponent(l):"";Object.prototype.hasOwnProperty.call(t,e)?Array.isArray(t[e])?t[e].push(s):t[e]=[t[e],s]:t[e]=s}return t}export{h as compilePath,y as matchRoute,A as parseQuery};
package/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // What Framework Router - TypeScript Definitions
2
2
 
3
- import { VNode, VNodeChild, Component, Signal, Computed } from '../core';
3
+ import { VNode, VNodeChild, Component, Signal, Computed } from 'what-core';
4
4
 
5
5
  // --- Route State ---
6
6
 
@@ -78,6 +78,25 @@ export interface RouterProps {
78
78
 
79
79
  export function Router(props: RouterProps): VNode;
80
80
 
81
+ // --- File-Based Router ---
82
+
83
+ export interface FileRouteConfig {
84
+ path: string;
85
+ component: Component<RouteComponentProps>;
86
+ layout?: Component<LayoutProps>;
87
+ mode?: 'static' | 'server' | 'client' | 'hybrid';
88
+ }
89
+
90
+ export interface FileRouterProps {
91
+ routes: FileRouteConfig[];
92
+ layout?: Component<{ children?: VNodeChild }>;
93
+ fallback?: Component<{}>;
94
+ error?: Component<{ error: Error }>;
95
+ }
96
+
97
+ /** Router driven by what-compiler's generated route manifest (virtual:what-routes). */
98
+ export function FileRouter(props: FileRouterProps): VNode;
99
+
81
100
  // --- Link Component ---
82
101
 
83
102
  export interface LinkProps {
@@ -160,6 +179,82 @@ export interface UseRouteResult {
160
179
 
161
180
  export function useRoute(): UseRouteResult;
162
181
 
182
+ // --- Route Accessors ---
183
+
184
+ /** Current route params. Subscribes when read inside a tracking scope. */
185
+ export function useParams<T = Record<string, string>>(): T;
186
+
187
+ /**
188
+ * Query string of the last successfully matched route, parsed. Subscribes when
189
+ * read inside a tracking scope. Only the Router's match branch writes it, so on
190
+ * an unmatched (404) route this is the previous route's query, not the current
191
+ * URL's. Same value and same caveat as `route.query`.
192
+ */
193
+ export function useSearch<T = Record<string, string>>(): T;
194
+
195
+ /** The navigate function, for symmetry with useParams/useSearch. */
196
+ export function useNavigate(): typeof navigate;
197
+
198
+ /** Prefetch a route's assets. */
199
+ export function prefetchRoute(href: string): void;
200
+
201
+ // --- Redirect Signal ---
202
+
203
+ /**
204
+ * Abort the current render and navigate.
205
+ *
206
+ * Throws a navigation signal. Two places catch it: route middleware, caught by
207
+ * the Router's matching pass, and a component body, caught by the runtime where
208
+ * it instantiates components. Anywhere else (an event handler, a promise
209
+ * callback, a timer, or a reactive thunk such as `{() => cond() && redirect(to)}`)
210
+ * nothing catches it and the signal surfaces as an uncaught error carrying
211
+ * `ERR_REDIRECT_NOT_CAUGHT`; call `navigate(to)` there instead. In a thunk the
212
+ * first render reports the error, but on a later re-run the navigation simply
213
+ * does not happen and the stale DOM stays, so prefer `navigate(to)` there.
214
+ * A `try/catch` around the call also swallows it, so rethrow anything whose
215
+ * `name` is `RouterRedirect`. On the server the signal escapes `renderToString`
216
+ * to its caller: read `.to` and emit a 302.
217
+ */
218
+ export function redirect(to: string, options?: NavigateOptions): never;
219
+
220
+ // --- Navigation Hooks ---
221
+
222
+ /**
223
+ * Run before every route navigation; return false to cancel. Returns an
224
+ * unsubscribe. Not consulted for same-page hash navigation (`navigate('#x')`
225
+ * scrolls, it does not change the route). Cancelling a back/forward navigation
226
+ * restores the address bar by pushing the previous URL as a new history entry:
227
+ * the entry the browser moved to is not recovered and its `history.state` is
228
+ * not carried over.
229
+ */
230
+ export function beforeNavigate(fn: (to: string, from: string) => boolean | Promise<boolean>): () => void;
231
+
232
+ /** Run after every committed navigation. Returns an unsubscribe. */
233
+ export function afterNavigate(fn: (to: string, from: string) => void): () => void;
234
+
163
235
  // --- Outlet ---
164
236
 
165
- export function Outlet(props: { children?: VNodeChild }): VNodeChild;
237
+ export function Outlet(props: { children?: VNodeChild }): VNode;
238
+
239
+ // --- Path Matching ---
240
+
241
+ export interface CompiledPath {
242
+ regex: RegExp;
243
+ paramNames: string[];
244
+ catchAll: string | null;
245
+ }
246
+
247
+ /** Compile a path pattern (`/users/:id`, `/posts/*`, `/[slug]`) to a matcher. */
248
+ export function compilePath(path: string): CompiledPath;
249
+
250
+ /** Match a pathname against routes, most specific first. */
251
+ export function matchRoute<T extends { path?: string }>(
252
+ path: string,
253
+ routes: T[],
254
+ ): { route: T; params: Record<string, string> } | null;
255
+
256
+ /** Parse a query string into a null-prototype object. */
257
+ export function parseQuery(search: string): Record<string, string>;
258
+
259
+ /** Reject javascript:, data:, vbscript: and protocol-relative URLs. */
260
+ export function isSafeUrl(url: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "what-router",
3
- "version": "0.11.6",
3
+ "version": "0.11.8",
4
4
  "description": "What Framework - File-based & programmatic router with View Transitions",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -33,8 +33,9 @@
33
33
  ],
34
34
  "author": "ZVN DEV (https://zvndev.com)",
35
35
  "license": "MIT",
36
+ "dependencies": {},
36
37
  "peerDependencies": {
37
- "what-core": "^0.11.6"
38
+ "what-core": "^0.11.8"
38
39
  },
39
40
  "repository": {
40
41
  "type": "git",
package/src/index.js CHANGED
@@ -6,7 +6,20 @@ import { signal, effect, computed, batch, h, ErrorBoundary } from 'what-core';
6
6
  import { compilePath, matchRoute, parseQuery } from './match.js';
7
7
 
8
8
  // --- URL Sanitization ---
9
- // Rejects javascript:, data:, vbscript: protocols (case-insensitive, trimmed).
9
+ // Rejects javascript:, data:, vbscript: protocols (case-insensitive, trimmed),
10
+ // any scheme outside the allowlist (blob:, about:, filesystem: ...), and
11
+ // protocol-relative / backslash-smuggled paths that resolve to a foreign
12
+ // origin. Browsers treat "\" like "/", so "/\evil.com" is an open redirect.
13
+ //
14
+ // Sibling predicate: safeLocalPath / safeRedirectTarget in
15
+ // packages/server/src/action-handler.js. That one gates a server-issued
16
+ // `Location:` header and must stay strictly narrower (same-origin local paths
17
+ // only), because a form POST target is attacker-controllable in a way a client
18
+ // navigation target is not. This one deliberately allows absolute http(s),
19
+ // mailto: and tel:. Harden one, re-read the other; do not unify them.
20
+ // packages/server/test/redirect-predicate-parity.test.js gates that ordering.
21
+
22
+ const SAFE_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
10
23
 
11
24
  export function isSafeUrl(url) {
12
25
  if (typeof url !== 'string') return false;
@@ -16,6 +29,13 @@ export function isSafeUrl(url) {
16
29
  if (normalized.startsWith('javascript:')) return false;
17
30
  if (normalized.startsWith('data:')) return false;
18
31
  if (normalized.startsWith('vbscript:')) return false;
32
+ if (/^[/\\]{2}/.test(normalized)) return false;
33
+ const scheme = normalized.match(/^([a-z][a-z0-9+.-]*:)/);
34
+ if (scheme) return SAFE_PROTOCOLS.has(scheme[1]);
35
+ // Scheme-less only: browsers normalize a backslash to a forward slash, so
36
+ // `\evil.com` resolves off-origin exactly as `/\evil.com` does. Absolute
37
+ // URLs already passed the protocol allowlist above.
38
+ if (normalized.includes('\\')) return false;
19
39
  return true;
20
40
  }
21
41
 
@@ -40,15 +60,49 @@ export const route = {
40
60
  get error() { return _navigationError(); },
41
61
  };
42
62
 
63
+ // --- Navigation Hooks ---
64
+ // Subscriber lists consulted by navigate(). Guards run before the URL changes
65
+ // and can cancel by returning false; afterNavigate runs once the URL committed.
66
+ // Module singletons on purpose: this module is browser-only (it listens for
67
+ // popstate and mutates history), so its scope is one tab, not one request. The
68
+ // server adapters import what-router/match, never this file, so there is no
69
+ // shared-process path here of the kind b066671 fixed for server actions.
70
+
71
+ const _beforeHooks = [];
72
+ const _afterHooks = [];
73
+
74
+ function subscribe(list, fn) {
75
+ list.push(fn);
76
+ return () => {
77
+ const i = list.indexOf(fn);
78
+ if (i !== -1) list.splice(i, 1);
79
+ };
80
+ }
81
+
82
+ export function beforeNavigate(fn) {
83
+ return subscribe(_beforeHooks, fn);
84
+ }
85
+
86
+ export function afterNavigate(fn) {
87
+ return subscribe(_afterHooks, fn);
88
+ }
89
+
43
90
  // --- Navigation with View Transitions ---
44
91
 
45
92
  export async function navigate(to, opts = {}) {
46
- const { replace = false, state = null, transition = true, _fromPopstate = false } = opts;
93
+ const { replace = false, state = null, transition = true, _fromPopstate = false, _redirectChain = false } = opts;
94
+
95
+ // A navigation the user asked for starts a fresh redirect chain; a hop
96
+ // queued by handleRedirect continues the current one.
97
+ if (!_redirectChain) _redirectHistory.length = 0;
47
98
 
48
99
  // Reject unsafe URLs
49
100
  if (!isSafeUrl(to)) {
50
101
  if (typeof console !== 'undefined') {
51
- console.warn(`[what-router] Blocked navigation to unsafe URL: ${to}`);
102
+ // isSafeUrl() rejects non-strings, so the value reaching this warning is
103
+ // exactly the kind that can throw on template-literal coercion (Symbol,
104
+ // null-prototype object). Log it as a separate argument.
105
+ console.warn('[what-router] Blocked navigation to unsafe URL:', to);
52
106
  }
53
107
  return;
54
108
  }
@@ -68,10 +122,36 @@ export async function navigate(to, opts = {}) {
68
122
  // Don't navigate if already on the same URL
69
123
  if (to === _url()) return;
70
124
 
71
- // Prevent concurrent navigations wait for current to finish
125
+ // Prevent concurrent navigations: wait for the current one to finish. The
126
+ // flag is claimed in the same tick as the check, because an awaited
127
+ // beforeNavigate hook otherwise leaves a gap two navigations both get through.
72
128
  if (_isNavigating.peek()) return;
73
-
74
129
  _isNavigating.set(true);
130
+
131
+ const from = _url();
132
+
133
+ // A popstate has already moved the browser URL, so cancelling one means
134
+ // pushing the previous entry back to keep the address bar in sync. That entry
135
+ // is a new one: the forward entry is not recoverable and its history.state is
136
+ // not carried over. Documented on beforeNavigate in index.d.ts.
137
+ if (_beforeHooks.length) {
138
+ let cancelled = false;
139
+ try {
140
+ for (const fn of _beforeHooks.slice()) {
141
+ if ((await fn(to, from)) === false) { cancelled = true; break; }
142
+ }
143
+ } catch (e) {
144
+ // A throwing guard must not leave the router permanently wedged.
145
+ _isNavigating.set(false);
146
+ throw e;
147
+ }
148
+ if (cancelled) {
149
+ _isNavigating.set(false);
150
+ if (_fromPopstate && typeof history !== 'undefined') history.pushState(null, '', from);
151
+ return;
152
+ }
153
+ }
154
+
75
155
  _navigationError.set(null);
76
156
 
77
157
  const doNavigation = () => {
@@ -101,6 +181,64 @@ export async function navigate(to, opts = {}) {
101
181
  } else {
102
182
  doNavigation();
103
183
  }
184
+
185
+ if (_afterHooks.length) {
186
+ for (const fn of _afterHooks.slice()) fn(to, from);
187
+ }
188
+ }
189
+
190
+ // --- redirect() ---
191
+ // Throws a navigation signal. Two places catch it:
192
+ //
193
+ // - Route middleware, caught by the Router's own matching pass below.
194
+ // - A component body, caught by core's createComponent, which invokes the
195
+ // handler the signal carries under Symbol.for('what.navigation.signal')
196
+ // rather than reporting the throw to an ErrorBoundary. h() is lazy, so a
197
+ // route component is instantiated after the matching pass has returned;
198
+ // that is why the second catch has to live in core.
199
+ //
200
+ // Anywhere else (an event handler, a promise callback, a timer) nothing catches
201
+ // it and the signal surfaces as an uncaught error. It carries a code, a fix and
202
+ // an example for exactly that case, because that is the only case a human reads
203
+ // it in.
204
+
205
+ const REDIRECT = Symbol.for('what.router.redirect');
206
+ const NAV_SIGNAL = Symbol.for('what.navigation.signal');
207
+
208
+ export function redirect(to, options = {}) {
209
+ if (!isSafeUrl(to)) {
210
+ const target = typeof to === 'string' ? to : Object.prototype.toString.call(to);
211
+ const err = new Error(`[what-router] redirect() refused an unsafe target: ${target}`);
212
+ err.code = 'ERR_UNSAFE_REDIRECT';
213
+ err.suggestion = 'redirect() accepts same-origin paths and http:, https:, mailto: or tel: URLs only. Protocol-relative ("//host"), backslash-smuggled and javascript:/data: targets are open-redirect vectors. Check a user-supplied target against an allowlist first.';
214
+ err.codeExample = `// Bad - a user-controlled target can leave your origin:
215
+ redirect(query.next);
216
+
217
+ // Good - allowlist the target first:
218
+ redirect(ALLOWED.has(query.next) ? query.next : '/');`;
219
+ throw err;
220
+ }
221
+
222
+ const sig = new Error(`[what-router] redirect to ${to}`);
223
+ sig.name = 'RouterRedirect';
224
+ sig.code = 'ERR_REDIRECT_NOT_CAUGHT';
225
+ sig.suggestion = 'Seeing this signal in your console means nothing caught it. redirect() works from route middleware and from a component body, where the Router catches it. From an event handler, a promise callback or a timer, call navigate(to) instead. A try/catch around the redirect() call also swallows it.';
226
+ sig.codeExample = `// Bad - an event handler runs long after the render the Router caught:
227
+ <button onclick={() => redirect('/login')}>Sign in</button>
228
+
229
+ // Good - navigate() from a handler:
230
+ <button onclick={() => navigate('/login')}>Sign in</button>
231
+
232
+ // Good - redirect() from a component body, which the Router catches:
233
+ function Private() {
234
+ if (!user()) redirect('/login');
235
+ return <Secret />;
236
+ }`;
237
+ sig[REDIRECT] = true;
238
+ sig[NAV_SIGNAL] = () => { handleRedirect(to, options); };
239
+ sig.to = to;
240
+ sig.options = options;
241
+ throw sig;
104
242
  }
105
243
 
106
244
  // Back/forward support — route through navigate() so middleware runs
@@ -164,6 +302,50 @@ function buildLayoutChain(route, routes) {
164
302
  const _redirectHistory = [];
165
303
  const MAX_REDIRECTS = 10;
166
304
 
305
+ function loopScreen(message) {
306
+ return h('div', { class: 'what-redirect-loop' },
307
+ h('h1', null, 'Redirect Loop'),
308
+ h('p', null, message)
309
+ );
310
+ }
311
+
312
+ // Shared by the middleware string form and by a thrown redirect() signal.
313
+ // Returns null once the navigation is queued, or the loop screen if the
314
+ // redirect chain is cycling.
315
+ //
316
+ // The chain is scoped to one user navigation: navigate() clears the history
317
+ // unless it is being called from here. Clearing it on a successful match
318
+ // instead would never catch a cycle between two route components, because each
319
+ // hop matches successfully before its component throws the next redirect.
320
+ function handleRedirect(target, options) {
321
+ _redirectHistory.push(target);
322
+
323
+ if (_redirectHistory.length > MAX_REDIRECTS) {
324
+ const cycle = _redirectHistory.slice(-5).join(' → ');
325
+ _redirectHistory.length = 0;
326
+ console.error(`[what-router] Redirect loop detected: ${cycle}`);
327
+ _isNavigating.set(false);
328
+ return loopScreen('Too many redirects. Check your middleware configuration.');
329
+ }
330
+
331
+ const seen = new Set();
332
+ let hasCycle = false;
333
+ for (const url of _redirectHistory) {
334
+ if (seen.has(url)) { hasCycle = true; break; }
335
+ seen.add(url);
336
+ }
337
+ if (hasCycle) {
338
+ const cycle = _redirectHistory.join(' → ');
339
+ _redirectHistory.length = 0;
340
+ console.error(`[what-router] Redirect cycle detected: ${cycle}`);
341
+ _isNavigating.set(false);
342
+ return loopScreen('Circular redirect detected. Check your middleware configuration.');
343
+ }
344
+
345
+ navigate(target, { replace: true, ...options, _redirectChain: true });
346
+ return null;
347
+ }
348
+
167
349
  // --- Router Component ---
168
350
 
169
351
  export function Router({ routes, fallback, globalLayout }) {
@@ -171,7 +353,7 @@ export function Router({ routes, fallback, globalLayout }) {
171
353
  // re-evaluates whenever _url changes; the fine-grained runtime reconciles only
172
354
  // the matched page in place. The globalLayout is rendered ONCE around it (below)
173
355
  // so the app shell persists across navigations instead of re-instantiating.
174
- const content = () => {
356
+ const renderMatch = () => {
175
357
  const currentUrl = _url();
176
358
  const path = currentUrl.split('?')[0].split('#')[0];
177
359
  const search = currentUrl.split('?')[1]?.split('#')[0] || '';
@@ -198,43 +380,11 @@ export function Router({ routes, fallback, globalLayout }) {
198
380
  return h('div', { class: 'what-403' }, h('h1', null, '403'), h('p', null, 'Access denied'));
199
381
  }
200
382
  if (typeof result === 'string') {
201
- // Redirect loop detection
202
- _redirectHistory.push(result);
203
- if (_redirectHistory.length > MAX_REDIRECTS) {
204
- const cycle = _redirectHistory.slice(-5).join(' → ');
205
- _redirectHistory.length = 0;
206
- console.error(`[what-router] Redirect loop detected: ${cycle}`);
207
- _isNavigating.set(false);
208
- return h('div', { class: 'what-redirect-loop' },
209
- h('h1', null, 'Redirect Loop'),
210
- h('p', null, 'Too many redirects. Check your middleware configuration.')
211
- );
212
- }
213
- // Check for direct cycle (A → B → A)
214
- const seen = new Set();
215
- let hasCycle = false;
216
- for (const url of _redirectHistory) {
217
- if (seen.has(url)) { hasCycle = true; break; }
218
- seen.add(url);
219
- }
220
- if (hasCycle) {
221
- const cycle = _redirectHistory.join(' → ');
222
- _redirectHistory.length = 0;
223
- console.error(`[what-router] Redirect cycle detected: ${cycle}`);
224
- _isNavigating.set(false);
225
- return h('div', { class: 'what-redirect-loop' },
226
- h('h1', null, 'Redirect Loop'),
227
- h('p', null, 'Circular redirect detected. Check your middleware configuration.')
228
- );
229
- }
230
383
  // Middleware returned a redirect path
231
- navigate(result, { replace: true });
232
- return null;
384
+ return handleRedirect(result);
233
385
  }
234
386
  }
235
387
  }
236
- // Successful render — clear redirect history
237
- _redirectHistory.length = 0;
238
388
 
239
389
  // Build element with loading state support
240
390
  let element;
@@ -271,6 +421,21 @@ export function Router({ routes, fallback, globalLayout }) {
271
421
  );
272
422
  };
273
423
 
424
+ // Catches a redirect() thrown by route middleware. A redirect() thrown by a
425
+ // route component is caught by core instead, because h() is lazy and the
426
+ // component runs after renderMatch has returned. Anything unbranded
427
+ // propagates to the app's ErrorBoundary unchanged.
428
+ const content = () => {
429
+ let sig = null;
430
+ try {
431
+ return renderMatch();
432
+ } catch (e) {
433
+ if (!e || !e[REDIRECT]) throw e;
434
+ sig = e;
435
+ }
436
+ return handleRedirect(sig.to, sig.options);
437
+ };
438
+
274
439
  // Render the global layout ONCE so it — and everything it mounts (sidebars,
275
440
  // toasters, command palettes, global key listeners) — PERSISTS across
276
441
  // navigations; the reactive `content` child swaps the matched page in place.
@@ -299,7 +464,7 @@ export function Link({
299
464
  // Sanitize href — reject dangerous protocols
300
465
  const safeHref = isSafeUrl(href) ? href : 'about:blank';
301
466
  if (!isSafeUrl(href) && typeof console !== 'undefined') {
302
- console.warn(`[what-router] Link blocked unsafe href: ${href}`);
467
+ console.warn('[what-router] Link blocked unsafe href:', href);
303
468
  }
304
469
 
305
470
  // Strip query string and hash from href for path comparison
@@ -535,6 +700,29 @@ export function useRoute() {
535
700
  };
536
701
  }
537
702
 
703
+ // --- Route Accessors ---
704
+ // Read the singleton route state directly. Called inside a tracking scope
705
+ // (a reactive text binding, computed or effect) they subscribe to it.
706
+
707
+ export function useParams() {
708
+ return route.params;
709
+ }
710
+
711
+ // Same singleton `_query` signal as route.query: only the Router's match branch
712
+ // writes it, so on an unmatched (404) route this is the last matched route's
713
+ // query, not the current URL's. Documented on both declarations.
714
+ export function useSearch() {
715
+ return route.query;
716
+ }
717
+
718
+ export function useNavigate() {
719
+ return navigate;
720
+ }
721
+
722
+ export function prefetchRoute(href) {
723
+ prefetch(href);
724
+ }
725
+
538
726
  // --- Outlet Component ---
539
727
  // For nested route rendering
540
728
 
package/src/match.js CHANGED
@@ -42,33 +42,75 @@ export function compilePath(path) {
42
42
  return { regex, paramNames, catchAll };
43
43
  }
44
44
 
45
+ // Compiled patterns are stable per route path, so cache them instead of
46
+ // rebuilding a RegExp for every route on every request.
47
+ const patternCache = new Map();
48
+ const PATTERN_CACHE_MAX = 1000;
49
+
50
+ function getPattern(path) {
51
+ let compiled = patternCache.get(path);
52
+ if (!compiled) {
53
+ compiled = compilePath(path);
54
+ if (patternCache.size >= PATTERN_CACHE_MAX) patternCache.clear();
55
+ patternCache.set(path, compiled);
56
+ }
57
+ return compiled;
58
+ }
59
+
60
+ // The `([^/]+)` segment guard runs against the still-encoded path, so a param
61
+ // only becomes multi-segment once it is decoded ("%2e%2e%2f" -> "../"). Reject
62
+ // any decoded value that smuggles in a separator or a traversal segment, and
63
+ // treat malformed percent-escapes as a non-match instead of throwing.
64
+ function decodeParam(value, isCatchAll) {
65
+ let decoded;
66
+ try {
67
+ decoded = decodeURIComponent(value);
68
+ } catch {
69
+ return null;
70
+ }
71
+ const segments = decoded.split(/[/\\]/);
72
+ if (!isCatchAll && segments.length > 1) return null;
73
+ if (segments.includes('..')) return null;
74
+ return decoded;
75
+ }
76
+
45
77
  export function matchRoute(path, routes) {
46
78
  // Filter out routes without a path (layout-only routes, etc.)
47
- const routable = routes.filter(r => r.path);
79
+ // `filter` already copies, so sorting below never touches the caller's array.
80
+ const sorted = routes.filter(r => r.path);
48
81
 
49
82
  // Sort routes by specificity (more specific first)
50
- const sorted = routable.sort((a, b) => {
83
+ sorted.sort((a, b) => {
51
84
  const aSpecific = (a.path.match(/:/g) || []).length + (a.path.includes('*') ? 100 : 0);
52
85
  const bSpecific = (b.path.match(/:/g) || []).length + (b.path.includes('*') ? 100 : 0);
53
86
  return aSpecific - bSpecific;
54
87
  });
55
88
 
56
89
  for (const route of sorted) {
57
- const { regex, paramNames } = compilePath(route.path);
90
+ const { regex, paramNames, catchAll } = getPattern(route.path);
58
91
  const match = path.match(regex);
59
92
  if (match) {
60
93
  const params = {};
94
+ let rejected = false;
61
95
  paramNames.forEach((name, i) => {
62
- params[name] = decodeURIComponent(match[i + 1]);
96
+ const decoded = decodeParam(match[i + 1], name === catchAll);
97
+ if (decoded === null) {
98
+ rejected = true;
99
+ return;
100
+ }
101
+ params[name] = decoded;
63
102
  });
103
+ if (rejected) continue;
64
104
  return { route, params };
65
105
  }
66
106
  }
67
107
  return null;
68
108
  }
69
109
 
110
+ // Null-prototype so query keys like "toString" or "__proto__" stay plain data
111
+ // instead of colliding with inherited members or replacing the prototype.
70
112
  export function parseQuery(search) {
71
- const params = {};
113
+ const params = Object.create(null);
72
114
  if (!search) return params;
73
115
  const qs = search.startsWith('?') ? search.slice(1) : search;
74
116
  for (const pair of qs.split('&')) {
@@ -76,7 +118,7 @@ export function parseQuery(search) {
76
118
  if (!key) continue;
77
119
  const decodedKey = decodeURIComponent(key);
78
120
  const decodedVal = val ? decodeURIComponent(val) : '';
79
- if (decodedKey in params) {
121
+ if (Object.prototype.hasOwnProperty.call(params, decodedKey)) {
80
122
  // Collect repeated keys into arrays
81
123
  if (Array.isArray(params[decodedKey])) {
82
124
  params[decodedKey].push(decodedVal);