stx-router 0.2.48 → 0.2.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,719 +1,170 @@
1
1
  // @bun
2
- // src/file-router.ts
3
- import fs3 from "fs";
4
- import path4 from "path";
2
+ // src/client.ts
3
+ function getRouterScript() {
4
+ return `
5
+ ;(function(){
6
+ 'use strict';
7
+ if(window.__stxRouter)return;
5
8
 
6
- // src/codegen.ts
7
- import fs from "fs";
8
- import path from "path";
9
- function generateRouteTypes(routes, outputDir) {
10
- const lines = [
11
- "// Auto-generated by STX - do not edit",
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
- // src/matcher.ts
54
- import path2 from "path";
55
- function filePathToPattern(filePath, pagesDir) {
56
- const relativePath = path2.relative(pagesDir, filePath);
57
- let route = relativePath.replace(/\.(stx|md)$/, "");
58
- route = route.split(path2.sep).join("/");
59
- if (route === "index") {
60
- return "/";
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
- if (route.endsWith("/index")) {
63
- route = route.slice(0, -6);
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
- route = route.replace(/\[\[([^\]]+)\]\]/g, ":$1?");
66
- route = route.replace(/\[([^\]]+)\]/g, ":$1");
67
- route = route.replace(/:\.\.\.([^/]+)/g, ":$1*");
68
- return `/${route}`;
69
- }
70
- function patternToRegex(pattern) {
71
- const params = [];
72
- let regexStr = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
73
- regexStr = regexStr.replace(/\/:([^/]+)\?/g, (_, name) => {
74
- params.push(name);
75
- return "(?:/([^/]+))?";
76
- });
77
- regexStr = regexStr.replace(/:([^/]+)\*/g, (_, name) => {
78
- params.push(name);
79
- return "(.+)";
80
- });
81
- regexStr = regexStr.replace(/:([^/]+)/g, (_, name) => {
82
- params.push(name);
83
- return "([^/]+)";
84
- });
85
- regexStr = `^${regexStr}$`;
86
- return { regex: new RegExp(regexStr), params };
87
- }
88
- function matchRoute(pathname, routes) {
89
- const normalizedPath = pathname === "" ? "/" : pathname;
90
- for (const route of routes) {
91
- const match = normalizedPath.match(route.regex);
92
- if (match) {
93
- const params = {};
94
- route.params.forEach((name, index) => {
95
- params[name] = match[index + 1];
96
- });
97
- return { route, params };
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
- // src/nested-layouts.ts
104
- import fs2 from "fs";
105
- import path3 from "path";
106
- function resolveLayoutChain(routeFilePath, pagesDir) {
107
- const layouts = [];
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 (dir === pagesDir)
115
- break;
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
- // src/file-router.ts
122
- function scanDirectory(dir, extensions) {
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
- const entries = fs3.readdirSync(dir, { withFileTypes: true });
128
- for (const entry of entries) {
129
- const fullPath = path4.join(dir, entry.name);
130
- if (entry.isDirectory()) {
131
- if (entry.name.startsWith(".")) {
132
- continue;
133
- }
134
- files.push(...scanDirectory(fullPath, extensions));
135
- } else if (entry.isFile()) {
136
- const ext = path4.extname(entry.name);
137
- if (extensions.includes(ext)) {
138
- if (entry.name.startsWith("_") && entry.name !== "_layout.stx") {
139
- continue;
140
- }
141
- if (entry.name === "_layout.stx") {
142
- continue;
143
- }
144
- files.push(fullPath);
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
  }
159
+ return false;
147
160
  }
148
- return files;
149
- }
150
161
 
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;
162
+ function shouldUseFragmentResponse(){
163
+ return containerSel==='main'||containerSel==='[data-stx-content]';
713
164
  }
714
165
 
715
166
  function navigate(url,pushState,force){
716
- console.log('[router] navigate() called:',url,'isNavigating:',isNavigating);
167
+ log('[router] navigate() called:',url,'isNavigating:',isNavigating);
717
168
  if(isNavigating)return;
718
169
  var t=new URL(url,location.origin);
719
170
 
@@ -739,20 +190,20 @@ function getRouterScript() {
739
190
  startProgress();
740
191
 
741
192
  var targetHref=t.href;
742
- var targetPath=t.pathname;
193
+ var targetPath=cacheKey(url);
743
194
  var targetHash=t.hash;
744
195
 
745
196
  function done(){isNavigating=false;document.body.classList.remove(o.loadingClass);finishProgress()}
746
197
 
747
198
  if(o.cache&&cache[targetPath]&&!force){
748
- if(checkLayoutChange(layoutCache[targetPath],url)){
199
+ if(checkLayoutChange(layoutCache[targetPath],url,layoutGroupCache[targetPath])){
749
200
  // Layout changed \u2014 fetch full page and do full document swap
750
- console.log('[router] cache hit but layout changed \u2014 fetching full page');
201
+ log('[router] cache hit but layout changed \u2014 fetching full page');
751
202
  fetch(url,{headers:{'Accept':'text/html'}}).then(function(r){
752
203
  if(!r.ok)throw new Error(r.status);
753
204
  return r.text();
754
205
  }).then(function(html){
755
- console.log('[router] full page fetched from cache path, len:',html.length);
206
+ log('[router] full page fetched from cache path, len:',html.length);
756
207
  swap(html,targetPath,pushState,targetHash);
757
208
  }).catch(function(err){
758
209
  console.error('[router] full page fetch error:',err);
@@ -765,28 +216,32 @@ function getRouterScript() {
765
216
  }
766
217
  else {
767
218
  if(force&&cache[targetPath])delete cache[targetPath];
768
- // First fetch as fragment (SPA mode)
769
- fetch(url,{headers:{'X-STX-Router':'true','Accept':'text/html'}}).then(function(r){
219
+ // First fetch as fragment (SPA mode) when the configured container is
220
+ // the page content. Custom app-shell containers need full documents so
221
+ // the router does not inject a <main> fragment into the wrong element.
222
+ var wantsFragment=shouldUseFragmentResponse();
223
+ fetch(url,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
770
224
  if(!r.ok)throw new Error(r.status);
771
- var isFragment=r.headers.get('X-STX-Fragment')==='true';
225
+ var isFragment=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
772
226
  var newLayout=r.headers.get('X-STX-Layout')||'';
227
+ var newGroup=r.headers.get('X-STX-Layout-Group')||'';
773
228
  // Layout change? Fetch the FULL page (no X-STX-Router header) and do full document swap
774
- if(isFragment&&checkLayoutChange(newLayout,url)){
775
- console.log('[router] layout change \u2014 fetching full page for document swap');
229
+ if(isFragment&&checkLayoutChange(newLayout,url,newGroup)){
230
+ log('[router] layout change \u2014 fetching full page for document swap');
776
231
  return fetch(url,{headers:{'Accept':'text/html'}}).then(function(fullRes){
777
- console.log('[router] full page fetched:',fullRes.status,'ok:',fullRes.ok);
232
+ log('[router] full page fetched:',fullRes.status,'ok:',fullRes.ok);
778
233
  if(!fullRes.ok)throw new Error(fullRes.status);
779
234
  return fullRes.text().then(function(html){
780
- console.log('[router] full page html length:',html.length);
781
- return{html:html,isFragment:false,layout:newLayout};
235
+ log('[router] full page html length:',html.length);
236
+ return{html:html,isFragment:false,layout:newLayout,layoutGroup:newGroup};
782
237
  });
783
238
  });
784
239
  }
785
- return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout}});
240
+ return r.text().then(function(html){return{html:html,isFragment:isFragment,layout:newLayout,layoutGroup:newGroup}});
786
241
  }).then(function(result){
787
242
  if(!result)return;
788
243
  if(result.isFragment)result.html='<!--stx-fragment-->'+result.html;
789
- if(o.cache){cache[targetPath]=result.html;layoutCache[targetPath]=result.layout}
244
+ if(o.cache){cache[targetPath]=result.html;layoutCache[targetPath]=result.layout;layoutGroupCache[targetPath]=result.layoutGroup}
790
245
  swap(result.html,targetPath,pushState,targetHash);
791
246
  }).catch(function(err){
792
247
  console.error('[router] fetch error:',err);
@@ -799,8 +254,8 @@ else {
799
254
  var isFragment=html.indexOf('<!--stx-fragment-->')===0;
800
255
  if(isFragment)html=html.slice('<!--stx-fragment-->'.length);
801
256
  var currentContent=getContainer();
802
- console.log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&&currentContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
803
- if(!currentContent){console.log('[router] no container \u2014 falling back');location.href=url;return}
257
+ log('[router] swap: isFragment='+isFragment+' container='+!!currentContent+' tag='+(currentContent&&currentContent.tagName)+' selector='+containerSel+' htmlLen='+html.length);
258
+ if(!currentContent){log('[router] no container \u2014 falling back');location.href=url;return}
804
259
 
805
260
  // Fragment mode: server returned just the page content (no document wrapper)
806
261
  if(isFragment){
@@ -855,10 +310,10 @@ else {
855
310
  else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
856
311
  window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
857
312
  // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
858
- console.log('[router] frag scripts:', fragScripts.length);
313
+ log('[router] frag scripts:', fragScripts.length);
859
314
  document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
860
315
  fragScripts.forEach(function(code){
861
- console.log('[router] exec script len:', code.length, 'has __stx_setup:', code.indexOf('__stx_setup')>-1);
316
+ log('[router] exec script len:', code.length, 'has __stx_setup:', code.indexOf('__stx_setup')>-1);
862
317
  // Skip scripts that were already executed (layout-level partials
863
318
  // like theme.stx, stores.stx, nav.stx). Their top-level const/let
864
319
  // declarations would throw "Identifier has already been declared"
@@ -867,7 +322,7 @@ else {
867
322
  var h=hashScript(code);
868
323
  var isSetup=code.indexOf('__stx_setup_')!==-1;
869
324
  if(!isSetup&&executedScriptHashes[h]){
870
- console.log('[router] skipping already-executed script (hash dedup)');
325
+ log('[router] skipping already-executed script (hash dedup)');
871
326
  return;
872
327
  }
873
328
  executedScriptHashes[h]=1;
@@ -881,7 +336,7 @@ else {
881
336
  ns.setAttribute('data-stx-page','');
882
337
  document.body.appendChild(ns);
883
338
  });
884
- console.log('[router] scripts done. _latestSetup:', !!window.stx._latestSetup);
339
+ log('[router] scripts done. _latestSetup:', !!window.stx._latestSetup);
885
340
  // THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
886
341
  window.dispatchEvent(new Event('stx:load'));
887
342
  }
@@ -953,12 +408,14 @@ else {
953
408
  if(newBody){
954
409
  var newMeta=doc.querySelector('meta[name="stx-layout"]');
955
410
  var curMeta=document.querySelector('meta[name="stx-layout"]');
956
- var curGroup=getLayoutGroup(curMeta?curMeta.getAttribute('content'):'');
957
- var newGroup=getLayoutGroup(newMeta?newMeta.getAttribute('content'):'');
958
- isLayoutChange=curGroup!==newGroup;
411
+ var curLayout=curMeta?curMeta.getAttribute('content'):'';
412
+ var newLayout=newMeta?newMeta.getAttribute('content'):'';
413
+ var curGroup=getCurrentLayoutGroup();
414
+ var newGroup=getDocLayoutGroup(doc,newLayout);
415
+ isLayoutChange=curGroup!==newGroup||(curLayout&&newLayout&&curLayout!==newLayout);
959
416
  }
960
417
  if(isLayoutChange&&newBody){
961
- console.log('[router] full body swap for layout change');
418
+ log('[router] full body swap for layout change');
962
419
  // Replace entire body content \u2014 layout chrome and all
963
420
  var bodyHTML=newBody.innerHTML.replace(new RegExp('<scr'+'ipt\\\\b[^>]*>[\\\\s\\\\S]*?<\\\\/scr'+'ipt\\\\s*>','gi'),'');
964
421
  document.body.innerHTML=bodyHTML;
@@ -969,6 +426,10 @@ else {
969
426
  var freshMeta=doc.querySelector('meta[name="stx-layout"]');
970
427
  if(oldMeta&&freshMeta)oldMeta.setAttribute('content',freshMeta.getAttribute('content')||'');
971
428
  else if(freshMeta){var m=document.createElement('meta');m.name='stx-layout';m.content=freshMeta.getAttribute('content')||'';document.head.appendChild(m)}
429
+ var oldGroupMeta=document.querySelector('meta[name="stx-layout-group"]');
430
+ var freshGroupMeta=doc.querySelector('meta[name="stx-layout-group"]');
431
+ if(oldGroupMeta&&freshGroupMeta)oldGroupMeta.setAttribute('content',freshGroupMeta.getAttribute('content')||'');
432
+ else if(freshGroupMeta){var gm=document.createElement('meta');gm.name='stx-layout-group';gm.content=freshGroupMeta.getAttribute('content')||'';document.head.appendChild(gm)}
972
433
  // Update container reference for script execution below
973
434
  currentContent=document.querySelector(containerSel)||document.querySelector('main')||document.body;
974
435
  } else {
@@ -999,6 +460,9 @@ else {
999
460
  document.querySelectorAll('script[data-stx-page]').forEach(function(s){s.remove()});
1000
461
 
1001
462
  var scripts=[];
463
+ function addScript(text, runAlways){
464
+ scripts.push({text:text,runAlways:!!runAlways});
465
+ }
1002
466
  if(isLayoutChange){
1003
467
  // Layout change: collect ALL scripts from the new document body
1004
468
  // (layout scripts, component mounts, setup functions \u2014 everything)
@@ -1011,7 +475,7 @@ else {
1011
475
  // Skip the signals runtime IIFE \u2014 it's already loaded
1012
476
  if(text.indexOf("'use strict';var cloakStyle")!==-1)return;
1013
477
  if(text.indexOf('__stxRouter')!==-1)return;
1014
- scripts.push(text);
478
+ addScript(text,true);
1015
479
  });
1016
480
  }
1017
481
  // Also collect setup functions from <head>
@@ -1019,7 +483,7 @@ else {
1019
483
  var text=s.textContent||'';
1020
484
  if(s.hasAttribute('src'))return;
1021
485
  if(!text.trim())return;
1022
- if(text.indexOf('__stx_setup_')!==-1)scripts.push(text);
486
+ if(text.indexOf('__stx_setup_')!==-1)addScript(text,true);
1023
487
  });
1024
488
  } else {
1025
489
  // Same layout: collect scripts from the container
@@ -1027,282 +491,885 @@ else {
1027
491
  var text=s.textContent||'';
1028
492
  if(s.hasAttribute('src'))return;
1029
493
  if(!text.trim())return;
1030
- scripts.push(text);
494
+ addScript(text,true);
1031
495
  });
1032
- // Collect setup scripts from <head> AND <body> (outside container).
496
+ // Collect page scripts from <head> AND <body> (outside container).
1033
497
  // SSG builds place the setup function in <body> before <main>, not
1034
498
  // in <head>. Without this, SPA navigation on static sites never
1035
499
  // runs the setup and reactive data stays empty.
1036
500
  var seenSetups={};
1037
- scripts.forEach(function(t){if(t.indexOf('__stx_setup_')!==-1)seenSetups[t.substring(0,80)]=1});
501
+ scripts.forEach(function(entry){var t=entry.text;if(t.indexOf('__stx_setup_')!==-1)seenSetups[t.substring(0,80)]=1});
1038
502
  doc.querySelectorAll('script').forEach(function(s){
1039
503
  var text=s.textContent||'';
1040
504
  if(s.hasAttribute('src'))return;
1041
505
  if(!text.trim())return;
1042
- if(text.indexOf('__stx_setup_')===-1)return;
1043
506
  // Skip signals runtime and router (they contain __stx_setup references but aren't setup functions)
1044
507
  if(text.indexOf("'use strict';var cloakStyle")!==-1)return;
1045
508
  if(text.indexOf('__stxRouter')!==-1)return;
509
+ if(newContent.contains(s))return;
510
+ if(text.indexOf('__stx_setup_')===-1&&!s.hasAttribute('data-stx-scoped')&&!s.hasAttribute('data-stx-page'))return;
1046
511
  var key=text.substring(0,80);
1047
512
  if(seenSetups[key])return;
1048
513
  seenSetups[key]=1;
1049
- scripts.push(text);
514
+ addScript(text,true);
515
+ });
516
+ }
517
+
518
+ // Push history state (before active link updates so location.pathname is current)
519
+ if(pushState!==false)history.pushState({},'',url+(hash||''));
520
+
521
+ // Update active nav links
522
+ updateNav(url);
523
+ updateActiveLinks();
524
+
525
+ // Scroll
526
+ if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
527
+ else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
528
+
529
+ // Update title
530
+ var newTitle=doc.querySelector('title');
531
+ if(newTitle)document.title=newTitle.textContent;
532
+
533
+ // Update <html lang> from the destination doc so screen readers,
534
+ // CSS :lang() selectors, and any i18n picker that mirrors
535
+ // document.documentElement.lang stay accurate after SPA hops.
536
+ if(doc.documentElement&&doc.documentElement.lang){
537
+ document.documentElement.lang=doc.documentElement.lang;
538
+ }
539
+
540
+ window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
541
+
542
+ // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
543
+ function execScripts(){
544
+ scripts.forEach(function(entry){
545
+ var text=typeof entry==='string'?entry:entry.text;
546
+ var runAlways=typeof entry==='string'?false:entry.runAlways;
547
+ // Skip scripts that were already executed (layout-level partials
548
+ // like theme.stx). Their top-level const/function declarations
549
+ // would throw "Identifier has already been declared" on re-execution.
550
+ // Exception: setup functions (__stx_setup_) must always re-execute
551
+ // because each page has its own setup, and import statements
552
+ // need special handling.
553
+ var h=hashScript(text);
554
+ var isSetup=text.indexOf('__stx_setup_')!==-1;
555
+ var hasImport=text.indexOf('import ')!==-1;
556
+ if(!runAlways&&!isSetup&&executedScriptHashes[h])return;
557
+ executedScriptHashes[h]=1;
558
+ // Wrap scripts with import statements as modules (Bug 3 fix)
559
+ var ns=document.createElement('script');
560
+ if(hasImport){
561
+ ns.type='module';
562
+ }
563
+ // Wrap in block scope to prevent const/let collisions on re-navigation.
564
+ // Top-level const/let in <script> tags are global-scoped in browsers
565
+ // and throw "Identifier has already been declared" on re-execution.
566
+ var alreadyScoped=text.trimStart().charAt(0)==='('||text.trimStart().charAt(0)===';'||text.indexOf('window.stx.mount')>-1;
567
+ ns.textContent=alreadyScoped?text:'{'+text+'}';
568
+ ns.setAttribute('data-stx-page','');
569
+ document.body.appendChild(ns);
1050
570
  });
571
+ // THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
572
+ window.dispatchEvent(new Event('stx:load'));
573
+ }
574
+
575
+ if(extPromises.length>0){
576
+ Promise.all(extPromises).then(execScripts).catch(execScripts);
577
+ }
578
+ else {
579
+ execScripts();
580
+ }
581
+ }
582
+
583
+ if(o.viewTransitions&&document.startViewTransition){
584
+ document.startViewTransition(doSwap);
585
+ }
586
+ else {
587
+ // Fallback fade for browsers without View Transitions API
588
+ currentContent.style.transition='opacity 0.12s ease-out';
589
+ currentContent.style.opacity='0';
590
+ setTimeout(function(){
591
+ doSwap();
592
+ currentContent.style.opacity='1';
593
+ setTimeout(function(){currentContent.style.transition=''},150);
594
+ },120);
595
+ }
596
+ }
597
+
598
+ // \u2500\u2500 Link interception \u2500\u2500
599
+ function shouldIntercept(link){
600
+ if(!link)return false;
601
+ var href=link.getAttribute('href');
602
+ if(!href)return false;
603
+ if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return false;
604
+ if(link.target==='_blank')return false;
605
+ if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return false;
606
+ if(href===location.pathname)return false;
607
+ if(!getContainer())return false;
608
+ return true;
609
+ }
610
+
611
+ // SPA click handling. By default only [data-stx-link] elements get
612
+ // SPA navigation; regular <a href> does native full page reload. Set
613
+ // interceptAllLinks:true (window.__stxRouterConfig or build-time
614
+ // config) to intercept any same-origin anchor \u2014 used by static-site
615
+ // mode where every page is part of the same SPA shell.
616
+ document.addEventListener('click',function(e){
617
+ if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0)return;
618
+ if(!e.target||!e.target.closest)return;
619
+ var link=e.target.closest('[data-stx-link]');
620
+ if(!link&&o.interceptAllLinks){
621
+ var anchor=e.target.closest('a[href]');
622
+ if(anchor&&shouldIntercept(anchor))link=anchor;
623
+ }
624
+ if(!link){return}
625
+ var href=link.getAttribute('href');
626
+ log('[router] click intercepted:',href,'container:',!!getContainer(),'defaultPrevented:',e.defaultPrevented);
627
+ if(!href||href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')){log('[router] skipped:',href);return}
628
+ if(link.target==='_blank'||link.hasAttribute('download'))return;
629
+ e.preventDefault();
630
+ e.stopPropagation();
631
+ log('[router] navigating to:',href);
632
+ navigate(href);
633
+ },true);
634
+
635
+ // \u2500\u2500 Back/forward \u2500\u2500
636
+ window.addEventListener('popstate',function(){
637
+ navigate(location.pathname+location.search+location.hash,false);
638
+ });
639
+
640
+ // \u2500\u2500 Prefetch on hover \u2500\u2500
641
+ if(o.prefetch){
642
+ document.addEventListener('mouseover',function(e){
643
+ if(!e.target||!e.target.closest)return;
644
+ var link=e.target.closest('[data-stx-link]');
645
+ if(!link)return;
646
+ var href=link.getAttribute('href');
647
+ var key=cacheKey(href);
648
+ if(cache[key]||prefetching[key])return;
649
+ prefetching[key]=true;
650
+ var wantsFragment=shouldUseFragmentResponse();
651
+ fetch(href,{headers:wantsFragment?{'X-STX-Router':'true','Accept':'text/html'}:{'Accept':'text/html'}}).then(function(r){
652
+ var isFrag=wantsFragment&&r.headers.get('X-STX-Fragment')==='true';
653
+ var pLayout=r.headers.get('X-STX-Layout')||'';
654
+ var pGroup=r.headers.get('X-STX-Layout-Group')||'';
655
+ return r.text().then(function(html){return{html:isFrag?'<!--stx-fragment-->'+html:html,layout:pLayout,layoutGroup:pGroup}});
656
+ }).then(function(result){
657
+ if(o.cache){cache[key]=result.html;layoutCache[key]=result.layout;layoutGroupCache[key]=result.layoutGroup}
658
+ }).catch(function(){}).finally(function(){delete prefetching[key]});
659
+ },true);
660
+ }
661
+
662
+ // \u2500\u2500 Active link management \u2500\u2500
663
+ function updateNav(url){
664
+ document.querySelectorAll('nav a[href], #mobileNav a[href], [data-stx-nav] a[href]').forEach(function(a){
665
+ var href=a.getAttribute('href');
666
+ if(!href||href.startsWith('#')||href.startsWith('http'))return;
667
+ var isActive=(href===url)||(href==='/'&&url==='/');
668
+ if(a.hasAttribute('data-stx-link')){
669
+ var ac=a.getAttribute('data-stx-active-class')||'active';
670
+ 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)});
671
+ }
672
+ });
673
+ }
674
+
675
+ function updateActiveLinks(){
676
+ // Update active classes on <stx-link> elements (and legacy data-stx-link)
677
+ var links=document.querySelectorAll('[data-stx-link]');
678
+ var cur=location.pathname;
679
+ links.forEach(function(link){
680
+ var href=link.getAttribute('to')||link.getAttribute('href')||'';
681
+ var ac=link.getAttribute('active-class')||link.getAttribute('data-stx-active-class')||'active';
682
+ var eac=link.getAttribute('exact-active-class')||link.getAttribute('data-stx-exact-active-class')||'exact-active';
683
+ ac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
684
+ eac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
685
+ var isExact=cur===href;
686
+ var isActive=href!=='/'?cur.startsWith(href):cur==='/';
687
+ if(isExact)eac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
688
+ if(isActive)ac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
689
+ });
690
+ }
691
+
692
+ // \u2500\u2500 Progress bar DOM + style \u2500\u2500
693
+ // Injects a fixed-position element at the top of the viewport plus the
694
+ // minimal CSS for its transform/opacity transitions. Kept idempotent so
695
+ // repeated init() calls (e.g. after full-body swaps) don't duplicate.
696
+ // Also ships the default View Transitions fade+slide CSS when
697
+ // viewTransitions is enabled and the browser supports it. Apps can
698
+ // override by defining more specific ::view-transition-* rules later in
699
+ // the cascade, or opt out entirely with viewTransitions:false.
700
+ function injectStyles(){
701
+ if(!document.getElementById('stx-r-css')){
702
+ var s=document.createElement('style');s.id='stx-r-css';
703
+ // Strip characters that could escape our CSS block and inject new
704
+ // declarations: ; { } ( ) " ' \\ < > plus whitespace control chars.
705
+ // This keeps the value safe to concat into a stylesheet even if a
706
+ // caller wires the config from an untrusted source.
707
+ var sanitize=function(v){return String(v).replace(/[;{}()"'\\\\<>\\n\\r\\t]/g,'')};
708
+ var pc=sanitize(o.progressColor);
709
+ var ph=sanitize(o.progressHeight);
710
+ 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}';
711
+ if(o.viewTransitions&&'startViewTransition' in document){
712
+ var dur=(o.viewTransitionDuration||220)+'ms';
713
+ var ease=o.viewTransitionEasing||'cubic-bezier(0.16, 1, 0.3, 1)';
714
+ css+='::view-transition-old(root),::view-transition-new(root){animation-duration:'+dur+';animation-timing-function:'+ease+'}';
715
+ css+='::view-transition-old(root){animation-name:stx-r-fade-out}::view-transition-new(root){animation-name:stx-r-fade-in}';
716
+ css+='@keyframes stx-r-fade-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-4px)}}';
717
+ css+='@keyframes stx-r-fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}';
718
+ css+='@media (prefers-reduced-motion: reduce){::view-transition-old(root),::view-transition-new(root){animation-duration:0s;animation-name:none}}';
719
+ }
720
+ s.textContent=css;
721
+ document.head.appendChild(s);
722
+ }
723
+ if(o.progress&&!document.getElementById('stx-router-progress')){
724
+ var el=document.createElement('div');
725
+ el.id='stx-router-progress';
726
+ el.setAttribute('role','progressbar');
727
+ el.setAttribute('aria-hidden','true');
728
+ // Append to <html>, not <body>: when the router swaps the body
729
+ // (container:'body' for full-page static-site SPAs) the progress
730
+ // element gets wiped along with it. <html> stays put across
731
+ // swaps, so the bar survives multi-hop navigation.
732
+ document.documentElement.appendChild(el);
733
+ progEl=el;
734
+ } else if(o.progress){
735
+ progEl=document.getElementById('stx-router-progress');
736
+ }
737
+ }
738
+
739
+ function injectViewTransitionCSS(){
740
+ if(document.getElementById('stx-view-transitions'))return;
741
+ var s=document.createElement('style');s.id='stx-view-transitions';
742
+ 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}';
743
+ (document.head||document.documentElement).appendChild(s);
744
+ }
745
+
746
+ // \u2500\u2500 Public API \u2500\u2500
747
+ var router={
748
+ navigate:navigate,
749
+ navigateTo:navigate,
750
+ prefetch:function(url){
751
+ var key=cacheKey(url);
752
+ if(!cache[key]){
753
+ var wantsFragment=shouldUseFragmentResponse();
754
+ 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')||'';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(){});
1051
755
  }
756
+ },
757
+ 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]},
758
+ cache:cache,
759
+ swap:swap,
760
+ updateNav:updateNav
761
+ };
762
+
763
+ window.__stxRouter=router;
764
+ window.stxRouter=router;
765
+ if(window.stx)window.stx.router=router;
766
+
767
+ // \u2500\u2500 Initialize \u2500\u2500
768
+ function init(){
769
+ injectStyles();
770
+ injectViewTransitionCSS();
771
+ updateActiveLinks();
772
+ updateNav(location.pathname);
773
+ }
1052
774
 
1053
- // Push history state (before active link updates so location.pathname is current)
1054
- if(pushState!==false)history.pushState({},'',url+(hash||''));
775
+ if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
776
+ })();
777
+ `;
778
+ }
1055
779
 
