stx-router 0.2.154 → 0.2.156

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 CHANGED
@@ -12,11 +12,11 @@ export function getRouterScript() {
12
12
  cachedRouterScript = minifyRouterScript(`
13
13
  ;(function(){
14
14
  'use strict';
15
- var ROUTER_REV=3;
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(!link)return false;
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
- if(!link)return;
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
- "// Auto-generated by STX - do not edit",
6
- 'declare module "stx/routes" {',
7
- " interface RouteMap {"
8
- ];
9
- for (const route of routes) {
10
- const paramEntries = [];
11
- for (const p of route.params) {
12
- const type = route.pattern.includes(`:${p}*`) ? "string[]" : "string";
13
- paramEntries.push(`${p}: ${type}`);
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
- const paramsStr = paramEntries.join("; ");
16
- lines.push(` '${route.pattern}': { ${paramsStr} }`);
19
+ lines.push(" }", "}", "", "export {}", "");
17
20
  }
18
- lines.push(" }", "}", "");
19
- fs.mkdirSync(outputDir, { recursive: !0 });
20
- fs.writeFileSync(path.join(outputDir, "route-types.d.ts"), lines.join(`
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), excludeDirs = ["components", "layouts", "partials"], pageRoutes = routes.filter((r) => !excludeDirs.some((dir) => r.pattern.startsWith(`/${dir}/`) || r.pattern === `/${dir}`)), lines = [
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
  "",
@@ -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";
@@ -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
- export declare function createMiddlewareContext(to: RouteLocation, from: RouteLocation | null, request?: Request): MiddlewareContext;
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>;
@@ -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.warn(`Middleware '${name}' not found`);
209
- continue;
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
- export type MiddlewareMode = 'universal' | 'server' | 'client';
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.154",
4
+ "version": "0.2.156",
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",