webspresso 0.0.74 → 0.0.76
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 +66 -4
- package/bin/commands/admin-password.js +21 -57
- package/bin/commands/orm-map.js +139 -0
- package/bin/commands/skill.js +22 -8
- 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 +2 -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/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 +3 -2
- package/plugins/admin-panel/modules/user-management.js +90 -20
- 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/swagger.js +2 -1
- package/plugins/upload/local-file-provider.js +6 -2
- package/src/file-router.js +191 -50
- package/src/njk-frontmatter.js +156 -0
- package/src/plugin-manager.js +4 -2
- package/src/server.js +26 -9
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +277 -0
- package/templates/skills/webspresso-usage/REFERENCE-kernel.md +93 -0
- package/templates/skills/webspresso-usage/SKILL.md +29 -278
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
|
}
|
|
@@ -260,7 +369,7 @@ function loadI18nFile(filePath) {
|
|
|
260
369
|
i18nCache.set(filePath, { mtime: stats.mtimeMs, data });
|
|
261
370
|
return data;
|
|
262
371
|
} catch (err) {
|
|
263
|
-
console.error(
|
|
372
|
+
console.error('Error loading i18n file:', filePath, err.message);
|
|
264
373
|
return {};
|
|
265
374
|
}
|
|
266
375
|
}
|
|
@@ -315,7 +424,8 @@ function createTranslator(translations) {
|
|
|
315
424
|
// Replace params like {{name}} in the translation
|
|
316
425
|
if (typeof value === 'string' && Object.keys(params).length > 0) {
|
|
317
426
|
for (const [paramKey, paramValue] of Object.entries(params)) {
|
|
318
|
-
|
|
427
|
+
const escaped = escapeRegExp(paramKey);
|
|
428
|
+
value = value.replace(new RegExp(`{{\\s*${escaped}\\s*}}`, 'g'), paramValue);
|
|
319
429
|
}
|
|
320
430
|
}
|
|
321
431
|
|
|
@@ -358,7 +468,7 @@ function loadRouteConfig(configPath, isDev) {
|
|
|
358
468
|
configCache.set(configPath, config);
|
|
359
469
|
return config;
|
|
360
470
|
} catch (err) {
|
|
361
|
-
console.error(
|
|
471
|
+
console.error('Error loading route config:', configPath, err.message);
|
|
362
472
|
return null;
|
|
363
473
|
}
|
|
364
474
|
}
|
|
@@ -380,9 +490,9 @@ function loadGlobalHooks(pagesDir, isDev) {
|
|
|
380
490
|
* @param {string} hookName - Hook name
|
|
381
491
|
* @param {Object} ctx - Context object
|
|
382
492
|
*/
|
|
383
|
-
async function executeHook(hooks, hookName, ctx) {
|
|
493
|
+
async function executeHook(hooks, hookName, ctx, ...extra) {
|
|
384
494
|
if (hooks && typeof hooks[hookName] === 'function') {
|
|
385
|
-
await hooks[hookName](ctx);
|
|
495
|
+
await hooks[hookName](ctx, ...extra);
|
|
386
496
|
}
|
|
387
497
|
}
|
|
388
498
|
|
|
@@ -392,23 +502,32 @@ async function executeHook(hooks, hookName, ctx) {
|
|
|
392
502
|
* @returns {string} Locale code
|
|
393
503
|
*/
|
|
394
504
|
function detectLocale(req) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
+
}
|
|
398
518
|
}
|
|
399
|
-
|
|
400
|
-
// 2. Check Accept-Language header
|
|
519
|
+
|
|
401
520
|
const acceptLanguage = req.get('Accept-Language');
|
|
402
521
|
if (acceptLanguage) {
|
|
403
|
-
const
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
522
|
+
const langPart = acceptLanguage.split(',')[0];
|
|
523
|
+
const a = normalizeLocaleCandidate(langPart);
|
|
524
|
+
const hit = pickMatchingLocale(a, supported);
|
|
525
|
+
if (hit) {
|
|
526
|
+
return hit;
|
|
407
527
|
}
|
|
408
528
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return process.env.DEFAULT_LOCALE || 'en';
|
|
529
|
+
|
|
530
|
+
return def;
|
|
412
531
|
}
|
|
413
532
|
|
|
414
533
|
/**
|
|
@@ -551,7 +670,7 @@ function mountPages(app, options) {
|
|
|
551
670
|
apiRoutes.push({
|
|
552
671
|
file,
|
|
553
672
|
method,
|
|
554
|
-
routePath: filePathToRoute(routePath
|
|
673
|
+
routePath: filePathToRoute(routePath, ''),
|
|
555
674
|
fullPath: path.join(absolutePagesDir, file)
|
|
556
675
|
});
|
|
557
676
|
} else if (ext === '.njk') {
|
|
@@ -586,6 +705,10 @@ function mountPages(app, options) {
|
|
|
586
705
|
const handler = require(route.fullPath);
|
|
587
706
|
const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
|
|
588
707
|
const routeMiddleware = handler.middleware;
|
|
708
|
+
|
|
709
|
+
const preResolvedMw = routeMiddleware
|
|
710
|
+
? resolveMiddlewares(routeMiddleware, middlewares)
|
|
711
|
+
: [];
|
|
589
712
|
|
|
590
713
|
if (typeof handlerFn !== 'function') {
|
|
591
714
|
console.warn(`API route ${route.file} does not export a function`);
|
|
@@ -634,11 +757,9 @@ function mountPages(app, options) {
|
|
|
634
757
|
throw err;
|
|
635
758
|
}
|
|
636
759
|
|
|
637
|
-
// Run middleware if defined
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
const resolvedMw = resolveMiddlewares(mwConfig, middlewares);
|
|
641
|
-
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) {
|
|
642
763
|
await new Promise((resolve, reject) => {
|
|
643
764
|
mw(req, res, (err) => {
|
|
644
765
|
if (err) reject(err);
|
|
@@ -651,7 +772,13 @@ function mountPages(app, options) {
|
|
|
651
772
|
await fn(req, res, next);
|
|
652
773
|
} catch (err) {
|
|
653
774
|
console.error(`API error ${route.routePath}:`, err);
|
|
654
|
-
|
|
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);
|
|
655
782
|
}
|
|
656
783
|
});
|
|
657
784
|
|
|
@@ -662,6 +789,11 @@ function mountPages(app, options) {
|
|
|
662
789
|
/** Register SSR GET routes (shared by static phase and dynamic phase). */
|
|
663
790
|
const registerSsrRoutes = (routes) => {
|
|
664
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
|
+
|
|
665
797
|
app.get(route.routePath, async (req, res, next) => {
|
|
666
798
|
try {
|
|
667
799
|
// Detect locale
|
|
@@ -679,6 +811,8 @@ function mountPages(app, options) {
|
|
|
679
811
|
const baseHelpers = createHelpers({ req, res, locale });
|
|
680
812
|
const pluginHelpers = pluginManager ? pluginManager.getHelpers() : {};
|
|
681
813
|
|
|
814
|
+
const njkTpl = loadNjkRouteTemplate(route.fullPath, isDev);
|
|
815
|
+
|
|
682
816
|
const ctx = {
|
|
683
817
|
req,
|
|
684
818
|
res,
|
|
@@ -688,12 +822,13 @@ function mountPages(app, options) {
|
|
|
688
822
|
routeDir: route.routeDir,
|
|
689
823
|
locale,
|
|
690
824
|
t,
|
|
691
|
-
data: {},
|
|
825
|
+
data: { ...njkTpl.dataPatch },
|
|
692
826
|
meta: {
|
|
693
827
|
title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
|
|
694
828
|
description: t('meta.description') !== 'meta.description' ? t('meta.description') : null,
|
|
695
829
|
indexable: true,
|
|
696
|
-
canonical: null
|
|
830
|
+
canonical: null,
|
|
831
|
+
...njkTpl.metaPatch,
|
|
697
832
|
},
|
|
698
833
|
fsy: { ...baseHelpers, ...pluginHelpers },
|
|
699
834
|
clientRuntime,
|
|
@@ -711,10 +846,9 @@ function mountPages(app, options) {
|
|
|
711
846
|
await executeHook(globalHooks, 'beforeMiddleware', ctx);
|
|
712
847
|
await executeHook(routeHooks, 'beforeMiddleware', ctx);
|
|
713
848
|
|
|
714
|
-
// Run route middleware
|
|
715
|
-
if (
|
|
716
|
-
const
|
|
717
|
-
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) {
|
|
718
852
|
await new Promise((resolve, reject) => {
|
|
719
853
|
mw(req, res, (err) => {
|
|
720
854
|
if (err) reject(err);
|
|
@@ -774,7 +908,10 @@ function mountPages(app, options) {
|
|
|
774
908
|
|
|
775
909
|
// Render the template
|
|
776
910
|
const templatePath = route.file.split(path.sep).join('/');
|
|
777
|
-
const html =
|
|
911
|
+
const html =
|
|
912
|
+
njkTpl.useStringRender && njkTpl.templateBody != null
|
|
913
|
+
? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
|
|
914
|
+
: nunjucks.render(templatePath, renderContext);
|
|
778
915
|
|
|
779
916
|
// Execute hooks: afterRender
|
|
780
917
|
ctx.html = html;
|
|
@@ -793,7 +930,7 @@ function mountPages(app, options) {
|
|
|
793
930
|
console.error('Error in onError hook:', hookErr);
|
|
794
931
|
}
|
|
795
932
|
|
|
796
|
-
|
|
933
|
+
return next(err);
|
|
797
934
|
}
|
|
798
935
|
});
|
|
799
936
|
|
|
@@ -845,5 +982,9 @@ module.exports = {
|
|
|
845
982
|
compareRouteRegistrationOrder,
|
|
846
983
|
resolvePageAssets,
|
|
847
984
|
applyPageAssetsToTemplateData,
|
|
985
|
+
parseNjkFrontmatter,
|
|
986
|
+
frontmatterToPatches,
|
|
987
|
+
loadNjkRouteTemplate,
|
|
988
|
+
clearNjkFrontmatterCaches,
|
|
848
989
|
};
|
|
849
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
|
package/src/server.js
CHANGED
|
@@ -11,7 +11,7 @@ const timeout = require('connect-timeout');
|
|
|
11
11
|
const { setAppContext } = require('./app-context');
|
|
12
12
|
const { mountClientRuntime } = require('./client-runtime/mount');
|
|
13
13
|
const { resolveClientRuntime } = require('./client-runtime/resolve');
|
|
14
|
-
const { mountPages } = require('./file-router');
|
|
14
|
+
const { mountPages, detectLocale } = require('./file-router');
|
|
15
15
|
const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
|
|
16
16
|
const { createPluginManager } = require('./plugin-manager');
|
|
17
17
|
|
|
@@ -57,6 +57,16 @@ function getDefaultHelmetConfig(isDev) {
|
|
|
57
57
|
};
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Use JSON error responses for `pages/api/*` routes and clients that do not prefer HTML.
|
|
62
|
+
* @param {import('express').Request} req
|
|
63
|
+
* @returns {boolean}
|
|
64
|
+
*/
|
|
65
|
+
function preferJsonErrorResponse(req) {
|
|
66
|
+
if (req.path && req.path.startsWith('/api')) return true;
|
|
67
|
+
return !req.accepts('html');
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/**
|
|
61
71
|
* Shared CSS for built-in HTML error pages (viewport-safe, fluid type, dark mode)
|
|
62
72
|
*/
|
|
@@ -376,6 +386,12 @@ function createApp(options = {}) {
|
|
|
376
386
|
middlewares.auth = authMiddleware.auth;
|
|
377
387
|
middlewares.guest = authMiddleware.guest;
|
|
378
388
|
}
|
|
389
|
+
|
|
390
|
+
// Under Vitest, shared API fixtures use `fixtureRequireAuth`; default no-op unless overridden.
|
|
391
|
+
const runsUnderVitest = process.env.VITEST === 'true' || process.env.VITEST_WORKER_ID !== undefined;
|
|
392
|
+
if (runsUnderVitest && middlewares.fixtureRequireAuth == null) {
|
|
393
|
+
middlewares.fixtureRequireAuth = (req, res, next) => next();
|
|
394
|
+
}
|
|
379
395
|
|
|
380
396
|
// Static files (if publicDir provided)
|
|
381
397
|
if (publicDir) {
|
|
@@ -421,8 +437,8 @@ function createApp(options = {}) {
|
|
|
421
437
|
return d.toString();
|
|
422
438
|
});
|
|
423
439
|
|
|
424
|
-
// Register plugins (sync)
|
|
425
|
-
const pluginContext = { app, nunjucksEnv, options };
|
|
440
|
+
// Register plugins (sync) — middlewares is the same object later passed to mountPages
|
|
441
|
+
const pluginContext = { app, nunjucksEnv, options, middlewares };
|
|
426
442
|
pluginManager.registerSync(plugins, pluginContext);
|
|
427
443
|
|
|
428
444
|
// Request logging middleware
|
|
@@ -463,6 +479,7 @@ function createApp(options = {}) {
|
|
|
463
479
|
app,
|
|
464
480
|
nunjucksEnv,
|
|
465
481
|
options,
|
|
482
|
+
middlewares,
|
|
466
483
|
db: options.db ?? null,
|
|
467
484
|
routes: pluginManager.routes,
|
|
468
485
|
usePlugin: (n) => pluginManager.getPluginAPI(n),
|
|
@@ -501,7 +518,7 @@ function createApp(options = {}) {
|
|
|
501
518
|
// Helper to create error page context with fsy
|
|
502
519
|
function createErrorContext(req, extraData = {}) {
|
|
503
520
|
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3000}`;
|
|
504
|
-
const locale = req
|
|
521
|
+
const locale = detectLocale(req);
|
|
505
522
|
|
|
506
523
|
// Create fsy helpers
|
|
507
524
|
const fsy = createHelpers({ req, res: {}, baseUrl, locale });
|
|
@@ -562,7 +579,7 @@ function createApp(options = {}) {
|
|
|
562
579
|
}
|
|
563
580
|
|
|
564
581
|
// Custom timeout template
|
|
565
|
-
if (typeof errorPages.timeout === 'string') {
|
|
582
|
+
if (typeof errorPages.timeout === 'string' && !preferJsonErrorResponse(req)) {
|
|
566
583
|
try {
|
|
567
584
|
const html = nunjucksEnv.render(errorPages.timeout, ctx);
|
|
568
585
|
return res.send(html);
|
|
@@ -572,7 +589,7 @@ function createApp(options = {}) {
|
|
|
572
589
|
}
|
|
573
590
|
|
|
574
591
|
// Default timeout response
|
|
575
|
-
if (req
|
|
592
|
+
if (!preferJsonErrorResponse(req)) {
|
|
576
593
|
return res.send(default503Html());
|
|
577
594
|
} else {
|
|
578
595
|
return res.json({ error: 'Request Timeout', status: 503 });
|
|
@@ -591,8 +608,8 @@ function createApp(options = {}) {
|
|
|
591
608
|
return errorPages.serverError(err, req, res, ctx);
|
|
592
609
|
}
|
|
593
610
|
|
|
594
|
-
// Custom template
|
|
595
|
-
if (typeof errorPages.serverError === 'string') {
|
|
611
|
+
// Custom template (skipped for /api and JSON-preferring clients so they never get HTML)
|
|
612
|
+
if (typeof errorPages.serverError === 'string' && !preferJsonErrorResponse(req)) {
|
|
596
613
|
try {
|
|
597
614
|
const html = nunjucksEnv.render(errorPages.serverError, ctx);
|
|
598
615
|
return res.send(html);
|
|
@@ -602,7 +619,7 @@ function createApp(options = {}) {
|
|
|
602
619
|
}
|
|
603
620
|
|
|
604
621
|
// Default response
|
|
605
|
-
if (req
|
|
622
|
+
if (!preferJsonErrorResponse(req)) {
|
|
606
623
|
res.send(default500Html(err, isDev));
|
|
607
624
|
} else {
|
|
608
625
|
res.json({
|