1056
- // Update active nav links
1057
- updateNav(url);
1058
- updateActiveLinks();
780
+ // src/codegen.ts
781
+ import fs from "fs";
782
+ import path from "path";
783
+ function generateRouteTypes(routes, outputDir) {
784
+ const lines = [
785
+ "// Auto-generated by STX - do not edit",
786
+ 'declare module "stx/routes" {',
787
+ " interface RouteMap {"
788
+ ];
789
+ for (const route of routes) {
790
+ const paramEntries = [];
791
+ for (const p of route.params) {
792
+ const type = route.pattern.includes(`:${p}*`) ? "string[]" : "string";
793
+ paramEntries.push(`${p}: ${type}`);
794
+ }
795
+ const paramsStr = paramEntries.join("; ");
796
+ lines.push(` '${route.pattern}': { ${paramsStr} }`);
797
+ }
798
+ lines.push(" }", "}", "");
799
+ fs.mkdirSync(outputDir, { recursive: true });
800
+ fs.writeFileSync(path.join(outputDir, "route-types.d.ts"), lines.join(`
801
+ `));
802
+ }
803
+ function generateRouteManifest(routes, outputDir, pagesDir) {
804
+ const cwd = process.cwd();
805
+ const relativePages = path.relative(cwd, pagesDir);
806
+ const excludeDirs = ["components", "layouts", "partials"];
807
+ const pageRoutes = routes.filter((r) => !excludeDirs.some((dir) => r.pattern.startsWith(`/${dir}/`) || r.pattern === `/${dir}`));
808
+ const lines = [
809
+ "// Auto-generated by stx \u2014 do not edit",
810
+ `// Source: ${relativePages || "pages"}/`,
811
+ "",
812
+ "export const routes = ["
813
+ ];
814
+ for (const route of pageRoutes) {
815
+ const relFile = path.relative(cwd, route.filePath).replace(/\\/g, "/");
816
+ const params = JSON.stringify(route.params);
817
+ const layoutStr = route.layout ? `, layout: '${path.relative(cwd, route.layout).replace(/\\/g, "/")}'` : "";
818
+ lines.push(` { pattern: '${route.pattern}', filePath: '${relFile}', isDynamic: ${route.isDynamic}, params: ${params}${layoutStr} },`);
819
+ }
820
+ lines.push("] as const", "");
821
+ lines.push("export type RouteName = typeof routes[number]['pattern']", "");
822
+ fs.mkdirSync(outputDir, { recursive: true });
823
+ fs.writeFileSync(path.join(outputDir, "routes.ts"), lines.join(`
824
+ `));
825
+ }
1059
826
 
