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.
Files changed (86) hide show
  1. package/README.md +103 -2864
  2. package/bin/commands/admin-password.js +8 -2
  3. package/bin/commands/api.js +7 -1
  4. package/bin/commands/favicon-generate.js +6 -4
  5. package/bin/commands/mcp.js +162 -0
  6. package/bin/commands/new.js +7 -1
  7. package/bin/commands/page.js +7 -1
  8. package/bin/commands/polar-migrate.js +50 -0
  9. package/bin/commands/seed.js +7 -1
  10. package/bin/commands/service.js +8 -2
  11. package/bin/commands/skill.js +7 -1
  12. package/bin/webspresso.js +4 -0
  13. package/core/auth/tokens.js +16 -0
  14. package/core/compileSchema.js +1 -5
  15. package/core/compression/compressible.js +17 -5
  16. package/core/compression/compression-stream.js +1 -1
  17. package/core/compression/middleware.js +9 -0
  18. package/core/compression/negotiate.js +23 -11
  19. package/core/kernel/app.js +2 -1
  20. package/core/kernel/index.js +5 -1
  21. package/core/orm/cache/fingerprint.js +23 -5
  22. package/core/orm/cache/layer.js +1 -1
  23. package/core/orm/eager-loader.js +54 -9
  24. package/core/orm/index.js +42 -3
  25. package/core/orm/model.js +11 -2
  26. package/core/orm/transaction.js +3 -1
  27. package/core/queue/adapters/database.js +46 -13
  28. package/core/queue/adapters/memory.js +21 -0
  29. package/core/queue/adapters/redis.js +21 -0
  30. package/core/queue/manager.js +46 -20
  31. package/core/ssr/stream.js +58 -14
  32. package/core/validation/index.js +70 -0
  33. package/index.d.ts +99 -5
  34. package/index.js +7 -1
  35. package/package.json +9 -1
  36. package/plugins/admin-panel/auth.js +10 -0
  37. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +6 -1
  38. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +18 -8
  39. package/plugins/admin-panel/client/parts/07-model-list.js +9 -4
  40. package/plugins/admin-panel/client/parts/08-record-list.js +20 -2
  41. package/plugins/admin-panel/index.js +65 -6
  42. package/plugins/admin-panel/modules/bulk-actions.js +9 -2
  43. package/plugins/admin-panel/modules/dashboard.js +8 -1
  44. package/plugins/content/index.js +5 -3
  45. package/plugins/data-exchange/export-xlsx.js +28 -6
  46. package/plugins/data-exchange/import.js +120 -58
  47. package/plugins/data-exchange/index.js +5 -0
  48. package/plugins/data-exchange/parse-table.js +14 -5
  49. package/plugins/data-exchange/record-selection.js +2 -0
  50. package/plugins/index.js +6 -0
  51. package/plugins/mcp/auth.js +104 -0
  52. package/plugins/mcp/discovery.js +88 -0
  53. package/plugins/mcp/index.js +208 -0
  54. package/plugins/mcp/prompts.js +149 -0
  55. package/plugins/mcp/providers/orm.js +325 -0
  56. package/plugins/mcp/providers/services.js +190 -0
  57. package/plugins/mcp/providers/system.js +87 -0
  58. package/plugins/mcp/server.js +472 -0
  59. package/plugins/mcp/transports/sse.js +156 -0
  60. package/plugins/mcp/transports/stdio.js +108 -0
  61. package/plugins/polar/.env.example +12 -0
  62. package/plugins/polar/CHANGELOG.md +38 -0
  63. package/plugins/polar/README.md +158 -0
  64. package/plugins/polar/index.js +144 -0
  65. package/plugins/polar/src/api.js +54 -0
  66. package/plugins/polar/src/checkout.js +111 -0
  67. package/plugins/polar/src/config.js +88 -0
  68. package/plugins/polar/src/env.schema.js +36 -0
  69. package/plugins/polar/src/middleware.js +37 -0
  70. package/plugins/polar/src/migration.js +58 -0
  71. package/plugins/polar/src/nunjucks-filters.js +56 -0
  72. package/plugins/polar/src/portal.js +51 -0
  73. package/plugins/polar/src/rate-limit.js +70 -0
  74. package/plugins/polar/src/routes.js +212 -0
  75. package/plugins/polar/src/sync.js +138 -0
  76. package/plugins/polar/src/urls.js +117 -0
  77. package/plugins/polar/src/user-resolver.js +195 -0
  78. package/plugins/polar/src/webhooks.js +248 -0
  79. package/src/file-router.js +203 -125
  80. package/src/helpers.js +60 -13
  81. package/src/njk-frontmatter.js +24 -14
  82. package/src/server.js +30 -12
  83. package/src/services/discovery.js +2 -1
  84. package/src/services/registry.js +2 -0
  85. package/src/services/validator.js +2 -53
  86. package/templates/skills/webspresso-usage/REFERENCE-framework.md +7 -4
