webspresso 0.0.97 → 0.0.99

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -8,14 +8,28 @@ const path = require('path');
8
8
  const { ZodError } = require('zod');
9
9
  const { compileSchema, invalidateSchema } = require('../core/compileSchema');
10
10
  const { applySchema } = require('../core/applySchema');
11
- const { createHelpers } = require('./helpers');
11
+ const { createHelpers, getAssetManager } = require('./helpers');
12
12
  const {
13
13
  loadNjkRouteTemplate,
14
14
  parseNjkFrontmatter,
15
15
  frontmatterToPatches,
16
16
  clearNjkFrontmatterCaches,
17
+ registerClearHook,
17
18
  } = require('./njk-frontmatter');
18
19
  const { renderStream } = require('../core/ssr/stream');
20
+ const {
21
+ loadCascadingMiddlewares,
22
+ runMiddlewareChain,
23
+ findCascadingLayouts,
24
+ executeCascadingLayoutLoads,
25
+ applyCascadingLayouts,
26
+ templateHasExtends,
27
+ } = require('./routing/cascading');
28
+ const {
29
+ isMiddlewareFactory,
30
+ resolveNamedMiddleware,
31
+ resolveMiddlewares,
32
+ } = require('./routing/middleware-resolver');
19
33
 
20
34
  const NOOP = () => {};
21
35
  const EMPTY_OBJECT = Object.freeze({});
@@ -27,12 +41,30 @@ const i18nCache = new Map();
27
41
  // Cache for merged i18n (key: `${pagesDir}::${routeDir}::${locale}`, value: { globalMtime, routeMtime, data })
28
42
  const mergedI18nCache = new Map();
29
43
 
44
+ // Cache for translator instances in production
45
+ const translatorCache = new Map();
46
+
47
+ function clearTranslatorCache() {
48
+ translatorCache.clear();
49
+ }
50
+
30
51
  // Cache for route configs in production
31
52
  const configCache = new Map();
32
53
 
33
54
  // Dev-only: avoid require() on every SSR request when the .js file is unchanged (mtime)
34
55
  const routeConfigDevCache = new Map();
35
56
 
57
+ // Cache for compiled frontmatter string templates (key: fullPath, value: compiled Template)
58
+ const compiledStringTemplateCache = new Map();
59
+
60
+ function clearCompiledStringTemplateCache() {
61
+ compiledStringTemplateCache.clear();
62
+ }
63
+
64
+ if (typeof registerClearHook === 'function') {
65
+ registerClearHook(clearCompiledStringTemplateCache);
66
+ }
67
+
36
68
  // Cache for API filename -> { method, baseName } (basename keys; stable per process)
37
69
  const methodFromFilenameCache = new Map();
38
70
 
@@ -43,6 +75,8 @@ const MAX_LOCALE_LEN = 16;
43
75
 
44
76
  let cachedSupportedLocalesRaw = null;
45
77
  let cachedSupportedLocaleSet = null;
78
+ let cachedDefaultLocaleKey = null;
79
+ let cachedDefaultLocale = 'en';
46
80
 
47
81
  /** @returns {Set<string>} */