1060
- // Scroll
1061
- if(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'});
1062
- else if(hash){var el=document.querySelector(hash);if(el)el.scrollIntoView({behavior:'smooth'})}
827
+ // src/matcher.ts
828
+ import path2 from "path";
829
+ function filePathToPattern(filePath, pagesDir) {
830
+ const relativePath = path2.relative(pagesDir, filePath);
831
+ let route = relativePath.replace(/\.(stx|md)$/, "");
832
+ route = route.split(path2.sep).join("/");
833
+ if (route === "index") {
834
+ return "/";
835
+ }
836
+ if (route.endsWith("/index")) {
837
+ route = route.slice(0, -6);
838
+ }
839
+ route = route.replace(/\[\[([^\]]+)\]\]/g, ":$1?");
840
+ route = route.replace(/\[([^\]]+)\]/g, ":$1");
841
+ route = route.replace(/:\.\.\.([^/]+)/g, ":$1*");
842
+ return `/${route}`;
843
+ }
844
+ function patternToRegex(pattern) {
845
+ const params = [];
846
+ let regexStr = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
847
+ regexStr = regexStr.replace(/\/:([^/]+)\?/g, (_, name) => {
848
+ params.push(name);
849
+ return "(?:/([^/]+))?";
850
+ });
851
+ regexStr = regexStr.replace(/:([^/]+)\*/g, (_, name) => {
852
+ params.push(name);
853
+ return "(.+)";
854
+ });
855
+ regexStr = regexStr.replace(/:([^/]+)/g, (_, name) => {
856
+ params.push(name);
857
+ return "([^/]+)";
858
+ });
859
+ regexStr = `^${regexStr}$`;
860
+ return { regex: new RegExp(regexStr), params };
861
+ }
862
+ function matchRoute(pathname, routes) {
863
+ const normalizedPath = pathname === "" ? "/" : pathname;
864
+ for (const route of routes) {
865
+ const match = normalizedPath.match(route.regex);
866
+ if (match) {
867
+ const params = {};
868
+ route.params.forEach((name, index) => {
869
+ params[name] = match[index + 1];
870
+ });
871
+ return { route, params };
872
+ }
873
+ }
874
+ return null;
875
+ }
1063
876
 
