what-router 0.12.3 → 0.12.4
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/dist/index.min.js +3 -3
- package/package.json +2 -2
- package/src/index.js +79 -14
package/dist/index.min.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import{signal as
|
|
1
|
+
import{signal as R,effect as $,computed as b,batch as M,h as c,ErrorBoundary as X}from"what-core";function O(e){let n=e.replace(/\([\w-]+\)\//g,"").replace(/\[\.\.\.(\w+)\]/g,(i,l)=>`*:${l}`).replace(/\[(\w+)\]/g,":$1"),t=[],r=null,o=n.split("/").map(i=>i.startsWith("*:")?(r=i.slice(2),t.push(r),"(.+)"):i==="*"?(r="rest",t.push("rest"),"(.+)"):i.startsWith(":")?(t.push(i.slice(1)),"([^/]+)"):i.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")).join("/");return{regex:new RegExp(`^${o}$`),paramNames:t,catchAll:r}}var S=new Map,z=1e3;function H(e){let n=S.get(e);return n||(n=O(e),S.size>=z&&S.clear(),S.set(e,n)),n}function K(e,n){let t;try{t=decodeURIComponent(e)}catch{return null}let r=t.split(/[/\\]/);return!n&&r.length>1||r.includes("..")?null:t}function _(e,n){let t=n.filter(r=>r.path);t.sort((r,o)=>{let a=(r.path.match(/:/g)||[]).length+(r.path.includes("*")?100:0),i=(o.path.match(/:/g)||[]).length+(o.path.includes("*")?100:0);return a-i});for(let r of t){let{regex:o,paramNames:a,catchAll:i}=H(r.path),l=e.match(o);if(l){let p={},u=!1;if(a.forEach((s,d)=>{let m=K(l[d+1],s===i);if(m===null){u=!0;return}p[s]=m}),u)continue;return{route:r,params:p}}}return null}function A(e){let n=Object.create(null);if(!e)return n;let t=e.startsWith("?")?e.slice(1):e;for(let r of t.split("&")){let[o,a]=r.split("=");if(!o)continue;let i=decodeURIComponent(o),l=a?decodeURIComponent(a):"";Object.prototype.hasOwnProperty.call(n,i)?Array.isArray(n[i])?n[i].push(l):n[i]=[n[i],l]:n[i]=l}return n}var Q=new Set(["http:","https:","mailto:","tel:"]);function C(e){if(typeof e!="string")return!1;let t=e.trim().replace(/[\s\x00-\x1f]/g,"").toLowerCase();if(t.startsWith("javascript:")||t.startsWith("data:")||t.startsWith("vbscript:")||/^[/\\]{2}/.test(t))return!1;let r=t.match(/^([a-z][a-z0-9+.-]*:)/);return r?Q.has(r[1]):!t.includes("\\")}var h=R(typeof location<"u"?location.pathname+location.search+location.hash:"/"),G=R({}),F=R({}),y=R(!1),x=R(null),B=R(null),g={get url(){return h()},get path(){return h().split("?")[0].split("#")[0]},get params(){return G()},get query(){return F()},get hash(){let e=h().split("#")[1];return e?"#"+e:""},get isNavigating(){return y()},get error(){return B()}},T=[],q=[];function D(e,n){return e.push(n),()=>{let t=e.indexOf(n);t!==-1&&e.splice(t,1)}}function le(e){return D(T,e)}function fe(e){return D(q,e)}async function v(e,n={}){let{replace:t=!1,state:r=null,transition:o=!0,_fromPopstate:a=!1,_redirectChain:i=!1}=n;if(i||(w.length=0),!C(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(r,"",d),h.set(d);let m=document.querySelector(e);m&&m.scrollIntoView({behavior:"smooth"});return}if(e===h()||y.peek())return;y.set(!0),x.set(e);let l=h();if(T.length){let u=!1;try{for(let s of T.slice())if(await s(e,l)===!1){u=!0;break}}catch(s){throw y.set(!1),x.set(null),s}if(u){y.set(!1),x.set(null),a&&typeof history<"u"&&history.pushState(null,"",l);return}}B.set(null);let p=()=>{a||(typeof window<"u"&&k.set(h(),{x:window.scrollX,y:window.scrollY}),t?history.replaceState(r,"",e):history.pushState(r,"",e)),h.set(e),y.set(!1),x.set(null)};if(o&&typeof document<"u"&&document.startViewTransition)try{let u=document.startViewTransition(p);u.ready&&typeof u.ready.catch=="function"&&u.ready.catch(()=>{}),await u.finished}catch{}else p();if(q.length)for(let u of q.slice())u(e,l)}var V=Symbol.for("what.router.redirect"),Y=Symbol.for("what.navigation.signal");function de(e,n={}){if(!C(e)){let r=typeof e=="string"?e:Object.prototype.toString.call(e),o=new Error(`[what-router] redirect() refused an unsafe target: ${r}`);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
2
|
redirect(query.next);
|
|
3
3
|
|
|
4
4
|
// Good - allowlist the target first:
|
|
5
|
-
redirect(ALLOWED.has(query.next) ? query.next : '/');`,o}let
|
|
5
|
+
redirect(ALLOWED.has(query.next) ? query.next : '/');`,o}let t=new Error(`[what-router] redirect to ${e}`);throw t.name="RouterRedirect",t.code="ERR_REDIRECT_NOT_CAUGHT",t.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.",t.codeExample=`// Bad - an event handler runs long after the render the Router caught:
|
|
6
6
|
<button onclick={() => redirect('/login')}>Sign in</button>
|
|
7
7
|
|
|
8
8
|
// Good - navigate() from a handler:
|
|
@@ -12,4 +12,4 @@ redirect(ALLOWED.has(query.next) ? query.next : '/');`,o}let n=new Error(`[what-
|
|
|
12
12
|
function Private() {
|
|
13
13
|
if (!user()) redirect('/login');
|
|
14
14
|
return <Secret />;
|
|
15
|
-
}`,
|
|
15
|
+
}`,t[V]=!0,t[Y]=()=>{L(e,n)},t.to=e,t.options=n,t}typeof window<"u"&&window.addEventListener("popstate",()=>{k.set(h(),{x:window.scrollX,y:window.scrollY});let e=location.pathname+location.search+location.hash;v(e,{replace:!0,_fromPopstate:!0,transition:!1}).then(()=>{let n=k.get(e);n&&requestAnimationFrame(()=>window.scrollTo(n.x,n.y))})});function J(e,n){let t=[];if(!e.path)return t;let r=e.path.split("/").filter(Boolean),o="";for(let a of r){o+="/"+a;let i=n.find(l=>l.layout&&l.path===o+"/_layout");i&&t.push(i.layout)}return e.layout&&t.push(e.layout),t}var w=[],Z=10;function I(e){return c("div",{class:"what-redirect-loop"},c("h1",null,"Redirect Loop"),c("p",null,e))}function L(e,n){if(x.peek()===e)return null;if(w.push(e),w.length>Z){let o=w.slice(-5).join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect loop detected: ${o}`),y.set(!1),x.set(null),I("Too many redirects. Check your middleware configuration.")}let t=new Set,r=!1;for(let o of w){if(t.has(o)){r=!0;break}t.add(o)}if(r){let o=w.join(" \u2192 ");return w.length=0,console.error(`[what-router] Redirect cycle detected: ${o}`),y.set(!1),x.set(null),I("Circular redirect detected. Check your middleware configuration.")}return v(e,{replace:!0,...n,_redirectChain:!0}),null}function ee(e,n,t){if(!e)return null;let r=e.split("?")[0].split("#")[0],o=_(r,n);return!o||o.route===t?null:o.route.loading||null}function te({routes:e,fallback:n,globalLayout:t}){let r=()=>{let a=h(),i=a.split("?")[0].split("#")[0],l=a.split("?")[1]?.split("#")[0]||"",p=y(),u=_(i,e);if(u){M(()=>{G.set(u.params),F.set(A(l))});let{route:s,params:d}=u,m=A(l);if(s.middleware&&s.middleware.length>0)for(let N of s.middleware){let P=N({path:i,params:d,query:m,route:s});if(P===!1)return n?c(n,{}):c("div",{class:"what-403"},c("h1",null,"403"),c("p",null,"Access denied"));if(typeof P=="string")return L(P)}let f,E=p?ee(x(),e,s):null;E?f=c(E,{}):s.loading&&p?f=c(s.loading,{}):f=c(s.component,{params:d,query:m,route:s}),s.error&&(f=c(X,{fallback:s.error},f));let j=J(s,e);for(let N of j.reverse())f=c(N,{params:d,query:m},f);return f}return n?c(n,{}):c("div",{class:"what-404"},c("h1",null,"404"),c("p",null,"Page not found"))},o=()=>{let a=null;try{return r()}catch(i){if(!i||!i[V])throw i;a=i}return L(a.to,a.options)};return t?c(t,{},o):o}function ne({href:e,class:n,className:t,children:r,replace:o,prefetch:a=!0,activeClass:i="active",exactActiveClass:l="exact-active",transition:p=!0,...u}){let s=C(e)?e:"about:blank";!C(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=g.path,E=d==="/"?f==="/":f===d||f.startsWith(d+"/");return[n||t,E&&i,f===d&&l].filter(Boolean).join(" ")||void 0},onclick:f=>{f.ctrlKey||f.metaKey||f.shiftKey||f.altKey||f.button!==0||(f.preventDefault(),v(s,{replace:o,transition:p}))},onmouseenter:a?()=>U(s):void 0,...u},...Array.isArray(r)?r:[r])}function pe(e){return ne(e)}function he(e){return Object.entries(e).map(([n,t])=>typeof t=="function"?{path:n,component:t}:{path:n,...t})}function re(e,n){let t=e.endsWith("/")?e.slice(0,-1):e,r=!n||n==="/"?"/":n.startsWith("/")?n:`/${n}`;return r==="/"?t||"/":t+r}function me(e,n,t={}){let{layout:r,loading:o,error:a}=t;return n.map(i=>({...i,path:re(e,i.path),layout:i.layout||r,loading:i.loading||o,error:i.error||a}))}function ge(e,n,t={}){let{layout:r,middleware:o}=t;return n.map(a=>({...a,_group:e,layout:a.layout||r,middleware:[...a.middleware||[],...o||[]]}))}function ye({to:e}){return v(e,{replace:!0}),null}function we(e,n){return t=>function(o){let a=e(o);return a instanceof Promise?c("div",{class:"what-guard-loading"},"Loading..."):a?c(t,o):typeof n=="string"?(v(n,{replace:!0}),null):c(n,o)}}function xe(e,n={}){let{fallback:t="/login",loading:r=null}=n;return o=>function(i){let l=R("pending"),p=R(null),u=!1;return $(()=>(u=!1,Promise.resolve(e(i)).then(s=>{u||(p.set(s),l.set(s?"allowed":"denied"))}).catch(()=>{u||l.set("denied")}),()=>{u=!0})),()=>{let s=l();return s==="pending"?r?c(r,{}):null:s==="allowed"?c(o,i):typeof t=="string"?(v(t,{replace:!0}),null):c(t,i)}}}var W=new Set;function U(e){if(typeof document>"u"||W.has(e))return;W.add(e);let n=document.createElement("link");n.rel="prefetch",n.href=e,document.head.appendChild(n)}var k=new Map;function Re(){if(typeof window>"u")return;let e=!0;$(()=>{let n=g.url,t=k.get(n),r=g.hash,o=e;e=!1,requestAnimationFrame(()=>{r?oe(r)?.scrollIntoView():t?window.scrollTo(t.x,t.y):o||window.scrollTo(0,0)})})}function oe(e){try{return document.querySelector(e)}catch{return document.getElementById(e.slice(1))}}function ve(e){return{style:{viewTransitionName:e}}}function be(e){typeof document>"u"||(document.documentElement.dataset.transition=e)}function Ee(){return{path:b(()=>g.path),params:b(()=>g.params),query:b(()=>g.query),hash:b(()=>g.hash),isNavigating:b(()=>g.isNavigating),navigate:v,prefetch:U}}function Se(){return g.params}function _e(){return g.query}function Ae(){return v}function Ce(e){U(e)}function ke({children:e}){return e||null}function Ne({routes:e,layout:n,fallback:t,error:r}){let o=e.map(a=>({path:a.path,component:a.component,layout:a.layout||void 0,loading:a.loading||void 0,error:a.error||r||void 0,_mode:a.mode||"client"}));return te({routes:o,globalLayout:n,fallback:t||ie})}function ie(){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{Ne as FileRouter,ne as Link,pe as NavLink,ke as Outlet,ye as Redirect,te as Router,fe as afterNavigate,xe as asyncGuard,le as beforeNavigate,O as compilePath,he as defineRoutes,Re as enableScrollRestoration,we as guard,C as isSafeUrl,_ as matchRoute,v as navigate,me as nestedRoutes,A as parseQuery,U as prefetch,Ce as prefetchRoute,de as redirect,g as route,ge as routeGroup,be as setViewTransition,Ae as useNavigate,Se as useParams,Ee as useRoute,_e as useSearch,ve as viewTransitionName};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "what-router",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.4",
|
|
4
4
|
"description": "What Framework - File-based & programmatic router with View Transitions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"license": "MIT",
|
|
36
36
|
"dependencies": {},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"what-core": "^0.12.
|
|
38
|
+
"what-core": "^0.12.4"
|
|
39
39
|
},
|
|
40
40
|
"repository": {
|
|
41
41
|
"type": "git",
|
package/src/index.js
CHANGED
|
@@ -166,7 +166,9 @@ export async function navigate(to, opts = {}) {
|
|
|
166
166
|
const doNavigation = () => {
|
|
167
167
|
// Skip history manipulation on popstate (browser already updated the URL)
|
|
168
168
|
if (!_fromPopstate) {
|
|
169
|
-
// Save scroll position for current URL before navigating away
|
|
169
|
+
// Save scroll position for current URL before navigating away.
|
|
170
|
+
// Full URL as the key, { x, y } as the value: see scrollPositions below,
|
|
171
|
+
// every writer and the reader must agree on both.
|
|
170
172
|
if (typeof window !== 'undefined') {
|
|
171
173
|
scrollPositions.set(_url(), { x: window.scrollX, y: window.scrollY });
|
|
172
174
|
}
|
|
@@ -716,34 +718,97 @@ export function prefetch(href) {
|
|
|
716
718
|
|
|
717
719
|
// --- Scroll Restoration ---
|
|
718
720
|
|
|
721
|
+
// Saved viewport offsets. ONE key (the full URL: path + search + hash) and ONE
|
|
722
|
+
// shape ({ x, y }) shared by every writer and by the reader below.
|
|
723
|
+
//
|
|
724
|
+
// They used to disagree on both. navigate() and the popstate handler wrote
|
|
725
|
+
// { x, y } keyed by the full URL, while enableScrollRestoration()'s own
|
|
726
|
+
// beforeunload handler wrote a bare NUMBER keyed by location.pathname, and the
|
|
727
|
+
// restore effect looked the entry up by route.path (the pattern-stripped path,
|
|
728
|
+
// never the query or the hash) and handed it straight to
|
|
729
|
+
// `window.scrollTo(0, savedPosition)`. A hit on a { x, y } record therefore
|
|
730
|
+
// produced `scrollTo(0, NaN)`, which browsers normalize to 0 and which jumps
|
|
731
|
+
// the page to the TOP; a URL carrying a query string or a hash missed the
|
|
732
|
+
// lookup entirely and got the same jump. Restoration could not work at all.
|
|
733
|
+
// If you add a writer, key it by the full URL and store { x, y }.
|
|
719
734
|
const scrollPositions = new Map();
|
|
720
735
|
|
|
721
736
|
export function enableScrollRestoration() {
|
|
722
737
|
if (typeof window === 'undefined') return;
|
|
723
738
|
|
|
724
|
-
//
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
//
|
|
739
|
+
// There is deliberately no beforeunload writer. scrollPositions is an
|
|
740
|
+
// in-memory Map that dies with the document, so anything written while the
|
|
741
|
+
// page is unloading can never be read back. The only case where the write
|
|
742
|
+
// survived is the one where the document does NOT go away (the user cancels
|
|
743
|
+
// the "leave site?" prompt), and there it poisoned the map with an entry of
|
|
744
|
+
// the wrong shape under the wrong key. Restoring scroll across a RELOAD is
|
|
745
|
+
// the browser's job (history.scrollRestoration), and the first-run guard
|
|
746
|
+
// below is what keeps this effect out of its way.
|
|
747
|
+
|
|
748
|
+
// The effect's first run is the URL the document loaded with, not a
|
|
749
|
+
// navigation this router performed: the browser has already positioned the
|
|
750
|
+
// document (top for a fresh load, the previous offset for a reload or a
|
|
751
|
+
// document-recreating back). Scrolling to the top there would undo exactly
|
|
752
|
+
// the restoration the comment above defers to. Nothing else is affected,
|
|
753
|
+
// because scrollPositions is necessarily empty on the first run, so the
|
|
754
|
+
// guard can only ever suppress the scroll-to-top branch.
|
|
755
|
+
let firstRun = true;
|
|
756
|
+
|
|
757
|
+
// Restore the saved position after each committed navigation. Reading
|
|
758
|
+
// route.url subscribes to the URL signal, so this re-runs for query-only and
|
|
759
|
+
// hash-only changes too, which is the granularity the map is keyed at.
|
|
730
760
|
effect(() => {
|
|
731
|
-
const
|
|
732
|
-
const
|
|
761
|
+
const url = route.url;
|
|
762
|
+
const saved = scrollPositions.get(url);
|
|
763
|
+
// Read inside the effect, not inside the frame callback: by the time the
|
|
764
|
+
// frame runs another navigation may already have moved the URL on. Same
|
|
765
|
+
// reason the first-run latch is consumed here rather than in the frame.
|
|
766
|
+
const hash = route.hash;
|
|
767
|
+
const isFirstRun = firstRun;
|
|
768
|
+
firstRun = false;
|
|
733
769
|
|
|
734
770
|
requestAnimationFrame(() => {
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
771
|
+
// Hash BEFORE saved, and the order is load-bearing. A URL carrying a
|
|
772
|
+
// hash is a request for a specific ELEMENT, and it outranks any offset
|
|
773
|
+
// saved under that same URL: clicking a "/guide#api" link a second time
|
|
774
|
+
// has to land on #api, not on wherever the user happened to be standing
|
|
775
|
+
// the last time they left /guide#api. Because navigate() keys the map by
|
|
776
|
+
// the full URL (hash included), checking `saved` first made this branch
|
|
777
|
+
// unreachable for every hash URL the user had already visited, and it
|
|
778
|
+
// also fought navigate()'s own same-page hash handling: that path
|
|
779
|
+
// scrollIntoViews the anchor synchronously, and a `saved` hit here
|
|
780
|
+
// clobbered it one frame later.
|
|
781
|
+
if (hash) {
|
|
782
|
+
// Deferred to the frame because an SSR-less deep link renders the
|
|
783
|
+
// target element after the URL commits, so it does not exist yet here.
|
|
784
|
+
const el = queryFragment(hash);
|
|
785
|
+
// No fallback when the anchor is missing: it may simply not have
|
|
786
|
+
// rendered yet, and yanking the page to the top (or to some unrelated
|
|
787
|
+
// saved offset) is the worse answer to "I asked for #api".
|
|
739
788
|
el?.scrollIntoView();
|
|
740
|
-
} else {
|
|
789
|
+
} else if (saved) {
|
|
790
|
+
window.scrollTo(saved.x, saved.y);
|
|
791
|
+
} else if (!isFirstRun) {
|
|
741
792
|
window.scrollTo(0, 0);
|
|
742
793
|
}
|
|
743
794
|
});
|
|
744
795
|
});
|
|
745
796
|
}
|
|
746
797
|
|
|
798
|
+
// Resolve a URL fragment to its element. A fragment is an id, and an id is not
|
|
799
|
+
// necessarily a valid CSS selector: "#1" is legal HTML that browsers navigate
|
|
800
|
+
// to happily, while querySelector('#1') THROWS "Invalid selector". The throw
|
|
801
|
+
// happened inside a requestAnimationFrame callback, where nothing could catch
|
|
802
|
+
// it, so a single numeric anchor took out the frame as an uncaught error.
|
|
803
|
+
function queryFragment(hash) {
|
|
804
|
+
try {
|
|
805
|
+
return document.querySelector(hash);
|
|
806
|
+
} catch {
|
|
807
|
+
// Not selector-parseable, so ask the way the browser itself does.
|
|
808
|
+
return document.getElementById(hash.slice(1));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
747
812
|
// --- View Transition Helpers ---
|
|
748
813
|
|
|
749
814
|
export function viewTransitionName(name) {
|