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.
- package/.agents/ADMIN.md +11 -1
- package/.agents/AGENTS.md +2 -2
- package/.agents/AUTH.md +415 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +178 -4
- package/.agents/PLUGINS.md +113 -8
- package/.agents/QUEUE.md +92 -0
- package/.agents/ROUTING.md +160 -7
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +2 -0
- package/README.md +145 -93
- package/bin/commands/add-docker.js +467 -0
- package/bin/commands/deploy-check.js +339 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/index.js +123 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +477 -3
- package/core/auth/middleware.js +118 -5
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/session-manager.js +255 -0
- package/core/auth/sessions.js +351 -0
- package/core/auth/tokens.js +1 -0
- package/core/auth/totp.js +507 -0
- package/core/auth/user-agent.js +161 -0
- package/core/orm/eager-loader.js +144 -10
- package/core/orm/index.js +10 -1
- package/core/orm/model.js +55 -1
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +327 -16
- package/core/orm/scopes.js +3 -2
- package/core/orm/search.js +213 -0
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +544 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +100 -1
- package/core/queue/scheduler.js +415 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +794 -6
- package/index.js +48 -0
- package/package.json +7 -5
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +323 -31
- package/plugins/admin-panel/client/parts/08-record-list.js +5 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -70
- package/plugins/audit-log/api-handlers.js +114 -0
- package/plugins/audit-log/index.js +24 -0
- package/plugins/content/client/inline-edit.js +2 -46
- package/plugins/cors.js +13 -2
- package/plugins/email/auth-bridge.js +66 -0
- package/plugins/email/bundled-templates.js +15 -0
- package/plugins/email/templates/auth-magic-link.mjml +13 -0
- package/plugins/file-manager/admin-component.js +69 -53
- package/plugins/file-manager/index.js +25 -8
- package/plugins/index.js +8 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/queue/index.js +4 -0
- package/plugins/webhooks/admin-component.js +608 -0
- package/plugins/webhooks/api-handlers.js +237 -0
- package/plugins/webhooks/crypto.js +144 -0
- package/plugins/webhooks/dispatcher.js +708 -0
- package/plugins/webhooks/index.js +263 -0
- package/plugins/webhooks/matcher.js +113 -0
- package/plugins/webhooks/migration-template.js +75 -0
- package/plugins/webhooks/models.js +98 -0
- package/plugins/webhooks/sender.js +176 -0
- package/src/api/api-loader.js +1 -0
- package/src/bundle/index.js +2 -1
- package/src/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +310 -104
- package/src/helpers.js +16 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +490 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +6 -0
package/src/file-router.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
1215
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1360
|
-
|
|
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
|
|
1678
|
+
const hookCtx = { req, res, error: err };
|
|
1489
1679
|
try {
|
|
1490
|
-
await executeHook(globalHooks, 'onError',
|
|
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()
|
|
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(':')) {
|
package/src/njk-frontmatter.js
CHANGED
|
@@ -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
|
}
|