webspresso 0.0.98 → 0.0.100

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 (88) hide show
  1. package/.agents/ADMIN.md +11 -1
  2. package/.agents/AGENTS.md +2 -2
  3. package/.agents/AUTH.md +415 -0
  4. package/.agents/CLI.md +47 -0
  5. package/.agents/ERRORS.md +59 -0
  6. package/.agents/ORM.md +178 -4
  7. package/.agents/PLUGINS.md +113 -8
  8. package/.agents/QUEUE.md +92 -0
  9. package/.agents/ROUTING.md +160 -7
  10. package/.agents/STREAMING.md +35 -0
  11. package/AGENTS.md +2 -0
  12. package/README.md +145 -93
  13. package/bin/commands/add-docker.js +467 -0
  14. package/bin/commands/deploy-check.js +339 -0
  15. package/bin/webspresso.js +4 -0
  16. package/core/auth/index.js +123 -1
  17. package/core/auth/magic-link.js +170 -0
  18. package/core/auth/manager.js +477 -3
  19. package/core/auth/middleware.js +118 -5
  20. package/core/auth/oauth.js +517 -0
  21. package/core/auth/qrcode.js +499 -0
  22. package/core/auth/session-manager.js +255 -0
  23. package/core/auth/sessions.js +351 -0
  24. package/core/auth/tokens.js +1 -0
  25. package/core/auth/totp.js +507 -0
  26. package/core/auth/user-agent.js +161 -0
  27. package/core/orm/eager-loader.js +144 -10
  28. package/core/orm/index.js +10 -1
  29. package/core/orm/model.js +55 -1
  30. package/core/orm/query-builder.js +90 -4
  31. package/core/orm/repository.js +327 -16
  32. package/core/orm/scopes.js +3 -2
  33. package/core/orm/search.js +213 -0
  34. package/core/orm/types.js +6 -1
  35. package/core/queue/cron.js +544 -0
  36. package/core/queue/index.js +8 -1
  37. package/core/queue/manager.js +100 -1
  38. package/core/queue/scheduler.js +415 -0
  39. package/core/sse/index.js +19 -0
  40. package/core/sse/stream.js +430 -0
  41. package/core/ssr/stream.js +62 -2
  42. package/index.d.ts +794 -6
  43. package/index.js +48 -0
  44. package/package.json +7 -5
  45. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
  46. package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
  47. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  48. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  49. package/plugins/admin-panel/field-renderers/index.js +2 -0
  50. package/plugins/admin-panel/modules/admin-users.js +1 -1
  51. package/plugins/api-key/crypto.js +101 -0
  52. package/plugins/api-key/index.js +288 -0
  53. package/plugins/api-key/middleware.js +461 -0
  54. package/plugins/api-key/migration-template.js +48 -0
  55. package/plugins/api-key/model.js +50 -0
  56. package/plugins/api-key/scopes.js +125 -0
  57. package/plugins/audit-log/admin-component.js +487 -70
  58. package/plugins/audit-log/api-handlers.js +114 -0
  59. package/plugins/audit-log/index.js +24 -0
  60. package/plugins/content/client/inline-edit.js +2 -46
  61. package/plugins/cors.js +13 -2
  62. package/plugins/email/auth-bridge.js +66 -0
  63. package/plugins/email/bundled-templates.js +15 -0
  64. package/plugins/email/templates/auth-magic-link.mjml +13 -0
  65. package/plugins/file-manager/admin-component.js +69 -53
  66. package/plugins/file-manager/index.js +25 -8
  67. package/plugins/index.js +8 -0
  68. package/plugins/oauth/index.js +338 -0
  69. package/plugins/queue/index.js +4 -0
  70. package/plugins/webhooks/admin-component.js +608 -0
  71. package/plugins/webhooks/api-handlers.js +237 -0
  72. package/plugins/webhooks/crypto.js +144 -0
  73. package/plugins/webhooks/dispatcher.js +708 -0
  74. package/plugins/webhooks/index.js +263 -0
  75. package/plugins/webhooks/matcher.js +113 -0
  76. package/plugins/webhooks/migration-template.js +75 -0
  77. package/plugins/webhooks/models.js +98 -0
  78. package/plugins/webhooks/sender.js +176 -0
  79. package/src/api/api-loader.js +1 -0
  80. package/src/bundle/index.js +2 -1
  81. package/src/discovery/file-route-parser.js +44 -1
  82. package/src/discovery/scan-routes.js +2 -2
  83. package/src/file-router.js +310 -104
  84. package/src/helpers.js +16 -1
  85. package/src/njk-frontmatter.js +3 -0
  86. package/src/routing/cascading.js +490 -0
  87. package/src/routing/middleware-resolver.js +92 -0
  88. package/src/server.js +6 -0
