stx-router 0.2.92 → 0.2.94

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.js CHANGED
@@ -1,1476 +1,4 @@
1
1
  // @bun
2
- // src/client.ts
3
- function getRouterScript() {
4
- return `
5
- ;(function(){
6
- 'use strict';
7
- var ROUTER_REV=3;
8
- if(window.__stxRouter&&window.__stxRouter.__rev===ROUTER_REV)return;
9
-
10
- // \u2500\u2500 Configuration \u2500\u2500
11
- 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};
12
- var o=Object.assign({},defaults,window.__stxRouterConfig||{},window.STX_ROUTER_OPTIONS||{});
13
- var containerSel=o.container;
14
- var debug=!!o.debug;
15
- function log(){if(debug&&typeof console!=='undefined'&&console.log)console.log.apply(console,arguments)}
16
- function runViewTransition(callback){
17
- if(!o.viewTransitions||!document.startViewTransition)return false;
18
- try{
19
- var transition=document.startViewTransition(callback);
20
- var onAbort=function(err){log('[router] view transition aborted:',err&&err.message?err.message:err)};
21
- if(transition&&transition.finished&&transition.finished.catch)transition.finished.catch(onAbort);
22
- if(transition&&transition.updateCallbackDone&&transition.updateCallbackDone.catch)transition.updateCallbackDone.catch(onAbort);
23
- return true;
24
- }catch(err){
25
- log('[router] view transition unavailable:',err&&err.message?err.message:err);
26
- return false;
27
- }
28
- }
29
-
30
- // \u2500\u2500 Progress bar (0\u2192100% at top of viewport) \u2500\u2500
31
- // Native loading indicator baked into the router. Starts when navigation
32
- // begins, trickles up to ~90% during fetch, snaps to 100% + fades on
33
- // completion. Opt-out via window.__stxRouterConfig={progress:false}.
34
- var progEl=null;
35
- var progVal=0;
36
- var progTimer=null;
37
- function setProgress(v){
38
- progVal=v<0?0:(v>100?100:v);
39
- if(!progEl)return;
40
- progEl.style.opacity=progVal>0?'1':'0';
41
- progEl.style.transform='scaleX('+(progVal/100)+')';
42
- }
43
- function startProgress(){
44
- if(!o.progress||!progEl)return;
45
- if(progTimer){clearInterval(progTimer);progTimer=null}
46
- setProgress(8);
47
- progTimer=setInterval(function(){
48
- if(progVal>=90)return;
49
- // Asymptotic trickle \u2014 fast at first, slower near 90%
50
- var inc=Math.max(0.4,(90-progVal)*0.08);
51
- setProgress(progVal+inc);
52
- },160);
53
- }
54
- function finishProgress(){
55
- if(!o.progress||!progEl)return;
56
- if(progTimer){clearInterval(progTimer);progTimer=null}
57
- setProgress(100);
58
- // Fade out, then reset. Delays chosen so the "full" state is briefly
59
- // visible before the bar disappears.
60
- setTimeout(function(){
61
- if(!progEl)return;
62
- progEl.style.opacity='0';
63
- setTimeout(function(){progVal=0;if(progEl)progEl.style.transform='scaleX(0)'},260);
64
- },180);
65
- }
66
-
67
- var cache={};
68
- var prefetching={};
69
- var isNavigating=false;
70
-
71
- // LRU bookkeeping for cache/layoutCache/layoutGroupCache. Pre-fix
72
- // (stacksjs/stx#1719) these were plain objects with no eviction, so
73
- // hover-prefetch on a sidebar of 200 internal links retained ~10MB
74
- // of HTML strings for the page's lifetime. cacheOrder is a most-
75
- // recently-used-last list of keys; setCache pushes new entries and
76
- // evicts the oldest when length exceeds o.prefetchCacheMax (default
77
- // 50). touchCache promotes a key on cache-hit. We use a plain array
78
- // rather than a Map to keep the diff localized and the runtime
79
- // hand-minified shape consistent.
80
- var cacheOrder=[];
81
- function setCache(key,html,layout,group,title){
82
- if(!(key in cache))cacheOrder.push(key);
83
- cache[key]=html;
84
- layoutCache[key]=layout;
85
- layoutGroupCache[key]=group;
86
- titleCache[key]=title||'';
87
- while(cacheOrder.length>o.prefetchCacheMax){
88
- var oldest=cacheOrder.shift();
89
- delete cache[oldest];
90
- delete layoutCache[oldest];
91
- delete layoutGroupCache[oldest];
92
- delete titleCache[oldest];
93
- }
94
- }
95
- // Apply a page title captured from the fragment response's X-STX-Title
96
- // header (URI-encoded) so SPA swaps keep document.title in sync with the
97
- // page \u2014 full-document swaps set the title from the <title> tag directly.
98
- function applyTitle(t){if(t){try{document.title=decodeURIComponent(t)}catch(e){document.title=t}}}
99
- function touchCache(key){
100
- var i=cacheOrder.indexOf(key);
101
- if(i>=0){cacheOrder.splice(i,1);cacheOrder.push(key)}
102
- }
103
- function evictCache(key){
104
- var i=cacheOrder.indexOf(key);
105
- if(i>=0)cacheOrder.splice(i,1);
106
- delete cache[key];
107
- delete layoutCache[key];
108
- delete layoutGroupCache[key];
109
- delete titleCache[key];
110
- }
111
-
112
- // Extract top-level CSS blocks (rules AND @media blocks with nested braces).
113
- // The old regex ([^{}]+{[^{}]*}) silently dropped @media blocks because
114
- // it couldn't handle nested braces \u2014 causing responsive classes (lg:, md:)
115
- // to vanish after SPA navigation.
116
- function extractCssBlocks(css){
117
- var blocks=[];
118
- var i=0;
119
- var len=css.length;
120
- while(i<len){
121
- // Skip whitespace
122
- while(i<len&&(css[i]===' '||css[i]==='\\n'||css[i]==='\\r'||css[i]==='\\t'))i++;
123
- if(i>=len)break;
124
- // Find the start of a block (first {)
125
- var blockStart=i;
126
- var bracePos=css.indexOf('{',i);
127
- if(bracePos===-1)break;
128
- // Walk forward tracking brace depth to find the matching close
129
- var depth=0;
130
- var j=bracePos;
131
- while(j<len){
132
- if(css[j]==='{')depth++;
133
- else if(css[j]==='}'){depth--;if(depth===0){j++;break}}
134
- j++;
135
- }
136
- if(depth!==0)break;
137
- blocks.push(css.slice(blockStart,j).trim());
138
- i=j;
139
- }
140
- return blocks;
141
- }
142
-
143
- function mergeCrosswindCSS(existing,incoming){
144
- var blocks=extractCssBlocks(incoming);
145
- var newBlocks=[];
146
- for(var bi=0;bi<blocks.length;bi++){
147
- if(existing.indexOf(blocks[bi])===-1)newBlocks.push(blocks[bi]);
148
- }
149
- if(newBlocks.length>0)return existing+'\\n'+newBlocks.join('\\n');
150
- return null;
151
- }
152
-
153
- function getContainer(){
154
- return document.querySelector(containerSel)||document.querySelector('[data-stx-content]')||document.querySelector('main');
155
- }
156
-
157
- // \u2500\u2500 Navigation \u2500\u2500
158
- // Layout group detection \u2014 layouts in the same group share a <main> container.
159
- // Only truly different layout groups trigger a full page reload.
160
- var layoutCache={};
161
- var layoutGroupCache={};
162
- var titleCache={};
163
- // Track executed script hashes to prevent redeclaration errors on navigation.
164
- // Layout-level scripts (theme, nav setup) execute on initial page load and
165
- // should NOT re-execute when navigating to another page with the same layout.
166
- var executedScriptHashes={};
167
- function hashScript(code){
168
- var h=0;for(var i=0;i<code.length;i++){h=((h<<5)-h)+code.charCodeAt(i);h|=0}
169
- return h;
170
- }
171
- // Record scripts from the initial page load
172
- document.querySelectorAll('script').forEach(function(s){
173
- var text=s.textContent||'';
174
- if(text.trim()&&!s.hasAttribute('src'))executedScriptHashes[hashScript(text)]=1;
175
- });
176
- function cacheKey(url){
177
- var u=new URL(url,location.origin);
178
- return u.pathname+u.search;
179
- }
180
- // Keep SPA fetches inside the active locale (/en/... when viewing English).
181
- function withCurrentLocale(href){
182
- var d=window.__stxI18n;
183
- if(d&&typeof d.localizeHref==='function')return d.localizeHref(href);
184
- return href;
185
- }
186
- function defaultLayoutGroup(layout){
187
- if(!layout)return 'app';
188
- var clean=String(layout).replace(/\\\\/g,'/');
189
- var parts=clean.split('/').filter(Boolean);
190
- var idx=parts.lastIndexOf('layouts');
191
- var part=idx>=0?parts[idx+1]:parts[parts.length-1];
192
- return part?part.replace(/\\.stx$/i,''):'app';
193
- }
194
- function getCurrentLayoutGroup(){
195
- var meta=document.querySelector('meta[name="stx-layout-group"]');
196
- if(meta&&meta.getAttribute('content'))return meta.getAttribute('content');
197
- var layout=document.querySelector('meta[name="stx-layout"]');
198
- return defaultLayoutGroup(layout?layout.getAttribute('content'):'');
199
- }
200
- function getDocLayoutGroup(doc,layout){
201
- var meta=doc&&doc.querySelector?doc.querySelector('meta[name="stx-layout-group"]'):null;
202
- return meta&&meta.getAttribute('content')?meta.getAttribute('content'):defaultLayoutGroup(layout||'');
203
- }
204
- function checkLayoutChange(newLayout,targetUrl,newGroup){
205
- var currentLayout=document.querySelector('meta[name="stx-layout"]');
206
- var curLayoutName=currentLayout?currentLayout.getAttribute('content'):'';
207
- var curGroup=getCurrentLayoutGroup();
208
- var nextGroup=newGroup||defaultLayoutGroup(newLayout);
209
- log('[router] layout check: current='+curLayoutName+'('+curGroup+') new='+(newLayout||'')+'('+nextGroup+')');
210
- // Different layout GROUP (e.g. app \u2192 auth): full reload
211
- if(curGroup!==nextGroup){
212
- log('[router] layout group change:',curGroup,'\u2192',nextGroup,'\u2014 full reload to:',targetUrl);
213
- return true;
214
- }
215
- // Same group but different SPECIFIC layout (e.g. layouts/app \u2192 layouts/coach):
216
- // full body swap so nav, sidebar, and other layout-level elements update
217
- if(curLayoutName && newLayout && curLayoutName!==newLayout){
218
- log('[router] layout name change:',curLayoutName,'\u2192',newLayout,'\u2014 full body swap');
219
- return true;
220
- }
221
- return false;
222
- }
223
-
224
- function shouldUseFragmentResponse(){
225
- return containerSel==='main'||containerSel==='[data-stx-content]';
226
- }
227
-
228
- function navigate(url,pushState,force){
229
- // Lang-picker passes force=true with an already-localized path (/en/...).
230
- // Re-localizing would map it back to the *current* locale and no-op.
231
- if(!force) url=withCurrentLocale(url);
232
- log('[router] navigate() called:',url,'isNavigating:',isNavigating);
233
- if(isNavigating)return;
234
- var t=new URL(url,location.origin);
235
-
236
- if(t.origin!==location.origin){location.href=url;return}
237
-
238
- if(t.pathname===location.pathname&&t.hash){
239
- if(pushState!==false)history.pushState({},'',t.href);
240
- var el=document.querySelector(t.hash);
241
- if(el)el.scrollIntoView({behavior:'smooth'});
242
- return;
243
- }
244
-
245
- // Skip if user clicks a link to the page they're already on. We
246
- // deliberately allow popstate (pushState===false) through because
247
- // the browser has already updated location.href to the popped
248
- // entry \u2014 without this allowance, hitting back would early-return
249
- // and the visible page content would be left frozen on the
250
- // forward-navigation page.
251
- if(pushState!==false&&t.href===location.href&&!t.hash&&!force)return;
252
-
253
- isNavigating=true;
254
- document.body.classList.add(o.loadingClass);
255
- startProgress();
256
-
257
- var targetHref=t.href;
258
- var targetPath=cacheKey(url);
259
- var targetHash=t.hash;
260
-
261
- function done(){isNavigating=false;document.body.classList.remove(o.loadingClass);finishProgress()}
262
-
263
- if(o.cache&&cache[targetPath]&&!force){
264
- // Cache hit \u2192 promote in LRU order so this entry survives eviction
265
- // longer than other entries that haven't been touched.
266
- touchCache(targetPath);
267
- if(checkLayoutChange(layoutCache[targetPath],url,layoutGroupCache[targetPath])){
268
- // Layout changed \u2014 fetch full page and do full document swap
269
- log('[router] cache hit but layout changed \u2014 fetching full page');
270
- fetch(url,{headers:{'Accept':'text/html'}}).then(function(r){
271
- if(!r.ok)throw new Error(r.status);
272
- return r.text();
273
- }).then(function(html){
274
- log('[router] full page fetched from cache path, len:',html.length);
275
- swap(html,targetPath,pushState,targetHash);
276
- }).catch(function(err){
277
- console.error('[router] full page fetch error:',err);
278
- location.href=url;
279
- }).finally(done);
280
- return;
281
- }
282
- swap(cache[targetPath],targetPath,pushState,targetHash);
283
- applyTitle(titleCache[targetPath]);
284
- done();
285
- }
286
- else {
287
- if(force&&cache[targetPath])evictCache(targetPath);
288
- // First fetch as fragment (SPA mode) when the configured container is
289
- // the page content. Custom app-shell containers need full documents so
290
- // the router does not inject a <main> fragment into the wrong element.
291
- var wantsFragment=shouldUseFragmentResponse();
292
- fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
293
- if(!r.ok)throw new Error(r.status);
294
- var isFragment=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
295
- var newLayout=r.headers.get('X-STX-Layout')||'';
296
- var newGroup=r.headers.get('X-STX-Layout-Group')||'';
297
- var newTitle=r.headers.get('X-STX-Title')||'';
298
- // Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
299
- if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
300
- log('[router] layout change \u2014 fetching full page for document swap');
301
- return fetch(url,{headers:{'Accept':'text/html'}}).then(function(fullRes){
302
- log('[router] full page fetched:',fullRes.status,'ok:',fullRes.ok);
303
- if(!fullRes.ok)throw new Error(fullRes.status);
304
- return fullRes.text().then(function(html){
305
- log('[router] full page html length:',html.length);
306
- return{html:html,isFragment:false,layout:newLayout,layoutGroup:newGroup,title:newTitle};
307
- });
308
- });
309
- }
310
- return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle}});
311
- }).then(function(result){
312
- if(!result)return;
313
- if(result.isFragment)result.html='<!--stx-fragment-->'+result.html;
314
- if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title);
315
- swap(result.html,targetPath,pushState,targetHash);
316
- // Fragment swaps carry no <head>; apply the title from the header.
317
- // Full-document swaps already set document.title from the <title> tag.
318
- if(result.isFragment)applyTitle(result.title);
319
- }).catch(function(err){
320
- console.error('[router] fetch error:',err);
321
- location.href=url;
322
- }).finally(done);
323
- }
324
- }
325
-
326
- // A full document is only safe to partial-swap into the stx shell if it is
327
- // itself stx-rendered. Pages served by another engine mounted on the same
328
- // origin (e.g. a docs/blog generator) carry none of these markers; swapping
329
- // their <main> + styles into the stx shell corrupts the layout. Detect them
330
- // and hand off to a native full navigation instead.
331
- function isStxDocument(html){
332
- return html.indexOf('name="stx-layout"')!==-1
333
- ||html.indexOf('__stxRouterConfig')!==-1
334
- ||html.indexOf('data-stx-content')!==-1;
335
- }
336
-
337
- function swap(html,url,pushState,hash){
338
- var isFragment=html.indexOf('<!--stx-fragment-->')===0;
339
- if(isFragment)html=html.slice('<!--stx-fragment-->'.length);
340
- if(!isFragment&&!isStxDocument(html)){log('[router] non-stx document \u2014 full navigation to:',url);location.href=url;return}
341
- var currentContent=getContainer();
342
- log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&&currentContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
343
- if(!currentContent){log('[router] no container \u2014 falling back');location.href=url;return}
344
-
345
- // Fragment mode: server returned just the page content (no document wrapper)
346
- if(isFragment){
347
- if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
348
- function doFragSwap(){
349
- // Extract scripts from fragment before injecting HTML
350
- var fragScripts=[];
351
- var fragStyles=[];
352
- var fragCrosswindCSS=null;
353
- var cleanFrag=html.replace(new RegExp('<scr'+'ipt\\\\b[^>]*>([\\\\s\\\\S]*?)<\\\\/scr'+'ipt>','gi'),function(m,code){
354
- if(code&&code.trim())fragScripts.push(code);
355
- return '';
356
- });
357
- cleanFrag=cleanFrag.replace(new RegExp('<sty'+'le\\\\b([^>]*)>([\\\\s\\\\S]*?)<\\\\/sty'+'le>','gi'),function(m,attrs,css){
358
- if(attrs.indexOf('data-crosswind')!==-1){
359
- fragCrosswindCSS=css;
360
- }else{
361
- fragStyles.push({attrs:attrs,css:css});
362
- }
363
- return '';
364
- });
365
- // Remove old page styles (not crosswind \u2014 that gets merged)
366
- document.querySelectorAll('style[data-stx-page]').forEach(function(s){s.remove()});
367
- // Merge crosswind CSS from fragment into existing crosswind style
368
- if(fragCrosswindCSS){
369
- var curCrosswind=document.querySelector('head style[data-crosswind]');
370
- if(curCrosswind){
371
- var merged=mergeCrosswindCSS(curCrosswind.textContent||'',fragCrosswindCSS);
372
- if(merged)curCrosswind.textContent=merged;
373
- }else{
374
- var cw=document.createElement('style');
375
- cw.setAttribute('data-crosswind','generated');
376
- cw.textContent=fragCrosswindCSS;
377
- document.head.appendChild(cw);
378
- }
379
- }
380
- // Add new page styles
381
- fragStyles.forEach(function(s){
382
- var el=document.createElement('style');
383
- el.textContent=s.css;
384
- el.setAttribute('data-stx-page','');
385
- document.head.appendChild(el);
386
- });
387
- // Swap content
388
- currentContent.innerHTML=cleanFrag;
389
- // Remove old page scripts
390
- document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
391
- if(pushState!==false)history.pushState({},'',url+(hash||''));
392
- updateNav(url);
393
- updateActiveLinks();
394
- if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
395
- else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
396
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
397
- // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
398
- log('[router] frag scripts:', fragScripts.length);
399
- document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
400
- fragScripts.forEach(function(code){
401
- log('[router] exec script len:', code.length, 'has __stx_setup:', code.indexOf('__stx_setup')>-1);
402
- // Skip scripts that were already executed (layout-level partials
403
- // like theme.stx, stores.stx, nav.stx). Their top-level const/let
404
- // declarations would throw "Identifier has already been declared"
405
- // on re-execution. Setup functions (__stx_setup_) must always
406
- // re-execute because each page has its own setup.
407
- var h=hashScript(code);
408
- var isSetup=code.indexOf('__stx_setup_')!==-1;
409
- // Compute isAlreadyScoped first so the dedup check can exempt
410
- // self-contained IIFE scripts. data-stx-scoped scope IIFEs
411
- // (the (function(){ ... })() shape) must re-execute on every
412
- // navigation. They own their own scope and won't collide
413
- // with prior runs, AND _cleanupContainer deleted their
414
- // entry from window.stx._scopes on the way out. Without
415
- // this exemption, navigating away and back leaves the page
416
- // leaf with no scope registered: bindIf/@event never re-bind
417
- // and all :if branches stay visible at once.
418
- // Mirrors the full-doc swap path's runAlways escape below.
419
- var isAlreadyScoped=code.trimStart().charAt(0)==='('||code.trimStart().charAt(0)===';'||code.indexOf('window.stx.mount')>-1;
420
- if(!isSetup&&!isAlreadyScoped&&executedScriptHashes[h]){
421
- log('[router] skipping already-executed script (hash dedup)');
422
- return;
423
- }
424
- executedScriptHashes[h]=1;
425
- var ns=document.createElement('script');
426
- var hasImport=code.indexOf('import ')!==-1;
427
- if(hasImport){
428
- ns.type='module';
429
- }
430
- ns.textContent=(hasImport||isAlreadyScoped)?code:'{'+code+'}';
431
- ns.setAttribute('data-stx-page','');
432
- document.body.appendChild(ns);
433
- });
434
- log('[router] scripts done. _latestSetup:', !!window.stx._latestSetup);
435
- // THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
436
- window.dispatchEvent(new Event('stx:load'));
437
- }
438
- if(runViewTransition(doFragSwap)){}
439
- else{currentContent.style.transition='opacity 0.12s ease-out';currentContent.style.opacity='0';setTimeout(function(){doFragSwap();currentContent.style.opacity='1';setTimeout(function(){currentContent.style.transition=''},150)},120)}
440
- return;
441
- }
442
-
443
- // Full document mode: parse with DOMParser and extract container content
444
- var parser=new DOMParser();
445
- var doc=parser.parseFromString(html,'text/html');
446
- var newContent=doc.querySelector(containerSel)||doc.querySelector('[data-stx-content]')||doc.querySelector('main');
447
- if(!newContent){location.href=url;return}
448
-
449
- // Clean up existing signals/effects
450
- if(window.stx&&window.stx._cleanupContainer){
451
- window.stx._cleanupContainer(currentContent);
452
- }
453
-
454
- function doSwap(){
455
- // \u2500\u2500 Swap <head> styles \u2500\u2500
456
- // Inject new styles FIRST, then remove old to prevent unstyled flash
457
- var keepIds={'stx-view-transitions':1,'stx-r-css':1};
458
- var curStyles=document.querySelectorAll('head style');
459
- var newStyles=doc.querySelectorAll('head style');
460
-
461
- // Merge crosswind styles instead of replacing \u2014 persistent elements
462
- // (nav, footer) outside <main> still need their utility classes
463
- var curCrosswind=document.querySelector('head style[data-crosswind]');
464
- var newCrosswind=null;
465
- newStyles.forEach(function(s){if(s.getAttribute('data-crosswind'))newCrosswind=s});
466
-
467
- if(curCrosswind&&newCrosswind){
468
- var merged=mergeCrosswindCSS(curCrosswind.textContent||'',newCrosswind.textContent||'');
469
- if(merged)curCrosswind.textContent=merged;
470
- }
471
-
472
- var incoming=[];
473
- newStyles.forEach(function(s){
474
- if(!keepIds[s.id]&&!s.getAttribute('data-crosswind')){
475
- var ns=document.createElement('style');
476
- ns.textContent=s.textContent;
477
- ns.setAttribute('data-stx-incoming','');
478
- document.head.appendChild(ns);
479
- incoming.push(ns);
480
- }
481
- });
482
-
483
- // If no existing crosswind but new page has one, add it
484
- if(!curCrosswind&&newCrosswind){
485
- var ns=document.createElement('style');
486
- ns.textContent=newCrosswind.textContent;
487
- ns.setAttribute('data-crosswind',newCrosswind.getAttribute('data-crosswind'));
488
- document.head.appendChild(ns);
489
- }
490
-
491
- // Remove old styles (except persistent ones, crosswind, and incoming)
492
- curStyles.forEach(function(s){
493
- if(!keepIds[s.id]&&!s.hasAttribute('data-stx-incoming')&&!s.hasAttribute('data-crosswind'))s.remove();
494
- });
495
-
496
- incoming.forEach(function(s){s.removeAttribute('data-stx-incoming')});
497
-
498
- // \u2500\u2500 Swap content \u2500\u2500
499
- // For layout changes: swap the entire <body> to replace layout chrome (nav, footer, etc.)
500
- // For same-layout: swap only the container (<main>)
501
- var newBody=doc.querySelector('body');
502
- var isLayoutChange=false;
503
- if(newBody){
504
- var newMeta=doc.querySelector('meta[name="stx-layout"]');
505
- var curMeta=document.querySelector('meta[name="stx-layout"]');
506
- var curLayout=curMeta?curMeta.getAttribute('content'):'';
507
- var newLayout=newMeta?newMeta.getAttribute('content'):'';
508
- var curGroup=getCurrentLayoutGroup();
509
- var newGroup=getDocLayoutGroup(doc,newLayout);
510
- isLayoutChange=curGroup!==newGroup||(curLayout&&newLayout&&curLayout!==newLayout);
511
- }
512
- if(isLayoutChange&&newBody){
513
- log('[router] full body swap for layout change');
514
- // Replace entire body content \u2014 layout chrome and all
515
- var bodyHTML=newBody.innerHTML.replace(new RegExp('<scr'+'ipt\\\\b[^>]*>[\\\\s\\\\S]*?<\\\\/scr'+'ipt\\\\s*>','gi'),'');
516
- document.body.innerHTML=bodyHTML;
517
- // Copy body attributes (class, data-stx, etc.)
518
- Array.from(newBody.attributes).forEach(function(attr){document.body.setAttribute(attr.name,attr.value)});
519
- // Update layout meta tag
520
- var oldMeta=document.querySelector('meta[name="stx-layout"]');
521
- var freshMeta=doc.querySelector('meta[name="stx-layout"]');
522
- if(oldMeta&&freshMeta)oldMeta.setAttribute('content',freshMeta.getAttribute('content')||'');
523
- else if(freshMeta){var m=document.createElement('meta');m.name='stx-layout';m.content=freshMeta.getAttribute('content')||'';document.head.appendChild(m)}
524
- var oldGroupMeta=document.querySelector('meta[name="stx-layout-group"]');
525
- var freshGroupMeta=doc.querySelector('meta[name="stx-layout-group"]');
526
- if(oldGroupMeta&&freshGroupMeta)oldGroupMeta.setAttribute('content',freshGroupMeta.getAttribute('content')||'');
527
- else if(freshGroupMeta){var gm=document.createElement('meta');gm.name='stx-layout-group';gm.content=freshGroupMeta.getAttribute('content')||'';document.head.appendChild(gm)}
528
- // Update container reference for script execution below
529
- currentContent=document.querySelector(containerSel)||document.querySelector('main')||document.body;
530
- } else {
531
- // Same layout \u2014 swap only container content
532
- var cleanHTML=newContent.innerHTML.replace(new RegExp('<scr'+'ipt\\\\b[^>]*>[\\\\s\\\\S]*?<\\\\/scr'+'ipt\\\\s*>','gi'),'');
533
- currentContent.innerHTML=cleanHTML;
534
- }
535
-
536
- // \u2500\u2500 Load new external <head> scripts \u2500\u2500
537
- var loadedSrcs={};
538
- document.querySelectorAll('head script[src]').forEach(function(s){loadedSrcs[s.src]=1});
539
- var extPromises=[];
540
- doc.querySelectorAll('head script[src]').forEach(function(s){
541
- var src=new URL(s.getAttribute('src'),location.origin).href;
542
- if(loadedSrcs[src])return;
543
- loadedSrcs[src]=1;
544
- extPromises.push(new Promise(function(resolve,reject){
545
- var ns=document.createElement('script');
546
- ns.src=src;
547
- ns.onload=resolve;
548
- ns.onerror=reject;
549
- document.head.appendChild(ns);
550
- }));
551
- });
552
-
553
- // \u2500\u2500 Script re-execution \u2500\u2500
554
- // Remove previously injected page scripts
555
- document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
556
-
557
- var scripts=[];
558
- function addScript(text, runAlways){
559
- scripts.push({text:text,runAlways:!!runAlways});
560
- }
561
- if(isLayoutChange){
562
- // Layout change: collect ALL scripts from the new document body
563
- // (layout scripts, component mounts, setup functions \u2014 everything)
564
- var newBodyEl=doc.querySelector('body');
565
- if(newBodyEl){
566
- newBodyEl.querySelectorAll('script').forEach(function(s){
567
- var text=s.textContent||'';
568
- if(s.hasAttribute('src'))return;
569
- if(!text.trim())return;
570
- // Skip the signals runtime IIFE \u2014 it's already loaded
571
- if(text.indexOf("'use strict';var cloakStyle")!==-1)return;
572
- if(text.indexOf('__stxRouter')!==-1)return;
573
- addScript(text,true);
574
- });
575
- }
576
- // Also collect setup functions from <head>
577
- doc.querySelectorAll('head script').forEach(function(s){
578
- var text=s.textContent||'';
579
- if(s.hasAttribute('src'))return;
580
- if(!text.trim())return;
581
- if(text.indexOf('__stx_setup_')!==-1)addScript(text,true);
582
- });
583
- } else {
584
- // Same layout: collect scripts from the container
585
- newContent.querySelectorAll('script').forEach(function(s){
586
- var text=s.textContent||'';
587
- if(s.hasAttribute('src'))return;
588
- if(!text.trim())return;
589
- addScript(text,true);
590
- });
591
- // Collect page scripts from <head> AND <body> (outside container).
592
- // SSG builds place the setup function in <body> before <main>, not
593
- // in <head>. Without this, SPA navigation on static sites never
594
- // runs the setup and reactive data stays empty.
595
- var seenSetups={};
596
- scripts.forEach(function(entry){var t=entry.text;if(t.indexOf('__stx_setup_')!==-1)seenSetups[t.substring(0,80)]=1});
597
- doc.querySelectorAll('script').forEach(function(s){
598
- var text=s.textContent||'';
599
- if(s.hasAttribute('src'))return;
600
- if(!text.trim())return;
601
- // Skip signals runtime and router (they contain __stx_setup references but aren't setup functions)
602
- if(text.indexOf("'use strict';var cloakStyle")!==-1)return;
603
- if(text.indexOf('__stxRouter')!==-1)return;
604
- if(newContent.contains(s))return;
605
- if(text.indexOf('__stx_setup_')===-1&&!s.hasAttribute('data-stx-scoped')&&!s.hasAttribute('data-stx-page'))return;
606
- var key=text.substring(0,80);
607
- if(seenSetups[key])return;
608
- seenSetups[key]=1;
609
- addScript(text,true);
610
- });
611
- }
612
-
613
- // Push history state (before active link updates so location.pathname is current)
614
- if(pushState!==false)history.pushState({},'',url+(hash||''));
615
-
616
- // Update active nav links
617
- updateNav(url);
618
- updateActiveLinks();
619
-
620
- // Scroll
621
- if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
622
- else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
623
-
624
- // Update title
625
- var newTitle=doc.querySelector('title');
626
- if(newTitle)document.title=newTitle.textContent;
627
-
628
- // Update <html lang> from the destination doc so screen readers,
629
- // CSS :lang() selectors, and any i18n picker that mirrors
630
- // document.documentElement.lang stay accurate after SPA hops.
631
- if(doc.documentElement&&doc.documentElement.lang){
632
- document.documentElement.lang=doc.documentElement.lang;
633
- }
634
-
635
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
636
-
637
- // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
638
- function execScripts(){
639
- scripts.forEach(function(entry){
640
- var text=typeof entry==='string'?entry:entry.text;
641
- var runAlways=typeof entry==='string'?false:entry.runAlways;
642
- // Skip scripts that were already executed (layout-level partials
643
- // like theme.stx). Their top-level const/function declarations
644
- // would throw "Identifier has already been declared" on re-execution.
645
- // Exception: setup functions (__stx_setup_) must always re-execute
646
- // because each page has its own setup, and import statements
647
- // need special handling.
648
- var h=hashScript(text);
649
- var isSetup=text.indexOf('__stx_setup_')!==-1;
650
- var hasImport=text.indexOf('import ')!==-1;
651
- if(!runAlways&&!isSetup&&executedScriptHashes[h])return;
652
- executedScriptHashes[h]=1;
653
- // Wrap scripts with import statements as modules (Bug 3 fix)
654
- var ns=document.createElement('script');
655
- if(hasImport){
656
- ns.type='module';
657
- }
658
- // Wrap in block scope to prevent const/let collisions on re-navigation.
659
- // Top-level const/let in <script> tags are global-scoped in browsers
660
- // and throw "Identifier has already been declared" on re-execution.
661
- // Skip the wrap for module scripts \u2014 they get their own scope from
662
- // ESM, and top-level 'import' is illegal inside a block, which
663
- // would throw SyntaxError before the script ever runs.
664
- var alreadyScoped=text.trimStart().charAt(0)==='('||text.trimStart().charAt(0)===';'||text.indexOf('window.stx.mount')>-1;
665
- ns.textContent=(hasImport||alreadyScoped)?text:'{'+text+'}';
666
- ns.setAttribute('data-stx-page','');
667
- document.body.appendChild(ns);
668
- });
669
- // THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
670
- window.dispatchEvent(new Event('stx:load'));
671
- }
672
-
673
- if(extPromises.length>0){
674
- Promise.all(extPromises).then(execScripts).catch(execScripts);
675
- }
676
- else {
677
- execScripts();
678
- }
679
- }
680
-
681
- if(runViewTransition(doSwap)){
682
- }
683
- else {
684
- // Fallback fade for browsers without View Transitions API
685
- currentContent.style.transition='opacity 0.12s ease-out';
686
- currentContent.style.opacity='0';
687
- setTimeout(function(){
688
- doSwap();
689
- currentContent.style.opacity='1';
690
- setTimeout(function(){currentContent.style.transition=''},150);
691
- },120);
692
- }
693
- }
694
-
695
- // \u2500\u2500 Link interception \u2500\u2500
696
- function shouldIntercept(link){
697
- if(!link)return false;
698
- var href=link.getAttribute('href');
699
- if(!href)return false;
700
- if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return false;
701
- if(link.target==='_blank')return false;
702
- if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return false;
703
- if(href.startsWith('?'))return true;
704
- if(href===location.pathname)return false;
705
- if(!getContainer())return false;
706
- return true;
707
- }
708
-
709
- // SPA click handling. By default only [data-stx-link] elements get
710
- // SPA navigation; regular <a href> does native full page reload. Set
711
- // interceptAllLinks:true (window.__stxRouterConfig or build-time
712
- // config) to intercept any same-origin anchor \u2014 used by static-site
713
- // mode where every page is part of the same SPA shell.
714
- document.addEventListener('click',function(e){
715
- if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0)return;
716
- if(!e.target||!e.target.closest)return;
717
- var link=e.target.closest('[data-stx-link]');
718
- if(!link&&o.interceptAllLinks){
719
- var anchor=e.target.closest('a[href]');
720
- if(anchor&&shouldIntercept(anchor))link=anchor;
721
- }
722
- if(!link){return}
723
- var href=link.getAttribute('href');
724
- log('[router] click intercepted:',href,'container:',!!getContainer(),'defaultPrevented:',e.defaultPrevented);
725
- if(!href||href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')){log('[router] skipped:',href);return}
726
- if(link.target==='_blank'||link.hasAttribute('download'))return;
727
- e.preventDefault();
728
- e.stopPropagation();
729
- log('[router] navigating to:',href);
730
- navigate(withCurrentLocale(href));
731
- },true);
732
-
733
- // \u2500\u2500 Back/forward \u2500\u2500
734
- window.addEventListener('popstate',function(){
735
- navigate(location.pathname+location.search+location.hash,false);
736
- });
737
-
738
- // \u2500\u2500 Prefetch on hover \u2500\u2500
739
- if(o.prefetch){
740
- document.addEventListener('mouseover',function(e){
741
- if(!e.target||!e.target.closest)return;
742
- var link=e.target.closest('[data-stx-link]');
743
- if(!link)return;
744
- var href=withCurrentLocale(link.getAttribute('href'));
745
- var key=cacheKey(href);
746
- if(cache[key]||prefetching[key])return;
747
- prefetching[key]=true;
748
- var wantsFragment=shouldUseFragmentResponse();
749
- fetch(href,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
750
- var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
751
- var pLayout=r.headers.get('X-STX-Layout')||'';
752
- var pGroup=r.headers.get('X-STX-Layout-Group')||'';
753
- var pTitle=r.headers.get('X-STX-Title')||'';
754
- return r.text().then(function(html){return{html:isFrag?'<!--stx-fragment-->'+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}});
755
- }).then(function(result){
756
- if(o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
757
- }).catch(function(){}).finally(function(){delete prefetching[key]});
758
- },true);
759
- }
760
-
761
- // \u2500\u2500 Active link management \u2500\u2500
762
- function updateNav(url){
763
- document.querySelectorAll('nav a[href], #mobileNav a[href], [data-stx-nav] a[href]').forEach(function(a){
764
- var href=a.getAttribute('href');
765
- if(!href||href.startsWith('#')||href.startsWith('http'))return;
766
- var isActive=(href===url)||(href==='/'&&url==='/');
767
- if(a.hasAttribute('data-stx-link')){
768
- var ac=a.getAttribute('data-stx-active-class')||'active';
769
- if(isActive)ac.split(' ').forEach(function(cls){if(cls)a.classList.add(cls)});else ac.split(' ').forEach(function(cls){if(cls)a.classList.remove(cls)});
770
- }
771
- });
772
- }
773
-
774
- function updateActiveLinks(){
775
- // Update active classes on <stx-link> elements (and legacy data-stx-link)
776
- var links=document.querySelectorAll('[data-stx-link]');
777
- var cur=location.pathname;
778
- links.forEach(function(link){
779
- var href=link.getAttribute('to')||link.getAttribute('href')||'';
780
- var ac=link.getAttribute('active-class')||link.getAttribute('data-stx-active-class')||'active';
781
- var eac=link.getAttribute('exact-active-class')||link.getAttribute('data-stx-exact-active-class')||'exact-active';
782
- ac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
783
- eac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
784
- var isExact=cur===href;
785
- var isActive=href!=='/'?cur.startsWith(href):cur==='/';
786
- if(isExact)eac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
787
- if(isActive)ac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
788
- });
789
- }
790
-
791
- // \u2500\u2500 Progress bar DOM + style \u2500\u2500
792
- // Injects a fixed-position element at the top of the viewport plus the
793
- // minimal CSS for its transform/opacity transitions. Kept idempotent so
794
- // repeated init() calls (e.g. after full-body swaps) don't duplicate.
795
- // Also ships the default View Transitions fade+slide CSS when
796
- // viewTransitions is enabled and the browser supports it. Apps can
797
- // override by defining more specific ::view-transition-* rules later in
798
- // the cascade, or opt out entirely with viewTransitions:false.
799
- function injectStyles(){
800
- if(!document.getElementById('stx-r-css')){
801
- var s=document.createElement('style');s.id='stx-r-css';
802
- // Strip characters that could escape our CSS block and inject new
803
- // declarations: ; { } ( ) " ' \\ < > plus whitespace control chars.
804
- // This keeps the value safe to concat into a stylesheet even if a
805
- // caller wires the config from an untrusted source.
806
- var sanitize=function(v){return String(v).replace(/[;{}()"'\\\\<>\\n\\r\\t]/g,'')};
807
- var pc=sanitize(o.progressColor);
808
- var ph=sanitize(o.progressHeight);
809
- var css='.stx-navigating{cursor:wait}.stx-navigating a,.stx-navigating button{pointer-events:none}#stx-router-progress{position:fixed;top:0;left:0;right:0;height:'+ph+';background:'+pc+';box-shadow:0 0 8px '+pc+',0 0 4px '+pc+';transform:scaleX(0);transform-origin:left;transition:transform .18s ease-out,opacity .26s ease;opacity:0;pointer-events:none;z-index:999999}';
810
- if(o.viewTransitions&&'startViewTransition' in document){
811
- var dur=(o.viewTransitionDuration||220)+'ms';
812
- var ease=o.viewTransitionEasing||'cubic-bezier(0.16, 1, 0.3, 1)';
813
- css+='::view-transition-old(root),::view-transition-new(root){animation-duration:'+dur+';animation-timing-function:'+ease+'}';
814
- css+='::view-transition-old(root){animation-name:stx-r-fade-out}::view-transition-new(root){animation-name:stx-r-fade-in}';
815
- css+='@keyframes stx-r-fade-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-4px)}}';
816
- css+='@keyframes stx-r-fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}';
817
- css+='@media (prefers-reduced-motion: reduce){::view-transition-old(root),::view-transition-new(root){animation-duration:0s;animation-name:none}}';
818
- }
819
- s.textContent=css;
820
- document.head.appendChild(s);
821
- }
822
- if(o.progress&&!document.getElementById('stx-router-progress')){
823
- var el=document.createElement('div');
824
- el.id='stx-router-progress';
825
- el.setAttribute('role','progressbar');
826
- el.setAttribute('aria-hidden','true');
827
- // Append to <html>, not <body>: when the router swaps the body
828
- // (container:'body' for full-page static-site SPAs) the progress
829
- // element gets wiped along with it. <html> stays put across
830
- // swaps, so the bar survives multi-hop navigation.
831
- document.documentElement.appendChild(el);
832
- progEl=el;
833
- } else if(o.progress){
834
- progEl=document.getElementById('stx-router-progress');
835
- }
836
- }
837
-
838
- function injectViewTransitionCSS(){
839
- if(document.getElementById('stx-view-transitions'))return;
840
- var s=document.createElement('style');s.id='stx-view-transitions';
841
- s.textContent='::view-transition-group(root){animation:none}::view-transition-old(root){animation:none}::view-transition-new(root){animation:none}main,#app-content,[data-stx-content]{view-transition-name:stx-content}::view-transition-old(stx-content){animation:stx-fade-out .15s ease-out both}::view-transition-new(stx-content){animation:stx-fade-in .15s ease-in .1s both}@keyframes stx-fade-out{from{opacity:1}to{opacity:0}}@keyframes stx-fade-in{from{opacity:0}to{opacity:1}}::view-transition{background:transparent}::view-transition-group(stx-content){background:inherit;overflow:hidden}';
842
- (document.head||document.documentElement).appendChild(s);
843
- }
844
-
845
- // \u2500\u2500 Public API \u2500\u2500
846
- var router={
847
- navigate:navigate,
848
- navigateTo:navigate,
849
- prefetch:function(url){
850
- var key=cacheKey(url);
851
- if(!cache[key]){
852
- var wantsFragment=shouldUseFragmentResponse();
853
- 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')||'';return r.text().then(function(html){return{html:isFrag?'<!--stx-fragment-->'+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}})}).then(function(result){setCache(key,result.html,result.layout,result.layoutGroup,result.title)}).catch(function(){});
854
- }
855
- },
856
- 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},
857
- cache:cache,
858
- swap:swap,
859
- updateNav:updateNav
860
- };
861
-
862
- router.__rev=ROUTER_REV;
863
- window.__stxRouter=router;
864
- window.stxRouter=router;
865
- if(window.stx)window.stx.router=router;
866
-
867
- // \u2500\u2500 Initialize \u2500\u2500
868
- function init(){
869
- injectStyles();
870
- injectViewTransitionCSS();
871
- updateActiveLinks();
872
- updateNav(location.pathname);
873
- }
874
-
875
- if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
876
- })();
877
- `;
878
- }
879
-
880
- // src/codegen.ts
881
- import fs from "fs";
882
- import path from "path";
883
- function generateRouteTypes(routes, outputDir) {
884
- const lines = [
885
- "// Auto-generated by STX - do not edit",
886
- 'declare module "stx/routes" {',
887
- " interface RouteMap {"
888
- ];
889
- for (const route of routes) {
890
- const paramEntries = [];
891
- for (const p of route.params) {
892
- const type = route.pattern.includes(`:${p}*`) ? "string[]" : "string";
893
- paramEntries.push(`${p}: ${type}`);
894
- }
895
- const paramsStr = paramEntries.join("; ");
896
- lines.push(` '${route.pattern}': { ${paramsStr} }`);
897
- }
898
- lines.push(" }", "}", "");
899
- fs.mkdirSync(outputDir, { recursive: true });
900
- fs.writeFileSync(path.join(outputDir, "route-types.d.ts"), lines.join(`
901
- `));
902
- }
903
- function generateRouteManifest(routes, outputDir, pagesDir) {
904
- const cwd = process.cwd();
905
- const relativePages = path.relative(cwd, pagesDir);
906
- const excludeDirs = ["components", "layouts", "partials"];
907
- const pageRoutes = routes.filter((r) => !excludeDirs.some((dir) => r.pattern.startsWith(`/${dir}/`) || r.pattern === `/${dir}`));
908
- const lines = [
909
- "// Auto-generated by stx \u2014 do not edit",
910
- `// Source: ${relativePages || "pages"}/`,
911
- "",
912
- "export const routes = ["
913
- ];
914
- for (const route of pageRoutes) {
915
- const relFile = path.relative(cwd, route.filePath).replace(/\\/g, "/");
916
- const params = JSON.stringify(route.params);
917
- const layoutStr = route.layout ? `, layout: '${path.relative(cwd, route.layout).replace(/\\/g, "/")}'` : "";
918
- lines.push(` { pattern: '${route.pattern}', filePath: '${relFile}', isDynamic: ${route.isDynamic}, params: ${params}${layoutStr} },`);
919
- }
920
- lines.push("] as const", "");
921
- lines.push("export type RouteName = typeof routes[number]['pattern']", "");
922
- fs.mkdirSync(outputDir, { recursive: true });
923
- fs.writeFileSync(path.join(outputDir, "routes.ts"), lines.join(`
924
- `));
925
- }
926
-
927
- // src/matcher.ts
928
- import path2 from "path";
929
- function filePathToPattern(filePath, pagesDir) {
930
- const relativePath = path2.relative(pagesDir, filePath);
931
- let route = relativePath.replace(/\.(stx|md)$/, "");
932
- route = route.split(path2.sep).join("/");
933
- if (route === "index") {
934
- return "/";
935
- }
936
- if (route.endsWith("/index")) {
937
- route = route.slice(0, -6);
938
- }
939
- route = route.replace(/\[\[([^\]]+)\]\]/g, ":$1?");
940
- route = route.replace(/\[([^\]]+)\]/g, ":$1");
941
- route = route.replace(/:\.\.\.([^/]+)/g, ":$1*");
942
- return `/${route}`;
943
- }
944
- function patternToRegex(pattern) {
945
- const params = [];
946
- let regexStr = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
947
- regexStr = regexStr.replace(/\/:([^/]+)\?/g, (_, name) => {
948
- params.push(name);
949
- return "(?:/([^/]+))?";
950
- });
951
- regexStr = regexStr.replace(/:([^/]+)\*/g, (_, name) => {
952
- params.push(name);
953
- return "(.+)";
954
- });
955
- regexStr = regexStr.replace(/:([^/]+)/g, (_, name) => {
956
- params.push(name);
957
- return "([^/]+)";
958
- });
959
- regexStr = `^${regexStr}$`;
960
- return { regex: new RegExp(regexStr), params };
961
- }
962
- function matchRoute(pathname, routes) {
963
- const normalizedPath = pathname === "" ? "/" : pathname;
964
- for (const route of routes) {
965
- const match = normalizedPath.match(route.regex);
966
- if (match) {
967
- const params = {};
968
- route.params.forEach((name, index) => {
969
- params[name] = match[index + 1];
970
- });
971
- return { route, params };
972
- }
973
- }
974
- return null;
975
- }
976
-
977
- // src/nested-layouts.ts
978
- import fs2 from "fs";
979
- import path3 from "path";
980
- function resolveLayoutChain(routeFilePath, pagesDir) {
981
- const layouts = [];
982
- let dir = path3.dirname(routeFilePath);
983
- while (dir.startsWith(pagesDir) || dir === pagesDir) {
984
- const layoutFile = path3.join(dir, "_layout.stx");
985
- if (fs2.existsSync(layoutFile)) {
986
- layouts.unshift(layoutFile);
987
- }
988
- if (dir === pagesDir)
989
- break;
990
- dir = path3.dirname(dir);
991
- }
992
- return layouts;
993
- }
994
-
995
- // src/file-router.ts
996
- import fs3 from "fs";
997
- import path4 from "path";
998
- function scanDirectory(dir, extensions) {
999
- const files = [];
1000
- if (!fs3.existsSync(dir)) {
1001
- return files;
1002
- }
1003
- const entries = fs3.readdirSync(dir, { withFileTypes: true });
1004
- for (const entry of entries) {
1005
- const fullPath = path4.join(dir, entry.name);
1006
- if (entry.isDirectory()) {
1007
- if (entry.name.startsWith(".")) {
1008
- continue;
1009
- }
1010
- files.push(...scanDirectory(fullPath, extensions));
1011
- } else if (entry.isFile()) {
1012
- const ext = path4.extname(entry.name);
1013
- if (extensions.includes(ext)) {
1014
- if (entry.name.startsWith("_") && entry.name !== "_layout.stx") {
1015
- continue;
1016
- }
1017
- if (entry.name === "_layout.stx") {
1018
- continue;
1019
- }
1020
- files.push(fullPath);
1021
- }
1022
- }
1023
- }
1024
- return files;
1025
- }
1026
-
1027
- class Router {
1028
- routes;
1029
- pagesDir;
1030
- pagesDirs;
1031
- config;
1032
- constructor(baseDir, config = {}) {
1033
- this.config = config;
1034
- const rawDirs = config.pagesDirs && config.pagesDirs.length > 0 ? config.pagesDirs : [config.pagesDir || "pages"];
1035
- this.pagesDirs = rawDirs.map((d) => path4.resolve(baseDir, d));
1036
- this.pagesDir = this.pagesDirs[0];
1037
- const extensions = config.extensions || [".stx"];
1038
- const seenPatterns = new Set;
1039
- this.routes = [];
1040
- for (const dir of this.pagesDirs) {
1041
- const files = scanDirectory(dir, extensions);
1042
- for (const filePath of files) {
1043
- const pattern = filePathToPattern(filePath, dir);
1044
- if (seenPatterns.has(pattern))
1045
- continue;
1046
- seenPatterns.add(pattern);
1047
- const { regex, params } = patternToRegex(pattern);
1048
- const layouts = config.layouts !== false ? resolveLayoutChain(filePath, dir) : [];
1049
- this.routes.push({
1050
- pattern,
1051
- regex,
1052
- params,
1053
- filePath,
1054
- isDynamic: params.length > 0,
1055
- layout: layouts.length > 0 ? layouts[layouts.length - 1] : undefined
1056
- });
1057
- }
1058
- }
1059
- this.routes.sort((a, b) => {
1060
- if (!a.isDynamic && b.isDynamic)
1061
- return -1;
1062
- if (a.isDynamic && !b.isDynamic)
1063
- return 1;
1064
- const aSegments = a.pattern.split("/").length;
1065
- const bSegments = b.pattern.split("/").length;
1066
- if (aSegments !== bSegments)
1067
- return bSegments - aSegments;
1068
- return a.pattern.localeCompare(b.pattern);
1069
- });
1070
- const stxDir = path4.join(baseDir, ".stx");
1071
- generateRouteTypes(this.routes, stxDir);
1072
- generateRouteManifest(this.routes, stxDir, this.pagesDir);
1073
- }
1074
- match(pathname) {
1075
- const normalizedPath = pathname === "" ? "/" : pathname;
1076
- for (const route of this.routes) {
1077
- const m = normalizedPath.match(route.regex);
1078
- if (m) {
1079
- const params = {};
1080
- route.params.forEach((name, index) => {
1081
- params[name] = m[index + 1];
1082
- });
1083
- return { route, params };
1084
- }
1085
- }
1086
- return null;
1087
- }
1088
- resolve(name, params) {
1089
- const route = this.routes.find((r) => r.pattern === name || r.filePath.includes(name));
1090
- if (!route)
1091
- return "#not-found";
1092
- let resolved = route.pattern;
1093
- if (params) {
1094
- for (const [key, value] of Object.entries(params)) {
1095
- resolved = resolved.replace(`:${key}`, encodeURIComponent(value));
1096
- }
1097
- }
1098
- return resolved;
1099
- }
1100
- addRoute(route) {
1101
- this.routes.push(route);
1102
- this.routes.sort((a, b) => {
1103
- if (!a.isDynamic && b.isDynamic)
1104
- return -1;
1105
- if (a.isDynamic && !b.isDynamic)
1106
- return 1;
1107
- return a.pattern.localeCompare(b.pattern);
1108
- });
1109
- }
1110
- getLayout(route) {
1111
- return route.layout ?? null;
1112
- }
1113
- }
1114
- function createRouter(baseDir, config) {
1115
- return new Router(baseDir, config);
1116
- }
1117
- function findErrorPage(pagesDir, statusCode) {
1118
- const candidates = [`${statusCode}.stx`, "error.stx"];
1119
- for (const candidate of candidates) {
1120
- const filePath = path4.join(pagesDir, candidate);
1121
- if (fs3.existsSync(filePath))
1122
- return filePath;
1123
- }
1124
- return null;
1125
- }
1126
- function formatRoutes(routes, baseDir) {
1127
- return routes.map((route) => {
1128
- const relativePath = path4.relative(baseDir, route.filePath);
1129
- const dynamicLabel = route.isDynamic ? " (dynamic)" : "";
1130
- return `${route.pattern} \u2192 ${relativePath}${dynamicLabel}`;
1131
- });
1132
- }
1133
-
1134
- // src/named-routes.ts
1135
- var routes = {};
1136
- var appUrl = "";
1137
- function setAppUrl(url) {
1138
- appUrl = url.replace(/\/$/, "");
1139
- }
1140
- function defineRoute(name, routePath, params = {}) {
1141
- routes[name] = { path: routePath, name, params };
1142
- }
1143
- function defineRoutes(routeDefinitions) {
1144
- for (const [name, definition] of Object.entries(routeDefinitions)) {
1145
- if (typeof definition === "string") {
1146
- routes[name] = { path: definition, name };
1147
- } else {
1148
- routes[name] = { ...definition, name };
1149
- }
1150
- }
1151
- }
1152
- function route(name, params = {}, absolute = false) {
1153
- const r = routes[name];
1154
- if (!r) {
1155
- console.warn(`Route [${name}] not defined.`);
1156
- return "#undefined-route";
1157
- }
1158
- const mergedParams = { ...r.params, ...params };
1159
- let routePath = r.path;
1160
- routePath = routePath.replace(/:(\w+)/g, (_, paramName) => {
1161
- const value = mergedParams[paramName];
1162
- if (value === undefined) {
1163
- console.warn(`Missing parameter [${paramName}] for route [${name}].`);
1164
- return `:${paramName}`;
1165
- }
1166
- delete mergedParams[paramName];
1167
- return encodeURIComponent(String(value));
1168
- });
1169
- const remainingParams = Object.entries(mergedParams);
1170
- if (remainingParams.length > 0) {
1171
- const queryString = remainingParams.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`).join("&");
1172
- routePath += (routePath.includes("?") ? "&" : "?") + queryString;
1173
- }
1174
- return absolute && appUrl ? `${appUrl}${routePath}` : routePath;
1175
- }
1176
- function resetRoutes() {
1177
- for (const key of Object.keys(routes)) {
1178
- delete routes[key];
1179
- }
1180
- appUrl = "";
1181
- }
1182
-
1183
- // src/middleware.ts
1184
- import fs4 from "fs";
1185
- import path5 from "path";
1186
- function navigateTo(navPath, options = {}) {
1187
- return {
1188
- type: "redirect",
1189
- path: navPath,
1190
- options: {
1191
- replace: options.replace ?? false,
1192
- external: options.external ?? false,
1193
- redirectCode: options.redirectCode ?? 302
1194
- }
1195
- };
1196
- }
1197
- function abortNavigation(error) {
1198
- const normalizedError = typeof error === "string" ? { statusCode: 500, message: error } : error;
1199
- return {
1200
- type: "abort",
1201
- error: normalizedError
1202
- };
1203
- }
1204
- function defineMiddleware(handler, options = {}) {
1205
- return {
1206
- handler,
1207
- mode: options.mode ?? "universal"
1208
- };
1209
- }
1210
- function parseCookies(cookieHeader) {
1211
- const cookies = {};
1212
- if (!cookieHeader)
1213
- return cookies;
1214
- const pairs = cookieHeader.split(";");
1215
- for (const pair of pairs) {
1216
- const [name, ...rest] = pair.split("=");
1217
- if (name) {
1218
- const key = name.trim();
1219
- const value = rest.join("=").trim();
1220
- if (key) {
1221
- cookies[key] = decodeURIComponent(value);
1222
- }
1223
- }
1224
- }
1225
- return cookies;
1226
- }
1227
- function serializeCookie(name, value, options = {}) {
1228
- let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
1229
- if (options.expires) {
1230
- const expires = typeof options.expires === "number" ? new Date(Date.now() + options.expires * 1000) : options.expires;
1231
- cookie += `; Expires=${expires.toUTCString()}`;
1232
- }
1233
- if (options.path)
1234
- cookie += `; Path=${options.path}`;
1235
- if (options.domain)
1236
- cookie += `; Domain=${options.domain}`;
1237
- if (options.secure)
1238
- cookie += "; Secure";
1239
- if (options.httpOnly)
1240
- cookie += "; HttpOnly";
1241
- if (options.sameSite)
1242
- cookie += `; SameSite=${options.sameSite}`;
1243
- return cookie;
1244
- }
1245
- function createServerCookieManager(request, responseHeaders) {
1246
- const cookieHeader = request.headers.get("cookie") || "";
1247
- const cookies = parseCookies(cookieHeader);
1248
- return {
1249
- get(name) {
1250
- return cookies[name];
1251
- },
1252
- set(name, value, options = {}) {
1253
- cookies[name] = value;
1254
- responseHeaders.append("Set-Cookie", serializeCookie(name, value, options));
1255
- },
1256
- delete(name) {
1257
- delete cookies[name];
1258
- responseHeaders.append("Set-Cookie", serializeCookie(name, "", { expires: new Date(0), path: "/" }));
1259
- },
1260
- getAll() {
1261
- return { ...cookies };
1262
- }
1263
- };
1264
- }
1265
- function createClientCookieManager() {
1266
- return {
1267
- get(name) {
1268
- if (typeof document === "undefined")
1269
- return;
1270
- const cookies = parseCookies(document.cookie);
1271
- return cookies[name];
1272
- },
1273
- set(name, value, options = {}) {
1274
- if (typeof document === "undefined")
1275
- return;
1276
- document.cookie = serializeCookie(name, value, options);
1277
- },
1278
- delete(name) {
1279
- if (typeof document === "undefined")
1280
- return;
1281
- document.cookie = serializeCookie(name, "", { expires: new Date(0), path: "/" });
1282
- },
1283
- getAll() {
1284
- if (typeof document === "undefined")
1285
- return {};
1286
- return parseCookies(document.cookie);
1287
- }
1288
- };
1289
- }
1290
- function createStorageManager() {
1291
- const available = typeof window !== "undefined" && typeof window.localStorage !== "undefined";
1292
- return {
1293
- get(key) {
1294
- if (!available)
1295
- return null;
1296
- try {
1297
- return window.localStorage.getItem(key);
1298
- } catch {
1299
- return null;
1300
- }
1301
- },
1302
- set(key, value) {
1303
- if (!available)
1304
- return;
1305
- try {
1306
- window.localStorage.setItem(key, value);
1307
- } catch {}
1308
- },
1309
- remove(key) {
1310
- if (!available)
1311
- return;
1312
- try {
1313
- window.localStorage.removeItem(key);
1314
- } catch {}
1315
- },
1316
- isAvailable() {
1317
- return available;
1318
- }
1319
- };
1320
- }
1321
- var middlewareRegistry = new Map;
1322
- function registerMiddleware(name, middleware) {
1323
- middlewareRegistry.set(name, middleware);
1324
- }
1325
- function getMiddleware(name) {
1326
- return middlewareRegistry.get(name);
1327
- }
1328
- function hasMiddleware(name) {
1329
- return middlewareRegistry.has(name);
1330
- }
1331
- function clearMiddleware() {
1332
- middlewareRegistry.clear();
1333
- }
1334
- function getMiddlewareNames() {
1335
- return Array.from(middlewareRegistry.keys());
1336
- }
1337
- async function loadMiddlewareFromDirectory(baseDir, middlewareDir = "middleware") {
1338
- const fullPath = path5.resolve(baseDir, middlewareDir);
1339
- if (!fs4.existsSync(fullPath)) {
1340
- return;
1341
- }
1342
- const files = fs4.readdirSync(fullPath);
1343
- for (const file of files) {
1344
- if (!file.endsWith(".ts") && !file.endsWith(".js"))
1345
- continue;
1346
- if (file.includes(".test.") || file.includes(".spec."))
1347
- continue;
1348
- const name = file.replace(/\.(ts|js)$/, "");
1349
- const filePath = path5.join(fullPath, file);
1350
- try {
1351
- const mod = await import(filePath);
1352
- const mw = mod.default;
1353
- if (mw && typeof mw.handler === "function") {
1354
- registerMiddleware(name, mw);
1355
- } else if (typeof mw === "function") {
1356
- registerMiddleware(name, { handler: mw, mode: "universal" });
1357
- }
1358
- } catch (error) {
1359
- console.warn(`Failed to load middleware '${name}' from ${filePath}:`, error);
1360
- }
1361
- }
1362
- }
1363
- function parseQuery(search) {
1364
- const query = {};
1365
- if (!search)
1366
- return query;
1367
- const params = new URLSearchParams(search);
1368
- for (const [key, value] of params) {
1369
- query[key] = value;
1370
- }
1371
- return query;
1372
- }
1373
- function createRouteLocation(pathname, params, meta, search = "") {
1374
- return {
1375
- path: pathname,
1376
- params,
1377
- query: parseQuery(search),
1378
- fullPath: search ? `${pathname}?${search}` : pathname,
1379
- meta
1380
- };
1381
- }
1382
- function createMiddlewareContext(to, from, request) {
1383
- const isServer = typeof window === "undefined";
1384
- const isClient = !isServer;
1385
- const responseHeaders = new Headers;
1386
- return {
1387
- to,
1388
- from,
1389
- isClient,
1390
- isServer,
1391
- cookies: isServer && request ? createServerCookieManager(request, responseHeaders) : createClientCookieManager(),
1392
- storage: createStorageManager(),
1393
- state: {},
1394
- request,
1395
- responseHeaders
1396
- };
1397
- }
1398
- async function runMiddleware(middlewareNames, context) {
1399
- const names = Array.isArray(middlewareNames) ? middlewareNames : [middlewareNames];
1400
- const isServer = context.isServer;
1401
- for (const name of names) {
1402
- const middleware = getMiddleware(name);
1403
- if (!middleware) {
1404
- console.warn(`Middleware '${name}' not found`);
1405
- continue;
1406
- }
1407
- if (middleware.mode === "server" && !isServer)
1408
- continue;
1409
- if (middleware.mode === "client" && isServer)
1410
- continue;
1411
- try {
1412
- const result = await middleware.handler(context);
1413
- if (result) {
1414
- if (result.type === "redirect") {
1415
- return {
1416
- passed: false,
1417
- redirect: result,
1418
- state: context.state,
1419
- responseHeaders: context.responseHeaders
1420
- };
1421
- }
1422
- if (result.type === "abort") {
1423
- return {
1424
- passed: false,
1425
- abort: result,
1426
- state: context.state,
1427
- responseHeaders: context.responseHeaders
1428
- };
1429
- }
1430
- }
1431
- } catch (error) {
1432
- console.error(`Error in middleware '${name}':`, error);
1433
- return {
1434
- passed: false,
1435
- abort: {
1436
- type: "abort",
1437
- error: {
1438
- statusCode: 500,
1439
- message: error instanceof Error ? error.message : "Middleware error"
1440
- }
1441
- },
1442
- state: context.state,
1443
- responseHeaders: context.responseHeaders
1444
- };
1445
- }
1446
- }
1447
- return {
1448
- passed: true,
1449
- state: context.state,
1450
- responseHeaders: context.responseHeaders
1451
- };
1452
- }
1453
-
1454
- // src/layout-metadata.ts
1455
- function deriveLayoutGroup(layoutPath) {
1456
- const normalized = layoutPath.replace(/\\/g, "/");
1457
- const segments = normalized.split("/").filter(Boolean);
1458
- const layoutsIndex = segments.lastIndexOf("layouts");
1459
- const layoutSegment = layoutsIndex >= 0 ? segments[layoutsIndex + 1] : segments.at(-1);
1460
- if (!layoutSegment)
1461
- return "app";
1462
- return layoutSegment.replace(/\.stx$/i, "") || "app";
1463
- }
1464
- function extractLayoutMetadata(html) {
1465
- const layoutMeta = html.match(/<meta\b[^>]*name=["']stx-layout["'][^>]*content=["']([^"']+)["'][^>]*>/i);
1466
- const groupMeta = html.match(/<meta\b[^>]*name=["']stx-layout-group["'][^>]*content=["']([^"']+)["'][^>]*>/i);
1467
- const layoutComment = html.match(/<!--\s*stx-layout:\s*([^ ]+)\s*-->/i);
1468
- const layout = layoutMeta?.[1] || layoutComment?.[1] || "";
1469
- return {
1470
- layout,
1471
- group: groupMeta?.[1] || deriveLayoutGroup(layout)
1472
- };
1473
- }
1474
2
  export {
1475
3
  setAppUrl,
1476
4
  runMiddleware,
@@ -1507,4 +35,4 @@ export {
1507
35
  Router
1508
36
  };
1509
37
 
1510
- //# debugId=B01DBC2475C778C964756E2164756E21
38
+ //# debugId=86E31E0E333546DD64756E2164756E21