stx-router 0.2.111 → 0.2.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/client.js +101 -8
  2. package/package.json +1 -1
package/dist/client.js CHANGED
@@ -76,24 +76,65 @@ export function getRouterScript() {
76
76
  // rather than a Map to keep the diff localized and the runtime
77
77
  // hand-minified shape consistent.
78
78
  var cacheOrder=[];
79
- function setCache(key,html,layout,group,title){
79
+ function setCache(key,html,layout,group,title,cattrs){
80
80
  if(!(key in cache))cacheOrder.push(key);
81
81
  cache[key]=html;
82
82
  layoutCache[key]=layout;
83
83
  layoutGroupCache[key]=group;
84
84
  titleCache[key]=title||'';
85
+ attrsCache[key]=cattrs||'';
85
86
  while(cacheOrder.length>o.prefetchCacheMax){
86
87
  var oldest=cacheOrder.shift();
87
88
  delete cache[oldest];
88
89
  delete layoutCache[oldest];
89
90
  delete layoutGroupCache[oldest];
90
91
  delete titleCache[oldest];
92
+ delete attrsCache[oldest];
91
93
  }
92
94
  }
93
95
  // Apply a page title captured from the fragment response's X-STX-Title
94
96
  // header (URI-encoded) so SPA swaps keep document.title in sync with the
95
97
  // page \u2014 full-document swaps set the title from the <title> tag directly.
96
98
  function applyTitle(t){if(t){try{document.title=decodeURIComponent(t)}catch(e){document.title=t}}}
99
+ // \u2500\u2500 Container attributes \u2500\u2500
100
+ // A fragment carries only the container's INNER content, so any layout that
101
+ // lives on the destination's <main> itself (e.g. a centered auth page whose
102
+ // <main> carries flex + min-h-[100dvh] + items-center) would be
103
+ // dropped when injected into the persistent container. The server sends the
104
+ // destination container's attributes in X-STX-Container-Attrs (URI-encoded);
105
+ // we apply them here and track what we applied so navigating onward to a page
106
+ // with a bare <main> clears them again. Attributes the router never applied
107
+ // (the app's own id/data-stx-content hooks) are left untouched.
108
+ var pendingContainerAttrs='';
109
+ function parseContainerAttrs(attrStr){
110
+ var map={};
111
+ if(!attrStr)return map;
112
+ var probe=document.createElement('div');
113
+ probe.innerHTML='<i '+attrStr+'></i>';
114
+ var src=probe.firstChild;
115
+ if(src&&src.attributes){
116
+ Array.prototype.forEach.call(src.attributes,function(a){map[a.name]=a.value});
117
+ }
118
+ return map;
119
+ }
120
+ function setContainerAttrs(el,map){
121
+ if(!el)return;
122
+ var prev=(el.getAttribute('data-stx-cattrs')||'').split(',');
123
+ prev.forEach(function(n){if(n&&!(n in map))el.removeAttribute(n)});
124
+ var names=[];
125
+ for(var k in map){
126
+ if(Object.prototype.hasOwnProperty.call(map,k)&&k!=='data-stx-cattrs'){
127
+ el.setAttribute(k,map[k]);names.push(k);
128
+ }
129
+ }
130
+ if(names.length)el.setAttribute('data-stx-cattrs',names.join(','));
131
+ else el.removeAttribute('data-stx-cattrs');
132
+ }
133
+ function applyContainerAttrs(el,encoded){
134
+ var attrStr='';
135
+ if(encoded){try{attrStr=decodeURIComponent(encoded)}catch(e){attrStr=encoded}}
136
+ setContainerAttrs(el,parseContainerAttrs(attrStr));
137
+ }
97
138
  function touchCache(key){
98
139
  var i=cacheOrder.indexOf(key);
99
140
  if(i>=0){cacheOrder.splice(i,1);cacheOrder.push(key)}
@@ -105,6 +146,7 @@ export function getRouterScript() {
105
146
  delete layoutCache[key];
106
147
  delete layoutGroupCache[key];
107
148
  delete titleCache[key];
149
+ delete attrsCache[key];
108
150
  }
109
151
 
110
152
  // Extract top-level CSS blocks (rules AND @media blocks with nested braces).
@@ -158,6 +200,7 @@ export function getRouterScript() {
158
200
  var layoutCache={};
159
201
  var layoutGroupCache={};
160
202
  var titleCache={};
203
+ var attrsCache={};
161
204
  // Track executed script hashes to prevent redeclaration errors on navigation.
162
205
  // Layout-level scripts (theme, nav setup) execute on initial page load and
163
206
  // should NOT re-execute when navigating to another page with the same layout.
@@ -172,7 +215,10 @@ export function getRouterScript() {
172
215
  if(text.trim()&&!s.hasAttribute('src'))executedScriptHashes[hashScript(text)]=1;
173
216
  });
174
217
  function cacheKey(url){
175
- var u=new URL(url,location.origin);
218
+ // Base is the CURRENT document, not the origin root: a relative key like
219
+ // '?status=resolved' on /dashboard must cache as /dashboard?status=resolved,
220
+ // not /?status=resolved (#1777).
221
+ var u=new URL(url,location.href);
176
222
  return u.pathname+u.search;
177
223
  }
178
224
  // Keep SPA fetches inside the active locale (/en/... when viewing English).
@@ -229,7 +275,12 @@ export function getRouterScript() {
229
275
  if(!force) url=withCurrentLocale(url);
230
276
  log('[router] navigate() called:',url,'isNavigating:',isNavigating);
231
277
  if(isNavigating)return;
232
- var t=new URL(url,location.origin);
278
+ // Resolve against the current document URL, matching native <a> semantics.
279
+ // With location.origin as the base, any relative href ('?status=resolved',
280
+ // '#anchor', './sibling') resolved to the SITE ROOT \u2014 so filter tabs,
281
+ // pagination and sort links silently bounced users to the landing page once
282
+ // the SPA interceptor was active (#1777).
283
+ var t=new URL(url,location.href);
233
284
 
234
285
  if(t.origin!==location.origin){location.href=url;return}
235
286
 
@@ -277,6 +328,7 @@ export function getRouterScript() {
277
328
  }).finally(done);
278
329
  return;
279
330
  }
331
+ pendingContainerAttrs=attrsCache[targetPath]||'';
280
332
  swap(cache[targetPath],targetPath,pushState,targetHash);
281
333
  applyTitle(titleCache[targetPath]);
282
334
  done();
@@ -293,6 +345,7 @@ else {
293
345
  var newLayout=r.headers.get('X-STX-Layout')||'';
294
346
  var newGroup=r.headers.get('X-STX-Layout-Group')||'';
295
347
  var newTitle=r.headers.get('X-STX-Title')||'';
348
+ var newCAttrs=r.headers.get('X-STX-Container-Attrs')||'';
296
349
  // Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
297
350
  if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
298
351
  log('[router] layout change \u2014 fetching full page for document swap');
@@ -305,11 +358,12 @@ else {
305
358
  });
306
359
  });
