webspresso 0.0.91 → 0.0.94
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 +103 -2864
- package/bin/commands/admin-password.js +8 -2
- package/bin/commands/api.js +7 -1
- package/bin/commands/favicon-generate.js +6 -4
- package/bin/commands/mcp.js +162 -0
- package/bin/commands/new.js +7 -1
- package/bin/commands/page.js +7 -1
- package/bin/commands/polar-migrate.js +50 -0
- package/bin/commands/seed.js +7 -1
- package/bin/commands/service.js +8 -2
- package/bin/commands/skill.js +7 -1
- package/bin/webspresso.js +4 -0
- package/core/auth/tokens.js +16 -0
- package/core/compileSchema.js +1 -5
- package/core/compression/compressible.js +17 -5
- package/core/compression/compression-stream.js +1 -1
- package/core/compression/middleware.js +9 -0
- package/core/compression/negotiate.js +23 -11
- package/core/kernel/app.js +2 -1
- package/core/kernel/index.js +5 -1
- package/core/orm/cache/fingerprint.js +23 -5
- package/core/orm/cache/layer.js +1 -1
- package/core/orm/eager-loader.js +54 -9
- package/core/orm/index.js +42 -3
- package/core/orm/model.js +11 -2
- package/core/orm/transaction.js +3 -1
- package/core/queue/adapters/database.js +46 -13
- package/core/queue/adapters/memory.js +21 -0
- package/core/queue/adapters/redis.js +21 -0
- package/core/queue/manager.js +46 -20
- package/core/ssr/stream.js +58 -14
- package/core/validation/index.js +70 -0
- package/index.d.ts +99 -5
- package/index.js +7 -1
- package/package.json +9 -1
- package/plugins/admin-panel/auth.js +10 -0
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
- package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
- package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
- package/plugins/admin-panel/index.js +65 -6
- package/plugins/admin-panel/modules/bulk-actions.js +9 -2
- package/plugins/admin-panel/modules/dashboard.js +8 -1
- package/plugins/content/index.js +5 -3
- package/plugins/data-exchange/export-xlsx.js +28 -6
- package/plugins/data-exchange/import.js +120 -58
- package/plugins/data-exchange/index.js +5 -0
- package/plugins/data-exchange/parse-table.js +14 -5
- package/plugins/data-exchange/record-selection.js +2 -0
- package/plugins/index.js +6 -0
- package/plugins/mcp/auth.js +104 -0
- package/plugins/mcp/discovery.js +88 -0
- package/plugins/mcp/index.js +208 -0
- package/plugins/mcp/prompts.js +149 -0
- package/plugins/mcp/providers/orm.js +325 -0
- package/plugins/mcp/providers/services.js +190 -0
- package/plugins/mcp/providers/system.js +87 -0
- package/plugins/mcp/server.js +472 -0
- package/plugins/mcp/transports/sse.js +156 -0
- package/plugins/mcp/transports/stdio.js +108 -0
- package/plugins/polar/.env.example +12 -0
- package/plugins/polar/CHANGELOG.md +38 -0
- package/plugins/polar/README.md +158 -0
- package/plugins/polar/index.js +144 -0
- package/plugins/polar/src/api.js +54 -0
- package/plugins/polar/src/checkout.js +111 -0
- package/plugins/polar/src/config.js +88 -0
- package/plugins/polar/src/env.schema.js +36 -0
- package/plugins/polar/src/middleware.js +37 -0
- package/plugins/polar/src/migration.js +58 -0
- package/plugins/polar/src/nunjucks-filters.js +56 -0
- package/plugins/polar/src/portal.js +51 -0
- package/plugins/polar/src/rate-limit.js +70 -0
- package/plugins/polar/src/routes.js +212 -0
- package/plugins/polar/src/sync.js +138 -0
- package/plugins/polar/src/urls.js +117 -0
- package/plugins/polar/src/user-resolver.js +195 -0
- package/plugins/polar/src/webhooks.js +248 -0
- package/src/file-router.js +203 -125
- package/src/helpers.js +60 -13
- package/src/njk-frontmatter.js +24 -14
- package/src/server.js +30 -12
- package/src/services/discovery.js +2 -1
- package/src/services/registry.js +2 -0
- package/src/services/validator.js +2 -53
- package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
package/src/file-router.js
CHANGED
|
@@ -113,8 +113,9 @@ function rewriteDynamicRouteMarkers(route) {
|
|
|
113
113
|
}
|
|
114
114
|
const inner = route.slice(open + 1, close);
|
|
115
115
|
let repl;
|
|
116
|
-
if (inner.startsWith('...')
|
|
117
|
-
|
|
116
|
+
if (inner.startsWith('...')) {
|
|
117
|
+
const paramName = inner.length > 3 ? inner.slice(3) : 'splat';
|
|
118
|
+
repl = '*' + paramName;
|
|
118
119
|
} else if (inner.length > 0 && inner.indexOf('[') === -1) {
|
|
119
120
|
repl = ':' + inner;
|
|
120
121
|
} else {
|
|
@@ -367,9 +368,31 @@ function scanDirectory(dir, baseDir = dir) {
|
|
|
367
368
|
/**
|
|
368
369
|
* Load i18n JSON file with caching
|
|
369
370
|
* @param {string} filePath - Path to JSON file
|
|
371
|
+
* @param {boolean} [isDev=false] - Whether in development mode
|
|
370
372
|
* @returns {Object} Parsed JSON or empty object
|
|
371
373
|
*/
|
|
372
|
-
function loadI18nFile(filePath) {
|
|
374
|
+
function loadI18nFile(filePath, isDev = (process.env.NODE_ENV !== 'production')) {
|
|
375
|
+
if (!isDev) {
|
|
376
|
+
const cached = i18nCache.get(filePath);
|
|
377
|
+
if (cached) {
|
|
378
|
+
return cached.data;
|
|
379
|
+
}
|
|
380
|
+
if (!fs.existsSync(filePath)) {
|
|
381
|
+
i18nCache.set(filePath, { mtime: 0, data: EMPTY_OBJECT });
|
|
382
|
+
return EMPTY_OBJECT;
|
|
383
|
+
}
|
|
384
|
+
try {
|
|
385
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
386
|
+
const data = JSON.parse(content);
|
|
387
|
+
i18nCache.set(filePath, { mtime: 0, data });
|
|
388
|
+
return data;
|
|
389
|
+
} catch (err) {
|
|
390
|
+
console.error('Error loading i18n file:', filePath, err.message);
|
|
391
|
+
i18nCache.set(filePath, { mtime: 0, data: EMPTY_OBJECT });
|
|
392
|
+
return EMPTY_OBJECT;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
373
396
|
if (!fs.existsSync(filePath)) {
|
|
374
397
|
return EMPTY_OBJECT;
|
|
375
398
|
}
|
|
@@ -398,25 +421,43 @@ function loadI18nFile(filePath) {
|
|
|
398
421
|
* @param {string} pagesDir - Pages directory path
|
|
399
422
|
* @param {string} routeDir - Route directory path
|
|
400
423
|
* @param {string} locale - Locale code
|
|
424
|
+
* @param {boolean} [isDev=false] - Whether in development mode
|
|
401
425
|
* @returns {Object} Merged translations
|
|
402
426
|
*/
|
|
403
|
-
function loadI18n(pagesDir, routeDir, locale) {
|
|
427
|
+
function loadI18n(pagesDir, routeDir, locale, isDev = (process.env.NODE_ENV !== 'production')) {
|
|
428
|
+
const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
|
|
429
|
+
|
|
430
|
+
if (!isDev) {
|
|
431
|
+
const mergedCached = mergedI18nCache.get(cacheKey);
|
|
432
|
+
if (mergedCached) {
|
|
433
|
+
return mergedCached.data;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
404
437
|
// Load global translations
|
|
405
438
|
const globalPath = path.join(pagesDir, 'locales', `${locale}.json`);
|
|
406
|
-
const globalTranslations = loadI18nFile(globalPath);
|
|
439
|
+
const globalTranslations = loadI18nFile(globalPath, isDev);
|
|
407
440
|
|
|
408
441
|
// Load route-specific translations
|
|
409
442
|
const routePath = path.join(routeDir, 'locales', `${locale}.json`);
|
|
410
|
-
const routeTranslations = loadI18nFile(routePath);
|
|
443
|
+
const routeTranslations = loadI18nFile(routePath, isDev);
|
|
411
444
|
|
|
412
445
|
const gEmpty = globalTranslations === EMPTY_OBJECT || !globalTranslations || Object.keys(globalTranslations).length === 0;
|
|
413
446
|
const rEmpty = routeTranslations === EMPTY_OBJECT || !routeTranslations || Object.keys(routeTranslations).length === 0;
|
|
414
447
|
|
|
415
|
-
if (gEmpty && rEmpty)
|
|
416
|
-
|
|
417
|
-
|
|
448
|
+
if (gEmpty && rEmpty) {
|
|
449
|
+
if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: EMPTY_OBJECT });
|
|
450
|
+
return EMPTY_OBJECT;
|
|
451
|
+
}
|
|
452
|
+
if (gEmpty) {
|
|
453
|
+
if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: routeTranslations });
|
|
454
|
+
return routeTranslations;
|
|
455
|
+
}
|
|
456
|
+
if (rEmpty) {
|
|
457
|
+
if (!isDev) mergedI18nCache.set(cacheKey, { gm: 0, rm: 0, data: globalTranslations });
|
|
458
|
+
return globalTranslations;
|
|
459
|
+
}
|
|
418
460
|
|
|
419
|
-
const cacheKey = `${pagesDir}::${routeDir}::${locale}`;
|
|
420
461
|
const gCached = i18nCache.get(globalPath);
|
|
421
462
|
const rCached = i18nCache.get(routePath);
|
|
422
463
|
const gm = gCached?.mtime ?? 0;
|
|
@@ -626,32 +667,41 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
|
|
|
626
667
|
* @returns {Object|null} Route config or null
|
|
627
668
|
*/
|
|
628
669
|
function loadRouteConfig(configPath, isDev) {
|
|
670
|
+
if (!isDev) {
|
|
671
|
+
if (configCache.has(configPath)) {
|
|
672
|
+
return configCache.get(configPath);
|
|
673
|
+
}
|
|
674
|
+
if (!fs.existsSync(configPath)) {
|
|
675
|
+
configCache.set(configPath, null);
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const config = require(configPath);
|
|
680
|
+
configCache.set(configPath, config);
|
|
681
|
+
return config;
|
|
682
|
+
} catch (err) {
|
|
683
|
+
console.error('Error loading route config:', configPath, err.message);
|
|
684
|
+
configCache.set(configPath, null);
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
629
689
|
if (!fs.existsSync(configPath)) {
|
|
630
690
|
routeConfigDevCache.delete(configPath);
|
|
631
691
|
return null;
|
|
632
692
|
}
|
|
633
693
|
|
|
634
694
|
try {
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
return devCached.config;
|
|
640
|
-
}
|
|
641
|
-
if (require.cache[require.resolve(configPath)]) {
|
|
642
|
-
delete require.cache[require.resolve(configPath)];
|
|
643
|
-
}
|
|
644
|
-
const config = require(configPath);
|
|
645
|
-
routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
|
|
646
|
-
return config;
|
|
695
|
+
const stats = fs.statSync(configPath);
|
|
696
|
+
const devCached = routeConfigDevCache.get(configPath);
|
|
697
|
+
if (devCached && devCached.mtime >= stats.mtimeMs) {
|
|
698
|
+
return devCached.config;
|
|
647
699
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
return configCache.get(configPath);
|
|
700
|
+
if (require.cache[require.resolve(configPath)]) {
|
|
701
|
+
delete require.cache[require.resolve(configPath)];
|
|
651
702
|
}
|
|
652
|
-
|
|
653
703
|
const config = require(configPath);
|
|
654
|
-
|
|
704
|
+
routeConfigDevCache.set(configPath, { mtime: stats.mtimeMs, config });
|
|
655
705
|
return config;
|
|
656
706
|
} catch (err) {
|
|
657
707
|
console.error('Error loading route config:', configPath, err.message);
|
|
@@ -976,123 +1026,149 @@ function mountPages(app, options) {
|
|
|
976
1026
|
/** Register API routes (shared by static phase and dynamic phase). */
|
|
977
1027
|
const registerApiRoutes = (routes) => {
|
|
978
1028
|
for (const route of routes) {
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1029
|
+
const handler = require(route.fullPath);
|
|
1030
|
+
const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
|
|
1031
|
+
const routeMiddleware = handler.middleware;
|
|
982
1032
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
if (typeof handlerFn !== 'function') {
|
|
988
|
-
console.warn(`API route ${route.file} does not export a function`);
|
|
989
|
-
continue;
|
|
990
|
-
}
|
|
1033
|
+
const preResolvedMw = routeMiddleware
|
|
1034
|
+
? resolveMiddlewares(routeMiddleware, middlewares)
|
|
1035
|
+
: EMPTY_ARRAY;
|
|
991
1036
|
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
req.db = db;
|
|
997
|
-
}
|
|
998
|
-
if (serviceRegistry != null) {
|
|
999
|
-
req.service = (name, input, opts) =>
|
|
1000
|
-
serviceRegistry.call(name, input, { req, res, db }, opts);
|
|
1001
|
-
}
|
|
1037
|
+
if (typeof handlerFn !== 'function') {
|
|
1038
|
+
console.warn(`API route ${route.file} does not export a function`);
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1002
1041
|
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
delete require.cache[require.resolve(route.fullPath)];
|
|
1006
|
-
}
|
|
1007
|
-
const currentHandler = isDev
|
|
1008
|
-
? require(route.fullPath)
|
|
1009
|
-
: handler;
|
|
1010
|
-
const fn = typeof currentHandler === 'function'
|
|
1011
|
-
? currentHandler
|
|
1012
|
-
: currentHandler.default || currentHandler.handler;
|
|
1013
|
-
|
|
1014
|
-
if (isDev) {
|
|
1015
|
-
invalidateSchema(route.fullPath);
|
|
1016
|
-
}
|
|
1017
|
-
let compiledSchema;
|
|
1042
|
+
let preCompiledSchema = null;
|
|
1043
|
+
if (!isDev) {
|
|
1018
1044
|
try {
|
|
1019
|
-
|
|
1045
|
+
preCompiledSchema = compileSchema(route.fullPath, handler);
|
|
1020
1046
|
} catch (schemaErr) {
|
|
1021
1047
|
console.error(`API schema compile error ${route.routePath}:`, schemaErr);
|
|
1022
|
-
res.status(500).json({ error: 'Internal Server Error', message: schemaErr.message });
|
|
1023
|
-
return;
|
|
1024
1048
|
}
|
|
1025
|
-
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
app[route.method](route.routePath, async (req, res, next) => {
|
|
1026
1052
|
try {
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
}
|
|
1053
|
+
if (route.routePath.includes('*') && req.params) {
|
|
1054
|
+
for (const val of Object.values(req.params)) {
|
|
1055
|
+
if (Array.isArray(val) && req.params[0] === undefined) {
|
|
1056
|
+
req.params[0] = val.join('/');
|
|
1057
|
+
break;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1034
1060
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1061
|
+
// Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
|
|
1062
|
+
if (db != null) {
|
|
1063
|
+
req.db = db;
|
|
1064
|
+
}
|
|
1065
|
+
if (serviceRegistry != null) {
|
|
1066
|
+
req.service = (name, input, opts) =>
|
|
1067
|
+
serviceRegistry.call(name, input, { req, res, db }, opts);
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
let fn = handlerFn;
|
|
1071
|
+
let compiledSchema = preCompiledSchema;
|
|
1072
|
+
|
|
1073
|
+
// Reload handler and schema in dev mode
|
|
1074
|
+
if (isDev) {
|
|
1075
|
+
if (require.cache[require.resolve(route.fullPath)]) {
|
|
1076
|
+
delete require.cache[require.resolve(route.fullPath)];
|
|
1077
|
+
}
|
|
1078
|
+
const currentHandler = require(route.fullPath);
|
|
1079
|
+
fn = typeof currentHandler === 'function'
|
|
1080
|
+
? currentHandler
|
|
1081
|
+
: currentHandler.default || currentHandler.handler;
|
|
1082
|
+
|
|
1083
|
+
invalidateSchema(route.fullPath);
|
|
1084
|
+
try {
|
|
1085
|
+
compiledSchema = compileSchema(route.fullPath, currentHandler);
|
|
1086
|
+
} catch (schemaErr) {
|
|
1087
|
+
console.error(`API schema compile error ${route.routePath}:`, schemaErr);
|
|
1088
|
+
res.status(500).json({ error: 'Internal Server Error', message: schemaErr.message });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
try {
|
|
1094
|
+
applySchema(req, compiledSchema);
|
|
1095
|
+
} catch (err) {
|
|
1096
|
+
if (err instanceof ZodError) {
|
|
1097
|
+
return res.status(400).json({
|
|
1098
|
+
error: 'Validation Error',
|
|
1099
|
+
issues: err.issues,
|
|
1045
1100
|
});
|
|
1046
|
-
}
|
|
1101
|
+
}
|
|
1102
|
+
throw err;
|
|
1047
1103
|
}
|
|
1104
|
+
|
|
1105
|
+
// Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
|
|
1106
|
+
if (preResolvedMw.length) {
|
|
1107
|
+
for (const mw of preResolvedMw) {
|
|
1108
|
+
await new Promise((resolve, reject) => {
|
|
1109
|
+
mw(req, res, (err) => {
|
|
1110
|
+
if (err) reject(err);
|
|
1111
|
+
else resolve();
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
await fn(req, res, next);
|
|
1118
|
+
} catch (err) {
|
|
1119
|
+
console.error(`API error ${route.routePath}:`, err);
|
|
1120
|
+
const hookCtx = { req, res, error: err };
|
|
1121
|
+
try {
|
|
1122
|
+
await executeHook(globalHooks, 'onError', hookCtx, err);
|
|
1123
|
+
} catch (hookErr) {
|
|
1124
|
+
console.error('Error in onError hook:', hookErr);
|
|
1125
|
+
}
|
|
1126
|
+
return next(err);
|
|
1048
1127
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
}
|
|
1052
|
-
console.error(`API error ${route.routePath}:`, err);
|
|
1053
|
-
const hookCtx = { req, res, error: err };
|
|
1054
|
-
try {
|
|
1055
|
-
await executeHook(globalHooks, 'onError', hookCtx, err);
|
|
1056
|
-
} catch (hookErr) {
|
|
1057
|
-
console.error('Error in onError hook:', hookErr);
|
|
1058
|
-
}
|
|
1059
|
-
return next(err);
|
|
1060
|
-
}
|
|
1061
|
-
});
|
|
1062
|
-
|
|
1063
|
-
log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
log(` ${route.method.toUpperCase()} ${route.routePath} -> ${route.file}`);
|
|
1064
1131
|
}
|
|
1065
1132
|
};
|
|
1066
1133
|
|
|
1067
1134
|
/** Register SSR GET routes (shared by static phase and dynamic phase). */
|
|
1068
1135
|
const registerSsrRoutes = (routes) => {
|
|
1069
1136
|
for (const route of routes) {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
app.get(route.routePath, async (req, res, next) => {
|
|
1076
|
-
try {
|
|
1077
|
-
// Detect locale
|
|
1078
|
-
const locale = detectLocale(req);
|
|
1079
|
-
const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
|
|
1080
|
-
|
|
1081
|
-
// Load translations (primary + fallback)
|
|
1082
|
-
const translations = loadI18n(absolutePagesDir, route.routeDir, locale);
|
|
1083
|
-
const fallbackTranslations = (locale !== defaultLocale)
|
|
1084
|
-
? loadI18n(absolutePagesDir, route.routeDir, defaultLocale)
|
|
1085
|
-
: EMPTY_OBJECT;
|
|
1137
|
+
const mountConfig = loadRouteConfig(route.configPath, isDev);
|
|
1138
|
+
const preResolvedPageMw = mountConfig?.middleware
|
|
1139
|
+
? resolveMiddlewares(mountConfig.middleware, middlewares)
|
|
1140
|
+
: EMPTY_ARRAY;
|
|
1141
|
+
const staticRouteHooks = mountConfig?.hooks || EMPTY_OBJECT;
|
|
1086
1142
|
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1143
|
+
app.get(route.routePath, async (req, res, next) => {
|
|
1144
|
+
try {
|
|
1145
|
+
if (route.routePath.includes('*') && req.params) {
|
|
1146
|
+
for (const val of Object.values(req.params)) {
|
|
1147
|
+
if (Array.isArray(val) && req.params[0] === undefined) {
|
|
1148
|
+
req.params[0] = val.join('/');
|
|
1149
|
+
break;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
// Detect locale
|
|
1154
|
+
const locale = detectLocale(req);
|
|
1155
|
+
const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
|
|
1156
|
+
|
|
1157
|
+
// Load translations (primary + fallback)
|
|
1158
|
+
const translations = loadI18n(absolutePagesDir, route.routeDir, locale, isDev);
|
|
1159
|
+
const fallbackTranslations = (locale !== defaultLocale)
|
|
1160
|
+
? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, isDev)
|
|
1161
|
+
: EMPTY_OBJECT;
|
|
1162
|
+
|
|
1163
|
+
const t = createTranslator(translations, {
|
|
1164
|
+
locale,
|
|
1165
|
+
fallbackTranslations,
|
|
1166
|
+
fallbackLocale: defaultLocale,
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1169
|
+
// Load route config
|
|
1170
|
+
const config = isDev ? loadRouteConfig(route.configPath, true) : mountConfig;
|
|
1171
|
+
const routeHooks = isDev ? (config?.hooks || EMPTY_OBJECT) : staticRouteHooks;
|
|
1096
1172
|
|
|
1097
1173
|
// Create context with plugin helpers merged
|
|
1098
1174
|
const baseHelpers = createHelpers({ req, res, locale, t });
|
|
@@ -1134,6 +1210,8 @@ function mountPages(app, options) {
|
|
|
1134
1210
|
clientRuntime,
|
|
1135
1211
|
};
|
|
1136
1212
|
|
|
1213
|
+
req.context = ctx;
|
|
1214
|
+
|
|
1137
1215
|
// Execute hooks: onRequest
|
|
1138
1216
|
await executeHook(globalHooks, 'onRequest', ctx);
|
|
1139
1217
|
await executeHook(routeHooks, 'onRequest', ctx);
|
package/src/helpers.js
CHANGED
|
@@ -616,31 +616,54 @@ function createHelpers(ctx) {
|
|
|
616
616
|
// ============================================
|
|
617
617
|
|
|
618
618
|
/**
|
|
619
|
-
* Get
|
|
619
|
+
* Get CSP nonce if available from request context
|
|
620
|
+
* @returns {string} Nonce string or empty string
|
|
621
|
+
*/
|
|
622
|
+
nonce() {
|
|
623
|
+
return (
|
|
624
|
+
req?.res?.locals?.cspNonce ||
|
|
625
|
+
req?.res?.locals?.nonce ||
|
|
626
|
+
res?.locals?.cspNonce ||
|
|
627
|
+
res?.locals?.nonce ||
|
|
628
|
+
req?.cspNonce ||
|
|
629
|
+
req?.nonce ||
|
|
630
|
+
''
|
|
631
|
+
);
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Get injected head content (CSS, scripts)
|
|
636
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
620
637
|
* @returns {string} HTML content for head section
|
|
621
638
|
*/
|
|
622
|
-
injectHead() {
|
|
639
|
+
injectHead(options) {
|
|
640
|
+
const explicitNonce = typeof options === 'string' ? options : options?.nonce;
|
|
641
|
+
const nonce = explicitNonce || this.nonce();
|
|
623
642
|
const injector = getScriptInjector();
|
|
624
643
|
let content = '';
|
|
625
644
|
|
|
626
645
|
// Add styles
|
|
627
|
-
const styles = injector.getStylesContent();
|
|
646
|
+
const styles = injector.getStylesContent(nonce ? { nonce } : options);
|
|
628
647
|
if (styles) {
|
|
629
|
-
|
|
648
|
+
const nonceAttr = nonce ? ` nonce="${nonce}"` : '';
|
|
649
|
+
content += `<style id="webspresso-injected-styles"${nonceAttr}>\n${styles}\n</style>\n`;
|
|
630
650
|
}
|
|
631
651
|
|
|
632
652
|
// Add head scripts
|
|
633
|
-
content += injector.getHeadContent();
|
|
653
|
+
content += injector.getHeadContent(nonce ? { nonce } : options);
|
|
634
654
|
|
|
635
655
|
return content;
|
|
636
656
|
},
|
|
637
657
|
|
|
638
658
|
/**
|
|
639
659
|
* Get injected body content (for end of body)
|
|
660
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
640
661
|
* @returns {string} HTML content for body end
|
|
641
662
|
*/
|
|
642
|
-
injectBody() {
|
|
643
|
-
|
|
663
|
+
injectBody(options) {
|
|
664
|
+
const explicitNonce = typeof options === 'string' ? options : options?.nonce;
|
|
665
|
+
const nonce = explicitNonce || this.nonce();
|
|
666
|
+
return getScriptInjector().getBodyContent(nonce ? { nonce } : options);
|
|
644
667
|
},
|
|
645
668
|
|
|
646
669
|
/**
|
|
@@ -726,25 +749,49 @@ class ScriptInjector {
|
|
|
726
749
|
|
|
727
750
|
/**
|
|
728
751
|
* Get head content sorted by priority
|
|
752
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
729
753
|
*/
|
|
730
|
-
getHeadContent() {
|
|
754
|
+
getHeadContent(options = {}) {
|
|
755
|
+
const nonce = typeof options === 'string' ? options : (options?.nonce || '');
|
|
731
756
|
const sorted = [...this.headScripts].sort((a, b) => b.priority - a.priority);
|
|
732
|
-
return sorted.map(s =>
|
|
757
|
+
return sorted.map(s => {
|
|
758
|
+
let content = typeof s.content === 'function' ? s.content(options) : s.content;
|
|
759
|
+
if (nonce && typeof content === 'string') {
|
|
760
|
+
content = content.replace(/<script(?![^>]*\bnonce=)/gi, `<script nonce="${nonce}"`);
|
|
761
|
+
}
|
|
762
|
+
return content;
|
|
763
|
+
}).join('\n');
|
|
733
764
|
}
|
|
734
765
|
|
|
735
766
|
/**
|
|
736
767
|
* Get body content sorted by priority
|
|
768
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
737
769
|
*/
|
|
738
|
-
getBodyContent() {
|
|
770
|
+
getBodyContent(options = {}) {
|
|
771
|
+
const nonce = typeof options === 'string' ? options : (options?.nonce || '');
|
|
739
772
|
const sorted = [...this.bodyScripts].sort((a, b) => b.priority - a.priority);
|
|
740
|
-
return sorted.map(s =>
|
|
773
|
+
return sorted.map(s => {
|
|
774
|
+
let content = typeof s.content === 'function' ? s.content(options) : s.content;
|
|
775
|
+
if (nonce && typeof content === 'string') {
|
|
776
|
+
content = content.replace(/<script(?![^>]*\bnonce=)/gi, `<script nonce="${nonce}"`);
|
|
777
|
+
}
|
|
778
|
+
return content;
|
|
779
|
+
}).join('\n');
|
|
741
780
|
}
|
|
742
781
|
|
|
743
782
|
/**
|
|
744
783
|
* Get styles content
|
|
784
|
+
* @param {Object|string} [options] - Optional { nonce: string } or nonce string
|
|
745
785
|
*/
|
|
746
|
-
getStylesContent() {
|
|
747
|
-
|
|
786
|
+
getStylesContent(options = {}) {
|
|
787
|
+
const nonce = typeof options === 'string' ? options : (options?.nonce || '');
|
|
788
|
+
return this.styles.map(s => {
|
|
789
|
+
let content = typeof s.content === 'function' ? s.content(options) : s.content;
|
|
790
|
+
if (nonce && typeof content === 'string') {
|
|
791
|
+
content = content.replace(/<style(?![^>]*\bnonce=)/gi, `<style nonce="${nonce}"`);
|
|
792
|
+
}
|
|
793
|
+
return content;
|
|
794
|
+
}).join('\n');
|
|
748
795
|
}
|
|
749
796
|
|
|
750
797
|
/**
|
package/src/njk-frontmatter.js
CHANGED
|
@@ -115,6 +115,24 @@ function readAndParse(absPath, isDevLike = false) {
|
|
|
115
115
|
* @param {boolean} isDev NODE_ENV !== 'production' style
|
|
116
116
|
*/
|
|
117
117
|
function loadNjkRouteTemplate(absPath, isDev) {
|
|
118
|
+
if (!isDev) {
|
|
119
|
+
const cached = prodRouteCache.get(absPath);
|
|
120
|
+
if (cached) {
|
|
121
|
+
return cached;
|
|
122
|
+
}
|
|
123
|
+
if (!fs.existsSync(absPath)) {
|
|
124
|
+
const empty = {
|
|
125
|
+
useStringRender: false,
|
|
126
|
+
templateBody: null,
|
|
127
|
+
metaPatch: {},
|
|
128
|
+
dataPatch: {},
|
|
129
|
+
};
|
|
130
|
+
prodRouteCache.set(absPath, empty);
|
|
131
|
+
return empty;
|
|
132
|
+
}
|
|
133
|
+
return readAndParse(absPath, false);
|
|
134
|
+
}
|
|
135
|
+
|
|
118
136
|
if (!fs.existsSync(absPath)) {
|
|
119
137
|
return {
|
|
120
138
|
useStringRender: false,
|
|
@@ -125,21 +143,13 @@ function loadNjkRouteTemplate(absPath, isDev) {
|
|
|
125
143
|
}
|
|
126
144
|
|
|
127
145
|
const stat = fs.statSync(absPath);
|
|
128
|
-
|
|
129
|
-
if (
|
|
130
|
-
|
|
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);
|
|
146
|
+
const prev = devRouteCache.get(absPath);
|
|
147
|
+
if (prev && prev.mtimeMs >= stat.mtimeMs) {
|
|
148
|
+
return prev.cached;
|
|
141
149
|
}
|
|
142
|
-
|
|
150
|
+
const fresh = readAndParse(absPath, true);
|
|
151
|
+
devRouteCache.set(absPath, { mtimeMs: stat.mtimeMs, cached: fresh });
|
|
152
|
+
return fresh;
|
|
143
153
|
}
|
|
144
154
|
|
|
145
155
|
function clearNjkFrontmatterCaches() {
|
package/src/server.js
CHANGED
|
@@ -47,7 +47,7 @@ function configureSafeNunjucks(templateDirs, options = {}) {
|
|
|
47
47
|
const origRender = env.render.bind(env);
|
|
48
48
|
const origRenderString = env.renderString.bind(env);
|
|
49
49
|
|
|
50
|
-
function wrapTemplateRoot(tmpl, name
|
|
50
|
+
function wrapTemplateRoot(tmpl, name) {
|
|
51
51
|
if (!tmpl || tmpl._safeWrapped) return;
|
|
52
52
|
tmpl._safeWrapped = true;
|
|
53
53
|
const origRoot = tmpl.rootRenderFunc;
|
|
@@ -55,21 +55,28 @@ function configureSafeNunjucks(templateDirs, options = {}) {
|
|
|
55
55
|
|
|
56
56
|
tmpl.rootRenderFunc = function(e, context, frame, runtime, renderCb) {
|
|
57
57
|
const normalizedName = String(name || tmpl.path || 'anonymous');
|
|
58
|
-
store.
|
|
58
|
+
const store = renderStackStorage.getStore();
|
|
59
|
+
if (store) {
|
|
60
|
+
store.stack.push(normalizedName);
|
|
61
|
+
}
|
|
59
62
|
let finished = false;
|
|
60
63
|
const done = (err, out) => {
|
|
61
64
|
if (!finished) {
|
|
62
65
|
finished = true;
|
|
63
|
-
|
|
64
|
-
|
|
66
|
+
if (store) {
|
|
67
|
+
const idx = store.stack.lastIndexOf(normalizedName);
|
|
68
|
+
if (idx !== -1) store.stack.splice(idx, 1);
|
|
69
|
+
}
|
|
65
70
|
}
|
|
66
71
|
renderCb(err, out);
|
|
67
72
|
};
|
|
68
73
|
try {
|
|
69
74
|
return origRoot.call(this, e, context, frame, runtime, done);
|
|
70
75
|
} catch (ex) {
|
|
71
|
-
|
|
72
|
-
|
|
76
|
+
if (store) {
|
|
77
|
+
const idx = store.stack.lastIndexOf(normalizedName);
|
|
78
|
+
if (idx !== -1) store.stack.splice(idx, 1);
|
|
79
|
+
}
|
|
73
80
|
throw ex;
|
|
74
81
|
}
|
|
75
82
|
};
|
|
@@ -97,15 +104,15 @@ function configureSafeNunjucks(templateDirs, options = {}) {
|
|
|
97
104
|
}
|
|
98
105
|
|
|
99
106
|
const wrappedCb = typeof cb === 'function' ? function(err, tmpl) {
|
|
100
|
-
if (tmpl
|
|
101
|
-
wrapTemplateRoot(tmpl, name
|
|
107
|
+
if (tmpl) {
|
|
108
|
+
wrapTemplateRoot(tmpl, name);
|
|
102
109
|
}
|
|
103
110
|
cb(err, tmpl);
|
|
104
111
|
} : undefined;
|
|
105
112
|
|
|
106
113
|
const res = origGetTemplate(name, eagerCompile, parentName, ignoreMissing, wrappedCb);
|
|
107
|
-
if (res
|
|
108
|
-
wrapTemplateRoot(res, name
|
|
114
|
+
if (res) {
|
|
115
|
+
wrapTemplateRoot(res, name);
|
|
109
116
|
}
|
|
110
117
|
return res;
|
|
111
118
|
};
|
|
@@ -467,10 +474,21 @@ function createApp(options = {}) {
|
|
|
467
474
|
const app = express();
|
|
468
475
|
app.serviceRegistry = serviceRegistry;
|
|
469
476
|
|
|
470
|
-
//
|
|
477
|
+
// Request context & services caller middleware
|
|
471
478
|
app.use((req, res, next) => {
|
|
479
|
+
if (options.db) {
|
|
480
|
+
req.db = options.db;
|
|
481
|
+
}
|
|
482
|
+
req.context = {
|
|
483
|
+
req,
|
|
484
|
+
res,
|
|
485
|
+
db: options.db ?? null,
|
|
486
|
+
app,
|
|
487
|
+
serviceRegistry,
|
|
488
|
+
...(req.context || {}),
|
|
489
|
+
};
|
|
472
490
|
req.service = (name, input, opts) =>
|
|
473
|
-
serviceRegistry.call(name, input,
|
|
491
|
+
serviceRegistry.call(name, input, req.context, opts);
|
|
474
492
|
next();
|
|
475
493
|
});
|
|
476
494
|
|