stx-router 0.2.155 → 0.2.157
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/client.js +306 -11
- package/dist/codegen.d.ts +15 -0
- package/dist/codegen.js +24 -17
- package/dist/file-router.js +3 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/middleware.d.ts +16 -1
- package/dist/middleware.js +15 -6
- package/dist/page-routes.d.ts +41 -0
- package/dist/page-routes.js +7 -0
- package/dist/types.d.ts +7 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -12,11 +12,11 @@ export function getRouterScript() {
|
|
|
12
12
|
cachedRouterScript = minifyRouterScript(`
|
|
13
13
|
;(function(){
|
|
14
14
|
'use strict';
|
|
15
|
-
var ROUTER_REV=
|
|
15
|
+
var ROUTER_REV=7;
|
|
16
16
|
if(window.__stxRouter&&window.__stxRouter.__rev===ROUTER_REV)return;
|
|
17
17
|
|
|
18
18
|
// \u2500\u2500 Configuration \u2500\u2500
|
|
19
|
-
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};
|
|
19
|
+
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};
|
|
20
20
|
var o=Object.assign({},defaults,window.__stxRouterConfig||{},window.STX_ROUTER_OPTIONS||{});
|
|
21
21
|
var containerSel=o.container;
|
|
22
22
|
var debug=!!o.debug;
|
|
@@ -527,6 +527,23 @@ else {
|
|
|
527
527
|
var wantsFragment=shouldUseFragmentResponse();
|
|
528
528
|
return fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
|
|
529
529
|
if(!r.ok)throw new Error(r.status);
|
|
530
|
+
// A route guard answered with a redirect and fetch followed it
|
|
531
|
+
// transparently, so r.ok is the DESTINATION's 200 and this markup
|
|
532
|
+
// belongs to r.url \u2014 not to the path that was requested. Without
|
|
533
|
+
// retargeting, an auth guard's /login body is swapped in while the
|
|
534
|
+
// address bar, the history entry and the cache key all still read
|
|
535
|
+
// /dashboard: the user sees a login form at a URL that claims to be
|
|
536
|
+
// the page they asked for, and reloading bounces them again (#1849).
|
|
537
|
+
if(r.redirected&&r.url){
|
|
538
|
+
var rd=new URL(r.url,location.href);
|
|
539
|
+
// A guard that sends you off-origin (a hosted IdP) cannot be
|
|
540
|
+
// resolved by swapping a fragment \u2014 hand it to the browser.
|
|
541
|
+
if(rd.origin!==location.origin){location.href=r.url;return null}
|
|
542
|
+
log('[router] guard redirected:',url,'->',rd.pathname);
|
|
543
|
+
url=rd.pathname+rd.search+rd.hash;
|
|
544
|
+
targetPath=cacheKey(url);
|
|
545
|
+
targetHash=rd.hash;
|
|
546
|
+
}
|
|
530
547
|
// Before anything is parsed or swapped: a fragment from a different
|
|
531
548
|
// build must not be handed to this page's runtime (#1772).
|
|
532
549
|
var incomingBuild=r.headers.get('X-STX-Build')||'';
|
|
@@ -588,6 +605,60 @@ else {
|
|
|
588
605
|
// pendingContainerAttrs \u2014 see the note above writeHistory about the cost of
|
|
589
606
|
// adding an argument to swap() and its three call sites (#1807).
|
|
590
607
|
var pendingLayoutDecl=null;
|
|
608
|
+
// \u2500\u2500 Focus and route announcement \u2500\u2500
|
|
609
|
+
// A fragment swap replaces content without a document load, so the browser
|
|
610
|
+
// does none of what it normally does for a navigation. Focus stays on a link
|
|
611
|
+
// that no longer exists, which the browser resets to <body> \u2014 a keyboard user
|
|
612
|
+
// is silently returned to the top of the tab order \u2014 and a screen reader says
|
|
613
|
+
// nothing at all, so there is no signal the page changed. Neither is fixable
|
|
614
|
+
// from app code, because only the router knows a navigation happened (#1862).
|
|
615
|
+
function routeAnnouncer(){
|
|
616
|
+
var el=document.getElementById('stx-route-announcer');
|
|
617
|
+
if(el)return el;
|
|
618
|
+
el=document.createElement('div');
|
|
619
|
+
el.id='stx-route-announcer';
|
|
620
|
+
el.setAttribute('aria-live','polite');
|
|
621
|
+
el.setAttribute('role','status');
|
|
622
|
+
el.setAttribute('aria-atomic','true');
|
|
623
|
+
// Visually hidden but still ANNOUNCED. display:none and visibility:hidden
|
|
624
|
+
// are both skipped by screen readers, which would make this a silent no-op
|
|
625
|
+
// that looks correct in the DOM.
|
|
626
|
+
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';
|
|
627
|
+
document.body.appendChild(el);
|
|
628
|
+
return el;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function announceRoute(){
|
|
632
|
+
if(o.announceRoute===false)return;
|
|
633
|
+
var c=getContainer();
|
|
634
|
+
var h=c&&c.querySelector?c.querySelector('h1'):null;
|
|
635
|
+
// The heading names the destination better than the title, which often
|
|
636
|
+
// carries a site-name suffix. Falls back to the title, then gives up
|
|
637
|
+
// rather than announcing an empty string.
|
|
638
|
+
var label=(h&&(h.textContent||'').trim())||document.title||'';
|
|
639
|
+
if(!label)return;
|
|
640
|
+
var el=routeAnnouncer();
|
|
641
|
+
// Cleared first: several screen readers ignore a live-region update whose
|
|
642
|
+
// text is identical to what is already there, so navigating between two
|
|
643
|
+
// pages with the same heading would announce only the first.
|
|
644
|
+
el.textContent='';
|
|
645
|
+
el.textContent=label;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function focusAfterNavigation(hash){
|
|
649
|
+
if(o.routeFocus===false)return;
|
|
650
|
+
var target=null;
|
|
651
|
+
if(hash){try{target=document.querySelector(hash)}catch(e){target=null}}
|
|
652
|
+
if(!target)target=getContainer();
|
|
653
|
+
if(!target||!target.focus)return;
|
|
654
|
+
// The container is not naturally focusable. tabindex="-1" makes it
|
|
655
|
+
// programmatically focusable without inserting it into the tab order.
|
|
656
|
+
if(!target.hasAttribute('tabindex'))target.setAttribute('tabindex','-1');
|
|
657
|
+
// preventScroll because scroll position is already decided just above \u2014
|
|
658
|
+
// focusing would otherwise fight scrollToTop and the hash scrollIntoView.
|
|
659
|
+
try{target.focus({preventScroll:true})}catch(e){try{target.focus()}catch(e2){}}
|
|
660
|
+
}
|
|
661
|
+
|
|
591
662
|
function swap(html,url,pushState,hash){
|
|
592
663
|
var fragMark=/^<!--stx-fragment(?: rt=([01]))?-->/.exec(html);
|
|
593
664
|
var isFragment=!!fragMark;
|
|
@@ -710,6 +781,12 @@ else {
|
|
|
710
781
|
if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
|
|
711
782
|
else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
|
|
712
783
|
window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
|
|
784
|
+
// Before page scripts run, so a page that focuses its own control on
|
|
785
|
+
// mount still wins \u2014 its script executes after this.
|
|
786
|
+
focusAfterNavigation(hash);
|
|
787
|
+
// Deferred a tick because the title is applied by the caller after this
|
|
788
|
+
// swap resolves, and the announcement should read the new one.
|
|
789
|
+
setTimeout(announceRoute,0);
|
|
713
790
|
// Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
|
|
714
791
|
log('[router] frag scripts:', fragScripts.length);
|
|
715
792
|
document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
|
|
@@ -1182,16 +1259,66 @@ else {
|
|
|
1182
1259
|
}
|
|
1183
1260
|
|
|
1184
1261
|
// \u2500\u2500 Link interception \u2500\u2500
|
|
1262
|
+
// Links the router must never touch, however it found them. This is the
|
|
1263
|
+
// opt-out set, and it is deliberately separate from shouldIntercept's
|
|
1264
|
+
// eligibility rules below: [data-stx-link] is an author saying "this is a
|
|
1265
|
+
// router link", which answers eligibility but must NOT override an explicit
|
|
1266
|
+
// opt-out or a non-navigational scheme.
|
|
1267
|
+
function isRouterExcluded(link){
|
|
1268
|
+
if(!link)return true;
|
|
1269
|
+
var href=link.getAttribute('href');
|
|
1270
|
+
if(!href)return true;
|
|
1271
|
+
if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return true;
|
|
1272
|
+
if(link.target==='_blank')return true;
|
|
1273
|
+
if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return true;
|
|
1274
|
+
return false;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Paths the router can actually render, compiled from the server's own route
|
|
1278
|
+
// table and shipped as regex sources (#1864). Absent when discovery found
|
|
1279
|
+
// nothing, which means UNKNOWN, not "owns nothing" \u2014 treating it as the
|
|
1280
|
+
// latter would disable SPA navigation for a whole site.
|
|
1281
|
+
var ownedRoutes=null;
|
|
1282
|
+
(function(){
|
|
1283
|
+
var raw=o.ownedRoutes;
|
|
1284
|
+
if(!raw||!raw.length)return;
|
|
1285
|
+
var compiled=[];
|
|
1286
|
+
for(var i=0;i<raw.length;i++){
|
|
1287
|
+
try{compiled.push(new RegExp(raw[i]))}
|
|
1288
|
+
catch(e){}
|
|
1289
|
+
}
|
|
1290
|
+
if(compiled.length)ownedRoutes=compiled;
|
|
1291
|
+
})();
|
|
1292
|
+
|
|
1293
|
+
// Does the router own this path? Only meaningful when the route table made it
|
|
1294
|
+
// to the client; otherwise every path is treated as owned, preserving the old
|
|
1295
|
+
// behaviour.
|
|
1296
|
+
function routerOwns(pathname){
|
|
1297
|
+
if(!ownedRoutes)return true;
|
|
1298
|
+
for(var i=0;i<ownedRoutes.length;i++){
|
|
1299
|
+
if(ownedRoutes[i].test(pathname))return true;
|
|
1300
|
+
}
|
|
1301
|
+
return false;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Eligibility for auto-claiming an arbitrary same-origin anchor under
|
|
1305
|
+
// interceptAllLinks. Only reached for anchors the author did not mark.
|
|
1185
1306
|
function shouldIntercept(link){
|
|
1186
|
-
if(
|
|
1307
|
+
if(isRouterExcluded(link))return false;
|
|
1187
1308
|
var href=link.getAttribute('href');
|
|
1188
|
-
if(!href)return false;
|
|
1189
|
-
if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return false;
|
|
1190
|
-
if(link.target==='_blank')return false;
|
|
1191
|
-
if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return false;
|
|
1192
1309
|
if(href.startsWith('?'))return true;
|
|
1193
1310
|
if(href===location.pathname)return false;
|
|
1194
1311
|
if(!getContainer())return false;
|
|
1312
|
+
// An endpoint the router cannot render must go to the browser. Claiming it
|
|
1313
|
+
// meant a failed fragment fetch, a fallback navigation, and the endpoint
|
|
1314
|
+
// hit TWICE \u2014 on OAuth redirects, that mints state twice.
|
|
1315
|
+
var path=href;
|
|
1316
|
+
var q=path.indexOf('?');if(q!==-1)path=path.slice(0,q);
|
|
1317
|
+
var h=path.indexOf('#');if(h!==-1)path=path.slice(0,h);
|
|
1318
|
+
if(path.charAt(0)==='/'&&!routerOwns(path)){
|
|
1319
|
+
log('[router] not a known route, leaving to the browser:',href);
|
|
1320
|
+
return false;
|
|
1321
|
+
}
|
|
1195
1322
|
return true;
|
|
1196
1323
|
}
|
|
1197
1324
|
|
|
@@ -1204,6 +1331,10 @@ else {
|
|
|
1204
1331
|
if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0)return;
|
|
1205
1332
|
if(!e.target||!e.target.closest)return;
|
|
1206
1333
|
var link=e.target.closest('[data-stx-link]');
|
|
1334
|
+
// A marked link still has to clear the opt-out set. It used to skip it
|
|
1335
|
+
// entirely, so data-no-router on a [data-stx-link] anchor was silently
|
|
1336
|
+
// ignored and an OAuth redirect got claimed by the router.
|
|
1337
|
+
if(link&&isRouterExcluded(link)){log('[router] excluded:',link.getAttribute('href'));return}
|
|
1207
1338
|
if(!link&&o.interceptAllLinks){
|
|
1208
1339
|
var anchor=e.target.closest('a[href]');
|
|
1209
1340
|
if(anchor&&shouldIntercept(anchor))link=anchor;
|
|
@@ -1211,14 +1342,147 @@ else {
|
|
|
1211
1342
|
if(!link){return}
|
|
1212
1343
|
var href=link.getAttribute('href');
|
|
1213
1344
|
log('[router] click intercepted:',href,'container:',!!getContainer(),'defaultPrevented:',e.defaultPrevented);
|
|
1214
|
-
if(!href||href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')){log('[router] skipped:',href);return}
|
|
1215
|
-
if(link.target==='_blank'||link.hasAttribute('download'))return;
|
|
1216
1345
|
e.preventDefault();
|
|
1217
1346
|
e.stopPropagation();
|
|
1218
1347
|
log('[router] navigating to:',href);
|
|
1219
1348
|
navigate(withCurrentLocale(href));
|
|
1220
1349
|
},true);
|
|
1221
1350
|
|
|
1351
|
+
// \u2500\u2500 Form submission \u2500\u2500
|
|
1352
|
+
// A submit IS a navigation, and the router only ever watched clicks. So any
|
|
1353
|
+
// in-app form tore down the SPA with a full document load and discarded
|
|
1354
|
+
// every signal on the page, and the only way out was a hand-written fetch in
|
|
1355
|
+
// a @submit handler with its own loading and error state.
|
|
1356
|
+
//
|
|
1357
|
+
// Gated exactly like links: a form opts in with data-stx-form, or
|
|
1358
|
+
// interceptForms claims them all \u2014 the same shape as [data-stx-link] /
|
|
1359
|
+
// interceptAllLinks. Deliberately not on by default: a POST has side
|
|
1360
|
+
// effects, so claiming one has to be a decision rather than something that
|
|
1361
|
+
// starts happening on upgrade (#1863).
|
|
1362
|
+
function formAttrOf(form,submitter,submitterAttr,formAttr){
|
|
1363
|
+
// A submit button overrides the form: formaction / formmethod / formenctype.
|
|
1364
|
+
var v=submitter&&submitter.getAttribute?submitter.getAttribute(submitterAttr):null;
|
|
1365
|
+
return (v!==null&&v!==undefined)?v:form.getAttribute(formAttr);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
function formTargetUrl(form,submitter){
|
|
1369
|
+
return formAttrOf(form,submitter,'formaction','action')||(location.pathname+location.search);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function formMethodOf(form,submitter){
|
|
1373
|
+
return String(formAttrOf(form,submitter,'formmethod','method')||'get').toLowerCase();
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
function formExcluded(form,submitter){
|
|
1377
|
+
if(!form||form.tagName!=='FORM')return true;
|
|
1378
|
+
if(form.hasAttribute('data-stx-no-router')||form.hasAttribute('data-no-router'))return true;
|
|
1379
|
+
// A form aimed at another browsing context is not this page's navigation.
|
|
1380
|
+
if(form.target&&form.target!=='_self')return true;
|
|
1381
|
+
try{
|
|
1382
|
+
// Resolving against location handles relative, absolute and foreign
|
|
1383
|
+
// schemes in one step: mailto: parses to a null origin and is excluded.
|
|
1384
|
+
if(new URL(formTargetUrl(form,submitter),location.href).origin!==location.origin)return true;
|
|
1385
|
+
}
|
|
1386
|
+
catch(e){return true}
|
|
1387
|
+
return false;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
function setFormPending(form,on){
|
|
1391
|
+
if(on){form.classList.add('stx-submitting');form.setAttribute('aria-busy','true')}
|
|
1392
|
+
else{form.classList.remove('stx-submitting');form.removeAttribute('aria-busy')}
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function submitForm(form,submitter){
|
|
1396
|
+
var method=formMethodOf(form,submitter);
|
|
1397
|
+
var action=formTargetUrl(form,submitter);
|
|
1398
|
+
var fd=new FormData(form);
|
|
1399
|
+
// A named submit button contributes its value, exactly as a native submit
|
|
1400
|
+
// would. FormData does not include it, so multi-button forms (Save vs
|
|
1401
|
+
// Delete) would otherwise lose the one piece of state that distinguishes them.
|
|
1402
|
+
if(submitter&&submitter.name)fd.append(submitter.name,submitter.value||'');
|
|
1403
|
+
|
|
1404
|
+
if(method==='get'){
|
|
1405
|
+
// A GET form is a link whose href the user filled in. Serialise and hand
|
|
1406
|
+
// it to the ordinary navigation path \u2014 no special swap handling, and it
|
|
1407
|
+
// inherits caching, prefetch and the guard-redirect behaviour for free.
|
|
1408
|
+
var qs=new URLSearchParams();
|
|
1409
|
+
fd.forEach(function(v,k){if(typeof v==='string')qs.append(k,v)});
|
|
1410
|
+
var gu=new URL(action,location.href);
|
|
1411
|
+
gu.search=qs.toString();
|
|
1412
|
+
return navigate(gu.pathname+gu.search+gu.hash);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
setFormPending(form,true);
|
|
1416
|
+
form.dispatchEvent(new CustomEvent('stx:form-submit',{bubbles:true,detail:{action:action,method:method}}));
|
|
1417
|
+
|
|
1418
|
+
var enctype=String(formAttrOf(form,submitter,'formenctype','enctype')||'').toLowerCase();
|
|
1419
|
+
// Respect the declared encoding. Handing FormData to fetch always produces
|
|
1420
|
+
// multipart, which a server expecting urlencoded will read as empty.
|
|
1421
|
+
var body=enctype.indexOf('multipart')===0?fd:new URLSearchParams(fd);
|
|
1422
|
+
var wantsFragment=shouldUseFragmentResponse();
|
|
1423
|
+
|
|
1424
|
+
return fetch(action,{
|
|
1425
|
+
method:method.toUpperCase(),
|
|
1426
|
+
body:body,
|
|
1427
|
+
headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'},
|
|
1428
|
+
credentials:'same-origin',
|
|
1429
|
+
}).then(function(r){
|
|
1430
|
+
// POST/redirect/GET. The server said where the result lives, so go there
|
|
1431
|
+
// through the normal path rather than swapping the POST's body under the
|
|
1432
|
+
// form's own URL \u2014 otherwise reloading re-submits.
|
|
1433
|
+
if(r.redirected&&r.url){
|
|
1434
|
+
var rd=new URL(r.url,location.href);
|
|
1435
|
+
if(rd.origin!==location.origin){location.href=r.url;return}
|
|
1436
|
+
return navigate(rd.pathname+rd.search+rd.hash);
|
|
1437
|
+
}
|
|
1438
|
+
return r.text().then(function(html){
|
|
1439
|
+
var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
|
|
1440
|
+
pendingContainerAttrs=isFrag?(r.headers.get('X-STX-Container-Attrs')||''):'';
|
|
1441
|
+
pendingLayoutDecl=isFrag?null:{layout:r.headers.get('X-STX-Layout')||'',group:r.headers.get('X-STX-Layout-Group')||''};
|
|
1442
|
+
var marked=isFrag?fragmentMarker(r.headers.get('X-STX-Runtime')||'')+html:html;
|
|
1443
|
+
// Deliberately NOT cached: this body is the answer to one POST, and
|
|
1444
|
+
// serving it later for a GET of the same path would be a lie.
|
|
1445
|
+
// 'replace' so re-rendered validation errors do not stack one history
|
|
1446
|
+
// entry per attempt.
|
|
1447
|
+
return Promise.resolve(swap(marked,cacheKey(action),'replace','')).then(function(){
|
|
1448
|
+
applyTitle(r.headers.get('X-STX-Title')||'');
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
}).catch(function(err){
|
|
1452
|
+
// No native re-submit on failure: that would send the POST a second time.
|
|
1453
|
+
// The app is told instead, and the form is left intact for a retry.
|
|
1454
|
+
console.error('[router] form submit failed:',err);
|
|
1455
|
+
form.dispatchEvent(new CustomEvent('stx:form-error',{bubbles:true,detail:{error:err,action:action}}));
|
|
1456
|
+
}).finally(function(){setFormPending(form,false)});
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
document.addEventListener('submit',function(e){
|
|
1460
|
+
// Bubble phase, and defaultPrevented is honoured, so a page's own @submit
|
|
1461
|
+
// handler always wins. This is a fallback for forms nobody else claimed,
|
|
1462
|
+
// never an override of one that is already handled.
|
|
1463
|
+
if(e.defaultPrevented)return;
|
|
1464
|
+
var form=e.target;
|
|
1465
|
+
if(!form||form.tagName!=='FORM')return;
|
|
1466
|
+
if(!form.hasAttribute('data-stx-form')&&!form.hasAttribute('data-stx-link')&&!o.interceptForms)return;
|
|
1467
|
+
var submitter=e.submitter||null;
|
|
1468
|
+
if(formExcluded(form,submitter))return;
|
|
1469
|
+
// Without a container there is nothing to swap into, so a native submit is
|
|
1470
|
+
// the only thing that can work.
|
|
1471
|
+
if(!getContainer())return;
|
|
1472
|
+
// Constraint validation has already run by the time submit fires, so an
|
|
1473
|
+
// invalid form never reaches here.
|
|
1474
|
+
e.preventDefault();
|
|
1475
|
+
// A synchronous throw in here would be swallowed by dispatchEvent, and
|
|
1476
|
+
// preventDefault has already run \u2014 so the form would be silently dead with
|
|
1477
|
+
// nothing in the console. Report it and clear the pending state instead.
|
|
1478
|
+
try{submitForm(form,submitter)}
|
|
1479
|
+
catch(err){
|
|
1480
|
+
console.error('[router] form submit failed:',err);
|
|
1481
|
+
setFormPending(form,false);
|
|
1482
|
+
form.dispatchEvent(new CustomEvent('stx:form-error',{bubbles:true,detail:{error:err}}));
|
|
1483
|
+
}
|
|
1484
|
+
});
|
|
1485
|
+
|
|
1222
1486
|
// \u2500\u2500 Back/forward \u2500\u2500
|
|
1223
1487
|
window.addEventListener('popstate',function(){
|
|
1224
1488
|
navigate(location.pathname+location.search+location.hash,false);
|
|
@@ -1229,13 +1493,23 @@ else {
|
|
|
1229
1493
|
document.addEventListener('mouseover',function(e){
|
|
1230
1494
|
if(!e.target||!e.target.closest)return;
|
|
1231
1495
|
var link=e.target.closest('[data-stx-link]');
|
|
1232
|
-
|
|
1496
|
+
// Same opt-out set as the click path. Without it, hovering a link the
|
|
1497
|
+
// router is not allowed to claim still fired a real GET at it \u2014 a
|
|
1498
|
+
// logout or OAuth URL was requested on hover alone.
|
|
1499
|
+
if(isRouterExcluded(link))return;
|
|
1233
1500
|
var href=withCurrentLocale(link.getAttribute('href'));
|
|
1234
1501
|
var key=cacheKey(href);
|
|
1235
1502
|
if(cache[key]||prefetching[key])return;
|
|
1236
1503
|
prefetching[key]=true;
|
|
1237
1504
|
var wantsFragment=shouldUseFragmentResponse();
|
|
1238
1505
|
fetch(href,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
|
|
1506
|
+
// Never cache a body that came from somewhere else. A guarded route
|
|
1507
|
+
// prefetched while logged out answers with /login's markup, and
|
|
1508
|
+
// storing it under the guarded key poisons the cache: the later real
|
|
1509
|
+
// navigation is a cache hit, so it swaps the login page in without
|
|
1510
|
+
// ever hitting the network, where the redirect check above would
|
|
1511
|
+
// have caught it (#1849).
|
|
1512
|
+
if(r.redirected)return null;
|
|
1239
1513
|
var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
|
|
1240
1514
|
var pLayout=r.headers.get('X-STX-Layout')||'';
|
|
1241
1515
|
var pGroup=r.headers.get('X-STX-Layout-Group')||'';
|
|
@@ -1243,7 +1517,7 @@ else {
|
|
|
1243
1517
|
var pRuntime=r.headers.get('X-STX-Runtime')||'';
|
|
1244
1518
|
return r.text().then(function(html){return{html:isFrag?fragmentMarker(pRuntime)+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}});
|
|
1245
1519
|
}).then(function(result){
|
|
1246
|
-
if(o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
|
|
1520
|
+
if(result&&o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
|
|
1247
1521
|
}).catch(function(){}).finally(function(){delete prefetching[key]});
|
|
1248
1522
|
},true);
|
|
1249
1523
|
}
|
|
@@ -1343,6 +1617,27 @@ else {
|
|
|
1343
1617
|
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(){});
|
|
1344
1618
|
}
|
|
1345
1619
|
},
|
|
1620
|
+
// Re-run the CURRENT route against the server and swap the result.
|
|
1621
|
+
//
|
|
1622
|
+
// There was no way to do this. invalidate() on a query only re-runs client
|
|
1623
|
+
// fetches, so anything the server rendered \u2014 a list the mutation just
|
|
1624
|
+
// changed, a count in the layout \u2014 could only be refreshed with a full
|
|
1625
|
+
// document load, which discards every signal on the page. That is the
|
|
1626
|
+
// exact thing the SPA exists to avoid, so apps reached for
|
|
1627
|
+
// location.reload() and lost their state (#1850, #1858).
|
|
1628
|
+
//
|
|
1629
|
+
// force:true both evicts the cache entry and defeats navigate's
|
|
1630
|
+
// same-URL early return, so this is exposing behaviour the router already
|
|
1631
|
+
// had rather than adding a second code path. 'replace' because a refresh
|
|
1632
|
+
// is not a new place \u2014 it must not add a history entry you can go Back to.
|
|
1633
|
+
refresh:function(){
|
|
1634
|
+
return navigate(location.pathname+location.search+location.hash,'replace',true);
|
|
1635
|
+
},
|
|
1636
|
+
// Expire ONE entry, so a mutation can invalidate just the page it affected
|
|
1637
|
+
// and let the next visit re-fetch, instead of throwing the whole cache away.
|
|
1638
|
+
invalidate:function(url){
|
|
1639
|
+
evictCache(cacheKey(url||(location.pathname+location.search)));
|
|
1640
|
+
},
|
|
1346
1641
|
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},
|
|
1347
1642
|
cache:cache,
|
|
1348
1643
|
swap:swap,
|
package/dist/codegen.d.ts
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
import type { Route } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Write the route declaration consumed by `CheckHref` in `@stacksjs/stx`.
|
|
4
|
+
*
|
|
5
|
+
* This used to emit `declare module "stx/routes"`, a specifier nothing in the
|
|
6
|
+
* source tree declares or imports. TypeScript therefore read it as an ambient
|
|
7
|
+
* module declaration rather than an augmentation, so it constrained nothing and
|
|
8
|
+
* `navigate('/typo')` type-checked (stacksjs/stx#1887).
|
|
9
|
+
*
|
|
10
|
+
* Two details are load-bearing rather than cosmetic:
|
|
11
|
+
*
|
|
12
|
+
* - the trailing `export {}` makes this a module, so `declare module` is an
|
|
13
|
+
* augmentation of the real package instead of a replacement for it;
|
|
14
|
+
* - non-page routes are filtered out, which `generateRouteManifest` already
|
|
15
|
+
* did and this did not — the two artifacts disagreed about membership.
|
|
16
|
+
*/
|
|
2
17
|
export declare function generateRouteTypes(routes: Route[], outputDir: string): void;
|
|
3
18
|
/**
|
|
4
19
|
* Generate a route manifest file (.stx/routes.ts).
|
package/dist/codegen.js
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { isNonPageRoutePattern } from "./page-routes.js";
|
|
3
4
|
export function generateRouteTypes(routes, outputDir) {
|
|
4
|
-
const lines = [
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
5
|
+
const pageRoutes = routes.filter((r) => !isNonPageRoutePattern(r.pattern)), lines = ["// Auto-generated by STX - do not edit"];
|
|
6
|
+
if (pageRoutes.length === 0)
|
|
7
|
+
lines.push("// No page routes found \u2014 route paths stay typed as string.", "export {}", "");
|
|
8
|
+
else {
|
|
9
|
+
lines.push("declare module '@stacksjs/stx' {", " interface KnownRoutes {");
|
|
10
|
+
for (const route of pageRoutes) {
|
|
11
|
+
const paramEntries = [];
|
|
12
|
+
for (const p of route.params) {
|
|
13
|
+
const optional = route.pattern.includes(`:${p}?`) ? "?" : "";
|
|
14
|
+
paramEntries.push(`${p}${optional}: string`);
|
|
15
|
+
}
|
|
16
|
+
const paramsStr = paramEntries.join("; ");
|
|
17
|
+
lines.push(` '${route.pattern}': { ${paramsStr} }`);
|
|
14
18
|
}
|
|
15
|
-
|
|
16
|
-
lines.push(` '${route.pattern}': { ${paramsStr} }`);
|
|
19
|
+
lines.push(" }", "}", "", "export {}", "");
|
|
17
20
|
}
|
|
18
|
-
lines.
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
const contents = lines.join(`
|
|
22
|
+
`), target = path.join(outputDir, "route-types.d.ts");
|
|
23
|
+
try {
|
|
24
|
+
if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === contents)
|
|
25
|
+
return;
|
|
26
|
+
fs.mkdirSync(outputDir, { recursive: !0 });
|
|
27
|
+
fs.writeFileSync(target, contents);
|
|
28
|
+
} catch {}
|
|
22
29
|
}
|
|
23
30
|
export function generateRouteManifest(routes, outputDir, pagesDir) {
|
|
24
|
-
const cwd = process.cwd(), relativePages = path.relative(cwd, pagesDir),
|
|
31
|
+
const cwd = process.cwd(), relativePages = path.relative(cwd, pagesDir), pageRoutes = routes.filter((r) => !isNonPageRoutePattern(r.pattern)), lines = [
|
|
25
32
|
"// Auto-generated by stx \u2014 do not edit",
|
|
26
33
|
`// Source: ${relativePages || "pages"}/`,
|
|
27
34
|
"",
|
package/dist/file-router.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { generateRouteManifest, generateRouteTypes } from "./codegen.js";
|
|
4
4
|
import { filePathToPattern, patternToRegex } from "./matcher.js";
|
|
5
5
|
import { resolveLayoutChain } from "./nested-layouts.js";
|
|
6
|
+
import { NON_PAGE_DIRS } from "./page-routes.js";
|
|
6
7
|
function scanDirectory(dir, extensions) {
|
|
7
8
|
const files = [];
|
|
8
9
|
if (!fs.existsSync(dir))
|
|
@@ -13,6 +14,8 @@ function scanDirectory(dir, extensions) {
|
|
|
13
14
|
if (entry.isDirectory()) {
|
|
14
15
|
if (entry.name.startsWith("."))
|
|
15
16
|
continue;
|
|
17
|
+
if (NON_PAGE_DIRS.includes(entry.name))
|
|
18
|
+
continue;
|
|
16
19
|
files.push(...scanDirectory(fullPath, extensions));
|
|
17
20
|
} else if (entry.isFile()) {
|
|
18
21
|
const ext = path.extname(entry.name);
|
package/dist/index.d.ts
CHANGED
|
@@ -49,5 +49,7 @@ export { resolveLayoutChain } from './nested-layouts';
|
|
|
49
49
|
export { deriveLayoutGroup, extractLayoutMetadata } from './layout-metadata';
|
|
50
50
|
// Route type generation
|
|
51
51
|
export { generateRouteTypes, generateRouteManifest } from './codegen';
|
|
52
|
+
// Which files under a page root are public routes (#1866)
|
|
53
|
+
export { NON_PAGE_DIRS, isNonPageRoutePath, isNonPageRoutePattern } from './page-routes';
|
|
52
54
|
// Client-side SPA navigation
|
|
53
55
|
export { getRouterScript } from './client';
|
package/dist/index.js
CHANGED
|
@@ -21,4 +21,5 @@ export {
|
|
|
21
21
|
export { resolveLayoutChain } from "./nested-layouts.js";
|
|
22
22
|
export { deriveLayoutGroup, extractLayoutMetadata } from "./layout-metadata.js";
|
|
23
23
|
export { generateRouteTypes, generateRouteManifest } from "./codegen.js";
|
|
24
|
+
export { NON_PAGE_DIRS, isNonPageRoutePath, isNonPageRoutePattern } from "./page-routes.js";
|
|
24
25
|
export { getRouterScript } from "./client.js";
|
package/dist/middleware.d.ts
CHANGED
|
@@ -24,5 +24,20 @@ export declare function getMiddlewareNames(): string[];
|
|
|
24
24
|
// =============================================================================
|
|
25
25
|
export declare function loadMiddlewareFromDirectory(baseDir: string, middlewareDir?: string): Promise<void>;
|
|
26
26
|
export declare function createRouteLocation(pathname: string, params: Record<string, string>, meta: Record<string, unknown>, search?: string): RouteLocation;
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Build the context a middleware chain runs against.
|
|
29
|
+
*
|
|
30
|
+
* `options.isServer` exists because sniffing `typeof window` is wrong whenever a
|
|
31
|
+
* DOM has been polyfilled into a server process, and this repo does exactly that
|
|
32
|
+
* — `bunfig.toml` preloads happy-dom for every test, and
|
|
33
|
+
* `story/visual-testing.ts` imports very-happy-dom at runtime. In those
|
|
34
|
+
* processes `window` is defined, so the sniff says "client" on the server and
|
|
35
|
+
* {@link runMiddleware}'s mode filters inverted: `mode: 'server'` middleware was
|
|
36
|
+
* SKIPPED and `mode: 'client'` middleware RAN. Every middleware test in the repo
|
|
37
|
+
* was therefore asserting the mirror image of production. See #1849.
|
|
38
|
+
*
|
|
39
|
+
* A caller that knows which side it is on should say so. The sniff remains the
|
|
40
|
+
* default so existing callers are unaffected.
|
|
41
|
+
*/
|
|
42
|
+
export declare function createMiddlewareContext(to: RouteLocation, from: RouteLocation | null, request?: Request, options?: { isServer?: boolean }): MiddlewareContext;
|
|
28
43
|
export declare function runMiddleware(middlewareNames: string | string[], context: MiddlewareContext): Promise<MiddlewareResult>;
|
package/dist/middleware.js
CHANGED
|
@@ -186,8 +186,8 @@ export function createRouteLocation(pathname, params, meta, search = "") {
|
|
|
186
186
|
meta
|
|
187
187
|
};
|
|
188
188
|
}
|
|
189
|
-
export function createMiddlewareContext(to, from, request) {
|
|
190
|
-
const isServer = typeof window > "u", isClient = !isServer, responseHeaders = new Headers;
|
|
189
|
+
export function createMiddlewareContext(to, from, request, options) {
|
|
190
|
+
const isServer = options?.isServer ?? typeof window > "u", isClient = !isServer, responseHeaders = new Headers;
|
|
191
191
|
return {
|
|
192
192
|
to,
|
|
193
193
|
from,
|
|
@@ -205,13 +205,22 @@ export async function runMiddleware(middlewareNames, context) {
|
|
|
205
205
|
for (const name of names) {
|
|
206
206
|
const middleware = getMiddleware(name);
|
|
207
207
|
if (!middleware) {
|
|
208
|
-
console.
|
|
209
|
-
|
|
208
|
+
console.error(`Route middleware '${name}' is not registered \u2014 failing the guard closed.`);
|
|
209
|
+
return {
|
|
210
|
+
passed: !1,
|
|
211
|
+
abort: {
|
|
212
|
+
type: "abort",
|
|
213
|
+
error: {
|
|
214
|
+
statusCode: 500,
|
|
215
|
+
message: `Route middleware '${name}' is not registered`
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
state: context.state,
|
|
219
|
+
responseHeaders: context.responseHeaders
|
|
220
|
+
};
|
|
210
221
|
}
|
|
211
222
|
if (middleware.mode === "server" && !isServer)
|
|
212
223
|
continue;
|
|
213
|
-
if (middleware.mode === "client" && isServer)
|
|
214
|
-
continue;
|
|
215
224
|
try {
|
|
216
225
|
const result = await middleware.handler(context);
|
|
217
226
|
if (result) {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when a path relative to a page root sits inside a non-page directory,
|
|
3
|
+
* at any depth.
|
|
4
|
+
*
|
|
5
|
+
* Accepts either OS-native or POSIX separators.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isNonPageRoutePath(relativePath: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* True when a URL pattern points *into* a non-page directory.
|
|
10
|
+
*
|
|
11
|
+
* `/components/foo` and `/admin/components/foo` qualify; bare `/components`
|
|
12
|
+
* does not, and must not — the scanner already skips the directory, so the only
|
|
13
|
+
* way that pattern exists is a real `components.stx` page. Excluding it here
|
|
14
|
+
* would route the page but leave it out of `.stx/routes.ts` and the generated
|
|
15
|
+
* route types, which is the manifest-versus-table disagreement this module was
|
|
16
|
+
* added to end.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isNonPageRoutePattern(pattern: string): boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Which files under a page root are actually public routes.
|
|
21
|
+
*
|
|
22
|
+
* `components/`, `layouts/` and `partials/` under `pagesDir` are building
|
|
23
|
+
* blocks, not pages. They were already filtered out of the generated route
|
|
24
|
+
* manifest and route types — but only there (`codegen.ts`), which meant the
|
|
25
|
+
* manifest and the live route table disagreed: `.stx/routes.ts` said a site had
|
|
26
|
+
* 30 routes while `Router.routes` had 34, and the four extra ones were served,
|
|
27
|
+
* statically built to HTML, and advertised in sitemap.xml as public URLs
|
|
28
|
+
* (stacksjs/stx#1866).
|
|
29
|
+
*
|
|
30
|
+
* One reporting app worked around it structurally — deleting
|
|
31
|
+
* `resources/views/layouts/` so layouts *could not* be routed — which is the
|
|
32
|
+
* kind of fix that only exists because the framework made it necessary.
|
|
33
|
+
*
|
|
34
|
+
* The rule lives here so the scanner and the codegen share it rather than
|
|
35
|
+
* restating it. The codegen's version also only matched a leading segment, so
|
|
36
|
+
* `resources/views/admin/components/x.stx` slipped through it.
|
|
37
|
+
*
|
|
38
|
+
* @module page-routes
|
|
39
|
+
*/
|
|
40
|
+
/** Directories under a page root whose contents are never public routes. */
|
|
41
|
+
export declare const NON_PAGE_DIRS: readonly string[];
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const NON_PAGE_DIRS = ["components", "layouts", "partials"];
|
|
2
|
+
export function isNonPageRoutePath(relativePath) {
|
|
3
|
+
return relativePath.split(/[\\/]/).filter(Boolean).slice(0, -1).some((segment) => NON_PAGE_DIRS.includes(segment));
|
|
4
|
+
}
|
|
5
|
+
export function isNonPageRoutePattern(pattern) {
|
|
6
|
+
return isNonPageRoutePath(pattern.replace(/^\//, ""));
|
|
7
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -92,7 +92,13 @@ export declare interface MiddlewareResult {
|
|
|
92
92
|
state: Record<string, unknown>
|
|
93
93
|
responseHeaders: Headers
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
// 'client' was removed (stacksjs/stx#1891): the SPA router is a stringified
|
|
96
|
+
// runtime with no access to this registry, and every runMiddleware caller is a
|
|
97
|
+
// server process, so a client-mode guard could never run. Keeping the mode was
|
|
98
|
+
// an API that looked like a browser-side security boundary while doing nothing —
|
|
99
|
+
// worse than not having it. If browser-side UX guards are wanted, they belong in
|
|
100
|
+
// a separately named, non-enforcement API.
|
|
101
|
+
export type MiddlewareMode = 'universal' | 'server';
|
|
96
102
|
export type RouteMiddlewareHandler = (
|
|
97
103
|
context: MiddlewareContext,
|
|
98
104
|
) => void | Promise<void> | NavigationResult | Promise<NavigationResult | void>;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stx-router",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.157",
|
|
5
5
|
"description": "File-based router for STX — .stx template discovery, nested layouts, typed route params, middleware, and client-side SPA navigation.",
|
|
6
6
|
"author": "Chris Breuer <chris@stacksjs.org>",
|
|
7
7
|
"license": "MIT",
|