307
360
  }
308
- return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle}});
361
+ return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle,containerAttrs:newCAttrs}});
309
362
  }).then(function(result){
310
363
  if(!result)return;
311
364
  if(result.isFragment)result.html='<!--stx-fragment-->'+result.html;
312
- if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title);
365
+ if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
366
+ pendingContainerAttrs=result.isFragment?(result.containerAttrs||''):'';
313
367
  swap(result.html,targetPath,pushState,targetHash);
314
368
  // Fragment swaps carry no <head>; apply the title from the header.
315
369
  // Full-document swaps already set document.title from the <title> tag.
@@ -382,7 +436,10 @@ else {
382
436
  el.setAttribute('data-stx-page','');
383
437
  document.head.appendChild(el);
384
438
  });
385
- // Swap content
439
+ // Swap content \u2014 apply the destination container's own attributes first
440
+ // so the incoming markup lands in a correctly-laid-out container instead
441
+ // of flashing (or sticking) unstyled.
442
+ applyContainerAttrs(currentContent,pendingContainerAttrs);
386
443
  currentContent.innerHTML=cleanFrag;
387
444
  // Remove old page scripts
388
445
  document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
@@ -493,6 +550,35 @@ else {
493
550
 
494
551
  incoming.forEach(function(s){s.removeAttribute('data-stx-incoming')});
495
552
 
553
+ // \u2500\u2500 Swap <head> stylesheet / font <link>s \u2500\u2500
554
+ // Without this, an SPA navigation to a page whose <link rel="stylesheet">
555
+ // (or font/preconnect <link>) the current page had not already loaded left
556
+ // the swapped-in content completely unstyled (e.g. a page that pulls in its
557
+ // own marketing.css, or Google Fonts). Additive and href-deduped, mirroring
558
+ // the external <head script[src]> reconcile below \u2014 never removes existing
559
+ // links, so stylesheets shared across pages simply persist.
560
+ // Relative asset hrefs resolve against the document that declared them:
561
+ // the CURRENT page for links already in <head>, and the page being
562
+ // navigated TO for links from the fetched document. Against location.origin
563
+ // both collapsed to the site root, so 'assets/app.css' on /docs/intro
564
+ // deduped as /assets/app.css \u2014 the wrong file, and a false cache hit that
565
+ // skipped the real stylesheet (#1777).
566
+ var docBase;
567
+ try{docBase=new URL(url,location.href).href}catch(e){docBase=location.href}
568
+ var linkSel='link[rel="stylesheet"],link[rel="preconnect"]';
569
+ var curLinks={};
570
+ document.querySelectorAll('head '+linkSel).forEach(function(l){var h=l.getAttribute('href');if(h)curLinks[new URL(h,location.href).href]=1});
571
+ doc.querySelectorAll('head '+linkSel).forEach(function(l){
572
+ var href=l.getAttribute('href');
573
+ if(!href)return;
574
+ var abs=new URL(href,docBase).href;
575
+ if(curLinks[abs])return;
576
+ curLinks[abs]=1;
577
+ var nl=document.createElement('link');
578
+ Array.from(l.attributes).forEach(function(a){nl.setAttribute(a.name,a.value)});
579
+ document.head.appendChild(nl);
580
+ });
581
+
496
582
  // \u2500\u2500 Swap content \u2500\u2500
497
583
  // For layout changes: swap the entire <body> to replace layout chrome (nav, footer, etc.)
498
584
  // For same-layout: swap only the container (<main>)
@@ -526,7 +612,12 @@ else {
526
612
  // Update container reference for script execution below
527
613
  currentContent=document.querySelector(containerSel)||document.querySelector('main')||document.body;
528
614
  } else {
529
- // Same layout \u2014 swap only container content
615
+ // Same layout \u2014 swap only container content. Carry the destination
616
+ // container's attributes across too (same reason as the fragment path:
617
+ // pages whose layout lives on <main> itself would otherwise lose it).
618
+ var attrMap={};
619
+ Array.prototype.forEach.call(newContent.attributes,function(a){attrMap[a.name]=a.value});
620
+ setContainerAttrs(currentContent,attrMap);
530
621
  var cleanHTML=newContent.innerHTML.replace(new RegExp('<scr'+'ipt\\\\b[^>]*>[\\\\s\\\\S]*?<\\\\/scr'+'ipt\\\\s*>','gi'),'');
531
622
  currentContent.innerHTML=cleanHTML;
532
623
  }
@@ -536,7 +627,9 @@ else {
536
627
  document.querySelectorAll('head script[src]').forEach(function(s){loadedSrcs[s.src]=1});
537
628
  var extPromises=[];
538
629
  doc.querySelectorAll('head script[src]').forEach(function(s){
539
- var src=new URL(s.getAttribute('src'),location.origin).href;
630
+ // Fetched-document script: resolve against the page being navigated to
631
+ // (docBase), not the origin root \u2014 same rationale as the <link> reconcile (#1777).
632
+ var src=new URL(s.getAttribute('src'),docBase).href;
540
633
  if(loadedSrcs[src])return;
541
634
  loadedSrcs[src]=1;
542
635
  extPromises.push(new Promise(function(resolve,reject){
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "stx-router",
3
3
  "type": "module",
4
- "version": "0.2.111",
4
+ "version": "0.2.113",
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",