48
82
  function parseSupportedLocaleSet() {
@@ -57,6 +91,27 @@ function parseSupportedLocaleSet() {
57
91
  return cachedSupportedLocaleSet;
58
92
  }
59
93
 
94
+ /**
95
+ * Get default locale matching supported set
96
+ * @param {Set<string>} [supported]
97
+ * @returns {string}
98
+ */
99
+ function getDefaultLocale(supported = parseSupportedLocaleSet()) {
100
+ const rawDef = process.env.DEFAULT_LOCALE || 'en';
101
+ const rawSupp = process.env.SUPPORTED_LOCALES || 'en';
102
+ const key = rawDef + '|' + rawSupp;
103
+ if (key === cachedDefaultLocaleKey && cachedDefaultLocale) {
104
+ return cachedDefaultLocale;
105
+ }
106
+ cachedDefaultLocaleKey = key;
107
+ const defaultCand = normalizeLocaleCandidate(rawDef);
108
+ cachedDefaultLocale =
109
+ pickMatchingLocale(defaultCand, supported)
110
+ ?? (supported.has('en') ? 'en' : [...supported][0])
111
+ ?? 'en';
112
+ return cachedDefaultLocale;
113
+ }
114
+
60
115
  /**
61
116
  * @param {string} raw
62
117
  * @returns {string|null}
@@ -131,6 +186,73 @@ function escapeRegExp(s) {
131
186
  return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
132
187
  }
133
188
 
189
+ /**
190
+ * Checks if a path segment is a route group, e.g. '(marketing)', '(auth)'.
191
+ * Route groups are enclosed in parentheses and do not affect the public URL path.
192
+ * @param {string} segment
193
+ * @returns {boolean}
194
+ */
195
+ function isRouteGroupSegment(segment) {
196
+ return (
197
+ typeof segment === 'string' &&
198
+ segment.length > 2 &&
199
+ segment.charCodeAt(0) === 40 /* '(' */ &&
200
+ segment.charCodeAt(segment.length - 1) === 41 /* ')' */
201
+ );
202
+ }
203
+
204
+ /**
205
+ * Strips route group segments from a slash-separated route path.
206
+ * E.g., '(marketing)/about' -> 'about'
207
+ * '/(auth)/login' -> '/login'
208
+ * '/(marketing)/(sub)/page' -> '/page'
209
+ * '(marketing)/index' -> 'index'
210
+ * @param {string} route
211
+ * @returns {string}
212
+ */
213
+ function stripRouteGroups(route) {
214
+ if (!route || typeof route !== 'string' || !route.includes('(')) {
215
+ return route;
216
+ }
217
+ const hadLeadingSlash = route.startsWith('/');
218
+ const rawSegments = route.split('/');
219
+ const filtered = rawSegments.filter((seg) => !isRouteGroupSegment(seg));
220
+
221
+ if (filtered.length === 0 || (filtered.length === 1 && filtered[0] === '')) {
222
+ return hadLeadingSlash ? '/' : '';
223
+ }
224
+
225
+ let res = filtered.join('/');
226
+ if (hadLeadingSlash && !res.startsWith('/')) {
227
+ res = '/' + res;
228
+ }
229
+ return res;
230
+ }
231
+
232
+ /**
233
+ * Checks if a file path belongs to an API route, accounting for optional parent route groups.
234
+ * E.g., 'api/users.get.js' -> true
235
+ * 'api/(v1)/users.get.js' -> true
236
+ * '(marketing)/api/newsletter.post.js' -> true
237
+ * 'about.njk' -> false
238
+ * @param {string} file
239
+ * @returns {boolean}
240
+ */
241
+ function isApiRouteFile(file) {
242
+ if (!file || typeof file !== 'string') return false;
243
+ const normalized = file.split(/[/\\]/).join('/');
244
+ if (normalized.startsWith('api/')) return true;
245
+ const parts = normalized.split('/');
246
+ const apiIndex = parts.indexOf('api');
247
+ if (apiIndex > 0) {
248
+ const prefix = parts.slice(0, apiIndex);
249
+ if (prefix.every(isRouteGroupSegment)) {
250
+ return true;
251
+ }
252
+ }
253
+ return false;
254
+ }
255
+
134
256
  /**
135
257
  * Convert a file path to an Express route pattern
136
258
  * @param {string} filePath - Relative path from pages/
@@ -144,11 +266,14 @@ function filePathToRoute(filePath, ext) {
144
266
  // Normalize path separators to forward slashes (handle both / and \)
145
267
  route = route.split(path.sep).join('/');
146
268
  route = route.split('\\').join('/'); // Also handle literal backslashes
269
+
270
+ // Strip route group segments (e.g. (marketing), (auth), (dashboard))
271
+ route = stripRouteGroups(route);
147
272
 
148
273
  // Handle index files
149
274
  if (route.endsWith('/index')) {
150
275
  route = route.slice(0, -6) || '/';
151
- } else if (route === 'index') {
276
+ } else if (route === 'index' || route === '') {
152
277
  route = '/';
153
278
  }
154
279
 
@@ -271,29 +396,31 @@ function extractMethodFromFilename(filename) {
271
396
 
272
397
  /**
273
398
  * Whether `load()` return values for `stylesheets` and `scripts` are promoted to
274
- * `pageHead` in Nunjucks (see `createApp({ pageAssets })`).
275
- * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}|null|undefined} raw
276
- * @returns {{ enabled: boolean, stylesheets: boolean, scripts: boolean }}
399
+ * `pageHead` in Nunjucks (see `createApp({ pageAssets })`), with optional automatic
400
+ * per-route script/stylesheet discovery.
401
+ * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean, auto?: boolean}|null|undefined} raw
402
+ * @returns {{ enabled: boolean, stylesheets: boolean, scripts: boolean, auto: boolean }}
277
403
  */
278
404
  function resolvePageAssets(raw) {
279
405
  if (raw === true) {
280
- return { enabled: true, stylesheets: true, scripts: true };
406
+ return { enabled: true, stylesheets: true, scripts: true, auto: true };
281
407
  }
282
408
  if (raw == null || raw === false) {
283
- return { enabled: false, stylesheets: false, scripts: false };
409
+ return { enabled: false, stylesheets: false, scripts: false, auto: false };
284
410
  }
285
411
  if (typeof raw === 'object') {
286
412
  const on = raw.enabled !== false;
287
413
  if (!on) {
288
- return { enabled: false, stylesheets: false, scripts: false };
414
+ return { enabled: false, stylesheets: false, scripts: false, auto: false };
289
415
  }
290
416
  return {
291
417
  enabled: true,
292
418
  stylesheets: raw.stylesheets !== false,
293
419
  scripts: raw.scripts !== false,
420
+ auto: raw.auto !== false,
294
421
  };
295
422
  }
296
- return { enabled: false, stylesheets: false, scripts: false };
423
+ return { enabled: false, stylesheets: false, scripts: false, auto: false };
297
424
  }
298
425
 
299
426
  /**
@@ -306,11 +433,13 @@ function toList(v) {
306
433
  }
307
434
 
308
435
  /**
309
- * @param {{ enabled: boolean, stylesheets: boolean, scripts: boolean }} cfg
436
+ * @param {{ enabled: boolean, stylesheets: boolean, scripts: boolean, auto?: boolean }} cfg
310
437
  * @param {Object} data
438
+ * @param {{ file?: string, routePath?: string }} [routeInfo={}]
439
+ * @param {Object|null} [assetManager=null]
311
440
  * @returns {{ data: Object, pageHead: { stylesheets: unknown[], scripts: unknown[] }|null, pageAssets: boolean }}
312
441
  */
313
- function applyPageAssetsToTemplateData(cfg, data) {
442
+ function applyPageAssetsToTemplateData(cfg, data, routeInfo = {}, assetManager = null) {
314
443
  if (!cfg || !cfg.enabled) {
315
444
  return { data, pageHead: null, pageAssets: false };
316
445
  }
@@ -325,6 +454,40 @@ function applyPageAssetsToTemplateData(cfg, data) {
325
454
  scriptItems = toList(out.scripts);
326
455
  delete out.scripts;
327
456
  }
457
+
458
+ // Auto-discover page assets from route if enabled
459
+ if (cfg.auto && assetManager && (routeInfo.file || routeInfo.routePath)) {
460
+ const candidates = typeof assetManager.getRouteAssetCandidates === 'function'
461
+ ? assetManager.getRouteAssetCandidates(routeInfo.file, routeInfo.routePath)
462
+ : [];
463
+
464
+ if (cfg.stylesheets) {
465
+ for (const cand of candidates) {
466
+ if (typeof assetManager.hasPageCss === 'function' && assetManager.hasPageCss(cand)) {
467
+ const cssUrl = assetManager.pageCssUrl(cand);
468
+ const already = styles.some((s) => (typeof s === 'string' ? s === cssUrl : s?.href === cssUrl));
469
+ if (!already) {
470
+ styles.push(cssUrl);
471
+ }
472
+ break;
473
+ }
474
+ }
475
+ }
476
+
477
+ if (cfg.scripts) {
478
+ for (const cand of candidates) {
479
+ if (typeof assetManager.hasPageJs === 'function' && assetManager.hasPageJs(cand)) {
480
+ const jsUrl = assetManager.pageJsUrl(cand);
481
+ const already = scriptItems.some((s) => (typeof s === 'string' ? s === jsUrl : s?.src === jsUrl));
482
+ if (!already) {
483
+ scriptItems.push({ src: jsUrl, defer: true });
484
+ }
485
+ break;
486
+ }
487
+ }
488
+ }
489
+ }
490
+
328
491
  return {
329
492
  data: out,
330
493
  pageHead: { stylesheets: styles, scripts: scriptItems },
@@ -492,15 +655,25 @@ function getPluralRules(locale) {
492
655
  return pr;
493
656
  }
494
657
 
658
+ const KEY_PARTS_CACHE = new Map();
659
+
495
660
  function resolveTranslationValue(dict, key) {
496
- if (!dict || typeof dict !== 'object') return undefined;
661
+ if (!dict || typeof dict !== 'object' || typeof key !== 'string') return undefined;
497
662
  if (dict[key] !== undefined) return dict[key];
663
+ if (!key.includes('.')) return undefined;
664
+
665
+ let parts = KEY_PARTS_CACHE.get(key);
666
+ if (!parts) {
667
+ parts = key.split('.');
668
+ if (KEY_PARTS_CACHE.size < 1000) {
669
+ KEY_PARTS_CACHE.set(key, parts);
670
+ }
671
+ }
498
672
 
499
- const parts = key.split('.');
500
673
  let curr = dict;
501
- for (const part of parts) {
674
+ for (let i = 0; i < parts.length; i++) {
502
675
  if (curr && typeof curr === 'object') {
503
- curr = curr[part];
676
+ curr = curr[parts[i]];
504
677
  } else {
505
678
  return undefined;
506
679
  }
@@ -726,25 +899,37 @@ function loadGlobalHooks(pagesDir, isDev) {
726
899
  * @param {string} hookName - Hook name
727
900
  * @param {Object} ctx - Context object
728
901
  */
729
- async function executeHook(hooks, hookName, ctx, ...extra) {
730
- if (hooks && typeof hooks[hookName] === 'function') {
731
- await hooks[hookName](ctx, ...extra);
902
+ function executeHook(hooks, hookName, ctx, ...extra) {
903
+ if (!hooks || typeof hooks[hookName] !== 'function') {
904
+ return;
732
905
  }
906
+ return hooks[hookName](ctx, ...extra);
907
+ }
908
+
909
+ function runHooks(globalHooks, routeHooks, hookName, ctx, ...extra) {
910
+ const gFn = globalHooks ? globalHooks[hookName] : undefined;
911
+ const rFn = routeHooks ? routeHooks[hookName] : undefined;
912
+ if (typeof gFn !== 'function' && typeof rFn !== 'function') return;
913
+ return (async () => {
914
+ if (typeof gFn === 'function') {
915
+ const gRes = gFn(ctx, ...extra);
916
+ if (gRes && typeof gRes.then === 'function') await gRes;
917
+ }
918
+ if (typeof rFn === 'function') {
919
+ const rRes = rFn(ctx, ...extra);
920
+ if (rRes && typeof rRes.then === 'function') await rRes;
921
+ }
922
+ })();
733
923
  }
734
924
 
735
925
  /**
736
926
  * Detect locale from request
737
927
  * @param {Object} req - Express request
928
+ * @param {Set<string>} [supported] - Optional precomputed supported locales set
929
+ * @param {string} [def] - Optional precomputed default locale
738
930
  * @returns {string} Locale code
739
931
  */
740
- function detectLocale(req) {
741
- const supported = parseSupportedLocaleSet();
742
- const defaultCand = normalizeLocaleCandidate(process.env.DEFAULT_LOCALE || 'en');
743
- const def =
744
- pickMatchingLocale(defaultCand, supported)
745
- ?? (supported.has('en') ? 'en' : [...supported][0])
746
- ?? 'en';
747
-
932
+ function detectLocale(req, supported = parseSupportedLocaleSet(), def = getDefaultLocale(supported)) {
748
933
  if (req.query && req.query.lang != null && req.query.lang !== '') {
749
934
  const q = normalizeLocaleCandidate(String(req.query.lang));
750
935
  const hit = pickMatchingLocale(q, supported);
@@ -768,86 +953,6 @@ function detectLocale(req) {
768
953
  return def;
769
954
  }
770
955
 
771
- /**
772
- * True when the registry entry is (options) => (req, res, next) => …
773
- * Express handlers typically have length >= 2 (req, res) or 3 (req, res, next).
774
- */
775
- function isMiddlewareFactory(fn) {
776
- return typeof fn === 'function' && fn.length <= 1;
777
- }
778
-
779
- /**
780
- * Resolve a named middleware from createApp({ middlewares }).
781
- * @param {string} name
782
- * @param {Function} entry
783
- * @param {boolean} fromTuple - true when route used ['name', options]
784
- * @param {unknown} tupleOptions - second element of the tuple (only when fromTuple)
785
- * @param {Object} middlewareRegistry - for error messages
786
- * @returns {Function} Express middleware
787
- */
788
- function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middlewareRegistry) {
789
- if (!entry) {
790
- throw new Error(`Middleware "${name}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
791
- }
792
- if (typeof entry !== 'function') {
793
- throw new Error(`Middleware "${name}" must be a function`);
794
- }
795
-
796
- if (fromTuple) {
797
- if (!isMiddlewareFactory(entry)) {
798
- throw new Error(
799
- `Middleware "${name}" must be a factory (options) => (req, res, next) => … when using ["${name}", options] tuple form`
800
- );
801
- }
802
- const produced = entry(tupleOptions);
803
- if (typeof produced !== 'function') {
804
- throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
805
- }
806
- return produced;
807
- }
808
-
809
- if (isMiddlewareFactory(entry)) {
810
- const produced = entry({});
811
- if (typeof produced !== 'function') {
812
- throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
813
- }
814
- return produced;
815
- }
816
-
817
- return entry;
818
- }
819
-
820
- /**
821
- * Resolve middleware from config — functions, string names, or [name, options] tuples
822
- * @param {Array} middlewareConfig - middleware functions, names, or ['name', options] tuples
823
- * @param {Object} middlewareRegistry - Named middleware registry (plain handlers or option factories)
824
- * @returns {Array} Array of resolved middleware functions
825
- */
826
- function resolveMiddlewares(middlewareConfig, middlewareRegistry = EMPTY_OBJECT) {
827
- if (!middlewareConfig || !Array.isArray(middlewareConfig) || middlewareConfig.length === 0) {
828
- return EMPTY_ARRAY;
829
- }
830
-
831
- return middlewareConfig.map((mw, index) => {
832
- if (typeof mw === 'function') {
833
- return mw;
834
- }
835
-
836
- if (typeof mw === 'string') {
837
- return resolveNamedMiddleware(mw, middlewareRegistry[mw], false, undefined, middlewareRegistry);
838
- }
839
-
840
- if (Array.isArray(mw) && mw.length === 2 && typeof mw[0] === 'string') {
841
- const name = mw[0];
842
- return resolveNamedMiddleware(name, middlewareRegistry[name], true, mw[1], middlewareRegistry);
843
- }
844
-
845
- throw new Error(
846
- `Invalid middleware at index ${index}: must be a function, string name, or [name, options] tuple`
847
- );
848
- });
849
- }
850
-
851
956
  /**
852
957
  * Mount all pages as routes on the Express app
853
958
  * @param {Object} app - Express app
@@ -971,7 +1076,7 @@ function mountPages(app, options) {
971
1076
 
972
1077
  for (const file of files) {
973
1078
  const ext = path.extname(file);
974
- const isApi = file.startsWith('api' + path.sep) || file.startsWith('api/');
1079
+ const isApi = isApiRouteFile(file);
975
1080
 
976
1081
  if (isApi && ext === '.js') {
977
1082
  // API route
@@ -1064,10 +1169,10 @@ function mountPages(app, options) {
1064
1169
  }
1065
1170
  }
1066
1171
  // Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
1067
- if (db != null) {
1172
+ if (db != null && !req.db) {
1068
1173
  req.db = db;
1069
1174
  }
1070
- if (serviceRegistry != null) {
1175
+ if (serviceRegistry != null && !req.service) {
1071
1176
  req.service = (name, input, opts) =>
1072
1177
  serviceRegistry.call(name, input, { req, res, db }, opts);
1073
1178
  }
@@ -1107,19 +1212,32 @@ function mountPages(app, options) {
1107
1212
  throw err;
1108
1213
  }
1109
1214
 
1215
+ // Run cascading folder middlewares (_middleware.js)
1216
+ const cascadingMw = loadCascadingMiddlewares(absolutePagesDir, route.file, middlewares, isDev);
1217
+ if (cascadingMw.length) {
1218
+ await runMiddlewareChain(cascadingMw, req, res);
1219
+ if (res.headersSent) return;
1220
+ }
1221
+
1110
1222
  // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
1111
1223
  if (preResolvedMw.length) {
1112
- for (const mw of preResolvedMw) {
1113
- await new Promise((resolve, reject) => {
1114
- mw(req, res, (err) => {
1115
- if (err) reject(err);
1116
- else resolve();
1117
- });
1118
- });
1119
- }
1224
+ await runMiddlewareChain(preResolvedMw, req, res);
1225
+ if (res.headersSent) return;
1120
1226
  }
1121
-
1122
- await fn(req, res, next);
1227
+ const apiCtx = Object.assign((err) => next(err), {
1228
+ req,
1229
+ res,
1230
+ next,
1231
+ params: req.params || {},
1232
+ query: req.query || {},
1233
+ body: req.body || {},
1234
+ input: req.input,
1235
+ service: req.service,
1236
+ db,
1237
+ sse: res.sse,
1238
+ });
1239
+
1240
+ await fn(req, res, apiCtx);
1123
1241
  } catch (err) {
1124
1242
  console.error(`API error ${route.routePath}:`, err);
1125
1243
  const hookCtx = { req, res, error: err };
@@ -1159,20 +1277,38 @@ function mountPages(app, options) {
1159
1277
  }
1160
1278
  }
1161
1279
  // Detect locale
1162
- const locale = detectLocale(req);
1163
- const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
1280
+ const supported = parseSupportedLocaleSet();
1281
+ const defaultLocale = getDefaultLocale(supported);
1282
+ const locale = detectLocale(req, supported, defaultLocale);
1164
1283
 
1165
1284
  // Load translations (primary + fallback)
1166
- const translations = loadI18n(absolutePagesDir, route.routeDir, locale, isDev);
1167
- const fallbackTranslations = (locale !== defaultLocale)
1168
- ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, isDev)
1169
- : EMPTY_OBJECT;
1170
-
1171
- const t = createTranslator(translations, {
1172
- locale,
1173
- fallbackTranslations,
1174
- fallbackLocale: defaultLocale,
1175
- });
1285
+ let t;
1286
+ if (!isDev) {
1287
+ const transKey = `${absolutePagesDir}::${route.routeDir}::${locale}::${defaultLocale}`;
1288
+ t = translatorCache.get(transKey);
1289
+ if (!t) {
1290
+ const translations = loadI18n(absolutePagesDir, route.routeDir, locale, false);
1291
+ const fallbackTranslations = (locale !== defaultLocale)
1292
+ ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, false)
1293
+ : EMPTY_OBJECT;
1294
+ t = createTranslator(translations, {
1295
+ locale,
1296
+ fallbackTranslations,
1297
+ fallbackLocale: defaultLocale,
1298
+ });
1299
+ translatorCache.set(transKey, t);
1300
+ }
1301
+ } else {
1302
+ const translations = loadI18n(absolutePagesDir, route.routeDir, locale, true);
1303
+ const fallbackTranslations = (locale !== defaultLocale)
1304
+ ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, true)
1305
+ : EMPTY_OBJECT;
1306
+ t = createTranslator(translations, {
1307
+ locale,
1308
+ fallbackTranslations,
1309
+ fallbackLocale: defaultLocale,
1310
+ });
1311
+ }
1176
1312
 
1177
1313
  // Load route config
1178
1314
  const config = isDev ? loadRouteConfig(route.configPath, true) : mountConfig;
@@ -1190,6 +1326,8 @@ function mountPages(app, options) {
1190
1326
  }
1191
1327
 
1192
1328
  const njkTpl = loadNjkRouteTemplate(route.fullPath, isDev);
1329
+ const metaTitle = t('meta.title');
1330
+ const metaDesc = t('meta.description');
1193
1331
 
1194
1332
  const ctx = {
1195
1333
  req,
@@ -1208,8 +1346,8 @@ function mountPages(app, options) {
1208
1346
  },
1209
1347
  data: { ...njkTpl.dataPatch },
1210
1348
  meta: {
1211
- title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
1212
- description: t('meta.description') !== 'meta.description' ? t('meta.description') : null,
1349
+ title: metaTitle !== 'meta.title' ? metaTitle : null,
1350
+ description: metaDesc !== 'meta.description' ? metaDesc : null,
1213
1351
  indexable: true,
1214
1352
  canonical: null,
1215
1353
  ...njkTpl.metaPatch,
@@ -1221,32 +1359,33 @@ function mountPages(app, options) {
1221
1359
  req.context = ctx;
1222
1360
 
1223
1361
  // Execute hooks: onRequest
1224
- await executeHook(globalHooks, 'onRequest', ctx);
1225
- await executeHook(routeHooks, 'onRequest', ctx);
1362
+ let hookPromise = runHooks(globalHooks, routeHooks, 'onRequest', ctx);
1363
+ if (hookPromise) await hookPromise;
1226
1364
 
1227
1365
  // Execute hooks: onRoute
1228
- await executeHook(globalHooks, 'onRoute', ctx);
1229
- await executeHook(routeHooks, 'onRoute', ctx);
1366
+ hookPromise = runHooks(globalHooks, routeHooks, 'onRoute', ctx);
1367
+ if (hookPromise) await hookPromise;
1230
1368
 
1231
1369
  // Execute hooks: beforeMiddleware
1232
- await executeHook(globalHooks, 'beforeMiddleware', ctx);
1233
- await executeHook(routeHooks, 'beforeMiddleware', ctx);
1370
+ hookPromise = runHooks(globalHooks, routeHooks, 'beforeMiddleware', ctx);
1371
+ if (hookPromise) await hookPromise;
1234
1372
 
1373
+ // Run cascading folder middlewares (_middleware.js)
1374
+ const cascadingMw = loadCascadingMiddlewares(absolutePagesDir, route.file, middlewares, isDev);
1375
+ if (cascadingMw.length) {
1376
+ await runMiddlewareChain(cascadingMw, req, res);
1377
+ if (res.headersSent) return;
1378
+ }
1379
+
1235
1380
  // Run route middleware (chain fixed at route registration; edit middleware in dev → restart)
1236
1381
  if (preResolvedPageMw.length) {
1237
- for (const mw of preResolvedPageMw) {
1238
- await new Promise((resolve, reject) => {
1239
- mw(req, res, (err) => {
1240
- if (err) reject(err);
1241
- else resolve();
1242
- });
1243
- });
1244
- }
1382
+ await runMiddlewareChain(preResolvedPageMw, req, res);
1383
+ if (res.headersSent) return;
1245
1384
  }
1246
1385
 
1247
1386
  // Execute hooks: afterMiddleware
1248
- await executeHook(globalHooks, 'afterMiddleware', ctx);
1249
- await executeHook(routeHooks, 'afterMiddleware', ctx);
1387
+ hookPromise = runHooks(globalHooks, routeHooks, 'afterMiddleware', ctx);
1388
+ if (hookPromise) await hookPromise;
1250
1389
 
1251
1390
  // Execute hooks: beforeLoad
1252
1391
  if (pluginManager && db) {
@@ -1255,9 +1394,17 @@ function mountPages(app, options) {
1255
1394
  ctx.content = contentApi.getContentService(db);
1256
1395
  }
1257
1396
  }
1258
- await executeHook(globalHooks, 'beforeLoad', ctx);
1259
- await executeHook(routeHooks, 'beforeLoad', ctx);
1397
+ hookPromise = runHooks(globalHooks, routeHooks, 'beforeLoad', ctx);
1398
+ if (hookPromise) await hookPromise;
1260
1399
 
1400
+ // Find cascading layouts (_layout.njk)
1401
+ const cascadingLayouts = findCascadingLayouts(absolutePagesDir, route.file, isDev);
1402
+
1403
+ // Run cascading _layout.js load() functions in top-down order (outermost first)
1404
+ if (cascadingLayouts.length > 0) {
1405
+ await executeCascadingLayoutLoads(cascadingLayouts, req, ctx, isDev);
1406
+ }
1407
+
1261
1408
  // Run load function
1262
1409
  if (config?.load && typeof config.load === 'function') {
1263
1410
  const loadData = await config.load(req, ctx);
@@ -1267,8 +1414,8 @@ function mountPages(app, options) {
1267
1414
  }
1268
1415
 
1269
1416
  // Execute hooks: afterLoad
1270
- await executeHook(globalHooks, 'afterLoad', ctx);
1271
- await executeHook(routeHooks, 'afterLoad', ctx);
1417
+ hookPromise = runHooks(globalHooks, routeHooks, 'afterLoad', ctx);
1418
+ if (hookPromise) await hookPromise;
1272
1419
 
1273
1420
  // Run meta function
1274
1421
  if (config?.meta && typeof config.meta === 'function') {
@@ -1279,10 +1426,18 @@ function mountPages(app, options) {
1279
1426
  }
1280
1427
 
1281
1428
  // Execute hooks: beforeRender
1282
- await executeHook(globalHooks, 'beforeRender', ctx);
1283
- await executeHook(routeHooks, 'beforeRender', ctx);
1429
+ hookPromise = runHooks(globalHooks, routeHooks, 'beforeRender', ctx);
1430
+ if (hookPromise) await hookPromise;
1284
1431
 
1285
- const pageAssetBundle = applyPageAssetsToTemplateData(pageAssetsResolved, ctx.data);
1432
+ const pageAssetBundle = applyPageAssetsToTemplateData(
1433
+ pageAssetsResolved,
1434
+ ctx.data,
1435
+ {
1436
+ file: route.file,
1437
+ routePath: route.routePath,
1438
+ },
1439
+ ctx.fsy?.assetManager || getAssetManager()
1440
+ );
1286
1441
  ctx.data = pageAssetBundle.data;
1287
1442
  const renderContext = {
1288
1443
  ...ctx.data,
@@ -1316,10 +1471,34 @@ function mountPages(app, options) {
1316
1471
  });
1317
1472
  }
1318
1473
 
1319
- let html =
1320
- njkTpl.useStringRender && njkTpl.templateBody != null
1321
- ? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
1322
- : nunjucks.render(templatePath, renderContext);
1474
+ let html;
1475
+ if (njkTpl.useStringRender && njkTpl.templateBody != null) {
1476
+ if (typeof nunjucks.Template === 'function') {
1477
+ let compiled = isDev ? null : compiledStringTemplateCache.get(route.fullPath);
1478
+ if (!compiled || (isDev && compiled._templateBody !== njkTpl.templateBody)) {
1479
+ compiled = new nunjucks.Template(njkTpl.templateBody, nunjucks, route.fullPath);
1480
+ compiled._templateBody = njkTpl.templateBody;
1481
+ compiledStringTemplateCache.set(route.fullPath, compiled);
1482
+ }
1483
+ html = compiled.render(renderContext);
1484
+ } else if (typeof nunjucks.renderString === 'function') {
1485
+ html = nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath });
1486
+ } else {
1487
+ html = nunjucks.render(templatePath, renderContext);
1488
+ }
1489
+ } else {
1490
+ html = nunjucks.render(templatePath, renderContext);
1491
+ }
1492
+
1493
+ // Apply cascading nested layouts if not opted-out and template does not extend another template
1494
+ const shouldApplyLayout = config?.layout !== false &&
1495
+ ctx.data?.layout !== false &&
1496
+ ctx.meta?.layout !== false &&
1497
+ !templateHasExtends(njkTpl.templateBody, route.fullPath);
1498
+
1499
+ if (shouldApplyLayout && cascadingLayouts.length > 0) {
1500
+ html = applyCascadingLayouts(nunjucks, cascadingLayouts, html, renderContext, isDev);
1501
+ }
1323
1502
 
1324
1503
  if (pluginManager) {
1325
1504
  const contentApi = pluginManager.getPluginAPI('content');
@@ -1330,8 +1509,8 @@ function mountPages(app, options) {
1330
1509
 
1331
1510
  // Execute hooks: afterRender
1332
1511
  ctx.html = html;
1333
- await executeHook(globalHooks, 'afterRender', ctx);
1334
- await executeHook(routeHooks, 'afterRender', ctx);
1512
+ hookPromise = runHooks(globalHooks, routeHooks, 'afterRender', ctx);
1513
+ if (hookPromise) await hookPromise;
1335
1514
 
1336
1515
  if (route.routePath === '/404' || route.file === '404.njk') {
1337
1516
  res.status(404);
@@ -1391,6 +1570,8 @@ module.exports = {
1391
1570
  mountPages,
1392
1571
  filePathToRoute,
1393
1572
  extractMethodFromFilename,
1573
+ isRouteGroupSegment,
1574
+ stripRouteGroups,
1394
1575
  scanDirectory,
1395
1576
  loadI18n,
1396
1577
  createTranslator,
@@ -1404,5 +1585,9 @@ module.exports = {
1404
1585
  frontmatterToPatches,
1405
1586
  loadNjkRouteTemplate,
1406
1587
  clearNjkFrontmatterCaches,
1588
+ compiledStringTemplateCache,
1589
+ clearCompiledStringTemplateCache,
1590
+ translatorCache,
1591
+ clearTranslatorCache,
1407
1592
  };
1408
1593