@@ -17,9 +17,166 @@ const {
17
17
  registerClearHook,
18
18
  } = require('./njk-frontmatter');
19
19
  const { renderStream } = require('../core/ssr/stream');
20
+ const {
21
+ loadCascadingMiddlewares,
22
+ runMiddlewareChain,
23
+ findCascadingLayouts,
24
+ executeCascadingLayoutLoads,
25
+ applyCascadingLayouts,
26
+ findNearestErrorBoundary,
27
+ findNearestLoadingTemplate,
28
+ templateHasExtends,
29
+ } = require('./routing/cascading');
30
+ const {
31
+ isMiddlewareFactory,
32
+ resolveNamedMiddleware,
33
+ resolveMiddlewares,
34
+ } = require('./routing/middleware-resolver');
20
35
 
21
36
  const NOOP = () => {};
22
37
  const EMPTY_OBJECT = Object.freeze({});
38
+
39
+ /**
40
+ * Handles directory-level error boundaries (_error.njk and companion _error.js)
41
+ * @returns {Promise<boolean>} True if rendered, false if unhandled
42
+ */
43
+ async function handleDirectoryErrorBoundary({
44
+ err,
45
+ route,
46
+ req,
47
+ res,
48
+ ctx,
49
+ absolutePagesDir,
50
+ nunjucks,
51
+ isDev,
52
+ compiledStringTemplateCache,
53
+ baseHelpers,
54
+ }) {
55
+ if (res.headersSent) {
56
+ return false;
57
+ }
58
+
59
+ const errorBoundary = findNearestErrorBoundary(absolutePagesDir, route.file, isDev);
60
+ if (!errorBoundary) {
61
+ return false;
62
+ }
63
+
64
+ const statusCode =
65
+ err?.status ||
66
+ err?.statusCode ||
67
+ (typeof err?.getStatus === 'function' ? err.getStatus() : null) ||
68
+ 500;
69
+
70
+ try {
71
+ let errorData = {};
72
+ let errorConfig = null;
73
+
74
+ if (errorBoundary.configPath) {
75
+ try {
76
+ if (isDev && require.cache[require.resolve(errorBoundary.configPath)]) {
77
+ delete require.cache[require.resolve(errorBoundary.configPath)];
78
+ }
79
+ const mod = require(errorBoundary.configPath);
80
+ errorConfig = mod?.default || mod;
81
+ const loadFn = typeof errorConfig === 'function' ? errorConfig : errorConfig?.load;
82
+ if (typeof loadFn === 'function') {
83
+ const loaded = await loadFn({
84
+ req,
85
+ res,
86
+ error: err,
87
+ ctx,
88
+ db: req.db,
89
+ });
90
+ if (loaded && typeof loaded === 'object' && !Array.isArray(loaded)) {
91
+ Object.assign(errorData, loaded);
92
+ }
93
+ }
94
+ } catch (loadErr) {
95
+ console.error(`[webspresso] Error in _error.js loader "${errorBoundary.configPath}":`, loadErr.message);
96
+ }
97
+ }
98
+
99
+ const njkTpl = loadNjkRouteTemplate(errorBoundary.fullPath, isDev);
100
+ if (njkTpl.dataPatch && Object.keys(njkTpl.dataPatch).length > 0) {
101
+ Object.assign(errorData, njkTpl.dataPatch);
102
+ }
103
+
104
+ const errorRenderContext = {
105
+ ...(ctx?.data || {}),
106
+ ...errorData,
107
+ error: {
108
+ message: err?.message || 'An error occurred',
109
+ status: statusCode,
110
+ statusCode,
111
+ code: err?.code || 'ERROR',
112
+ name: err?.name || 'Error',
113
+ details: err?.details,
114
+ fields: err?.fields,
115
+ stack: isDev ? err?.stack : undefined,
116
+ },
117
+ route: {
118
+ routePath: route.routePath,
119
+ file: route.file,
120
+ },
121
+ meta: Object.assign({}, ctx?.meta, {
122
+ title: errorData.title || `Error ${statusCode}`,
123
+ description: errorData.description || err?.message,
124
+ }),
125
+ locale: ctx?.locale,
126
+ t: ctx?.t,
127
+ fsy: ctx?.fsy || (typeof createHelpers === 'function' ? createHelpers({ req, res, locale: ctx?.locale || 'en' }) : {}),
128
+ clientRuntime: ctx?.clientRuntime,
129
+ req: {
130
+ path: req.path,
131
+ query: req.query,
132
+ params: req.params,
133
+ method: req.method,
134
+ url: req.url,
135
+ },
136
+ isDev,
137
+ };
138
+
139
+ let html;
140
+ if (njkTpl.useStringRender && njkTpl.templateBody != null) {
141
+ if (typeof nunjucks.Template === 'function') {
142
+ let compiled = isDev ? null : compiledStringTemplateCache.get(errorBoundary.fullPath);
143
+ if (!compiled || (isDev && compiled._templateBody !== njkTpl.templateBody)) {
144
+ compiled = new nunjucks.Template(njkTpl.templateBody, nunjucks, errorBoundary.fullPath);
145
+ compiled._templateBody = njkTpl.templateBody;
146
+ compiledStringTemplateCache.set(errorBoundary.fullPath, compiled);
147
+ }
148
+ html = compiled.render(errorRenderContext);
149
+ } else if (typeof nunjucks.renderString === 'function') {
150
+ html = nunjucks.renderString(njkTpl.templateBody, errorRenderContext, { path: errorBoundary.fullPath });
151
+ } else {
152
+ html = nunjucks.render(errorBoundary.errorRelPath, errorRenderContext);
153
+ }
154
+ } else {
155
+ html = nunjucks.render(errorBoundary.errorRelPath, errorRenderContext);
156
+ }
157
+
158
+ const shouldApplyLayout = errorConfig?.layout !== false &&
159
+ errorData?.layout !== false &&
160
+ !templateHasExtends(njkTpl.templateBody, errorBoundary.fullPath);
161
+
162
+ if (shouldApplyLayout) {
163
+ const errorLayouts = findCascadingLayouts(absolutePagesDir, errorBoundary.errorRelPath, isDev);
164
+ if (errorLayouts.length > 0) {
165
+ const layoutCtx = { data: { ...errorRenderContext } };
166
+ await executeCascadingLayoutLoads(errorLayouts, req, layoutCtx, isDev);
167
+ const mergedLayoutContext = { ...errorRenderContext, ...(layoutCtx.data || {}) };
168
+ html = applyCascadingLayouts(nunjucks, errorLayouts, html, mergedLayoutContext, isDev);
169
+ }
170
+ }
171
+
172
+ res.status(statusCode);
173
+ res.send(html);
174
+ return true;
175
+ } catch (renderErr) {
176
+ console.error(`[webspresso] Failed rendering error boundary "${errorBoundary.errorRelPath}":`, renderErr);
177
+ return false;
178
+ }
179
+ }
23
180
  const EMPTY_ARRAY = Object.freeze([]);
