webspresso 0.0.73 → 0.0.75
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/README.md +44 -4
- package/bin/commands/orm-map.js +139 -0
- package/bin/commands/skill.js +22 -8
- package/bin/commands/upgrade.js +146 -0
- package/bin/utils/orm-map-html.js +689 -0
- package/bin/utils/orm-map-load.js +85 -0
- package/bin/utils/orm-map-snapshot.js +179 -0
- package/bin/utils/resolve-webspresso-orm.js +23 -0
- package/bin/webspresso.js +4 -0
- package/core/auth/manager.js +14 -1
- package/core/kernel/app.js +96 -0
- package/core/kernel/base-repository.js +143 -0
- package/core/kernel/events.js +101 -0
- package/core/kernel/flow.js +22 -0
- package/core/kernel/index.js +17 -0
- package/core/kernel/plugin.js +23 -0
- package/core/kernel/plugins/sample-seo.js +26 -0
- package/core/kernel/run-demo.js +58 -0
- package/core/kernel/view.js +167 -0
- package/core/openapi/build-from-api-routes.js +8 -2
- package/core/orm/model.js +3 -1
- package/core/url-path-normalize.js +30 -0
- package/index.d.ts +168 -1
- package/index.js +20 -2
- package/package.json +11 -1
- package/plugins/admin-panel/api.js +43 -15
- package/plugins/admin-panel/app.js +109 -0
- package/plugins/admin-panel/client/README.md +39 -0
- package/plugins/admin-panel/client/load-parts.js +74 -0
- package/plugins/admin-panel/client/manifest.parts.json +12 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +150 -0
- package/plugins/admin-panel/client/parts/02-filter-components.js +554 -0
- package/plugins/admin-panel/client/parts/03-pagination-intro.js +70 -0
- package/plugins/admin-panel/client/parts/04-field-renderers.js +287 -0
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +335 -0
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +125 -0
- package/plugins/admin-panel/client/parts/07-model-list.js +596 -0
- package/plugins/admin-panel/client/parts/08-record-list.js +536 -0
- package/plugins/admin-panel/client/parts/09-record-form.js +170 -0
- package/plugins/admin-panel/client/parts/10-export-registry.js +11 -0
- package/plugins/admin-panel/client/verify-spa-parts.js +32 -0
- package/plugins/admin-panel/client/vite.config.example.mjs +22 -0
- package/plugins/admin-panel/components.js +4 -2640
- package/plugins/admin-panel/core/api-extensions.js +100 -10
- package/plugins/admin-panel/index.js +3 -0
- package/plugins/admin-panel/lib/is-rich-text-empty.js +23 -0
- package/plugins/admin-panel/lib/sanitize-rich-html.js +106 -0
- package/plugins/admin-panel/modules/dashboard.js +17 -13
- package/plugins/admin-panel/modules/user-management.js +118 -27
- package/plugins/data-exchange/export-xlsx.js +3 -0
- package/plugins/data-exchange/record-selection.js +21 -5
- package/plugins/index.js +4 -0
- package/plugins/rate-limit/index.js +178 -0
- package/plugins/redirect/index.js +204 -0
- package/plugins/rest-resources/index.js +2 -1
- package/plugins/site-analytics/admin-component.js +88 -78
- package/plugins/swagger.js +2 -1
- package/plugins/upload/local-file-provider.js +6 -2
- package/src/file-router.js +270 -53
- package/src/njk-frontmatter.js +156 -0
- package/src/plugin-manager.js +4 -2
- package/src/server.js +28 -9
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +276 -0
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
- package/templates/skills/webspresso-usage/SKILL.md +29 -275
package/src/file-router.js
CHANGED
|
@@ -9,6 +9,12 @@ const { ZodError } = require('zod');
|
|
|
9
9
|
const { compileSchema, invalidateSchema } = require('../core/compileSchema');
|
|
10
10
|
const { applySchema } = require('../core/applySchema');
|
|
11
11
|
const { createHelpers } = require('./helpers');
|
|
12
|
+
const {
|
|
13
|
+
loadNjkRouteTemplate,
|
|
14
|
+
parseNjkFrontmatter,
|
|
15
|
+
frontmatterToPatches,
|
|
16
|
+
clearNjkFrontmatterCaches,
|
|
17
|
+
} = require('./njk-frontmatter');
|
|
12
18
|
|
|
13
19
|
// Cache for i18n files (key: filePath, value: { mtime, data })
|
|
14
20
|
const i18nCache = new Map();
|
|
@@ -22,6 +28,89 @@ const routeConfigDevCache = new Map();
|
|
|
22
28
|
// Cache for API filename -> { method, baseName } (basename keys; stable per process)
|
|
23
29
|
const methodFromFilenameCache = new Map();
|
|
24
30
|
|
|
31
|
+
const MAX_LOCALE_LEN = 16;
|
|
32
|
+
|
|
33
|
+
/** @returns {Set<string>} */
|
|
34
|
+
function parseSupportedLocaleSet() {
|
|
35
|
+
const raw = process.env.SUPPORTED_LOCALES || 'en';
|
|
36
|
+
return new Set(
|
|
37
|
+
raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean)
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} raw
|
|
43
|
+
* @returns {string|null}
|
|
44
|
+
*/
|
|
45
|
+
function normalizeLocaleCandidate(raw) {
|
|
46
|
+
let s = String(raw).trim().toLowerCase().split(';')[0].split(',')[0].trim().replace(/_/g, '-');
|
|
47
|
+
if (s.length < 1 || s.length > MAX_LOCALE_LEN) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s)) {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
return s;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string|null} normalized
|
|
58
|
+
* @param {Set<string>} supported
|
|
59
|
+
* @returns {string|null}
|
|
60
|
+
*/
|
|
61
|
+
function pickMatchingLocale(normalized, supported) {
|
|
62
|
+
if (!normalized) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (supported.has(normalized)) {
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
const base = normalized.split('-')[0];
|
|
69
|
+
if (supported.has(base)) {
|
|
70
|
+
return base;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Linear-time conversion of [...x] → * then [x] → :x (filesystem route segments only).
|
|
77
|
+
* @param {string} route
|
|
78
|
+
*/
|
|
79
|
+
function rewriteDynamicRouteMarkers(route) {
|
|
80
|
+
let i = 0;
|
|
81
|
+
let out = '';
|
|
82
|
+
const n = route.length;
|
|
83
|
+
while (i < n) {
|
|
84
|
+
const open = route.indexOf('[', i);
|
|
85
|
+
if (open === -1) {
|
|
86
|
+
out += route.slice(i);
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
out += route.slice(i, open);
|
|
90
|
+
const close = route.indexOf(']', open + 1);
|
|
91
|
+
if (close === -1) {
|
|
92
|
+
out += route.slice(open);
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
const inner = route.slice(open + 1, close);
|
|
96
|
+
let repl;
|
|
97
|
+
if (inner.startsWith('...') && inner.length > 3) {
|
|
98
|
+
repl = '*';
|
|
99
|
+
} else if (inner.length > 0 && inner.indexOf('[') === -1) {
|
|
100
|
+
repl = ':' + inner;
|
|
101
|
+
} else {
|
|
102
|
+
repl = route.slice(open, close + 1);
|
|
103
|
+
}
|
|
104
|
+
out += repl;
|
|
105
|
+
i = close + 1;
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function escapeRegExp(s) {
|
|
111
|
+
return String(s).replace(/[\\^$*+?.()|[\]{}]/g, '\\$&');
|
|
112
|
+
}
|
|
113
|
+
|
|
25
114
|
/**
|
|
26
115
|
* Convert a file path to an Express route pattern
|
|
27
116
|
* @param {string} filePath - Relative path from pages/
|
|
@@ -43,12 +132,9 @@ function filePathToRoute(filePath, ext) {
|
|
|
43
132
|
route = '/';
|
|
44
133
|
}
|
|
45
134
|
|
|
46
|
-
// Convert [param]
|
|
47
|
-
route = route
|
|
48
|
-
|
|
49
|
-
// Convert [...param] to * (catch-all)
|
|
50
|
-
route = route.replace(/\[\.\.\.([^\]]+)\]/g, '*');
|
|
51
|
-
|
|
135
|
+
// Convert [param] / [...param] without regex backtracking hazards
|
|
136
|
+
route = rewriteDynamicRouteMarkers(route);
|
|
137
|
+
|
|
52
138
|
// Ensure leading slash
|
|
53
139
|
if (!route.startsWith('/')) {
|
|
54
140
|
route = '/' + route;
|
|
@@ -65,27 +151,50 @@ function filePathToRoute(filePath, ext) {
|
|
|
65
151
|
* @returns {{ tier: number, literalSegCount: number, paramSegCount: number, depth: number, routePath: string }}
|
|
66
152
|
*/
|
|
67
153
|
function routeRegistrationMeta(routePath) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
let
|
|
71
|
-
if (hasCatchAll) tier = 2;
|
|
72
|
-
else if (hasDynamic) tier = 1;
|
|
73
|
-
else tier = 0;
|
|
74
|
-
|
|
75
|
-
const segments = routePath.split('/').filter(Boolean);
|
|
154
|
+
let pathHasStar = false;
|
|
155
|
+
let pathHasColon = false;
|
|
156
|
+
let depth = 0;
|
|
76
157
|
let literalSegCount = 0;
|
|
77
158
|
let paramSegCount = 0;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
159
|
+
|
|
160
|
+
const s = routePath;
|
|
161
|
+
const n = s.length;
|
|
162
|
+
let i = 0;
|
|
163
|
+
while (i < n) {
|
|
164
|
+
while (i < n && s.charCodeAt(i) === 47 /* / */) i++;
|
|
165
|
+
if (i >= n) break;
|
|
166
|
+
const start = i;
|
|
167
|
+
while (i < n && s.charCodeAt(i) !== 47) i++;
|
|
168
|
+
|
|
169
|
+
depth++;
|
|
170
|
+
let segHasStar = false;
|
|
171
|
+
let segHasColon = false;
|
|
172
|
+
for (let j = start; j < i; j++) {
|
|
173
|
+
const c = s.charCodeAt(j);
|
|
174
|
+
if (c === 42 /* * */) segHasStar = true;
|
|
175
|
+
else if (c === 58 /* : */) segHasColon = true;
|
|
176
|
+
}
|
|
177
|
+
if (segHasStar) pathHasStar = true;
|
|
178
|
+
if (segHasColon) pathHasColon = true;
|
|
179
|
+
|
|
180
|
+
if (segHasStar) {
|
|
181
|
+
// Same as: seg === '*' || (seg.length > 0 && seg.includes('*'))
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (segHasColon) paramSegCount += 1;
|
|
81
185
|
else literalSegCount += 1;
|
|
82
186
|
}
|
|
83
187
|
|
|
188
|
+
let tier;
|
|
189
|
+
if (pathHasStar) tier = 2;
|
|
190
|
+
else if (pathHasColon) tier = 1;
|
|
191
|
+
else tier = 0;
|
|
192
|
+
|
|
84
193
|
return {
|
|
85
194
|
tier,
|
|
86
195
|
literalSegCount,
|
|
87
196
|
paramSegCount,
|
|
88
|
-
depth
|
|
197
|
+
depth,
|
|
89
198
|
routePath,
|
|
90
199
|
};
|
|
91
200
|
}
|
|
@@ -140,6 +249,69 @@ function extractMethodFromFilename(filename) {
|
|
|
140
249
|
return result;
|
|
141
250
|
}
|
|
142
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Whether `load()` return values for `stylesheets` and `scripts` are promoted to
|
|
254
|
+
* `pageHead` in Nunjucks (see `createApp({ pageAssets })`).
|
|
255
|
+
* @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}|null|undefined} raw
|
|
256
|
+
* @returns {{ enabled: boolean, stylesheets: boolean, scripts: boolean }}
|
|
257
|
+
*/
|
|
258
|
+
function resolvePageAssets(raw) {
|
|
259
|
+
if (raw === true) {
|
|
260
|
+
return { enabled: true, stylesheets: true, scripts: true };
|
|
261
|
+
}
|
|
262
|
+
if (raw == null || raw === false) {
|
|
263
|
+
return { enabled: false, stylesheets: false, scripts: false };
|
|
264
|
+
}
|
|
265
|
+
if (typeof raw === 'object') {
|
|
266
|
+
const on = raw.enabled !== false;
|
|
267
|
+
if (!on) {
|
|
268
|
+
return { enabled: false, stylesheets: false, scripts: false };
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
enabled: true,
|
|
272
|
+
stylesheets: raw.stylesheets !== false,
|
|
273
|
+
scripts: raw.scripts !== false,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
return { enabled: false, stylesheets: false, scripts: false };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* @param {unknown} v
|
|
281
|
+
* @returns {unknown[]}
|
|
282
|
+
*/
|
|
283
|
+
function toList(v) {
|
|
284
|
+
if (v == null) return [];
|
|
285
|
+
return Array.isArray(v) ? v : [v];
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {{ enabled: boolean, stylesheets: boolean, scripts: boolean }} cfg
|
|
290
|
+
* @param {Object} data
|
|
291
|
+
* @returns {{ data: Object, pageHead: { stylesheets: unknown[], scripts: unknown[] }|null, pageAssets: boolean }}
|
|
292
|
+
*/
|
|
293
|
+
function applyPageAssetsToTemplateData(cfg, data) {
|
|
294
|
+
if (!cfg || !cfg.enabled) {
|
|
295
|
+
return { data, pageHead: null, pageAssets: false };
|
|
296
|
+
}
|
|
297
|
+
const out = { ...data };
|
|
298
|
+
let styles = [];
|
|
299
|
+
let scriptItems = [];
|
|
300
|
+
if (cfg.stylesheets && Object.prototype.hasOwnProperty.call(out, 'stylesheets')) {
|
|
301
|
+
styles = toList(out.stylesheets);
|
|
302
|
+
delete out.stylesheets;
|
|
303
|
+
}
|
|
304
|
+
if (cfg.scripts && Object.prototype.hasOwnProperty.call(out, 'scripts')) {
|
|
305
|
+
scriptItems = toList(out.scripts);
|
|
306
|
+
delete out.scripts;
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
data: out,
|
|
310
|
+
pageHead: { stylesheets: styles, scripts: scriptItems },
|
|
311
|
+
pageAssets: true,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
143
315
|
/**
|
|
144
316
|
* Recursively scan a directory for files
|
|
145
317
|
* @param {string} dir - Directory to scan
|
|
@@ -197,7 +369,7 @@ function loadI18nFile(filePath) {
|
|
|
197
369
|
i18nCache.set(filePath, { mtime: stats.mtimeMs, data });
|
|
198
370
|
return data;
|
|
199
371
|
} catch (err) {
|
|
200
|
-
console.error(
|
|
372
|
+
console.error('Error loading i18n file:', filePath, err.message);
|
|
201
373
|
return {};
|
|
202
374
|
}
|
|
203
375
|
}
|
|
@@ -252,7 +424,8 @@ function createTranslator(translations) {
|
|
|
252
424
|
// Replace params like {{name}} in the translation
|
|
253
425
|
if (typeof value === 'string' && Object.keys(params).length > 0) {
|
|
254
426
|
for (const [paramKey, paramValue] of Object.entries(params)) {
|
|
255
|
-
|
|
427
|
+
const escaped = escapeRegExp(paramKey);
|
|
428
|
+
value = value.replace(new RegExp(`{{\\s*${escaped}\\s*}}`, 'g'), paramValue);
|
|
256
429
|
}
|
|
257
430
|
}
|
|
258
431
|
|
|
@@ -295,7 +468,7 @@ function loadRouteConfig(configPath, isDev) {
|
|
|
295
468
|
configCache.set(configPath, config);
|
|
296
469
|
return config;
|
|
297
470
|
} catch (err) {
|
|
298
|
-
console.error(
|
|
471
|
+
console.error('Error loading route config:', configPath, err.message);
|
|
299
472
|
return null;
|
|
300
473
|
}
|
|
301
474
|
}
|
|
@@ -317,9 +490,9 @@ function loadGlobalHooks(pagesDir, isDev) {
|
|
|
317
490
|
* @param {string} hookName - Hook name
|
|
318
491
|
* @param {Object} ctx - Context object
|
|
319
492
|
*/
|
|
320
|
-
async function executeHook(hooks, hookName, ctx) {
|
|
493
|
+
async function executeHook(hooks, hookName, ctx, ...extra) {
|
|
321
494
|
if (hooks && typeof hooks[hookName] === 'function') {
|
|
322
|
-
await hooks[hookName](ctx);
|
|
495
|
+
await hooks[hookName](ctx, ...extra);
|
|
323
496
|
}
|
|
324
497
|
}
|
|
325
498
|
|
|
@@ -329,23 +502,32 @@ async function executeHook(hooks, hookName, ctx) {
|
|
|
329
502
|
* @returns {string} Locale code
|
|
330
503
|
*/
|
|
331
504
|
function detectLocale(req) {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
505
|
+
const supported = parseSupportedLocaleSet();
|
|
506
|
+
const defaultCand = normalizeLocaleCandidate(process.env.DEFAULT_LOCALE || 'en');
|
|
507
|
+
const def =
|
|
508
|
+
pickMatchingLocale(defaultCand, supported)
|
|
509
|
+
?? (supported.has('en') ? 'en' : [...supported][0])
|
|
510
|
+
?? 'en';
|
|
511
|
+
|
|
512
|
+
if (req.query && req.query.lang != null && req.query.lang !== '') {
|
|
513
|
+
const q = normalizeLocaleCandidate(String(req.query.lang));
|
|
514
|
+
const hit = pickMatchingLocale(q, supported);
|
|
515
|
+
if (hit) {
|
|
516
|
+
return hit;
|
|
517
|
+
}
|
|
335
518
|
}
|
|
336
|
-
|
|
337
|
-
// 2. Check Accept-Language header
|
|
519
|
+
|
|
338
520
|
const acceptLanguage = req.get('Accept-Language');
|
|
339
521
|
if (acceptLanguage) {
|
|
340
|
-
const
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
522
|
+
const langPart = acceptLanguage.split(',')[0];
|
|
523
|
+
const a = normalizeLocaleCandidate(langPart);
|
|
524
|
+
const hit = pickMatchingLocale(a, supported);
|
|
525
|
+
if (hit) {
|
|
526
|
+
return hit;
|
|
344
527
|
}
|
|
345
528
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
return process.env.DEFAULT_LOCALE || 'en';
|
|
529
|
+
|
|
530
|
+
return def;
|
|
349
531
|
}
|
|
350
532
|
|
|
351
533
|
/**
|
|
@@ -439,6 +621,7 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
|
439
621
|
* @param {boolean} options.silent - Suppress console output
|
|
440
622
|
* @param {Object} options.db - Database instance (exposed as ctx.db in load/meta)
|
|
441
623
|
* @param {{ alpine?: boolean, swup?: boolean }} [options.clientRuntime] - Passed to Nunjucks as `clientRuntime` (default both false)
|
|
624
|
+
* @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}} [options.pageAssets] - If set, `load()` may return `stylesheets` / `scripts` promoted to `pageHead` in templates
|
|
442
625
|
* @returns {Array} Route metadata for plugins
|
|
443
626
|
*/
|
|
444
627
|
function mountPages(app, options) {
|
|
@@ -450,7 +633,9 @@ function mountPages(app, options) {
|
|
|
450
633
|
silent = false,
|
|
451
634
|
db = null,
|
|
452
635
|
clientRuntime: clientRuntimeOpt = null,
|
|
636
|
+
pageAssets: pageAssetsOpt = null,
|
|
453
637
|
} = options;
|
|
638
|
+
const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
|
|
454
639
|
const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
|
|
455
640
|
? { alpine: !!clientRuntimeOpt.alpine, swup: !!clientRuntimeOpt.swup }
|
|
456
641
|
: { alpine: false, swup: false };
|
|
@@ -485,7 +670,7 @@ function mountPages(app, options) {
|
|
|
485
670
|
apiRoutes.push({
|
|
486
671
|
file,
|
|
487
672
|
method,
|
|
488
|
-
routePath: filePathToRoute(routePath
|
|
673
|
+
routePath: filePathToRoute(routePath, ''),
|
|
489
674
|
fullPath: path.join(absolutePagesDir, file)
|
|
490
675
|
});
|
|
491
676
|
} else if (ext === '.njk') {
|
|
@@ -520,6 +705,10 @@ function mountPages(app, options) {
|
|
|
520
705
|
const handler = require(route.fullPath);
|
|
521
706
|
const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
|
|
522
707
|
const routeMiddleware = handler.middleware;
|
|
708
|
+
|
|
709
|
+
const preResolvedMw = routeMiddleware
|
|
710
|
+
? resolveMiddlewares(routeMiddleware, middlewares)
|
|
711
|
+
: [];
|
|
523
712
|
|
|
524
713
|
if (typeof handlerFn !== 'function') {
|
|
525
714
|
console.warn(`API route ${route.file} does not export a function`);
|
|
@@ -568,11 +757,9 @@ function mountPages(app, options) {
|
|
|
568
757
|
throw err;
|
|
569
758
|
}
|
|
570
759
|
|
|
571
|
-
// Run middleware if defined
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
const resolvedMw = resolveMiddlewares(mwConfig, middlewares);
|
|
575
|
-
for (const mw of resolvedMw) {
|
|
760
|
+
// Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
|
|
761
|
+
if (preResolvedMw.length) {
|
|
762
|
+
for (const mw of preResolvedMw) {
|
|
576
763
|
await new Promise((resolve, reject) => {
|
|
577
764
|
mw(req, res, (err) => {
|
|
578
765
|
if (err) reject(err);
|
|
@@ -585,7 +772,13 @@ function mountPages(app, options) {
|
|
|
585
772
|
await fn(req, res, next);
|
|
586
773
|
} catch (err) {
|
|
587
774
|
console.error(`API error ${route.routePath}:`, err);
|
|
588
|
-
|
|
775
|
+
const hookCtx = { req, res, error: err };
|
|
776
|
+
try {
|
|
777
|
+
await executeHook(globalHooks, 'onError', hookCtx, err);
|
|
778
|
+
} catch (hookErr) {
|
|
779
|
+
console.error('Error in onError hook:', hookErr);
|
|
780
|
+
}
|
|
781
|
+
return next(err);
|
|
589
782
|
}
|
|
590
783
|
});
|
|
591
784
|
|
|
@@ -596,6 +789,11 @@ function mountPages(app, options) {
|
|
|
596
789
|
/** Register SSR GET routes (shared by static phase and dynamic phase). */
|
|
597
790
|
const registerSsrRoutes = (routes) => {
|
|
598
791
|
for (const route of routes) {
|
|
792
|
+
const mountConfig = loadRouteConfig(route.configPath, isDev);
|
|
793
|
+
const preResolvedPageMw = mountConfig?.middleware
|
|
794
|
+
? resolveMiddlewares(mountConfig.middleware, middlewares)
|
|
795
|
+
: [];
|
|
796
|
+
|
|
599
797
|
app.get(route.routePath, async (req, res, next) => {
|
|
600
798
|
try {
|
|
601
799
|
// Detect locale
|
|
@@ -613,6 +811,8 @@ function mountPages(app, options) {
|
|
|
613
811
|
const baseHelpers = createHelpers({ req, res, locale });
|
|
614
812
|
const pluginHelpers = pluginManager ? pluginManager.getHelpers() : {};
|
|
615
813
|
|
|
814
|
+
const njkTpl = loadNjkRouteTemplate(route.fullPath, isDev);
|
|
815
|
+
|
|
616
816
|
const ctx = {
|
|
617
817
|
req,
|
|
618
818
|
res,
|
|
@@ -622,12 +822,13 @@ function mountPages(app, options) {
|
|
|
622
822
|
routeDir: route.routeDir,
|
|
623
823
|
locale,
|
|
624
824
|
t,
|
|
625
|
-
data: {},
|
|
825
|
+
data: { ...njkTpl.dataPatch },
|
|
626
826
|
meta: {
|
|
627
827
|
title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
|
|
628
828
|
description: t('meta.description') !== 'meta.description' ? t('meta.description') : null,
|
|
629
829
|
indexable: true,
|
|
630
|
-
canonical: null
|
|
830
|
+
canonical: null,
|
|
831
|
+
...njkTpl.metaPatch,
|
|
631
832
|
},
|
|
632
833
|
fsy: { ...baseHelpers, ...pluginHelpers },
|
|
633
834
|
clientRuntime,
|
|
@@ -645,10 +846,9 @@ function mountPages(app, options) {
|
|
|
645
846
|
await executeHook(globalHooks, 'beforeMiddleware', ctx);
|
|
646
847
|
await executeHook(routeHooks, 'beforeMiddleware', ctx);
|
|
647
848
|
|
|
648
|
-
// Run route middleware
|
|
649
|
-
if (
|
|
650
|
-
const
|
|
651
|
-
for (const mw of resolvedMiddlewares) {
|
|
849
|
+
// Run route middleware (chain fixed at route registration; edit middleware in dev → restart)
|
|
850
|
+
if (preResolvedPageMw.length) {
|
|
851
|
+
for (const mw of preResolvedPageMw) {
|
|
652
852
|
await new Promise((resolve, reject) => {
|
|
653
853
|
mw(req, res, (err) => {
|
|
654
854
|
if (err) reject(err);
|
|
@@ -686,9 +886,9 @@ function mountPages(app, options) {
|
|
|
686
886
|
await executeHook(globalHooks, 'beforeRender', ctx);
|
|
687
887
|
await executeHook(routeHooks, 'beforeRender', ctx);
|
|
688
888
|
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
const
|
|
889
|
+
const pageAssetBundle = applyPageAssetsToTemplateData(pageAssetsResolved, ctx.data);
|
|
890
|
+
ctx.data = pageAssetBundle.data;
|
|
891
|
+
const renderContext = {
|
|
692
892
|
...ctx.data,
|
|
693
893
|
meta: ctx.meta,
|
|
694
894
|
locale: ctx.locale,
|
|
@@ -700,7 +900,18 @@ function mountPages(app, options) {
|
|
|
700
900
|
query: req.query,
|
|
701
901
|
params: req.params
|
|
702
902
|
}
|
|
703
|
-
}
|
|
903
|
+
};
|
|
904
|
+
if (pageAssetBundle.pageAssets) {
|
|
905
|
+
renderContext.pageAssets = true;
|
|
906
|
+
renderContext.pageHead = pageAssetBundle.pageHead;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Render the template
|
|
910
|
+
const templatePath = route.file.split(path.sep).join('/');
|
|
911
|
+
const html =
|
|
912
|
+
njkTpl.useStringRender && njkTpl.templateBody != null
|
|
913
|
+
? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
|
|
914
|
+
: nunjucks.render(templatePath, renderContext);
|
|
704
915
|
|
|
705
916
|
// Execute hooks: afterRender
|
|
706
917
|
ctx.html = html;
|
|
@@ -719,7 +930,7 @@ function mountPages(app, options) {
|
|
|
719
930
|
console.error('Error in onError hook:', hookErr);
|
|
720
931
|
}
|
|
721
932
|
|
|
722
|
-
|
|
933
|
+
return next(err);
|
|
723
934
|
}
|
|
724
935
|
});
|
|
725
936
|
|
|
@@ -769,5 +980,11 @@ module.exports = {
|
|
|
769
980
|
resolveMiddlewares,
|
|
770
981
|
routeRegistrationMeta,
|
|
771
982
|
compareRouteRegistrationOrder,
|
|
983
|
+
resolvePageAssets,
|
|
984
|
+
applyPageAssetsToTemplateData,
|
|
985
|
+
parseNjkFrontmatter,
|
|
986
|
+
frontmatterToPatches,
|
|
987
|
+
loadNjkRouteTemplate,
|
|
988
|
+
clearNjkFrontmatterCaches,
|
|
772
989
|
};
|
|
773
990
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* YAML frontmatter for pages/*.njk (optional).
|
|
3
|
+
* Opens with --- … --- at file top; merges into ctx.meta / ctx.data before route hooks/load.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { parse } = require('yaml');
|
|
8
|
+
|
|
9
|
+
const FRONTMATTER_BLOCK = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/;
|
|
10
|
+
|
|
11
|
+
/** @typedef {{ metaPatch: Record<string, unknown>, dataPatch: Record<string, unknown> }} Patch */
|
|
12
|
+
|
|
13
|
+
const prodRouteCache = new Map();
|
|
14
|
+
/** @type {Map<string, { mtimeMs: number, cached: LoadedNjk }>} */
|
|
15
|
+
const devRouteCache = new Map();
|
|
16
|
+
|
|
17
|
+
/** @typedef {{ useStringRender: boolean, templateBody: string|null, metaPatch: Record<string, unknown>, dataPatch: Record<string, unknown> }} LoadedNjk */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} raw
|
|
21
|
+
* @returns {{ body: string, yamlText: string|null, extracted: boolean }}
|
|
22
|
+
*/
|
|
23
|
+
function extractFrontmatterBlock(raw) {
|
|
24
|
+
const strippedBom = raw.replace(/^\uFEFF/, '');
|
|
25
|
+
const match = strippedBom.match(FRONTMATTER_BLOCK);
|
|
26
|
+
if (!match) {
|
|
27
|
+
return { body: strippedBom, yamlText: null, extracted: false };
|
|
28
|
+
}
|
|
29
|
+
const body = strippedBom.slice(match[0].length);
|
|
30
|
+
const yamlText = match[1] != null ? String(match[1]).trimEnd() : '';
|
|
31
|
+
return { body, yamlText, extracted: true };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} content Full .njk file contents
|
|
36
|
+
*/
|
|
37
|
+
function parseNjkFrontmatter(content) {
|
|
38
|
+
const { body, yamlText, extracted } = extractFrontmatterBlock(content);
|
|
39
|
+
if (!extracted || yamlText == null) {
|
|
40
|
+
return { body, fm: null, hasDelimiter: extracted };
|
|
41
|
+
}
|
|
42
|
+
if (yamlText === '') {
|
|
43
|
+
return { body, fm: {}, hasDelimiter: true };
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const fm = parse(yamlText);
|
|
47
|
+
return {
|
|
48
|
+
body,
|
|
49
|
+
fm: fm && typeof fm === 'object' && !Array.isArray(fm) ? fm : null,
|
|
50
|
+
hasDelimiter: true,
|
|
51
|
+
};
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.warn('[webspresso] .njk frontmatter YAML parse failed — rendering without fm meta:', err.message);
|
|
54
|
+
return { body, fm: null, hasDelimiter: true };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {unknown} fm
|
|
60
|
+
* @returns {Patch}
|
|
61
|
+
*/
|
|
62
|
+
function frontmatterToPatches(fm) {
|
|
63
|
+
if (!fm || typeof fm !== 'object' || Array.isArray(fm)) {
|
|
64
|
+
return { metaPatch: {}, dataPatch: {} };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @type {Record<string, unknown>} */
|
|
68
|
+
const metaPatch = {};
|
|
69
|
+
const fmObj = fm;
|
|
70
|
+
const nestedMeta = fmObj.meta;
|
|
71
|
+
if (nestedMeta && typeof nestedMeta === 'object' && !Array.isArray(nestedMeta)) {
|
|
72
|
+
Object.assign(metaPatch, nestedMeta);
|
|
73
|
+
}
|
|
74
|
+
for (const k of ['title', 'description', 'canonical', 'indexable']) {
|
|
75
|
+
if (Object.prototype.hasOwnProperty.call(fmObj, k) && fmObj[k] !== undefined) {
|
|
76
|
+
metaPatch[k] = fmObj[k];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** @type {Record<string, unknown>} */
|
|
81
|
+
let dataPatch = {};
|
|
82
|
+
const d = fmObj.data;
|
|
83
|
+
if (d && typeof d === 'object' && !Array.isArray(d)) {
|
|
84
|
+
dataPatch = { ...d };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { metaPatch, dataPatch };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {boolean} [isDevLike]
|
|
92
|
+
*/
|
|
93
|
+
function readAndParse(absPath, isDevLike = false) {
|
|
94
|
+
const raw = fs.readFileSync(absPath, 'utf8');
|
|
95
|
+
const { body, fm, hasDelimiter } = parseNjkFrontmatter(raw);
|
|
96
|
+
const patches = frontmatterToPatches(fm);
|
|
97
|
+
|
|
98
|
+
/** @type {LoadedNjk} */
|
|
99
|
+
const loaded = {
|
|
100
|
+
useStringRender: !!hasDelimiter,
|
|
101
|
+
templateBody: hasDelimiter ? body : null,
|
|
102
|
+
metaPatch: patches.metaPatch,
|
|
103
|
+
dataPatch: patches.dataPatch,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
if (isDevLike) {
|
|
107
|
+
return loaded;
|
|
108
|
+
}
|
|
109
|
+
prodRouteCache.set(absPath, loaded);
|
|
110
|
+
return loaded;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {string} absPath Absolute path to pages/…/*.njk
|
|
115
|
+
* @param {boolean} isDev NODE_ENV !== 'production' style
|
|
116
|
+
*/
|
|
117
|
+
function loadNjkRouteTemplate(absPath, isDev) {
|
|
118
|
+
if (!fs.existsSync(absPath)) {
|
|
119
|
+
return {
|
|
120
|
+
useStringRender: false,
|
|
121
|
+
templateBody: null,
|
|
122
|
+
metaPatch: {},
|
|
123
|
+
dataPatch: {},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stat = fs.statSync(absPath);
|
|
128
|
+
|
|
129
|
+
if (isDev) {
|
|
130
|
+
const prev = devRouteCache.get(absPath);
|
|
131
|
+
if (prev && prev.mtimeMs >= stat.mtimeMs) {
|
|
132
|
+
return prev.cached;
|
|
133
|
+
}
|
|
134
|
+
const fresh = readAndParse(absPath, true);
|
|
135
|
+
devRouteCache.set(absPath, { mtimeMs: stat.mtimeMs, cached: fresh });
|
|
136
|
+
return fresh;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (prodRouteCache.has(absPath)) {
|
|
140
|
+
return prodRouteCache.get(absPath);
|
|
141
|
+
}
|
|
142
|
+
return readAndParse(absPath, false);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function clearNjkFrontmatterCaches() {
|
|
146
|
+
prodRouteCache.clear();
|
|
147
|
+
devRouteCache.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = {
|
|
151
|
+
parseNjkFrontmatter,
|
|
152
|
+
frontmatterToPatches,
|
|
153
|
+
loadNjkRouteTemplate,
|
|
154
|
+
clearNjkFrontmatterCaches,
|
|
155
|
+
extractFrontmatterBlock,
|
|
156
|
+
};
|
package/src/plugin-manager.js
CHANGED
|
@@ -130,7 +130,7 @@ class PluginManager {
|
|
|
130
130
|
/**
|
|
131
131
|
* Register plugins with the manager (async version)
|
|
132
132
|
* @param {Array} plugins - Array of plugin definitions or factory functions
|
|
133
|
-
* @param {Object} context - Context object { app, nunjucksEnv, options }
|
|
133
|
+
* @param {Object} context - Context object `{ app, nunjucksEnv, options, middlewares? }`
|
|
134
134
|
*/
|
|
135
135
|
async register(plugins, context) {
|
|
136
136
|
if (!plugins || !Array.isArray(plugins)) return;
|
|
@@ -159,7 +159,7 @@ class PluginManager {
|
|
|
159
159
|
/**
|
|
160
160
|
* Register plugins with the manager (sync version)
|
|
161
161
|
* @param {Array} plugins - Array of plugin definitions or factory functions
|
|
162
|
-
* @param {Object} context - Context object { app, nunjucksEnv, options }
|
|
162
|
+
* @param {Object} context - Context object `{ app, nunjucksEnv, options, middlewares? }`
|
|
163
163
|
*/
|
|
164
164
|
registerSync(plugins, context) {
|
|
165
165
|
if (!plugins || !Array.isArray(plugins)) return;
|
|
@@ -372,6 +372,8 @@ class PluginManager {
|
|
|
372
372
|
options: plugin._options || {},
|
|
373
373
|
nunjucksEnv: context.nunjucksEnv,
|
|
374
374
|
db: context.options?.db ?? null,
|
|
375
|
+
/** @type {Record<string, unknown>} Named middleware registry (same reference as createApp → mountPages) */
|
|
376
|
+
middlewares: context.middlewares ?? {},
|
|
375
377
|
|
|
376
378
|
/**
|
|
377
379
|
* Get another plugin's API
|