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