@@ -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('...') && inner.length > 3) {
117
- repl = '*';
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) return EMPTY_OBJECT;
416
- if (gEmpty) return routeTranslations;
417
- if (rEmpty) return globalTranslations;
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
- if (isDev) {
636
- const stats = fs.statSync(configPath);
637
- const devCached = routeConfigDevCache.get(configPath);
638
- if (devCached && devCached.mtime >= stats.mtimeMs) {
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
- if (configCache.has(configPath)) {
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
- configCache.set(configPath, config);
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
- const handler = require(route.fullPath);
980
- const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
981
- const routeMiddleware = handler.middleware;
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
- const preResolvedMw = routeMiddleware
984
- ? resolveMiddlewares(routeMiddleware, middlewares)
985
- : EMPTY_ARRAY;
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
- app[route.method](route.routePath, async (req, res, next) => {
993
- try {
994
- // Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
995
- if (db != null) {
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
- // Reload handler in dev mode
1004
- if (isDev && require.cache[require.resolve(route.fullPath)]) {
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
- compiledSchema = compileSchema(route.fullPath, currentHandler);
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
- applySchema(req, compiledSchema);
1028
- } catch (err) {
1029
- if (err instanceof ZodError) {
1030
- return res.status(400).json({
1031
- error: 'Validation Error',
1032
- issues: err.issues,
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
- throw err;
1036
- }
1037
-
1038
- // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
1039
- if (preResolvedMw.length) {
1040
- for (const mw of preResolvedMw) {
1041
- await new Promise((resolve, reject) => {
1042
- mw(req, res, (err) => {
1043
- if (err) reject(err);
1044
- else resolve();
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
- await fn(req, res, next);
1051
- } catch (err) {
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
- const mountConfig = loadRouteConfig(route.configPath, isDev);
1071
- const preResolvedPageMw = mountConfig?.middleware
1072
- ? resolveMiddlewares(mountConfig.middleware, middlewares)
1073
- : EMPTY_ARRAY;
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
- const t = createTranslator(translations, {
1088
- locale,
1089
- fallbackTranslations,
1090
- fallbackLocale: defaultLocale,
1091
- });
1092
-
1093
- // Load route config
1094
- const config = loadRouteConfig(route.configPath, isDev);
1095
- const routeHooks = config?.hooks || EMPTY_OBJECT;
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 injected head content
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
- content += `<style id="webspresso-injected-styles">\n${styles}\n</style>\n`;
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
- return getScriptInjector().getBodyContent();
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 => s.content).join('\n');
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 => s.content).join('\n');
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
- return this.styles.map(s => s.content).join('\n');
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
  /**
@@ -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 (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);
146
+ const prev = devRouteCache.get(absPath);
147
+ if (prev && prev.mtimeMs >= stat.mtimeMs) {
148
+ return prev.cached;
141
149
  }
142
- return readAndParse(absPath, false);
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, store) {
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.stack.push(normalizedName);
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
- const idx = store.stack.lastIndexOf(normalizedName);
64
- if (idx !== -1) store.stack.splice(idx, 1);
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
- const idx = store.stack.lastIndexOf(normalizedName);
72
- if (idx !== -1) store.stack.splice(idx, 1);
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 && store) {
101
- wrapTemplateRoot(tmpl, name, store);
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 && store) {
108
- wrapTemplateRoot(res, name, store);
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
- // Services caller middleware
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, { req, res, db: options.db ?? null, ...(req.context || {}) }, opts);
491
+ serviceRegistry.call(name, input, req.context, opts);
474
492
  next();
475
493
  });
476
494