webspresso 0.0.99 → 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/AGENTS.md +2 -2
- package/.agents/AUTH.md +246 -0
- package/.agents/CLI.md +47 -0
- package/.agents/ERRORS.md +59 -0
- package/.agents/ORM.md +90 -0
- package/.agents/PLUGINS.md +44 -7
- package/.agents/QUEUE.md +18 -10
- package/.agents/ROUTING.md +33 -0
- package/.agents/STREAMING.md +35 -0
- package/AGENTS.md +1 -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 +63 -1
- package/core/auth/magic-link.js +170 -0
- package/core/auth/manager.js +343 -3
- package/core/auth/middleware.js +30 -5
- 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 +4 -1
- package/core/auth/user-agent.js +161 -0
- package/core/orm/index.js +2 -1
- package/core/orm/model.js +51 -1
- package/core/orm/repository.js +283 -13
- package/core/orm/scopes.js +3 -2
- package/core/orm/types.js +6 -1
- package/core/queue/cron.js +252 -39
- package/core/queue/manager.js +20 -2
- package/core/queue/scheduler.js +115 -59
- package/core/ssr/stream.js +62 -2
- package/index.d.ts +274 -14
- package/index.js +4 -0
- package/package.json +7 -5
- package/plugins/admin-panel/modules/admin-users.js +1 -1
- package/plugins/audit-log/admin-component.js +54 -54
- package/plugins/audit-log/index.js +2 -2
- 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 +49 -49
- package/plugins/file-manager/index.js +4 -4
- package/plugins/index.js +2 -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/bundle/index.js +2 -1
- package/src/file-router.js +173 -4
- package/src/helpers.js +7 -1
- package/src/njk-frontmatter.js +3 -0
- package/src/routing/cascading.js +112 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Sender Module
|
|
3
|
+
* Zero-dependency HTTP/HTTPS delivery using native fetch with signatures & timeouts
|
|
4
|
+
* @module plugins/webhooks/sender
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const { signPayload } = require('./crypto');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Send a webhook HTTP request to a target endpoint
|
|
14
|
+
*
|
|
15
|
+
* @param {Object} options
|
|
16
|
+
* @param {string} options.url - Destination URL
|
|
17
|
+
* @param {string} options.event - Event name (e.g. 'order.created')
|
|
18
|
+
* @param {any} options.payload - Event payload data
|
|
19
|
+
* @param {string} [options.secret] - Signing secret
|
|
20
|
+
* @param {string} [options.deliveryId] - Unique delivery ID
|
|
21
|
+
* @param {Object} [options.headers] - Custom headers to merge
|
|
22
|
+
* @param {number} [options.timeoutMs=10000] - Request timeout in milliseconds
|
|
23
|
+
* @param {boolean} [options.wrapPayload=true] - Wrap data in standard event envelope
|
|
24
|
+
* @param {number} [options.maxResponseBodyLength=2048] - Max response length to store
|
|
25
|
+
* @returns {Promise<{
|
|
26
|
+
* success: boolean,
|
|
27
|
+
* statusCode: number,
|
|
28
|
+
* responseBody: string,
|
|
29
|
+
* durationMs: number,
|
|
30
|
+
* error: string|null,
|
|
31
|
+
* deliveryId: string,
|
|
32
|
+
* timestamp: number
|
|
33
|
+
* }>}
|
|
34
|
+
*/
|
|
35
|
+
async function sendWebhook(options) {
|
|
36
|
+
const {
|
|
37
|
+
url,
|
|
38
|
+
event,
|
|
39
|
+
payload,
|
|
40
|
+
secret,
|
|
41
|
+
deliveryId = crypto.randomUUID ? crypto.randomUUID() : `dlv_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`,
|
|
42
|
+
headers: customHeaders = {},
|
|
43
|
+
timeoutMs = 10000,
|
|
44
|
+
wrapPayload = true,
|
|
45
|
+
maxResponseBodyLength = 2048,
|
|
46
|
+
} = options;
|
|
47
|
+
|
|
48
|
+
if (!url || typeof url !== 'string') {
|
|
49
|
+
return {
|
|
50
|
+
success: false,
|
|
51
|
+
statusCode: 0,
|
|
52
|
+
responseBody: '',
|
|
53
|
+
durationMs: 0,
|
|
54
|
+
error: 'Invalid webhook URL: destination URL is required.',
|
|
55
|
+
deliveryId,
|
|
56
|
+
timestamp: Math.floor(Date.now() / 1000),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
61
|
+
|
|
62
|
+
// Construct request body
|
|
63
|
+
let bodyObj = payload;
|
|
64
|
+
if (wrapPayload && (typeof payload !== 'object' || payload === null || !payload.event)) {
|
|
65
|
+
bodyObj = {
|
|
66
|
+
id: deliveryId,
|
|
67
|
+
event,
|
|
68
|
+
timestamp,
|
|
69
|
+
data: payload,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const bodyStr = typeof bodyObj === 'string' ? bodyObj : JSON.stringify(bodyObj);
|
|
73
|
+
|
|
74
|
+
// Calculate cryptographic signatures if secret is configured
|
|
75
|
+
let signatureHeaders = {};
|
|
76
|
+
if (secret) {
|
|
77
|
+
const { signature, timestampedSignature } = signPayload(bodyStr, secret, { timestamp });
|
|
78
|
+
signatureHeaders = {
|
|
79
|
+
'x-webspresso-signature': signature,
|
|
80
|
+
'x-hub-signature-256': signature,
|
|
81
|
+
'webhook-signature': timestampedSignature,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Parse custom headers if stored as JSON string
|
|
86
|
+
let parsedCustomHeaders = {};
|
|
87
|
+
if (typeof customHeaders === 'string') {
|
|
88
|
+
try {
|
|
89
|
+
parsedCustomHeaders = JSON.parse(customHeaders);
|
|
90
|
+
} catch (e) {
|
|
91
|
+
parsedCustomHeaders = {};
|
|
92
|
+
}
|
|
93
|
+
} else if (typeof customHeaders === 'object' && customHeaders !== null) {
|
|
94
|
+
parsedCustomHeaders = customHeaders;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const requestHeaders = {
|
|
98
|
+
'content-type': 'application/json',
|
|
99
|
+
'accept': 'application/json, text/plain, */*',
|
|
100
|
+
'user-agent': 'Webspresso-Webhooks/1.0',
|
|
101
|
+
'x-webspresso-event': event,
|
|
102
|
+
'x-webspresso-delivery': deliveryId,
|
|
103
|
+
'x-webspresso-timestamp': String(timestamp),
|
|
104
|
+
'webhook-id': deliveryId,
|
|
105
|
+
'webhook-timestamp': String(timestamp),
|
|
106
|
+
...signatureHeaders,
|
|
107
|
+
...parsedCustomHeaders,
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const startTime = Date.now();
|
|
111
|
+
let controller = null;
|
|
112
|
+
let timeoutId = null;
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
controller = new AbortController();
|
|
116
|
+
timeoutId = setTimeout(() => {
|
|
117
|
+
controller.abort();
|
|
118
|
+
}, timeoutMs);
|
|
119
|
+
|
|
120
|
+
const response = await fetch(url, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: requestHeaders,
|
|
123
|
+
body: bodyStr,
|
|
124
|
+
signal: controller.signal,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
clearTimeout(timeoutId);
|
|
128
|
+
const durationMs = Date.now() - startTime;
|
|
129
|
+
|
|
130
|
+
let responseBody = '';
|
|
131
|
+
try {
|
|
132
|
+
const rawText = await response.text();
|
|
133
|
+
responseBody = rawText ? rawText.slice(0, maxResponseBodyLength) : '';
|
|
134
|
+
} catch (textErr) {
|
|
135
|
+
responseBody = '';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const success = response.status >= 200 && response.status < 300;
|
|
139
|
+
const error = success ? null : `HTTP ${response.status}: ${response.statusText || 'Delivery Failed'}`;
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
success,
|
|
143
|
+
statusCode: response.status,
|
|
144
|
+
responseBody,
|
|
145
|
+
durationMs,
|
|
146
|
+
error,
|
|
147
|
+
deliveryId,
|
|
148
|
+
timestamp,
|
|
149
|
+
bodyStr,
|
|
150
|
+
};
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (timeoutId) {
|
|
153
|
+
clearTimeout(timeoutId);
|
|
154
|
+
}
|
|
155
|
+
const durationMs = Date.now() - startTime;
|
|
156
|
+
const isTimeout = err.name === 'AbortError' || err.code === 'ABORT_ERR';
|
|
157
|
+
const errorMsg = isTimeout
|
|
158
|
+
? `Webhook delivery timed out after ${timeoutMs}ms`
|
|
159
|
+
: (err.message || 'Network error');
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
success: false,
|
|
163
|
+
statusCode: 0,
|
|
164
|
+
responseBody: '',
|
|
165
|
+
durationMs,
|
|
166
|
+
error: errorMsg,
|
|
167
|
+
deliveryId,
|
|
168
|
+
timestamp,
|
|
169
|
+
bodyStr,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = {
|
|
175
|
+
sendWebhook,
|
|
176
|
+
};
|
package/src/bundle/index.js
CHANGED
|
@@ -132,7 +132,8 @@ function expandGlob(pattern, cwd = process.cwd()) {
|
|
|
132
132
|
|
|
133
133
|
const isRecursive = normalized.includes('**');
|
|
134
134
|
const regexPattern = '^' + normalized
|
|
135
|
-
.replace(
|
|
135
|
+
.replace(/\\/g, '\\\\')
|
|
136
|
+
.replace(/[.+?^${}()|[\]]/g, '\\$&')
|
|
136
137
|
.replace(/\*\*\//g, '(.+/)?')
|
|
137
138
|
.replace(/\*\*/g, '.*')
|
|
138
139
|
.replace(/\*/g, '[^/]*') + '$';
|
package/src/file-router.js
CHANGED
|
@@ -23,6 +23,8 @@ const {
|
|
|
23
23
|
findCascadingLayouts,
|
|
24
24
|
executeCascadingLayoutLoads,
|
|
25
25
|
applyCascadingLayouts,
|
|
26
|
+
findNearestErrorBoundary,
|
|
27
|
+
findNearestLoadingTemplate,
|
|
26
28
|
templateHasExtends,
|
|
27
29
|
} = require('./routing/cascading');
|
|
28
30
|
const {
|
|
@@ -33,6 +35,148 @@ const {
|
|
|
33
35
|
|
|
34
36
|
const NOOP = () => {};
|
|
35
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
|
+
}
|
|
36
180
|
const EMPTY_ARRAY = Object.freeze([]);
|
|
37
181
|
|
|
38
182
|
// Cache for i18n files (key: filePath, value: { mtime, data })
|
|
@@ -1459,15 +1603,26 @@ function mountPages(app, options) {
|
|
|
1459
1603
|
|
|
1460
1604
|
// Render the template
|
|
1461
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
|
+
}
|
|
1462
1610
|
|
|
1463
1611
|
// SSR Streaming mode (chunked transfer)
|
|
1464
|
-
if (config?.stream || ctx.data?.__stream || ctx.defer) {
|
|
1465
|
-
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 || {};
|
|
1466
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
|
+
|
|
1467
1620
|
return await renderStream(res, templatePath, renderContext, {
|
|
1468
1621
|
env: nunjucks,
|
|
1469
1622
|
defer: streamDefer,
|
|
1470
1623
|
status,
|
|
1624
|
+
loadingTemplate: nearestLoading?.loadingRelPath,
|
|
1625
|
+
layouts: shouldApplyLayout && cascadingLayouts.length > 0 ? cascadingLayouts : undefined,
|
|
1471
1626
|
});
|
|
1472
1627
|
}
|
|
1473
1628
|
|
|
@@ -1520,13 +1675,27 @@ function mountPages(app, options) {
|
|
|
1520
1675
|
console.error(`SSR error ${route.routePath}:`, err);
|
|
1521
1676
|
|
|
1522
1677
|
// Execute onError hook
|
|
1523
|
-
const
|
|
1678
|
+
const hookCtx = { req, res, error: err };
|
|
1524
1679
|
try {
|
|
1525
|
-
await executeHook(globalHooks, 'onError',
|
|
1680
|
+
await executeHook(globalHooks, 'onError', hookCtx, err);
|
|
1526
1681
|
} catch (hookErr) {
|
|
1527
1682
|
console.error('Error in onError hook:', hookErr);
|
|
1528
1683
|
}
|
|
1529
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
|
+
|
|
1530
1699
|
return next(err);
|
|
1531
1700
|
}
|
|
1532
1701
|
});
|
package/src/helpers.js
CHANGED
|
@@ -294,7 +294,13 @@ class AssetManager {
|
|
|
294
294
|
}
|
|
295
295
|
|
|
296
296
|
if (routePath) {
|
|
297
|
-
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
|
+
}
|
|
298
304
|
if (cleanPath) {
|
|
299
305
|
candidates.add(cleanPath);
|
|
300
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
|
}
|
package/src/routing/cascading.js
CHANGED
|
@@ -16,6 +16,8 @@ const EMPTY_ARRAY = Object.freeze([]);
|
|
|
16
16
|
// Caches for production mode (key: directory path)
|
|
17
17
|
const middlewareFileCache = new Map();
|
|
18
18
|
const layoutFileCache = new Map();
|
|
19
|
+
const errorFileCache = new Map();
|
|
20
|
+
const loadingFileCache = new Map();
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Returns ancestor directory segments from root down to file's directory.
|
|
@@ -359,9 +361,117 @@ function applyCascadingLayouts(nunjucksEnv, layouts, pageHtml, renderContext, is
|
|
|
359
361
|
return currentHtml;
|
|
360
362
|
}
|
|
361
363
|
|
|
364
|
+
/**
|
|
365
|
+
* Discover the nearest _error.njk error boundary template in the directory hierarchy
|
|
366
|
+
* (from the file's directory segment climbing up to the root pages directory).
|
|
367
|
+
*
|
|
368
|
+
* @param {string} baseDir - Base pages directory
|
|
369
|
+
* @param {string} relativeFilePath - Relative file path (e.g. 'dashboard/billing/invoices.njk')
|
|
370
|
+
* @param {boolean} [isDev=false]
|
|
371
|
+
* @returns {{ dirSegment: string, errorRelPath: string, fullPath: string, configPath: string|null }|null}
|
|
372
|
+
*/
|
|
373
|
+
function findNearestErrorBoundary(baseDir, relativeFilePath, isDev = false) {
|
|
374
|
+
const chain = getDirectoryChain(relativeFilePath);
|
|
375
|
+
const cacheKey = `${baseDir}::${chain.join('>')}`;
|
|
376
|
+
|
|
377
|
+
if (!isDev && errorFileCache.has(cacheKey)) {
|
|
378
|
+
return errorFileCache.get(cacheKey);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const reversedChain = [...chain].reverse();
|
|
382
|
+
|
|
383
|
+
for (const dirSegment of reversedChain) {
|
|
384
|
+
const errorNjkName = '_error.njk';
|
|
385
|
+
const errorJsName = '_error.js';
|
|
386
|
+
|
|
387
|
+
const errorRelPath = dirSegment === ''
|
|
388
|
+
? errorNjkName
|
|
389
|
+
: `${dirSegment}/${errorNjkName}`;
|
|
390
|
+
|
|
391
|
+
const fullPath = dirSegment === ''
|
|
392
|
+
? path.join(baseDir, errorNjkName)
|
|
393
|
+
: path.join(baseDir, ...dirSegment.split('/'), errorNjkName);
|
|
394
|
+
|
|
395
|
+
const fullJsPath = dirSegment === ''
|
|
396
|
+
? path.join(baseDir, errorJsName)
|
|
397
|
+
: path.join(baseDir, ...dirSegment.split('/'), errorJsName);
|
|
398
|
+
|
|
399
|
+
if (fs.existsSync(fullPath)) {
|
|
400
|
+
const result = {
|
|
401
|
+
dirSegment,
|
|
402
|
+
errorRelPath: errorRelPath.split(path.sep).join('/'),
|
|
403
|
+
fullPath,
|
|
404
|
+
configPath: fs.existsSync(fullJsPath) ? fullJsPath : null,
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
if (!isDev) {
|
|
408
|
+
errorFileCache.set(cacheKey, result);
|
|
409
|
+
}
|
|
410
|
+
return result;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (!isDev) {
|
|
415
|
+
errorFileCache.set(cacheKey, null);
|
|
416
|
+
}
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Discover the nearest _loading.njk loading/skeleton template in the directory hierarchy
|
|
422
|
+
* (from the file's directory segment climbing up to the root pages directory).
|
|
423
|
+
*
|
|
424
|
+
* @param {string} baseDir - Base pages directory
|
|
425
|
+
* @param {string} relativeFilePath - Relative file path (e.g. 'dashboard/billing/invoices.njk')
|
|
426
|
+
* @param {boolean} [isDev=false]
|
|
427
|
+
* @returns {{ dirSegment: string, loadingRelPath: string, fullPath: string }|null}
|
|
428
|
+
*/
|
|
429
|
+
function findNearestLoadingTemplate(baseDir, relativeFilePath, isDev = false) {
|
|
430
|
+
const chain = getDirectoryChain(relativeFilePath);
|
|
431
|
+
const cacheKey = `${baseDir}::${chain.join('>')}`;
|
|
432
|
+
|
|
433
|
+
if (!isDev && loadingFileCache.has(cacheKey)) {
|
|
434
|
+
return loadingFileCache.get(cacheKey);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const reversedChain = [...chain].reverse();
|
|
438
|
+
|
|
439
|
+
for (const dirSegment of reversedChain) {
|
|
440
|
+
const loadingNjkName = '_loading.njk';
|
|
441
|
+
|
|
442
|
+
const loadingRelPath = dirSegment === ''
|
|
443
|
+
? loadingNjkName
|
|
444
|
+
: `${dirSegment}/${loadingNjkName}`;
|
|
445
|
+
|
|
446
|
+
const fullPath = dirSegment === ''
|
|
447
|
+
? path.join(baseDir, loadingNjkName)
|
|
448
|
+
: path.join(baseDir, ...dirSegment.split('/'), loadingNjkName);
|
|
449
|
+
|
|
450
|
+
if (fs.existsSync(fullPath)) {
|
|
451
|
+
const result = {
|
|
452
|
+
dirSegment,
|
|
453
|
+
loadingRelPath: loadingRelPath.split(path.sep).join('/'),
|
|
454
|
+
fullPath,
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
if (!isDev) {
|
|
458
|
+
loadingFileCache.set(cacheKey, result);
|
|
459
|
+
}
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (!isDev) {
|
|
465
|
+
loadingFileCache.set(cacheKey, null);
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
|
|
362
470
|
function clearCascadingCaches() {
|
|
363
471
|
middlewareFileCache.clear();
|
|
364
472
|
layoutFileCache.clear();
|
|
473
|
+
errorFileCache.clear();
|
|
474
|
+
loadingFileCache.clear();
|
|
365
475
|
}
|
|
366
476
|
|
|
367
477
|
module.exports = {
|
|
@@ -374,5 +484,7 @@ module.exports = {
|
|
|
374
484
|
findCascadingLayouts,
|
|
375
485
|
executeCascadingLayoutLoads,
|
|
376
486
|
applyCascadingLayouts,
|
|
487
|
+
findNearestErrorBoundary,
|
|
488
|
+
findNearestLoadingTemplate,
|
|
377
489
|
clearCascadingCaches,
|
|
378
490
|
};
|