stx-router 0.2.152 → 0.2.154
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 +305 -28
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -21,6 +21,28 @@ export function getRouterScript() {
|
|
|
21
21
|
var containerSel=o.container;
|
|
22
22
|
var debug=!!o.debug;
|
|
23
23
|
function log(){if(debug&&typeof console!=='undefined'&&console.log)console.log.apply(console,arguments)}
|
|
24
|
+
|
|
25
|
+
// \u2500\u2500 Build skew (stacksjs/stx#1772) \u2500\u2500
|
|
26
|
+
// The build that rendered THIS document, and therefore the build the runtime
|
|
27
|
+
// executing right now came from. Under bun --watch a save restarts the server,
|
|
28
|
+
// so the next navigation can fetch a fragment produced by a newer build; when
|
|
29
|
+
// the scoped-script or binding format drifted between the two, the old runtime
|
|
30
|
+
// cannot hydrate the new fragment \u2014 literal moustaches, dead bindings, stale
|
|
31
|
+
// canvas. Sporadic, and never on a clean boot.
|
|
32
|
+
//
|
|
33
|
+
// Conservative on purpose: act only when BOTH ids are known. A missing id is
|
|
34
|
+
// "no information", never "mismatch", so statically hosted output (no headers)
|
|
35
|
+
// and older servers behave exactly as before.
|
|
36
|
+
var buildMeta=document.querySelector('meta[name="stx-build"]');
|
|
37
|
+
var loadedBuild=buildMeta?(buildMeta.getAttribute('content')||''):'';
|
|
38
|
+
window.__stxBuild=loadedBuild;
|
|
39
|
+
function isBuildSkew(incoming){
|
|
40
|
+
return !!(loadedBuild&&incoming&&incoming!==loadedBuild);
|
|
41
|
+
}
|
|
42
|
+
function reloadForSkew(url,incoming){
|
|
43
|
+
log('[router] build skew: page is',loadedBuild,'server is',incoming,'\u2014 full navigation');
|
|
44
|
+
location.href=url;
|
|
45
|
+
}
|
|
24
46
|
function runViewTransition(callback){
|
|
25
47
|
if(!o.viewTransitions||!document.startViewTransition)return false;
|
|
26
48
|
try{
|
|
@@ -216,6 +238,44 @@ export function getRouterScript() {
|
|
|
216
238
|
// Layout-level scripts (theme, nav setup) execute on initial page load and
|
|
217
239
|
// should NOT re-execute when navigating to another page with the same layout.
|
|
218
240
|
var executedScriptHashes={};
|
|
241
|
+
|
|
242
|
+
// \u2500\u2500 Re-execution contract (stacksjs/stx#1773) \u2500\u2500
|
|
243
|
+
// Whether a generated script may run again after an SPA swap used to be
|
|
244
|
+
// SNIFFED from its source: does it start with '(' or ';', or mention
|
|
245
|
+
// window.stx.mount. That is a property of the emitted shape, not of intent,
|
|
246
|
+
// so every change to how scripts are wrapped was one edit away from silently
|
|
247
|
+
// reclassifying them \u2014 and the symptom of getting it wrong is a component
|
|
248
|
+
// that renders completely dead (literal moustaches, stuck :show) only on a
|
|
249
|
+
// REVISIT, because nav-away already disposed its scope.
|
|
250
|
+
//
|
|
251
|
+
// Emitters now declare it: data-stx-run="always" on self-contained scope
|
|
252
|
+
// IIFEs and mount() wrappers, which own their scope and whose registration
|
|
253
|
+
// _cleanupContainer removed on the way out. Anything unstamped falls back to
|
|
254
|
+
// the old sniff, so a page rendered by an older server behaves exactly as
|
|
255
|
+
// before.
|
|
256
|
+
//
|
|
257
|
+
// The sniff itself now asks what the script REGISTERS rather than how it is
|
|
258
|
+
// spelled (#1828). cleanupContainer deletes window.stx._scopes entries,
|
|
259
|
+
// element disposers and destroy hooks on the way out, so the scripts that
|
|
260
|
+
// must run again are exactly the ones that put a scope back. That is
|
|
261
|
+
// detectable; a leading character is not.
|
|
262
|
+
//
|
|
263
|
+
// Deliberately NOT "strip leading comments, then test the first character",
|
|
264
|
+
// which is the obvious repair and is wrong in the expensive direction. Three
|
|
265
|
+
// emitters open with a comment and MUST NOT re-run: the animation scripts
|
|
266
|
+
// register matchMedia and DOMContentLoaded listeners plus an
|
|
267
|
+
// IntersectionObserver, and the STX lifecycle runtime owns a Map of live
|
|
268
|
+
// instances. Comment-stripping would flip all three to re-running on every
|
|
269
|
+
// navigation, leaking a listener and an observer each time and resetting the
|
|
270
|
+
// instance registry \u2014 trading a silent missing component for a silent leak.
|
|
271
|
+
var REGISTERS_SCOPE=/window\\.stx\\.mount|_scopes\\s*\\[|\\.initScope\\s*\\(|__stxComponentFactories\\s*\\[/;
|
|
272
|
+
function runsAlways(declared, code){
|
|
273
|
+
if(declared==='always')return true;
|
|
274
|
+
if(declared==='once')return false;
|
|
275
|
+
if(REGISTERS_SCOPE.test(code))return true;
|
|
276
|
+
var head=code.trimStart();
|
|
277
|
+
return head.charAt(0)==='('||head.charAt(0)===';';
|
|
278
|
+
}
|
|
219
279
|
function hashScript(code){
|
|
220
280
|
var h=0;for(var i=0;i<code.length;i++){h=((h<<5)-h)+code.charCodeAt(i);h|=0}
|
|
221
281
|
return h;
|
|
@@ -227,11 +287,99 @@ export function getRouterScript() {
|
|
|
227
287
|
var match=code.match(/function (__stx_setup_[A-Za-z0-9_]+)\\s*\\(/);
|
|
228
288
|
return match?match[1]:'';
|
|
229
289
|
}
|
|
290
|
+
// Does this fetched DOCUMENT carry the signals runtime?
|
|
291
|
+
//
|
|
292
|
+
// Unlike a fragment, a document holds the answer directly, so there is
|
|
293
|
+
// nothing to infer from markers or ask the server for: look for the script.
|
|
294
|
+
// Both emitted shapes carry data-stx-runtime \u2014 inline, and the serve-mode
|
|
295
|
+
// src="/_stx/runtime.js" \u2014 with the content sniff behind it for a document
|
|
296
|
+
// rendered by a server old enough not to stamp the attribute.
|
|
297
|
+
function documentShipsRuntime(doc,html){
|
|
298
|
+
if(doc&&doc.querySelector&&doc.querySelector('script[data-stx-runtime]'))return true;
|
|
299
|
+
return html.indexOf("'use strict';var cloakStyle")!==-1
|
|
300
|
+
||(html.indexOf('_cleanupContainer')!==-1&&html.indexOf('signals runtime loading')!==-1);
|
|
301
|
+
}
|
|
230
302
|
function isSignalsRuntimeScript(script,code){
|
|
231
303
|
return !!(script&&script.hasAttribute&&script.hasAttribute('data-stx-runtime'))
|
|
232
304
|
||(code.indexOf('_cleanupContainer')!==-1&&code.indexOf('signals runtime loading')!==-1)
|
|
233
305
|
||code.indexOf("'use strict';var cloakStyle")!==-1;
|
|
234
306
|
}
|
|
307
|
+
// A fragment never carries the signals runtime: the server strips the runtime
|
|
308
|
+
// IIFE out of it, and every re-execution path here filters it out again. So a
|
|
309
|
+
// page that never needed a runtime cannot hydrate a fragment that does \u2014 the
|
|
310
|
+
// setup function would be defined but never invoked, scope IIFEs throw while
|
|
311
|
+
// destructuring window.stx, and every x-cloak element stays display:none
|
|
312
|
+
// forever against the cloak style shipped on EVERY page. Hand those off to a
|
|
313
|
+
// real navigation, which loads the destination's own runtime (#1809).
|
|
314
|
+
//
|
|
315
|
+
// The SERVER decides this now, and says so in X-STX-Runtime, which travels
|
|
316
|
+
// into the fragment marker as rt=1 / rt=0 so it survives the prefetch cache.
|
|
317
|
+
// It is answering a question it knows the answer to exactly \u2014 did I put a
|
|
318
|
+
// runtime in this page \u2014 where the router could only guess from markup.
|
|
319
|
+
//
|
|
320
|
+
// Guessing missed a whole page shape (#1827). Every marker below originates in
|
|
321
|
+
// a client SCRIPT, so a page with reactive syntax and no script anywhere \u2014 a
|
|
322
|
+
// <script server>-only login form built from :if / x-model / :disabled \u2014 emits
|
|
323
|
+
// none of them, while the server ships it the runtime. Whatever root marker it
|
|
324
|
+
// does get lands on <body>, which a fragment excludes by construction. So
|
|
325
|
+
// <p :if="error" x-cloak>Invalid credentials</p>
|
|
326
|
+
// swapped into a runtime-less page keeps the x-cloak the server stamped on it,
|
|
327
|
+
// and only the runtime ever removes that: server-rendered text, in the DOM, at
|
|
328
|
+
// display:none forever.
|
|
329
|
+
//
|
|
330
|
+
// The same held for x-data on the routed container, whose scope attribute is
|
|
331
|
+
// hoisted out of the fragment body into X-STX-Container-Attrs. Asking the
|
|
332
|
+
// server covers both without either being enumerated.
|
|
333
|
+
//
|
|
334
|
+
// The sniff stays as the fallback for a server too old to declare it. Its
|
|
335
|
+
// discriminators matter. x-cloak and data-stx-scoped are NOT usable: each
|
|
336
|
+
// fragment inlines the head styles, cloak rule included, and the appearance
|
|
337
|
+
// bootstrap emits a self-contained data-stx-scoped script on pages with no
|
|
338
|
+
// reactivity at all. A bare window.stx is not usable either \u2014 the server
|
|
339
|
+
// prepends a _latestSetup=null clear script to every fragment. Any of those
|
|
340
|
+
// would make this always true and disable SPA navigation everywhere.
|
|
341
|
+
function fragmentNeedsRuntime(frag,declared){
|
|
342
|
+
if(declared==='1')return true;
|
|
343
|
+
if(declared==='0')return false;
|
|
344
|
+
if(frag.indexOf('__stx_setup_')!==-1
|
|
345
|
+
||frag.indexOf('data-stx-scope=')!==-1
|
|
346
|
+
||frag.indexOf('data-stx-reactive')!==-1
|
|
347
|
+
||/=\\s*window\\.stx\\s*;/.test(frag))return true;
|
|
348
|
+
var content=stripInertRegions(frag);
|
|
349
|
+
return BARE_DIRECTIVE.test(content)||content.indexOf('{{')!==-1;
|
|
350
|
+
}
|
|
351
|
+
// Documentation is both the largest population of runtime-less pages and the
|
|
352
|
+
// likeliest to contain directive syntax as literal CONTENT, so the fallback
|
|
353
|
+
// reads past the regions where that content lives. A page of stx examples must
|
|
354
|
+
// not full-reload on every hop through the docs \u2014 that is the regression
|
|
355
|
+
// #1809 existed to remove.
|
|
356
|
+
function stripInertRegions(frag){
|
|
357
|
+
return frag
|
|
358
|
+
.replace(new RegExp('<scr'+'ipt\\\\b[\\\\s\\\\S]*?<\\\\/scr'+'ipt>','gi'),'')
|
|
359
|
+
.replace(/<style\\b[\\s\\S]*?<\\/style>/gi,'')
|
|
360
|
+
.replace(/<pre\\b[\\s\\S]*?<\\/pre>/gi,'')
|
|
361
|
+
.replace(/<code\\b[\\s\\S]*?<\\/code>/gi,'')
|
|
362
|
+
.replace(/<!--[\\s\\S]*?-->/g,'');
|
|
363
|
+
}
|
|
364
|
+
// Anchored on the whitespace before an attribute name and the = that follows,
|
|
365
|
+
// so it reads attribute POSITIONS rather than characters. Bare ':' and '@' are
|
|
366
|
+
// everywhere in ordinary content \u2014 'xlink:href', 'background: red', '10:00',
|
|
367
|
+
// an email address \u2014 and none of those sit in that position.
|
|
368
|
+
//
|
|
369
|
+
// The x- arm is deliberately open rather than a list of known directives:
|
|
370
|
+
// x-attr is a GENERIC binding prefix, so x-href / x-src / x-value / x-alt bind
|
|
371
|
+
// exactly like x-text does. Measured over the 339 rendered docs pages, the
|
|
372
|
+
// open form costs nothing over an allowlist (3 matches either way, all three
|
|
373
|
+
// of them pages the CURRENT four-marker sniff already matches because they
|
|
374
|
+
// document its marker names) while covering directives an allowlist forgets.
|
|
375
|
+
var BARE_DIRECTIVE=/\\s(?::[a-z][\\w-]*|@[a-z][\\w.-]*|x-[a-z][\\w-]*)\\s*=\\s*["']/i;
|
|
376
|
+
// The fragment marker doubles as the carrier for the server's declaration.
|
|
377
|
+
// Encoding it in the HTML rather than threading a parameter keeps it inside
|
|
378
|
+
// the one value that already reaches swap() through every path, the prefetch
|
|
379
|
+
// cache included.
|
|
380
|
+
function fragmentMarker(declared){
|
|
381
|
+
return '<!--stx-fragment'+(declared==='true'?' rt=1':declared==='false'?' rt=0':'')+'-->';
|
|
382
|
+
}
|
|
235
383
|
// Record scripts from the initial page load
|
|
236
384
|
document.querySelectorAll('script').forEach(function(s){
|
|
237
385
|
var text=s.textContent||'';
|
|
@@ -292,7 +440,20 @@ export function getRouterScript() {
|
|
|
292
440
|
return containerSel==='main'||containerSel==='[data-stx-content]';
|
|
293
441
|
}
|
|
294
442
|
|
|
443
|
+
// Write the address bar. The mode travels in the same slot as the legacy
|
|
444
|
+
// pushState boolean: false still means "do not touch history", true still
|
|
445
|
+
// pushes, and the string 'replace' replaces. Threading a fourth argument
|
|
446
|
+
// through swap() to each of the three history sites would have been the
|
|
447
|
+
// alternative (#1807).
|
|
448
|
+
function writeHistory(mode,href){
|
|
449
|
+
if(mode==='replace')history.replaceState({},'',href);
|
|
450
|
+
else history.pushState({},'',href);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Second arg accepts the legacy pushState boolean OR an options object
|
|
454
|
+
// { replace }. Callers inside this file still pass the boolean.
|
|
295
455
|
function navigate(url,pushState,force){
|
|
456
|
+
if(pushState&&typeof pushState==='object')pushState=pushState.replace?'replace':true;
|
|
296
457
|
// Lang-picker passes force=true with an already-localized path (/en/...).
|
|
297
458
|
// Re-localizing would map it back to the *current* locale and no-op.
|
|
298
459
|
if(!force) url=withCurrentLocale(url);
|
|
@@ -308,7 +469,7 @@ export function getRouterScript() {
|
|
|
308
469
|
if(t.origin!==location.origin){location.href=url;return Promise.resolve(false)}
|
|
309
470
|
|
|
310
471
|
if(t.pathname===location.pathname&&t.hash){
|
|
311
|
-
if(pushState!==false)
|
|
472
|
+
if(pushState!==false)writeHistory(pushState,t.href);
|
|
312
473
|
var el=document.querySelector(t.hash);
|
|
313
474
|
if(el)el.scrollIntoView({behavior:'smooth'});
|
|
314
475
|
return Promise.resolve(true);
|
|
@@ -344,6 +505,7 @@ export function getRouterScript() {
|
|
|
344
505
|
return r.text();
|
|
345
506
|
}).then(function(html){
|
|
346
507
|
log('[router] full page fetched from cache path, len:',html.length);
|
|
508
|
+
pendingLayoutDecl={layout:layoutCache[targetPath]||'',group:layoutGroupCache[targetPath]||''};
|
|
347
509
|
return swap(html,targetPath,pushState,targetHash);
|
|
348
510
|
}).catch(function(err){
|
|
349
511
|
console.error('[router] full page fetch error:',err);
|
|
@@ -351,6 +513,7 @@ export function getRouterScript() {
|
|
|
351
513
|
}).finally(done);
|
|
352
514
|
}
|
|
353
515
|
pendingContainerAttrs=attrsCache[targetPath]||'';
|
|
516
|
+
pendingLayoutDecl=null;
|
|
354
517
|
return Promise.resolve(swap(cache[targetPath],targetPath,pushState,targetHash)).then(function(){
|
|
355
518
|
applyTitle(titleCache[targetPath]);
|
|
356
519
|
return true;
|
|
@@ -364,11 +527,16 @@ else {
|
|
|
364
527
|
var wantsFragment=shouldUseFragmentResponse();
|
|
365
528
|
return fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
|
|
366
529
|
if(!r.ok)throw new Error(r.status);
|
|
530
|
+
// Before anything is parsed or swapped: a fragment from a different
|
|
531
|
+
// build must not be handed to this page's runtime (#1772).
|
|
532
|
+
var incomingBuild=r.headers.get('X-STX-Build')||'';
|
|
533
|
+
if(isBuildSkew(incomingBuild)){reloadForSkew(url,incomingBuild);return null}
|
|
367
534
|
var isFragment=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
|
|
368
535
|
var newLayout=r.headers.get('X-STX-Layout')||'';
|
|
369
536
|
var newGroup=r.headers.get('X-STX-Layout-Group')||'';
|
|
370
537
|
var newTitle=r.headers.get('X-STX-Title')||'';
|
|
371
538
|
var newCAttrs=r.headers.get('X-STX-Container-Attrs')||'';
|
|
539
|
+
var newRuntime=r.headers.get('X-STX-Runtime')||'';
|
|
372
540
|
// Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
|
|
373
541
|
if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
|
|
374
542
|
log('[router] layout change \u2014 fetching full page for document swap');
|
|
@@ -381,12 +549,13 @@ else {
|
|
|
381
549
|
});
|
|
382
550
|
});
|
|
383
551
|
}
|
|
384
|
-
return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle,containerAttrs:newCAttrs}});
|
|
552
|
+
return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup,title:newTitle,containerAttrs:newCAttrs,runtime:newRuntime}});
|
|
385
553
|
}).then(function(result){
|
|
386
554
|
if(!result)return;
|
|
387
|
-
if(result.isFragment)result.html=
|
|
555
|
+
if(result.isFragment)result.html=fragmentMarker(result.runtime)+result.html;
|
|
388
556
|
if(o.cache)setCache(targetPath,result.html,result.layout,result.layoutGroup,result.title,result.containerAttrs);
|
|
389
557
|
pendingContainerAttrs=result.isFragment?(result.containerAttrs||''):'';
|
|
558
|
+
pendingLayoutDecl=result.isFragment?null:{layout:result.layout||'',group:result.layoutGroup||''};
|
|
390
559
|
return Promise.resolve(swap(result.html,targetPath,pushState,targetHash)).then(function(){
|
|
391
560
|
// Fragment swaps carry no <head>; apply the title from the header.
|
|
392
561
|
// Full-document swaps already set document.title from the <title> tag.
|
|
@@ -411,9 +580,19 @@ else {
|
|
|
411
580
|
||html.indexOf('data-stx-content')!==-1;
|
|
412
581
|
}
|
|
413
582
|
|
|
583
|
+
// The layout and group the SERVER declared for the document about to be
|
|
584
|
+
// swapped in. Set immediately before each swap() call by whichever path
|
|
585
|
+
// fetched it, and consumed once.
|
|
586
|
+
//
|
|
587
|
+
// Threaded this way rather than as a swap() parameter, matching
|
|
588
|
+
// pendingContainerAttrs \u2014 see the note above writeHistory about the cost of
|
|
589
|
+
// adding an argument to swap() and its three call sites (#1807).
|
|
590
|
+
var pendingLayoutDecl=null;
|
|
414
591
|
function swap(html,url,pushState,hash){
|
|
415
|
-
var
|
|
416
|
-
|
|
592
|
+
var fragMark=/^<!--stx-fragment(?: rt=([01]))?-->/.exec(html);
|
|
593
|
+
var isFragment=!!fragMark;
|
|
594
|
+
var declaredRuntime=fragMark&&fragMark[1]?fragMark[1]:'';
|
|
595
|
+
if(isFragment)html=html.slice(fragMark[0].length);
|
|
417
596
|
if(!isFragment&&!isStxDocument(html)){log('[router] non-stx document \u2014 full navigation to:',url);location.href=url;return Promise.resolve(false)}
|
|
418
597
|
var currentContent=getContainer();
|
|
419
598
|
log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&¤tContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
|
|
@@ -421,6 +600,14 @@ else {
|
|
|
421
600
|
|
|
422
601
|
// Fragment mode: server returned just the page content (no document wrapper)
|
|
423
602
|
if(isFragment){
|
|
603
|
+
// Before _cleanupContainer, not after: handing off later would dispose
|
|
604
|
+
// the OUTGOING page's scopes and then reload anyway. Inside doFragSwap
|
|
605
|
+
// would be later still \u2014 innerHTML is already replaced by then.
|
|
606
|
+
if(!window.stx&&fragmentNeedsRuntime(html,declaredRuntime)){
|
|
607
|
+
log('[router] fragment needs the signals runtime and this page has none \u2014 full navigation to:',url);
|
|
608
|
+
location.href=url;
|
|
609
|
+
return Promise.resolve(false);
|
|
610
|
+
}
|
|
424
611
|
if(window.stx&&window.stx._cleanupContainer)window.stx._cleanupContainer(currentContent);
|
|
425
612
|
function doFragSwap(){
|
|
426
613
|
// Extract scripts from fragment before injecting HTML
|
|
@@ -430,15 +617,34 @@ else {
|
|
|
430
617
|
var fragCrosswindHrefs=[];
|
|
431
618
|
var fragCrosswindCSS=null;
|
|
432
619
|
var cleanFrag=html.replace(new RegExp('<scr'+'ipt\\\\b([^>]*)>([\\\\s\\\\S]*?)<\\\\/scr'+'ipt>','gi'),function(m,attrs,code){
|
|
620
|
+
// A data block is not code, and everything below this line assumes it
|
|
621
|
+
// is: the body gets stashed in fragScripts, re-emitted as a pending
|
|
622
|
+
// placeholder, then wrapped in braces and executed. Wrapping a JSON
|
|
623
|
+
// object in braces reparses it as a labelled block, so its first ':'
|
|
624
|
+
// is a SyntaxError on every single fragment swap
|
|
625
|
+
// (stacksjs/stx#1801). @structuredData renders at its call site,
|
|
626
|
+
// which inside a layout is @section('content') \u2014 i.e. in the routed
|
|
627
|
+
// container \u2014 so the natural way to write it hit this.
|
|
628
|
+
//
|
|
629
|
+
// Left exactly as found rather than stripped: crawlers read
|
|
630
|
+
// structured data wherever it sits, and the browser will not execute
|
|
631
|
+
// a non-JS type anyway. Mirrors prepareRoutedBodyScripts' notion of
|
|
632
|
+
// executable, which this path never had.
|
|
633
|
+
var typeMatch=attrs.match(/\\btype\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))/i);
|
|
634
|
+
var scriptType=((typeMatch&&(typeMatch[1]||typeMatch[2]||typeMatch[3]))||'').trim().toLowerCase();
|
|
635
|
+
if(scriptType&&scriptType!=='text/javascript'&&scriptType!=='application/javascript'&&scriptType!=='module')return m;
|
|
433
636
|
if(code&&code.trim()&&!isSignalsRuntimeScript({hasAttribute:function(name){return name==='data-stx-runtime'&&attrs.indexOf('data-stx-runtime')!==-1}},code)){
|
|
434
637
|
var slot='fragment-'+(++fragScriptId);
|
|
435
638
|
var scoped=/(?:^|\\s)data-stx-scoped(?:\\s|=|$)/i.test(attrs);
|
|
436
|
-
|
|
639
|
+
var runDecl=(attrs.match(/data-stx-run\\s*=\\s*["']?(always|once)["']?/i)||[])[1];
|
|
640
|
+
fragScripts.push({text:code,slot:slot,setupName:generatedSetupName(code),scoped:scoped,run:runDecl?runDecl.toLowerCase():''});
|
|
437
641
|
// Retain scoped setup code in its inert placeholder. A placeholder
|
|
438
642
|
// inside template.content is unreachable through document, so the
|
|
439
643
|
// repeated component runtime must execute it for each clone.
|
|
440
644
|
return '<scr'+'ipt type="application/stx-pending" data-stx-route-script="'+slot+'"'
|
|
441
|
-
+(scoped?' data-stx-scoped':'')
|
|
645
|
+
+(scoped?' data-stx-scoped':'')
|
|
646
|
+
+(runDecl?' data-stx-run="'+runDecl.toLowerCase()+'"':'')
|
|
647
|
+
+'>'+code+'<\\/scr'+'ipt>';
|
|
442
648
|
}
|
|
443
649
|
return '';
|
|
444
650
|
});
|
|
@@ -498,7 +704,7 @@ else {
|
|
|
498
704
|
currentContent.innerHTML=cleanFrag;
|
|
499
705
|
// Remove old page scripts
|
|
500
706
|
document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
|
|
501
|
-
if(pushState!==false)
|
|
707
|
+
if(pushState!==false)writeHistory(pushState,url+(hash||''));
|
|
502
708
|
updateNav(url);
|
|
503
709
|
updateActiveLinks();
|
|
504
710
|
if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
|
|
@@ -541,17 +747,14 @@ else {
|
|
|
541
747
|
// re-execute because each page has its own setup.
|
|
542
748
|
var h=hashScript(code);
|
|
543
749
|
var isSetup=code.indexOf('__stx_setup_')!==-1;
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
|
|
552
|
-
// and all :if branches stay visible at once.
|
|
553
|
-
// Mirrors the full-doc swap path's runAlways escape below.
|
|
554
|
-
var isAlreadyScoped=code.trimStart().charAt(0)==='('||code.trimStart().charAt(0)===';'||code.indexOf('window.stx.mount')>-1;
|
|
750
|
+
// Self-contained scope scripts must re-execute on every navigation:
|
|
751
|
+
// they own their scope, won't collide with prior runs, and
|
|
752
|
+
// _cleanupContainer deleted their entry from window.stx._scopes on
|
|
753
|
+
// the way out. Skipping one leaves the page leaf with no scope
|
|
754
|
+
// registered \u2014 bindIf/@event never re-bind and every :if branch
|
|
755
|
+
// stays visible at once. Declared by the emitter via data-stx-run,
|
|
756
|
+
// sniffed only as a fallback (#1773).
|
|
757
|
+
var isAlreadyScoped=runsAlways(entry.run,code);
|
|
555
758
|
if(!isSetup&&!isAlreadyScoped&&executedScriptHashes[h]){
|
|
556
759
|
log('[router] skipping already-executed script (hash dedup)');
|
|
557
760
|
return;
|
|
@@ -571,7 +774,12 @@ else {
|
|
|
571
774
|
document.body.appendChild(ns);
|
|
572
775
|
}
|
|
573
776
|
});
|
|
574
|
-
|
|
777
|
+
// Guarded: the router ships on every page, the signals runtime only on
|
|
778
|
+
// pages that have a client script. log() is debug-gated but its
|
|
779
|
+
// ARGUMENTS are evaluated at the call site regardless, so an unguarded
|
|
780
|
+
// read here threw on every runtime-less page \u2014 aborting the swap and
|
|
781
|
+
// falling back to a full document load (#1809).
|
|
782
|
+
log('[router] scripts done. _latestSetup:', !!(window.stx&&window.stx._latestSetup));
|
|
575
783
|
// THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
|
|
576
784
|
window.dispatchEvent(new Event('stx:load'));
|
|
577
785
|
}
|
|
@@ -590,6 +798,27 @@ else {
|
|
|
590
798
|
// Full document mode: parse with DOMParser and extract container content
|
|
591
799
|
var parser=new DOMParser();
|
|
592
800
|
var doc=parser.parseFromString(html,'text/html');
|
|
801
|
+
// Full documents carry the build id as a meta rather than a header, and
|
|
802
|
+
// this path also serves cached HTML, so check here too (#1772).
|
|
803
|
+
var docBuildMeta=doc.querySelector('meta[name="stx-build"]');
|
|
804
|
+
var docBuild=docBuildMeta?(docBuildMeta.getAttribute('content')||''):'';
|
|
805
|
+
if(isBuildSkew(docBuild)){reloadForSkew(url,docBuild);return Promise.resolve(false)}
|
|
806
|
+
// Same hand-off as the fragment path, for the same reason (#1809, #1839).
|
|
807
|
+
// A full document DOES carry the destination's runtime \u2014 and this path
|
|
808
|
+
// throws it away: prepareRoutedBodyScripts drops every script[src] and
|
|
809
|
+
// everything isSignalsRuntimeScript matches, on the assumption that the
|
|
810
|
+
// current page already has one. When it does not, nothing puts a runtime
|
|
811
|
+
// back, so the destination's client code dies on its first line with
|
|
812
|
+
// "defineStore is not defined" (defineStore lives on window.stx) and the
|
|
813
|
+
// page renders quietly inert \u2014 no error reaches the user.
|
|
814
|
+
//
|
|
815
|
+
// Before the swap and before _cleanupContainer, so we neither dispose the
|
|
816
|
+
// outgoing page's scopes nor half-replace the document and then reload.
|
|
817
|
+
if(!window.stx&&documentShipsRuntime(doc,html)){
|
|
818
|
+
log('[router] document needs the signals runtime and this page has none \u2014 full navigation to:',url);
|
|
819
|
+
location.href=url;
|
|
820
|
+
return Promise.resolve(false);
|
|
821
|
+
}
|
|
593
822
|
var newContent=doc.querySelector(containerSel)||doc.querySelector('[data-stx-content]')||doc.querySelector('main');
|
|
594
823
|
if(!newContent){location.href=url;return Promise.resolve(false)}
|
|
595
824
|
|
|
@@ -676,14 +905,33 @@ else {
|
|
|
676
905
|
// For same-layout: swap only the container (<main>)
|
|
677
906
|
var newBody=doc.querySelector('body');
|
|
678
907
|
var isLayoutChange=false;
|
|
908
|
+
var declaredLayout=pendingLayoutDecl;
|
|
909
|
+
pendingLayoutDecl=null;
|
|
679
910
|
if(newBody){
|
|
680
911
|
var newMeta=doc.querySelector('meta[name="stx-layout"]');
|
|
681
912
|
var curMeta=document.querySelector('meta[name="stx-layout"]');
|
|
682
913
|
var curLayout=curMeta?curMeta.getAttribute('content'):'';
|
|
683
|
-
|
|
914
|
+
// The server's answer wins over the document's metas. We are only here
|
|
915
|
+
// because checkLayoutChange already said the group changed, based on
|
|
916
|
+
// exactly these headers \u2014 re-deriving from metas and disagreeing meant
|
|
917
|
+
// paying for the full-document fetch and then discarding the reason we
|
|
918
|
+
// made it, so the destination's <main> was swapped into the previous
|
|
919
|
+
// page's chrome (#1833).
|
|
920
|
+
//
|
|
921
|
+
// They disagree more easily than they look. The bun-plugin serve path
|
|
922
|
+
// calls a layout-less page 'default' while the client's own fallback
|
|
923
|
+
// calls it 'app'; a <head> with attributes silently skips the meta
|
|
924
|
+
// injection entirely; and the i18n path rewrites every page's group to
|
|
925
|
+
// i18n:<locale>. Any one of those leaves the metas saying "unchanged"
|
|
926
|
+
// about a document the headers said had changed.
|
|
927
|
+
var newLayout=declaredLayout&&declaredLayout.layout
|
|
928
|
+
? declaredLayout.layout
|
|
929
|
+
: (newMeta?newMeta.getAttribute('content'):'');
|
|
684
930
|
var curGroup=getCurrentLayoutGroup();
|
|
685
|
-
var newGroup=
|
|
686
|
-
|
|
931
|
+
var newGroup=declaredLayout&&declaredLayout.group
|
|
932
|
+
? declaredLayout.group
|
|
933
|
+
: getDocLayoutGroup(doc,newLayout);
|
|
934
|
+
isLayoutChange=curGroup!==newGroup||!!(curLayout&&newLayout&&curLayout!==newLayout);
|
|
687
935
|
}
|
|
688
936
|
var incomingSetupName=newBody?(newBody.getAttribute('data-stx')||''):'';
|
|
689
937
|
// Scripts assigned through innerHTML are inert. Preserve executable
|
|
@@ -703,7 +951,7 @@ else {
|
|
|
703
951
|
var slot='document-'+(++routedBodyScriptId);
|
|
704
952
|
var setupName=generatedSetupName(text);
|
|
705
953
|
if(setupName)incomingSetupName=setupName;
|
|
706
|
-
routedBodyScripts.push({text:text,runAlways:true,slot:slot,setupName:setupName});
|
|
954
|
+
routedBodyScripts.push({text:text,runAlways:true,slot:slot,setupName:setupName,run:s.getAttribute('data-stx-run')||''});
|
|
707
955
|
s.textContent='';
|
|
708
956
|
s.setAttribute('type','application/stx-pending');
|
|
709
957
|
s.setAttribute('data-stx-route-script',slot);
|
|
@@ -810,7 +1058,7 @@ else {
|
|
|
810
1058
|
}
|
|
811
1059
|
|
|
812
1060
|
// Push history state (before active link updates so location.pathname is current)
|
|
813
|
-
if(pushState!==false)
|
|
1061
|
+
if(pushState!==false)writeHistory(pushState,url+(hash||''));
|
|
814
1062
|
|
|
815
1063
|
// Update active nav links
|
|
816
1064
|
updateNav(url);
|
|
@@ -831,6 +1079,34 @@ else {
|
|
|
831
1079
|
document.documentElement.lang=doc.documentElement.lang;
|
|
832
1080
|
}
|
|
833
1081
|
|
|
1082
|
+
// \u2500\u2500 Reconcile <html> attributes (stacksjs/stx#1798) \u2500\u2500
|
|
1083
|
+
// A layout that scopes its design tokens to the root element
|
|
1084
|
+
// (html.marketing { --bg: \u2026 }) needs that class to LEAVE when you
|
|
1085
|
+
// navigate to a layout that doesn't want it \u2014 otherwise both token sets
|
|
1086
|
+
// match at once and the second layout paints with the first one's
|
|
1087
|
+
// palette. Head stylesheets are additive across a swap, so the class is
|
|
1088
|
+
// the only thing disambiguating them.
|
|
1089
|
+
//
|
|
1090
|
+
// Only what stx wrote is touched, per the markers emitted by
|
|
1091
|
+
// document-shell.ts. Diffing the whole element against the incoming
|
|
1092
|
+
// document would strip the color-mode boot's dark class and
|
|
1093
|
+
// data-reduced-motion, which exist only on the live page.
|
|
1094
|
+
if(doc.documentElement){
|
|
1095
|
+
var curRoot=document.documentElement,incRoot=doc.documentElement;
|
|
1096
|
+
var tokens=function(el,attr){var v=el.getAttribute(attr);return v?v.split(/\\s+/).filter(Boolean):[]};
|
|
1097
|
+
var prevCls=tokens(curRoot,'data-stx-html-class'),nextCls=tokens(incRoot,'data-stx-html-class');
|
|
1098
|
+
prevCls.forEach(function(c){if(nextCls.indexOf(c)===-1)curRoot.classList.remove(c)});
|
|
1099
|
+
nextCls.forEach(function(c){curRoot.classList.add(c)});
|
|
1100
|
+
if(nextCls.length)curRoot.setAttribute('data-stx-html-class',nextCls.join(' '));
|
|
1101
|
+
else curRoot.removeAttribute('data-stx-html-class');
|
|
1102
|
+
|
|
1103
|
+
var prevNames=tokens(curRoot,'data-stx-html-attrs'),nextNames=tokens(incRoot,'data-stx-html-attrs');
|
|
1104
|
+
prevNames.forEach(function(n){if(nextNames.indexOf(n)===-1)curRoot.removeAttribute(n)});
|
|
1105
|
+
nextNames.forEach(function(n){var v=incRoot.getAttribute(n);if(v!==null)curRoot.setAttribute(n,v)});
|
|
1106
|
+
if(nextNames.length)curRoot.setAttribute('data-stx-html-attrs',nextNames.join(' '));
|
|
1107
|
+
else curRoot.removeAttribute('data-stx-html-attrs');
|
|
1108
|
+
}
|
|
1109
|
+
|
|
834
1110
|
window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
|
|
835
1111
|
|
|
836
1112
|
// Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
|
|
@@ -860,7 +1136,7 @@ else {
|
|
|
860
1136
|
// Skip the wrap for module scripts \u2014 they get their own scope from
|
|
861
1137
|
// ESM, and top-level 'import' is illegal inside a block, which
|
|
862
1138
|
// would throw SyntaxError before the script ever runs.
|
|
863
|
-
var alreadyScoped=
|
|
1139
|
+
var alreadyScoped=runsAlways(typeof entry==='string'?'':entry.run,text);
|
|
864
1140
|
ns.textContent=(hasImport||alreadyScoped)?text:'{'+text+'}';
|
|
865
1141
|
ns.setAttribute('data-stx-page','');
|
|
866
1142
|
var placeholder=entry.slot?document.querySelector('script[data-stx-route-script="'+entry.slot+'"]'):null;
|
|
@@ -964,7 +1240,8 @@ else {
|
|
|
964
1240
|
var pLayout=r.headers.get('X-STX-Layout')||'';
|
|
965
1241
|
var pGroup=r.headers.get('X-STX-Layout-Group')||'';
|
|
966
1242
|
var pTitle=r.headers.get('X-STX-Title')||'';
|
|
967
|
-
|
|
1243
|
+
var pRuntime=r.headers.get('X-STX-Runtime')||'';
|
|
1244
|
+
return r.text().then(function(html){return{html:isFrag?fragmentMarker(pRuntime)+html:html,layout:pLayout,layoutGroup:pGroup,title:pTitle}});
|
|
968
1245
|
}).then(function(result){
|
|
969
1246
|
if(o.cache)setCache(key,result.html,result.layout,result.layoutGroup,result.title);
|
|
970
1247
|
}).catch(function(){}).finally(function(){delete prefetching[key]});
|
|
@@ -1063,7 +1340,7 @@ else {
|
|
|
1063
1340
|
var key=cacheKey(url);
|
|
1064
1341
|
if(!cache[key]){
|
|
1065
1342
|
var wantsFragment=shouldUseFragmentResponse();
|
|
1066
|
-
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?
|
|
1343
|
+
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(){});
|
|
1067
1344
|
}
|
|
1068
1345
|
},
|
|
1069
1346
|
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},
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stx-router",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.2.
|
|
4
|
+
"version": "0.2.154",
|
|
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",
|