24
181
 
25
182
  // Cache for i18n files (key: filePath, value: { mtime, data })
@@ -173,6 +330,73 @@ function escapeRegExp(s) {
173
330
  return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
174
331
  }
175
332
 
333
+ /**
334
+ * Checks if a path segment is a route group, e.g. '(marketing)', '(auth)'.
335
+ * Route groups are enclosed in parentheses and do not affect the public URL path.
336
+ * @param {string} segment
337
+ * @returns {boolean}
338
+ */
339
+ function isRouteGroupSegment(segment) {
340
+ return (
341
+ typeof segment === 'string' &&
342
+ segment.length > 2 &&
343
+ segment.charCodeAt(0) === 40 /* '(' */ &&
344
+ segment.charCodeAt(segment.length - 1) === 41 /* ')' */
345
+ );
346
+ }
347
+
348
+ /**
349
+ * Strips route group segments from a slash-separated route path.
350
+ * E.g., '(marketing)/about' -> 'about'
351
+ * '/(auth)/login' -> '/login'
352
+ * '/(marketing)/(sub)/page' -> '/page'
353
+ * '(marketing)/index' -> 'index'
354
+ * @param {string} route
355
+ * @returns {string}
356
+ */
357
+ function stripRouteGroups(route) {
358
+ if (!route || typeof route !== 'string' || !route.includes('(')) {
359
+ return route;
360
+ }
361
+ const hadLeadingSlash = route.startsWith('/');
362
+ const rawSegments = route.split('/');
363
+ const filtered = rawSegments.filter((seg) => !isRouteGroupSegment(seg));
364
+
365
+ if (filtered.length === 0 || (filtered.length === 1 && filtered[0] === '')) {
366
+ return hadLeadingSlash ? '/' : '';
367
+ }
368
+
369
+ let res = filtered.join('/');
370
+ if (hadLeadingSlash && !res.startsWith('/')) {
371
+ res = '/' + res;
372
+ }
373
+ return res;
374
+ }
375
+
376
+ /**
377
+ * Checks if a file path belongs to an API route, accounting for optional parent route groups.
378
+ * E.g., 'api/users.get.js' -> true
379
+ * 'api/(v1)/users.get.js' -> true
380
+ * '(marketing)/api/newsletter.post.js' -> true
381
+ * 'about.njk' -> false
382
+ * @param {string} file
383
+ * @returns {boolean}
384
+ */
385
+ function isApiRouteFile(file) {
386
+ if (!file || typeof file !== 'string') return false;
387
+ const normalized = file.split(/[/\\]/).join('/');
388
+ if (normalized.startsWith('api/')) return true;
389
+ const parts = normalized.split('/');
390
+ const apiIndex = parts.indexOf('api');
391
+ if (apiIndex > 0) {
392
+ const prefix = parts.slice(0, apiIndex);
393
+ if (prefix.every(isRouteGroupSegment)) {
394
+ return true;
395
+ }
396
+ }
397
+ return false;
398
+ }
399
+
176
400
  /**
177
401
  * Convert a file path to an Express route pattern
178
402
  * @param {string} filePath - Relative path from pages/
@@ -186,11 +410,14 @@ function filePathToRoute(filePath, ext) {
186
410
  // Normalize path separators to forward slashes (handle both / and \)
187
411
  route = route.split(path.sep).join('/');
188
412
  route = route.split('\\').join('/'); // Also handle literal backslashes
413
+
414
+ // Strip route group segments (e.g. (marketing), (auth), (dashboard))
415
+ route = stripRouteGroups(route);
189
416
 
190
417
  // Handle index files
191
418
  if (route.endsWith('/index')) {
192
419
  route = route.slice(0, -6) || '/';
193
- } else if (route === 'index') {
420
+ } else if (route === 'index' || route === '') {
194
421
  route = '/';
195
422
  }
196
423
 
@@ -870,86 +1097,6 @@ function detectLocale(req, supported = parseSupportedLocaleSet(), def = getDefau
870
1097
  return def;
871
1098
  }
872
1099
 
873
- /**
874
- * True when the registry entry is (options) => (req, res, next) => …
875
- * Express handlers typically have length >= 2 (req, res) or 3 (req, res, next).
876
- */
877
- function isMiddlewareFactory(fn) {
878
- return typeof fn === 'function' && fn.length <= 1;
879
- }
880
-
881
- /**
882
- * Resolve a named middleware from createApp({ middlewares }).
883
- * @param {string} name
884
- * @param {Function} entry
885
- * @param {boolean} fromTuple - true when route used ['name', options]
886
- * @param {unknown} tupleOptions - second element of the tuple (only when fromTuple)
887
- * @param {Object} middlewareRegistry - for error messages
888
- * @returns {Function} Express middleware
889
- */
890
- function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middlewareRegistry) {
891
- if (!entry) {
892
- throw new Error(`Middleware "${name}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
893
- }
894
- if (typeof entry !== 'function') {
895
- throw new Error(`Middleware "${name}" must be a function`);
896
- }
897
-
898
- if (fromTuple) {
899
- if (!isMiddlewareFactory(entry)) {
900
- throw new Error(
901
- `Middleware "${name}" must be a factory (options) => (req, res, next) => … when using ["${name}", options] tuple form`
902
- );
903
- }
904
- const produced = entry(tupleOptions);
905
- if (typeof produced !== 'function') {
906
- throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
907
- }
908
- return produced;
909
- }
910
-
911
- if (isMiddlewareFactory(entry)) {
912
- const produced = entry({});
913
- if (typeof produced !== 'function') {
914
- throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
915
- }
916
- return produced;
917
- }
918
-
919
- return entry;
920
- }
921
-
922
- /**
923
- * Resolve middleware from config — functions, string names, or [name, options] tuples
924
- * @param {Array} middlewareConfig - middleware functions, names, or ['name', options] tuples
925
- * @param {Object} middlewareRegistry - Named middleware registry (plain handlers or option factories)
926
- * @returns {Array} Array of resolved middleware functions
927
- */
928
- function resolveMiddlewares(middlewareConfig, middlewareRegistry = EMPTY_OBJECT) {
929
- if (!middlewareConfig || !Array.isArray(middlewareConfig) || middlewareConfig.length === 0) {
930
- return EMPTY_ARRAY;
931
- }
932
-
933
- return middlewareConfig.map((mw, index) => {
934
- if (typeof mw === 'function') {
935
- return mw;
936
- }
937
-
938
- if (typeof mw === 'string') {
939
- return resolveNamedMiddleware(mw, middlewareRegistry[mw], false, undefined, middlewareRegistry);
940
- }
941
-
942
- if (Array.isArray(mw) && mw.length === 2 && typeof mw[0] === 'string') {
943
- const name = mw[0];
944
- return resolveNamedMiddleware(name, middlewareRegistry[name], true, mw[1], middlewareRegistry);
945
- }
946
-
947
- throw new Error(
948
- `Invalid middleware at index ${index}: must be a function, string name, or [name, options] tuple`
949
- );
950
- });
951
- }
952
-
953
1100
  /**
954
1101
  * Mount all pages as routes on the Express app
955
1102
  * @param {Object} app - Express app
@@ -1073,7 +1220,7 @@ function mountPages(app, options) {
1073
1220
 
1074
1221
  for (const file of files) {
1075
1222
  const ext = path.extname(file);
1076
- const isApi = file.startsWith('api' + path.sep) || file.startsWith('api/');
1223
+ const isApi = isApiRouteFile(file);
1077
1224
 
1078
1225
  if (isApi && ext === '.js') {
1079
1226
  // API route
@@ -1209,19 +1356,32 @@ function mountPages(app, options) {
1209
1356
  throw err;
1210
1357
  }
1211
1358
 
1359
+ // Run cascading folder middlewares (_middleware.js)
1360
+ const cascadingMw = loadCascadingMiddlewares(absolutePagesDir, route.file, middlewares, isDev);
1361
+ if (cascadingMw.length) {
1362
+ await runMiddlewareChain(cascadingMw, req, res);
1363
+ if (res.headersSent) return;
1364
+ }
1365
+
1212
1366
  // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
1213
1367
  if (preResolvedMw.length) {
1214
- for (const mw of preResolvedMw) {
1215
- await new Promise((resolve, reject) => {
1216
- mw(req, res, (err) => {
1217
- if (err) reject(err);
1218
- else resolve();
1219
- });
1220
- });
1221
- }
1368
+ await runMiddlewareChain(preResolvedMw, req, res);
1369
+ if (res.headersSent) return;
1222
1370
  }
1223
-
1224
- await fn(req, res, next);
1371
+ const apiCtx = Object.assign((err) => next(err), {
1372
+ req,
1373
+ res,
1374
+ next,
1375
+ params: req.params || {},
1376
+ query: req.query || {},
1377
+ body: req.body || {},
1378
+ input: req.input,
1379
+ service: req.service,
1380
+ db,
1381
+ sse: res.sse,
1382
+ });
1383
+
1384
+ await fn(req, res, apiCtx);
1225
1385
  } catch (err) {
1226
1386
  console.error(`API error ${route.routePath}:`, err);
1227
1387
  const hookCtx = { req, res, error: err };
@@ -1354,16 +1514,17 @@ function mountPages(app, options) {
1354
1514
  hookPromise = runHooks(globalHooks, routeHooks, 'beforeMiddleware', ctx);
1355
1515
  if (hookPromise) await hookPromise;
1356
1516
 
1517
+ // Run cascading folder middlewares (_middleware.js)
1518
+ const cascadingMw = loadCascadingMiddlewares(absolutePagesDir, route.file, middlewares, isDev);
1519
+ if (cascadingMw.length) {
1520
+ await runMiddlewareChain(cascadingMw, req, res);
1521
+ if (res.headersSent) return;
1522
+ }
1523
+
1357
1524
  // Run route middleware (chain fixed at route registration; edit middleware in dev → restart)
1358
1525
  if (preResolvedPageMw.length) {
1359
- for (const mw of preResolvedPageMw) {
1360
- await new Promise((resolve, reject) => {
1361
- mw(req, res, (err) => {
1362
- if (err) reject(err);
1363
- else resolve();
1364
- });
1365
- });
1366
- }
1526
+ await runMiddlewareChain(preResolvedPageMw, req, res);
1527
+ if (res.headersSent) return;
1367
1528
  }
1368
1529
 
1369
1530
  // Execute hooks: afterMiddleware
@@ -1380,6 +1541,14 @@ function mountPages(app, options) {
1380
1541
  hookPromise = runHooks(globalHooks, routeHooks, 'beforeLoad', ctx);
1381
1542
  if (hookPromise) await hookPromise;
1382
1543
 
1544
+ // Find cascading layouts (_layout.njk)
1545
+ const cascadingLayouts = findCascadingLayouts(absolutePagesDir, route.file, isDev);
1546
+
1547
+ // Run cascading _layout.js load() functions in top-down order (outermost first)
1548
+ if (cascadingLayouts.length > 0) {
1549
+ await executeCascadingLayoutLoads(cascadingLayouts, req, ctx, isDev);
1550
+ }
1551
+
1383
1552
  // Run load function
1384
1553
  if (config?.load && typeof config.load === 'function') {
1385
1554
  const loadData = await config.load(req, ctx);
@@ -1434,15 +1603,26 @@ function mountPages(app, options) {
1434
1603
 
1435
1604
  // Render the template
1436
1605
  const templatePath = route.file.split(path.sep).join('/');
1606
+ const nearestLoading = findNearestLoadingTemplate(absolutePagesDir, route.file, isDev);
1607
+ if (nearestLoading) {
1608
+ renderContext.loadingTemplate = nearestLoading.loadingRelPath;
1609
+ }
1437
1610
 
1438
1611
  // SSR Streaming mode (chunked transfer)
1439
- if (config?.stream || ctx.data?.__stream || ctx.defer) {
1440
- const streamDefer = ctx.defer || ctx.data?.__defer || {};
1612
+ if (config?.stream || ctx.data?.__stream || ctx.data?.stream || ctx.defer || ctx.data?.defer) {
1613
+ const streamDefer = ctx.defer || ctx.data?.defer || ctx.data?.__defer || {};
1441
1614
  const status = (route.routePath === '/404' || route.file === '404.njk') ? 404 : 200;
1615
+ const shouldApplyLayout = config?.layout !== false &&
1616
+ ctx.data?.layout !== false &&
1617
+ ctx.meta?.layout !== false &&
1618
+ !templateHasExtends(njkTpl.templateBody, route.fullPath);
1619
+
1442
1620
  return await renderStream(res, templatePath, renderContext, {
1443
1621
  env: nunjucks,
1444
1622
  defer: streamDefer,
1445
1623
  status,
1624
+ loadingTemplate: nearestLoading?.loadingRelPath,
1625
+ layouts: shouldApplyLayout && cascadingLayouts.length > 0 ? cascadingLayouts : undefined,
1446
1626
  });
1447
1627
  }
1448
1628
 
@@ -1465,6 +1645,16 @@ function mountPages(app, options) {
1465
1645
  html = nunjucks.render(templatePath, renderContext);
1466
1646
  }
1467
1647
 
1648
+ // Apply cascading nested layouts if not opted-out and template does not extend another template
1649
+ const shouldApplyLayout = config?.layout !== false &&
1650
+ ctx.data?.layout !== false &&
1651
+ ctx.meta?.layout !== false &&
1652
+ !templateHasExtends(njkTpl.templateBody, route.fullPath);
1653
+
1654
+ if (shouldApplyLayout && cascadingLayouts.length > 0) {
1655
+ html = applyCascadingLayouts(nunjucks, cascadingLayouts, html, renderContext, isDev);
1656
+ }
1657
+
1468
1658
  if (pluginManager) {
1469
1659
  const contentApi = pluginManager.getPluginAPI('content');
1470
1660
  if (contentApi?.maybeInjectInlineEdit) {
@@ -1485,13 +1675,27 @@ function mountPages(app, options) {
1485
1675
  console.error(`SSR error ${route.routePath}:`, err);
1486
1676
 
1487
1677
  // Execute onError hook
1488
- const ctx = { req, res, error: err };
1678
+ const hookCtx = { req, res, error: err };
1489
1679
  try {
1490
- await executeHook(globalHooks, 'onError', ctx, err);
1680
+ await executeHook(globalHooks, 'onError', hookCtx, err);
1491
1681
  } catch (hookErr) {
1492
1682
  console.error('Error in onError hook:', hookErr);
1493
1683
  }
1494
1684
 
1685
+ const handled = await handleDirectoryErrorBoundary({
1686
+ err,
1687
+ route,
1688
+ req,
1689
+ res,
1690
+ ctx: req.context || hookCtx,
1691
+ absolutePagesDir,
1692
+ nunjucks,
1693
+ isDev,
1694
+ compiledStringTemplateCache,
1695
+ });
1696
+
1697
+ if (handled) return;
1698
+
1495
1699
  return next(err);
1496
1700
  }
1497
1701
  });
@@ -1535,6 +1739,8 @@ module.exports = {
1535
1739
  mountPages,
1536
1740
  filePathToRoute,
1537
1741
  extractMethodFromFilename,
1742
+ isRouteGroupSegment,
1743
+ stripRouteGroups,
1538
1744
  scanDirectory,
1539
1745
  loadI18n,
1540
1746
  createTranslator,
package/src/helpers.js CHANGED
@@ -278,6 +278,15 @@ class AssetManager {
278
278
  candidates.add(staticParts.join('/'));
279
279
  }
280
280
  }
281
+ if (cleanFile.includes('(')) {
282
+ const withoutGroups = cleanFile.split('/').filter(p => !(p.startsWith('(') && p.endsWith(')'))).join('/');
283
+ if (withoutGroups) {
284
+ candidates.add(withoutGroups);
285
+ if (withoutGroups.endsWith('/index')) {
286
+ candidates.add(withoutGroups.slice(0, -6));
287
+ }
288
+ }
289
+ }
281
290
  if (cleanFile === 'index') {
282
291
  candidates.add('home');
283
292
  }
@@ -285,7 +294,13 @@ class AssetManager {
285
294
  }
286
295
 
287
296
  if (routePath) {
288
- let cleanPath = String(routePath).trim().replace(/^\/+/, '').replace(/\/+$/, '');
297
+ let cleanPath = String(routePath).trim();
298
+ while (cleanPath.startsWith('/')) {
299
+ cleanPath = cleanPath.slice(1);
300
+ }
301
+ while (cleanPath.endsWith('/')) {
302
+ cleanPath = cleanPath.slice(0, -1);
303
+ }
289
304
  if (cleanPath) {
290
305
  candidates.add(cleanPath);
291
306
  if (cleanPath.includes(':')) {
@@ -83,6 +83,9 @@ function frontmatterToPatches(fm) {
83
83
  if (d && typeof d === 'object' && !Array.isArray(d)) {
84
84
  dataPatch = { ...d };
85
85
  }
86
+ if (Object.prototype.hasOwnProperty.call(fmObj, 'layout') && fmObj.layout !== undefined) {
87
+ dataPatch.layout = fmObj.layout;
88
+ }
86
89
 
87
90
  return { metaPatch, dataPatch };
88
91
  }