1064
- // Update title
1065
- var newTitle=doc.querySelector('title');
1066
- if(newTitle)document.title=newTitle.textContent;
877
+ // src/nested-layouts.ts
878
+ import fs2 from "fs";
879
+ import path3 from "path";
880
+ function resolveLayoutChain(routeFilePath, pagesDir) {
881
+ const layouts = [];
882
+ let dir = path3.dirname(routeFilePath);
883
+ while (dir.startsWith(pagesDir) || dir === pagesDir) {
884
+ const layoutFile = path3.join(dir, "_layout.stx");
885
+ if (fs2.existsSync(layoutFile)) {
886
+ layouts.unshift(layoutFile);
887
+ }
888
+ if (dir === pagesDir)
889
+ break;
890
+ dir = path3.dirname(dir);
891
+ }
892
+ return layouts;
893
+ }
1067
894
 
1068
- // Update <html lang> from the destination doc so screen readers,
1069
- // CSS :lang() selectors, and any i18n picker that mirrors
1070
- // document.documentElement.lang stay accurate after SPA hops.
1071
- if(doc.documentElement&&doc.documentElement.lang){
1072
- document.documentElement.lang=doc.documentElement.lang;
895
+ // src/file-router.ts
896
+ import fs3 from "fs";
897
+ import path4 from "path";
898
+ function scanDirectory(dir, extensions) {
899
+ const files = [];
900
+ if (!fs3.existsSync(dir)) {
901
+ return files;
902
+ }
903
+ const entries = fs3.readdirSync(dir, { withFileTypes: true });
904
+ for (const entry of entries) {
905
+ const fullPath = path4.join(dir, entry.name);
906
+ if (entry.isDirectory()) {
907
+ if (entry.name.startsWith(".")) {
908
+ continue;
1073
909
  }
910
+ files.push(...scanDirectory(fullPath, extensions));
911
+ } else if (entry.isFile()) {
912
+ const ext = path4.extname(entry.name);
913
+ if (extensions.includes(ext)) {
914
+ if (entry.name.startsWith("_") && entry.name !== "_layout.stx") {
915
+ continue;
916
+ }
917
+ if (entry.name === "_layout.stx") {
918
+ continue;
919
+ }
920
+ files.push(fullPath);
921
+ }
922
+ }
923
+ }
924
+ return files;
925
+ }
1074
926
 
1075
- window.dispatchEvent(new CustomEvent('stx:navigate',{detail:{url:url}}));
1076
-
1077
- // Execute page scripts FIRST \u2014 they define setup functions and set _latestSetup
1078
- function execScripts(){
1079
- scripts.forEach(function(text){
1080
- // Skip scripts that were already executed (layout-level partials
1081
- // like theme.stx). Their top-level const/function declarations
1082
- // would throw "Identifier has already been declared" on re-execution.
1083
- // Exception: setup functions (__stx_setup_) must always re-execute
1084
- // because each page has its own setup, and import statements
1085
- // need special handling.
1086
- var h=hashScript(text);
1087
- var isSetup=text.indexOf('__stx_setup_')!==-1;
1088
- var hasImport=text.indexOf('import ')!==-1;
1089
- if(!isSetup&&executedScriptHashes[h])return;
1090
- executedScriptHashes[h]=1;
1091
- // Wrap scripts with import statements as modules (Bug 3 fix)
1092
- var ns=document.createElement('script');
1093
- if(hasImport){
1094
- ns.type='module';
1095
- }
1096
- // Wrap in block scope to prevent const/let collisions on re-navigation.
1097
- // Top-level const/let in <script> tags are global-scoped in browsers
1098
- // and throw "Identifier has already been declared" on re-execution.
1099
- var alreadyScoped=text.trimStart().charAt(0)==='('||text.trimStart().charAt(0)===';'||text.indexOf('window.stx.mount')>-1;
1100
- ns.textContent=alreadyScoped?text:'{'+text+'}';
1101
- ns.setAttribute('data-stx-page','');
1102
- document.body.appendChild(ns);
927
+ class Router {
928
+ routes;
929
+ pagesDir;
930
+ pagesDirs;
931
+ config;
932
+ constructor(baseDir, config = {}) {
933
+ this.config = config;
934
+ const rawDirs = config.pagesDirs && config.pagesDirs.length > 0 ? config.pagesDirs : [config.pagesDir || "pages"];
935
+ this.pagesDirs = rawDirs.map((d) => path4.resolve(baseDir, d));
936
+ this.pagesDir = this.pagesDirs[0];
937
+ const extensions = config.extensions || [".stx"];
938
+ const seenPatterns = new Set;
939
+ this.routes = [];
940
+ for (const dir of this.pagesDirs) {
941
+ const files = scanDirectory(dir, extensions);
942
+ for (const filePath of files) {
943
+ const pattern = filePathToPattern(filePath, dir);
944
+ if (seenPatterns.has(pattern))
945
+ continue;
946
+ seenPatterns.add(pattern);
947
+ const { regex, params } = patternToRegex(pattern);
948
+ const layouts = config.layouts !== false ? resolveLayoutChain(filePath, dir) : [];
949
+ this.routes.push({
950
+ pattern,
951
+ regex,
952
+ params,
953
+ filePath,
954
+ isDynamic: params.length > 0,
955
+ layout: layouts.length > 0 ? layouts[layouts.length - 1] : undefined
1103
956
  });
1104
- // THEN fire stx:load \u2014 now _latestSetup is set and processElement has the right scope
1105
- window.dispatchEvent(new Event('stx:load'));
1106
957
  }
1107
-
1108
- if(extPromises.length>0){
1109
- Promise.all(extPromises).then(execScripts).catch(execScripts);
958
+ }
959
+ this.routes.sort((a, b) => {
960
+ if (!a.isDynamic && b.isDynamic)
961
+ return -1;
962
+ if (a.isDynamic && !b.isDynamic)
963
+ return 1;
964
+ const aSegments = a.pattern.split("/").length;
965
+ const bSegments = b.pattern.split("/").length;
966
+ if (aSegments !== bSegments)
967
+ return bSegments - aSegments;
968
+ return a.pattern.localeCompare(b.pattern);
969
+ });
970
+ const stxDir = path4.join(baseDir, ".stx");
971
+ generateRouteTypes(this.routes, stxDir);
972
+ generateRouteManifest(this.routes, stxDir, this.pagesDir);
973
+ }
974
+ match(pathname) {
975
+ const normalizedPath = pathname === "" ? "/" : pathname;
976
+ for (const route of this.routes) {
977
+ const m = normalizedPath.match(route.regex);
978
+ if (m) {
979
+ const params = {};
980
+ route.params.forEach((name, index) => {
981
+ params[name] = m[index + 1];
982
+ });
983
+ return { route, params };
1110
984
  }
1111
- else {
1112
- execScripts();
985
+ }
986
+ return null;
987
+ }
988
+ resolve(name, params) {
989
+ const route = this.routes.find((r) => r.pattern === name || r.filePath.includes(name));
990
+ if (!route)
991
+ return "#not-found";
992
+ let resolved = route.pattern;
993
+ if (params) {
994
+ for (const [key, value] of Object.entries(params)) {
995
+ resolved = resolved.replace(`:${key}`, encodeURIComponent(value));
1113
996
  }
1114
997
  }
998
+ return resolved;
999
+ }
1000
+ addRoute(route) {
1001
+ this.routes.push(route);
1002
+ this.routes.sort((a, b) => {
1003
+ if (!a.isDynamic && b.isDynamic)
1004
+ return -1;
1005
+ if (a.isDynamic && !b.isDynamic)
1006
+ return 1;
1007
+ return a.pattern.localeCompare(b.pattern);
1008
+ });
1009
+ }
1010
+ getLayout(route) {
1011
+ return route.layout ?? null;
1012
+ }
1013
+ }
1014
+ function createRouter(baseDir, config) {
1015
+ return new Router(baseDir, config);
1016
+ }
1017
+ function findErrorPage(pagesDir, statusCode) {
1018
+ const candidates = [`${statusCode}.stx`, "error.stx"];
1019
+ for (const candidate of candidates) {
1020
+ const filePath = path4.join(pagesDir, candidate);
1021
+ if (fs3.existsSync(filePath))
1022
+ return filePath;
1023
+ }
1024
+ return null;
1025
+ }
1026
+ function formatRoutes(routes, baseDir) {
1027
+ return routes.map((route) => {
1028
+ const relativePath = path4.relative(baseDir, route.filePath);
1029
+ const dynamicLabel = route.isDynamic ? " (dynamic)" : "";
1030
+ return `${route.pattern} \u2192 ${relativePath}${dynamicLabel}`;
1031
+ });
1032
+ }
1115
1033
 
1116
- if(o.viewTransitions&&document.startViewTransition){
1117
- document.startViewTransition(doSwap);
1034
+ // src/named-routes.ts
1035
+ var routes = {};
1036
+ var appUrl = "";
1037
+ function setAppUrl(url) {
1038
+ appUrl = url.replace(/\/$/, "");
1039
+ }
1040
+ function defineRoute(name, routePath, params = {}) {
1041
+ routes[name] = { path: routePath, name, params };
1042
+ }
1043
+ function defineRoutes(routeDefinitions) {
1044
+ for (const [name, definition] of Object.entries(routeDefinitions)) {
1045
+ if (typeof definition === "string") {
1046
+ routes[name] = { path: definition, name };
1047
+ } else {
1048
+ routes[name] = { ...definition, name };
1118
1049
  }
1119
- else {
1120
- // Fallback fade for browsers without View Transitions API
1121
- currentContent.style.transition='opacity 0.12s ease-out';
1122
- currentContent.style.opacity='0';
1123
- setTimeout(function(){
1124
- doSwap();
1125
- currentContent.style.opacity='1';
1126
- setTimeout(function(){currentContent.style.transition=''},150);
1127
- },120);
1050
+ }
1051
+ }
1052
+ function route(name, params = {}, absolute = false) {
1053
+ const r = routes[name];
1054
+ if (!r) {
1055
+ console.warn(`Route [${name}] not defined.`);
1056
+ return "#undefined-route";
1057
+ }
1058
+ const mergedParams = { ...r.params, ...params };
1059
+ let routePath = r.path;
1060
+ routePath = routePath.replace(/:(\w+)/g, (_, paramName) => {
1061
+ const value = mergedParams[paramName];
1062
+ if (value === undefined) {
1063
+ console.warn(`Missing parameter [${paramName}] for route [${name}].`);
1064
+ return `:${paramName}`;
1128
1065
  }
1066
+ delete mergedParams[paramName];
1067
+ return encodeURIComponent(String(value));
1068
+ });
1069
+ const remainingParams = Object.entries(mergedParams);
1070
+ if (remainingParams.length > 0) {
1071
+ const queryString = remainingParams.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`).join("&");
1072
+ routePath += (routePath.includes("?") ? "&" : "?") + queryString;
1129
1073
  }
1130
-
1131
- // \u2500\u2500 Link interception \u2500\u2500
1132
- function shouldIntercept(link){
1133
- if(!link)return false;
1134
- var href=link.getAttribute('href');
1135
- if(!href)return false;
1136
- if(href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')||href.startsWith('javascript:'))return false;
1137
- if(link.target==='_blank')return false;
1138
- if(link.hasAttribute('data-stx-no-router')||link.hasAttribute('data-no-router')||link.hasAttribute('download'))return false;
1139
- if(href===location.pathname)return false;
1140
- if(!getContainer())return false;
1141
- return true;
1074
+ return absolute && appUrl ? `${appUrl}${routePath}` : routePath;
1075
+ }
1076
+ function resetRoutes() {
1077
+ for (const key of Object.keys(routes)) {
1078
+ delete routes[key];
1142
1079
  }
1080
+ appUrl = "";
1081
+ }
1143
1082
 
1144
- // SPA click handling. By default only [data-stx-link] elements get
1145
- // SPA navigation; regular <a href> does native full page reload. Set
1146
- // interceptAllLinks:true (window.__stxRouterConfig or build-time
1147
- // config) to intercept any same-origin anchor \u2014 used by static-site
1148
- // mode where every page is part of the same SPA shell.
1149
- document.addEventListener('click',function(e){
1150
- if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button!==0)return;
1151
- if(!e.target||!e.target.closest)return;
1152
- var link=e.target.closest('[data-stx-link]');
1153
- if(!link&&o.interceptAllLinks){
1154
- var anchor=e.target.closest('a[href]');
1155
- if(anchor&&shouldIntercept(anchor))link=anchor;
1083
+ // src/middleware.ts
1084
+ import fs4 from "fs";
1085
+ import path5 from "path";
1086
+ function navigateTo(navPath, options = {}) {
1087
+ return {
1088
+ type: "redirect",
1089
+ path: navPath,
1090
+ options: {
1091
+ replace: options.replace ?? false,
1092
+ external: options.external ?? false,
1093
+ redirectCode: options.redirectCode ?? 302
1094
+ }
1095
+ };
1096
+ }
1097
+ function abortNavigation(error) {
1098
+ const normalizedError = typeof error === "string" ? { statusCode: 500, message: error } : error;
1099
+ return {
1100
+ type: "abort",
1101
+ error: normalizedError
1102
+ };
1103
+ }
1104
+ function defineMiddleware(handler, options = {}) {
1105
+ return {
1106
+ handler,
1107
+ mode: options.mode ?? "universal"
1108
+ };
1109
+ }
1110
+ function parseCookies(cookieHeader) {
1111
+ const cookies = {};
1112
+ if (!cookieHeader)
1113
+ return cookies;
1114
+ const pairs = cookieHeader.split(";");
1115
+ for (const pair of pairs) {
1116
+ const [name, ...rest] = pair.split("=");
1117
+ if (name) {
1118
+ const key = name.trim();
1119
+ const value = rest.join("=").trim();
1120
+ if (key) {
1121
+ cookies[key] = decodeURIComponent(value);
1122
+ }
1123
+ }
1124
+ }
1125
+ return cookies;
1126
+ }
1127
+ function serializeCookie(name, value, options = {}) {
1128
+ let cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}`;
1129
+ if (options.expires) {
1130
+ const expires = typeof options.expires === "number" ? new Date(Date.now() + options.expires * 1000) : options.expires;
1131
+ cookie += `; Expires=${expires.toUTCString()}`;
1132
+ }
1133
+ if (options.path)
1134
+ cookie += `; Path=${options.path}`;
1135
+ if (options.domain)
1136
+ cookie += `; Domain=${options.domain}`;
1137
+ if (options.secure)
1138
+ cookie += "; Secure";
1139
+ if (options.httpOnly)
1140
+ cookie += "; HttpOnly";
1141
+ if (options.sameSite)
1142
+ cookie += `; SameSite=${options.sameSite}`;
1143
+ return cookie;
1144
+ }
1145
+ function createServerCookieManager(request, responseHeaders) {
1146
+ const cookieHeader = request.headers.get("cookie") || "";
1147
+ const cookies = parseCookies(cookieHeader);
1148
+ return {
1149
+ get(name) {
1150
+ return cookies[name];
1151
+ },
1152
+ set(name, value, options = {}) {
1153
+ cookies[name] = value;
1154
+ responseHeaders.append("Set-Cookie", serializeCookie(name, value, options));
1155
+ },
1156
+ delete(name) {
1157
+ delete cookies[name];
1158
+ responseHeaders.append("Set-Cookie", serializeCookie(name, "", { expires: new Date(0), path: "/" }));
1159
+ },
1160
+ getAll() {
1161
+ return { ...cookies };
1162
+ }
1163
+ };
1164
+ }
1165
+ function createClientCookieManager() {
1166
+ return {
1167
+ get(name) {
1168
+ if (typeof document === "undefined")
1169
+ return;
1170
+ const cookies = parseCookies(document.cookie);
1171
+ return cookies[name];
1172
+ },
1173
+ set(name, value, options = {}) {
1174
+ if (typeof document === "undefined")
1175
+ return;
1176
+ document.cookie = serializeCookie(name, value, options);
1177
+ },
1178
+ delete(name) {
1179
+ if (typeof document === "undefined")
1180
+ return;
1181
+ document.cookie = serializeCookie(name, "", { expires: new Date(0), path: "/" });
1182
+ },
1183
+ getAll() {
1184
+ if (typeof document === "undefined")
1185
+ return {};
1186
+ return parseCookies(document.cookie);
1187
+ }
1188
+ };
1189
+ }
1190
+ function createStorageManager() {
1191
+ const available = typeof window !== "undefined" && typeof window.localStorage !== "undefined";
1192
+ return {
1193
+ get(key) {
1194
+ if (!available)
1195
+ return null;
1196
+ try {
1197
+ return window.localStorage.getItem(key);
1198
+ } catch {
1199
+ return null;
1200
+ }
1201
+ },
1202
+ set(key, value) {
1203
+ if (!available)
1204
+ return;
1205
+ try {
1206
+ window.localStorage.setItem(key, value);
1207
+ } catch {}
1208
+ },
1209
+ remove(key) {
1210
+ if (!available)
1211
+ return;
1212
+ try {
1213
+ window.localStorage.removeItem(key);
1214
+ } catch {}
1215
+ },
1216
+ isAvailable() {
1217
+ return available;
1156
1218
  }
1157
- if(!link){return}
1158
- var href=link.getAttribute('href');
1159
- console.log('[router] click intercepted:',href,'container:',!!getContainer(),'defaultPrevented:',e.defaultPrevented);
1160
- if(!href||href.startsWith('http')||href.startsWith('#')||href.startsWith('mailto:')||href.startsWith('tel:')){console.log('[router] skipped:',href);return}
1161
- if(link.target==='_blank'||link.hasAttribute('download'))return;
1162
- e.preventDefault();
1163
- e.stopPropagation();
1164
- console.log('[router] navigating to:',href);
1165
- navigate(href);
1166
- },true);
1167
-
1168
- // \u2500\u2500 Back/forward \u2500\u2500
1169
- window.addEventListener('popstate',function(){
1170
- navigate(location.pathname+location.search+location.hash,false);
1171
- });
1172
-
1173
- // \u2500\u2500 Prefetch on hover \u2500\u2500
1174
- if(o.prefetch){
1175
- document.addEventListener('mouseover',function(e){
1176
- if(!e.target||!e.target.closest)return;
1177
- var link=e.target.closest('[data-stx-link]');
1178
- if(!link)return;
1179
- var href=link.getAttribute('href');
1180
- if(cache[href]||prefetching[href])return;
1181
- prefetching[href]=true;
1182
- fetch(href,{headers:{'X-STX-Router':'true','Accept':'text/html'}}).then(function(r){
1183
- var isFrag=r.headers.get('X-STX-Fragment')==='true';
1184
- var pLayout=r.headers.get('X-STX-Layout')||'';
1185
- return r.text().then(function(html){return{html:isFrag?'<!--stx-fragment-->'+html:html,layout:pLayout}});
1186
- }).then(function(result){
1187
- if(o.cache){cache[href]=result.html;layoutCache[href]=result.layout}
1188
- }).catch(function(){}).finally(function(){delete prefetching[href]});
1189
- },true);
1219
+ };
1220
+ }
1221
+ var middlewareRegistry = new Map;
1222
+ function registerMiddleware(name, middleware) {
1223
+ middlewareRegistry.set(name, middleware);
1224
+ }
1225
+ function getMiddleware(name) {
1226
+ return middlewareRegistry.get(name);
1227
+ }
1228
+ function hasMiddleware(name) {
1229
+ return middlewareRegistry.has(name);
1230
+ }
1231
+ function clearMiddleware() {
1232
+ middlewareRegistry.clear();
1233
+ }
1234
+ function getMiddlewareNames() {
1235
+ return Array.from(middlewareRegistry.keys());
1236
+ }
1237
+ async function loadMiddlewareFromDirectory(baseDir, middlewareDir = "middleware") {
1238
+ const fullPath = path5.resolve(baseDir, middlewareDir);
1239
+ if (!fs4.existsSync(fullPath)) {
1240
+ return;
1190
1241
  }
1191
-
1192
- // \u2500\u2500 Active link management \u2500\u2500
1193
- function updateNav(url){
1194
- document.querySelectorAll('nav a[href], #mobileNav a[href], [data-stx-nav] a[href]').forEach(function(a){
1195
- var href=a.getAttribute('href');
1196
- if(!href||href.startsWith('#')||href.startsWith('http'))return;
1197
- var isActive=(href===url)||(href==='/'&&url==='/');
1198
- if(a.hasAttribute('data-stx-link')){
1199
- var ac=a.getAttribute('data-stx-active-class')||'active';
1200
- 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)});
1242
+ const files = fs4.readdirSync(fullPath);
1243
+ for (const file of files) {
1244
+ if (!file.endsWith(".ts") && !file.endsWith(".js"))
1245
+ continue;
1246
+ if (file.includes(".test.") || file.includes(".spec."))
1247
+ continue;
1248
+ const name = file.replace(/\.(ts|js)$/, "");
1249
+ const filePath = path5.join(fullPath, file);
1250
+ try {
1251
+ const mod = await import(filePath);
1252
+ const mw = mod.default;
1253
+ if (mw && typeof mw.handler === "function") {
1254
+ registerMiddleware(name, mw);
1255
+ } else if (typeof mw === "function") {
1256
+ registerMiddleware(name, { handler: mw, mode: "universal" });
1201
1257
  }
1202
- });
1258
+ } catch (error) {
1259
+ console.warn(`Failed to load middleware '${name}' from ${filePath}:`, error);
1260
+ }
1203
1261
  }
1204
-
1205
- function updateActiveLinks(){
1206
- // Update active classes on <stx-link> elements (and legacy data-stx-link)
1207
- var links=document.querySelectorAll('[data-stx-link]');
1208
- var cur=location.pathname;
1209
- links.forEach(function(link){
1210
- var href=link.getAttribute('to')||link.getAttribute('href')||'';
1211
- var ac=link.getAttribute('active-class')||link.getAttribute('data-stx-active-class')||'active';
1212
- var eac=link.getAttribute('exact-active-class')||link.getAttribute('data-stx-exact-active-class')||'exact-active';
1213
- ac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
1214
- eac.split(' ').forEach(function(cls){if(cls)link.classList.remove(cls)});
1215
- var isExact=cur===href;
1216
- var isActive=href!=='/'?cur.startsWith(href):cur==='/';
1217
- if(isExact)eac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
1218
- if(isActive)ac.split(' ').forEach(function(cls){if(cls)link.classList.add(cls)});
1219
- });
1262
+ }
1263
+ function parseQuery(search) {
1264
+ const query = {};
1265
+ if (!search)
1266
+ return query;
1267
+ const params = new URLSearchParams(search);
1268
+ for (const [key, value] of params) {
1269
+ query[key] = value;
1220
1270
  }
1221
-
1222
- // \u2500\u2500 Progress bar DOM + style \u2500\u2500
1223
- // Injects a fixed-position element at the top of the viewport plus the
1224
- // minimal CSS for its transform/opacity transitions. Kept idempotent so
1225
- // repeated init() calls (e.g. after full-body swaps) don't duplicate.
1226
- // Also ships the default View Transitions fade+slide CSS when
1227
- // viewTransitions is enabled and the browser supports it. Apps can
1228
- // override by defining more specific ::view-transition-* rules later in
1229
- // the cascade, or opt out entirely with viewTransitions:false.
1230
- function injectStyles(){
1231
- if(!document.getElementById('stx-r-css')){
1232
- var s=document.createElement('style');s.id='stx-r-css';
1233
- // Strip characters that could escape our CSS block and inject new
1234
- // declarations: ; { } ( ) " ' \\ < > plus whitespace control chars.
1235
- // This keeps the value safe to concat into a stylesheet even if a
1236
- // caller wires the config from an untrusted source.
1237
- var sanitize=function(v){return String(v).replace(/[;{}()"'\\\\<>\\n\\r\\t]/g,'')};
1238
- var pc=sanitize(o.progressColor);
1239
- var ph=sanitize(o.progressHeight);
1240
- 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}';
1241
- if(o.viewTransitions&&'startViewTransition' in document){
1242
- var dur=(o.viewTransitionDuration||220)+'ms';
1243
- var ease=o.viewTransitionEasing||'cubic-bezier(0.16, 1, 0.3, 1)';
1244
- css+='::view-transition-old(root),::view-transition-new(root){animation-duration:'+dur+';animation-timing-function:'+ease+'}';
1245
- css+='::view-transition-old(root){animation-name:stx-r-fade-out}::view-transition-new(root){animation-name:stx-r-fade-in}';
1246
- css+='@keyframes stx-r-fade-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-4px)}}';
1247
- css+='@keyframes stx-r-fade-in{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}';
1248
- css+='@media (prefers-reduced-motion: reduce){::view-transition-old(root),::view-transition-new(root){animation-duration:0s;animation-name:none}}';
1249
- }
1250
- s.textContent=css;
1251
- document.head.appendChild(s);
1271
+ return query;
1272
+ }
1273
+ function createRouteLocation(pathname, params, meta, search = "") {
1274
+ return {
1275
+ path: pathname,
1276
+ params,
1277
+ query: parseQuery(search),
1278
+ fullPath: search ? `${pathname}?${search}` : pathname,
1279
+ meta
1280
+ };
1281
+ }
1282
+ function createMiddlewareContext(to, from, request) {
1283
+ const isServer = typeof window === "undefined";
1284
+ const isClient = !isServer;
1285
+ const responseHeaders = new Headers;
1286
+ return {
1287
+ to,
1288
+ from,
1289
+ isClient,
1290
+ isServer,
1291
+ cookies: isServer && request ? createServerCookieManager(request, responseHeaders) : createClientCookieManager(),
1292
+ storage: createStorageManager(),
1293
+ state: {},
1294
+ request,
1295
+ responseHeaders
1296
+ };
1297
+ }
1298
+ async function runMiddleware(middlewareNames, context) {
1299
+ const names = Array.isArray(middlewareNames) ? middlewareNames : [middlewareNames];
1300
+ const isServer = context.isServer;
1301
+ for (const name of names) {
1302
+ const middleware = getMiddleware(name);
1303
+ if (!middleware) {
1304
+ console.warn(`Middleware '${name}' not found`);
1305
+ continue;
1252
1306
  }
1253
- if(o.progress&&!document.getElementById('stx-router-progress')){
1254
- var el=document.createElement('div');
1255
- el.id='stx-router-progress';
1256
- el.setAttribute('role','progressbar');
1257
- el.setAttribute('aria-hidden','true');
1258
- // Append to <html>, not <body>: when the router swaps the body
1259
- // (container:'body' for full-page static-site SPAs) the progress
1260
- // element gets wiped along with it. <html> stays put across
1261
- // swaps, so the bar survives multi-hop navigation.
1262
- document.documentElement.appendChild(el);
1263
- progEl=el;
1264
- } else if(o.progress){
1265
- progEl=document.getElementById('stx-router-progress');
1307
+ if (middleware.mode === "server" && !isServer)
1308
+ continue;
1309
+ if (middleware.mode === "client" && isServer)
1310
+ continue;
1311
+ try {
1312
+ const result = await middleware.handler(context);
1313
+ if (result) {
1314
+ if (result.type === "redirect") {
1315
+ return {
1316
+ passed: false,
1317
+ redirect: result,
1318
+ state: context.state,
1319
+ responseHeaders: context.responseHeaders
1320
+ };
1321
+ }
1322
+ if (result.type === "abort") {
1323
+ return {
1324
+ passed: false,
1325
+ abort: result,
1326
+ state: context.state,
1327
+ responseHeaders: context.responseHeaders
1328
+ };
1329
+ }
1330
+ }
1331
+ } catch (error) {
1332
+ console.error(`Error in middleware '${name}':`, error);
1333
+ return {
1334
+ passed: false,
1335
+ abort: {
1336
+ type: "abort",
1337
+ error: {
1338
+ statusCode: 500,
1339
+ message: error instanceof Error ? error.message : "Middleware error"
1340
+ }
1341
+ },
1342
+ state: context.state,
1343
+ responseHeaders: context.responseHeaders
1344
+ };
1266
1345
  }
1267
1346
  }
1268
-
1269
- function injectViewTransitionCSS(){
1270
- if(document.getElementById('stx-view-transitions'))return;
1271
- var s=document.createElement('style');s.id='stx-view-transitions';
1272
- 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}';
1273
- (document.head||document.documentElement).appendChild(s);
1274
- }
1275
-
1276
- // \u2500\u2500 Public API \u2500\u2500
1277
- var router={
1278
- navigate:navigate,
1279
- navigateTo:navigate,
1280
- prefetch:function(url){
1281
- if(!cache[url]){
1282
- fetch(url,{headers:{'X-STX-Router':'true'}}).then(function(r){var isFrag=r.headers.get('X-STX-Fragment')==='true';return r.text().then(function(html){return isFrag?'<!--stx-fragment-->'+html:html})}).then(function(html){cache[url]=html}).catch(function(){});
1283
- }
1284
- },
1285
- clearCache:function(){for(var k in cache)delete cache[k]},
1286
- cache:cache,
1287
- swap:swap,
1288
- updateNav:updateNav
1347
+ return {
1348
+ passed: true,
1349
+ state: context.state,
1350
+ responseHeaders: context.responseHeaders
1289
1351
  };
1352
+ }
1290
1353
 
1291
- window.__stxRouter=router;
1292
- window.stxRouter=router;
1293
- if(window.stx)window.stx.router=router;
1294
-
1295
- // \u2500\u2500 Initialize \u2500\u2500
1296
- function init(){
1297
- injectStyles();
1298
- injectViewTransitionCSS();
1299
- updateActiveLinks();
1300
- updateNav(location.pathname);
1301
- }
1302
-
1303
- if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',init);else init();
1304
- })();
1305
- `;
1354
+ // src/layout-metadata.ts
1355
+ function deriveLayoutGroup(layoutPath) {
1356
+ const normalized = layoutPath.replace(/\\/g, "/");
1357
+ const segments = normalized.split("/").filter(Boolean);
1358
+ const layoutsIndex = segments.lastIndexOf("layouts");
1359
+ const layoutSegment = layoutsIndex >= 0 ? segments[layoutsIndex + 1] : segments.at(-1);
1360
+ if (!layoutSegment)
1361
+ return "app";
1362
+ return layoutSegment.replace(/\.stx$/i, "") || "app";
1363
+ }
1364
+ function extractLayoutMetadata(html) {
1365
+ const layoutMeta = html.match(/<meta\b[^>]*name=["']stx-layout["'][^>]*content=["']([^"']+)["'][^>]*>/i);
1366
+ const groupMeta = html.match(/<meta\b[^>]*name=["']stx-layout-group["'][^>]*content=["']([^"']+)["'][^>]*>/i);
1367
+ const layoutComment = html.match(/<!--\s*stx-layout:\s*([^ ]+)\s*-->/i);
1368
+ const layout = layoutMeta?.[1] || layoutComment?.[1] || "";
1369
+ return {
1370
+ layout,
1371
+ group: groupMeta?.[1] || deriveLayoutGroup(layout)
1372
+ };
1306
1373
  }
1307
1374
  export {
1308
1375
  setAppUrl,
@@ -1324,6 +1391,8 @@ export {
1324
1391
  formatRoutes,
1325
1392
  findErrorPage,
1326
1393
  filePathToPattern,
1394
+ extractLayoutMetadata,
1395
+ deriveLayoutGroup,
1327
1396
  defineRoutes,
1328
1397
  defineRoute,
1329
1398
  defineMiddleware,
@@ -1338,4 +1407,4 @@ export {
1338
1407
  Router
1339
1408
  };
1340
1409
 
1341
- //# debugId=E741941A15B6252664756E2164756E21
1410
+ //# debugId=A9CA11D7A722A40F64756E2164756E21