webspresso 0.0.96 → 0.0.98

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 (121) hide show
  1. package/.agents/ADMIN.md +153 -5
  2. package/.agents/ARCHITECTURE.md +10 -0
  3. package/.agents/CLI.md +195 -1
  4. package/.agents/PLUGINS.md +153 -9
  5. package/.agents/QUEUE.md +43 -0
  6. package/.agents/skills/webspresso-admin-and-plugins/SKILL.md +122 -0
  7. package/.agents/skills/webspresso-framework/SKILL.md +56 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +119 -0
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +114 -0
  10. package/.agents/skills/webspresso-services/SKILL.md +101 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/admin-password.js +2 -1
  14. package/bin/commands/admin-setup.js +1 -1
  15. package/bin/commands/agents-init.js +51 -11
  16. package/bin/commands/analytics-prune.js +57 -0
  17. package/bin/commands/api.js +207 -15
  18. package/bin/commands/audit-prune.js +1 -1
  19. package/bin/commands/build.js +99 -0
  20. package/bin/commands/dev.js +64 -35
  21. package/bin/commands/doctor.js +26 -0
  22. package/bin/commands/email-prune.js +47 -0
  23. package/bin/commands/favicon-generate.js +1 -1
  24. package/bin/commands/make-job.js +86 -0
  25. package/bin/commands/make-model.js +250 -0
  26. package/bin/commands/new.js +45 -28
  27. package/bin/commands/openapi.js +102 -0
  28. package/bin/commands/orm-map.js +5 -4
  29. package/bin/commands/page.js +6 -3
  30. package/bin/commands/polar-migrate.js +1 -1
  31. package/bin/commands/queue-prune.js +59 -0
  32. package/bin/commands/routes.js +52 -0
  33. package/bin/commands/seed.js +1 -1
  34. package/bin/commands/service.js +6 -3
  35. package/bin/commands/typegen.js +106 -0
  36. package/bin/commands/upload-prune.js +79 -0
  37. package/bin/webspresso.js +22 -0
  38. package/core/applySchema.js +13 -6
  39. package/core/auth/hash.js +31 -17
  40. package/core/compression/middleware.js +23 -17
  41. package/core/openapi/build-from-api-routes.js +160 -29
  42. package/core/orm/cache/fingerprint.js +15 -1
  43. package/core/orm/cache/memory-provider.js +27 -2
  44. package/core/orm/index.js +12 -7
  45. package/core/orm/model.js +49 -7
  46. package/core/queue/adapters/database.js +94 -0
  47. package/core/queue/adapters/memory.js +62 -0
  48. package/core/queue/adapters/redis.js +80 -0
  49. package/core/queue/manager.js +123 -0
  50. package/core/ssr/stream.js +11 -0
  51. package/index.d.ts +660 -10
  52. package/index.js +44 -96
  53. package/package.json +32 -12
  54. package/plugins/admin-panel/api.js +279 -17
  55. package/plugins/admin-panel/app.js +124 -31
  56. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  57. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  58. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
  59. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  60. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  61. package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
  62. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  63. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  64. package/plugins/admin-panel/core/admin-module.js +13 -7
  65. package/plugins/admin-panel/core/api-extensions.js +34 -2
  66. package/plugins/admin-panel/core/registry.js +96 -16
  67. package/plugins/admin-panel/index.js +96 -0
  68. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  69. package/plugins/admin-panel/modules/admin-users.js +16 -1
  70. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  71. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  72. package/plugins/admin-panel/modules/dashboard.js +283 -0
  73. package/plugins/admin-panel/modules/menu.js +56 -18
  74. package/plugins/admin-panel/modules/user-management.js +24 -6
  75. package/plugins/audit-log/admin-component.js +11 -0
  76. package/plugins/audit-log/api-handlers.js +34 -0
  77. package/plugins/audit-log/index.js +129 -3
  78. package/plugins/audit-log/purge.js +60 -10
  79. package/plugins/basic-auth/index.js +3 -1
  80. package/plugins/content/client/inline-edit.js +12 -12
  81. package/plugins/cors.js +6 -7
  82. package/plugins/dashboard/app.js +152 -27
  83. package/plugins/dashboard/index.js +143 -39
  84. package/plugins/dashboard/styles.js +78 -0
  85. package/plugins/data-exchange/export-xlsx.js +9 -2
  86. package/plugins/data-exchange/import.js +9 -2
  87. package/plugins/data-exchange/index.js +6 -3
  88. package/plugins/email/index.js +132 -2
  89. package/plugins/email/purge.js +60 -10
  90. package/plugins/file-manager/core.js +1 -1
  91. package/plugins/file-manager/security.js +33 -23
  92. package/plugins/index.js +49 -87
  93. package/plugins/mcp/transports/sse.js +4 -2
  94. package/plugins/orm-cache-admin/index.js +4 -2
  95. package/plugins/queue/index.js +11 -0
  96. package/plugins/schema-explorer/admin-component.js +714 -0
  97. package/plugins/schema-explorer/relations-graph.js +205 -0
  98. package/plugins/schema-explorer.js +248 -69
  99. package/plugins/site-analytics/admin-component.js +30 -8
  100. package/plugins/site-analytics/api-handlers.js +42 -0
  101. package/plugins/site-analytics/index.js +147 -2
  102. package/plugins/site-analytics/purge.js +168 -0
  103. package/plugins/swagger.js +261 -13
  104. package/plugins/upload/index.js +26 -0
  105. package/plugins/upload/orphan-pruner.js +292 -0
  106. package/src/api/api-loader.js +3 -1
  107. package/src/bundle/index.js +517 -0
  108. package/src/bundle/manifest.js +165 -0
  109. package/src/dev/banner.js +296 -0
  110. package/src/dev/colors.js +70 -0
  111. package/src/dev/index.js +62 -0
  112. package/src/dev/profiler.js +354 -0
  113. package/src/dev/typegen.js +497 -0
  114. package/src/dev-reload/index.js +611 -0
  115. package/src/file-router.js +213 -63
  116. package/src/helpers.js +958 -98
  117. package/src/njk-frontmatter.js +12 -0
  118. package/src/pages/page-loader.js +61 -11
  119. package/src/server.js +629 -63
  120. package/src/services/discovery.js +6 -1
  121. package/src/services/index.js +6 -13
@@ -8,12 +8,13 @@ const path = require('path');
8
8
  const { ZodError } = require('zod');
9
9
  const { compileSchema, invalidateSchema } = require('../core/compileSchema');
10
10
  const { applySchema } = require('../core/applySchema');
11
- const { createHelpers } = require('./helpers');
11
+ const { createHelpers, getAssetManager } = require('./helpers');
12
12
  const {
13
13
  loadNjkRouteTemplate,
14
14
  parseNjkFrontmatter,
15
15
  frontmatterToPatches,
16
16
  clearNjkFrontmatterCaches,
17
+ registerClearHook,
17
18
  } = require('./njk-frontmatter');
18
19
  const { renderStream } = require('../core/ssr/stream');
19
20
 
@@ -27,12 +28,30 @@ const i18nCache = new Map();
27
28
  // Cache for merged i18n (key: `${pagesDir}::${routeDir}::${locale}`, value: { globalMtime, routeMtime, data })
28
29
  const mergedI18nCache = new Map();
29
30
 
31
+ // Cache for translator instances in production
32
+ const translatorCache = new Map();
33
+
34
+ function clearTranslatorCache() {
35
+ translatorCache.clear();
36
+ }
37
+
30
38
  // Cache for route configs in production
31
39
  const configCache = new Map();
32
40
 
33
41
  // Dev-only: avoid require() on every SSR request when the .js file is unchanged (mtime)
34
42
  const routeConfigDevCache = new Map();
35
43
 
44
+ // Cache for compiled frontmatter string templates (key: fullPath, value: compiled Template)
45
+ const compiledStringTemplateCache = new Map();
46
+
47
+ function clearCompiledStringTemplateCache() {
48
+ compiledStringTemplateCache.clear();
49
+ }
50
+
51
+ if (typeof registerClearHook === 'function') {
52
+ registerClearHook(clearCompiledStringTemplateCache);
53
+ }
54
+
36
55
  // Cache for API filename -> { method, baseName } (basename keys; stable per process)
37
56
  const methodFromFilenameCache = new Map();
38
57
 
@@ -43,6 +62,8 @@ const MAX_LOCALE_LEN = 16;
43
62
 
44
63
  let cachedSupportedLocalesRaw = null;
45
64
  let cachedSupportedLocaleSet = null;
65
+ let cachedDefaultLocaleKey = null;
66
+ let cachedDefaultLocale = 'en';
46
67
 
47
68
  /** @returns {Set<string>} */
48
69
  function parseSupportedLocaleSet() {
@@ -57,6 +78,27 @@ function parseSupportedLocaleSet() {
57
78
  return cachedSupportedLocaleSet;
58
79
  }
59
80
 
81
+ /**
82
+ * Get default locale matching supported set
83
+ * @param {Set<string>} [supported]
84
+ * @returns {string}
85
+ */
86
+ function getDefaultLocale(supported = parseSupportedLocaleSet()) {
87
+ const rawDef = process.env.DEFAULT_LOCALE || 'en';
88
+ const rawSupp = process.env.SUPPORTED_LOCALES || 'en';
89
+ const key = rawDef + '|' + rawSupp;
90
+ if (key === cachedDefaultLocaleKey && cachedDefaultLocale) {
91
+ return cachedDefaultLocale;
92
+ }
93
+ cachedDefaultLocaleKey = key;
94
+ const defaultCand = normalizeLocaleCandidate(rawDef);
95
+ cachedDefaultLocale =
96
+ pickMatchingLocale(defaultCand, supported)
97
+ ?? (supported.has('en') ? 'en' : [...supported][0])
98
+ ?? 'en';
99
+ return cachedDefaultLocale;
100
+ }
101
+
60
102
  /**
61
103
  * @param {string} raw
62
104
  * @returns {string|null}
@@ -271,29 +313,31 @@ function extractMethodFromFilename(filename) {
271
313
 
272
314
  /**
273
315
  * Whether `load()` return values for `stylesheets` and `scripts` are promoted to
274
- * `pageHead` in Nunjucks (see `createApp({ pageAssets })`).
275
- * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean}|null|undefined} raw
276
- * @returns {{ enabled: boolean, stylesheets: boolean, scripts: boolean }}
316
+ * `pageHead` in Nunjucks (see `createApp({ pageAssets })`), with optional automatic
317
+ * per-route script/stylesheet discovery.
318
+ * @param {boolean|{enabled?: boolean, stylesheets?: boolean, scripts?: boolean, auto?: boolean}|null|undefined} raw
319
+ * @returns {{ enabled: boolean, stylesheets: boolean, scripts: boolean, auto: boolean }}
277
320
  */
278
321
  function resolvePageAssets(raw) {
279
322
  if (raw === true) {
280
- return { enabled: true, stylesheets: true, scripts: true };
323
+ return { enabled: true, stylesheets: true, scripts: true, auto: true };
281
324
  }
282
325
  if (raw == null || raw === false) {
283
- return { enabled: false, stylesheets: false, scripts: false };
326
+ return { enabled: false, stylesheets: false, scripts: false, auto: false };
284
327
  }
285
328
  if (typeof raw === 'object') {
286
329
  const on = raw.enabled !== false;
287
330
  if (!on) {
288
- return { enabled: false, stylesheets: false, scripts: false };
331
+ return { enabled: false, stylesheets: false, scripts: false, auto: false };
289
332
  }
290
333
  return {
291
334
  enabled: true,
292
335
  stylesheets: raw.stylesheets !== false,
293
336
  scripts: raw.scripts !== false,
337
+ auto: raw.auto !== false,
294
338
  };
295
339
  }
296
- return { enabled: false, stylesheets: false, scripts: false };
340
+ return { enabled: false, stylesheets: false, scripts: false, auto: false };
297
341
  }
298
342
 
299
343
  /**
@@ -306,11 +350,13 @@ function toList(v) {
306
350
  }
307
351
 
308
352
  /**
309
- * @param {{ enabled: boolean, stylesheets: boolean, scripts: boolean }} cfg
353
+ * @param {{ enabled: boolean, stylesheets: boolean, scripts: boolean, auto?: boolean }} cfg
310
354
  * @param {Object} data
355
+ * @param {{ file?: string, routePath?: string }} [routeInfo={}]
356
+ * @param {Object|null} [assetManager=null]
311
357
  * @returns {{ data: Object, pageHead: { stylesheets: unknown[], scripts: unknown[] }|null, pageAssets: boolean }}
312
358
  */
313
- function applyPageAssetsToTemplateData(cfg, data) {
359
+ function applyPageAssetsToTemplateData(cfg, data, routeInfo = {}, assetManager = null) {
314
360
  if (!cfg || !cfg.enabled) {
315
361
  return { data, pageHead: null, pageAssets: false };
316
362
  }
@@ -325,6 +371,40 @@ function applyPageAssetsToTemplateData(cfg, data) {
325
371
  scriptItems = toList(out.scripts);
326
372
  delete out.scripts;
327
373
  }
374
+
375
+ // Auto-discover page assets from route if enabled
376
+ if (cfg.auto && assetManager && (routeInfo.file || routeInfo.routePath)) {
377
+ const candidates = typeof assetManager.getRouteAssetCandidates === 'function'
378
+ ? assetManager.getRouteAssetCandidates(routeInfo.file, routeInfo.routePath)
379
+ : [];
380
+
381
+ if (cfg.stylesheets) {
382
+ for (const cand of candidates) {
383
+ if (typeof assetManager.hasPageCss === 'function' && assetManager.hasPageCss(cand)) {
384
+ const cssUrl = assetManager.pageCssUrl(cand);
385
+ const already = styles.some((s) => (typeof s === 'string' ? s === cssUrl : s?.href === cssUrl));
386
+ if (!already) {
387
+ styles.push(cssUrl);
388
+ }
389
+ break;
390
+ }
391
+ }
392
+ }
393
+
394
+ if (cfg.scripts) {
395
+ for (const cand of candidates) {
396
+ if (typeof assetManager.hasPageJs === 'function' && assetManager.hasPageJs(cand)) {
397
+ const jsUrl = assetManager.pageJsUrl(cand);
398
+ const already = scriptItems.some((s) => (typeof s === 'string' ? s === jsUrl : s?.src === jsUrl));
399
+ if (!already) {
400
+ scriptItems.push({ src: jsUrl, defer: true });
401
+ }
402
+ break;
403
+ }
404
+ }
405
+ }
406
+ }
407
+
328
408
  return {
329
409
  data: out,
330
410
  pageHead: { stylesheets: styles, scripts: scriptItems },
@@ -492,15 +572,25 @@ function getPluralRules(locale) {
492
572
  return pr;
493
573
  }
494
574
 
575
+ const KEY_PARTS_CACHE = new Map();
576
+
495
577
  function resolveTranslationValue(dict, key) {
496
- if (!dict || typeof dict !== 'object') return undefined;
578
+ if (!dict || typeof dict !== 'object' || typeof key !== 'string') return undefined;
497
579
  if (dict[key] !== undefined) return dict[key];
580
+ if (!key.includes('.')) return undefined;
581
+
582
+ let parts = KEY_PARTS_CACHE.get(key);
583
+ if (!parts) {
584
+ parts = key.split('.');
585
+ if (KEY_PARTS_CACHE.size < 1000) {
586
+ KEY_PARTS_CACHE.set(key, parts);
587
+ }
588
+ }
498
589
 
499
- const parts = key.split('.');
500
590
  let curr = dict;
501
- for (const part of parts) {
591
+ for (let i = 0; i < parts.length; i++) {
502
592
  if (curr && typeof curr === 'object') {
503
- curr = curr[part];
593
+ curr = curr[parts[i]];
504
594
  } else {
505
595
  return undefined;
506
596
  }
@@ -726,25 +816,37 @@ function loadGlobalHooks(pagesDir, isDev) {
726
816
  * @param {string} hookName - Hook name
727
817
  * @param {Object} ctx - Context object
728
818
  */
729
- async function executeHook(hooks, hookName, ctx, ...extra) {
730
- if (hooks && typeof hooks[hookName] === 'function') {
731
- await hooks[hookName](ctx, ...extra);
819
+ function executeHook(hooks, hookName, ctx, ...extra) {
820
+ if (!hooks || typeof hooks[hookName] !== 'function') {
821
+ return;
732
822
  }
823
+ return hooks[hookName](ctx, ...extra);
824
+ }
825
+
826
+ function runHooks(globalHooks, routeHooks, hookName, ctx, ...extra) {
827
+ const gFn = globalHooks ? globalHooks[hookName] : undefined;
828
+ const rFn = routeHooks ? routeHooks[hookName] : undefined;
829
+ if (typeof gFn !== 'function' && typeof rFn !== 'function') return;
830
+ return (async () => {
831
+ if (typeof gFn === 'function') {
832
+ const gRes = gFn(ctx, ...extra);
833
+ if (gRes && typeof gRes.then === 'function') await gRes;
834
+ }
835
+ if (typeof rFn === 'function') {
836
+ const rRes = rFn(ctx, ...extra);
837
+ if (rRes && typeof rRes.then === 'function') await rRes;
838
+ }
839
+ })();
733
840
  }
734
841
 
735
842
  /**
736
843
  * Detect locale from request
737
844
  * @param {Object} req - Express request
845
+ * @param {Set<string>} [supported] - Optional precomputed supported locales set
846
+ * @param {string} [def] - Optional precomputed default locale
738
847
  * @returns {string} Locale code
739
848
  */
740
- function detectLocale(req) {
741
- const supported = parseSupportedLocaleSet();
742
- const defaultCand = normalizeLocaleCandidate(process.env.DEFAULT_LOCALE || 'en');
743
- const def =
744
- pickMatchingLocale(defaultCand, supported)
745
- ?? (supported.has('en') ? 'en' : [...supported][0])
746
- ?? 'en';
747
-
849
+ function detectLocale(req, supported = parseSupportedLocaleSet(), def = getDefaultLocale(supported)) {
748
850
  if (req.query && req.query.lang != null && req.query.lang !== '') {
749
851
  const q = normalizeLocaleCandidate(String(req.query.lang));
750
852
  const hit = pickMatchingLocale(q, supported);
@@ -753,7 +855,9 @@ function detectLocale(req) {
753
855
  }
754
856
  }
755
857
 
756
- const acceptLanguage = req.get('Accept-Language');
858
+ const acceptLanguage = typeof req.get === 'function'
859
+ ? req.get('Accept-Language')
860
+ : (req.headers && (req.headers['accept-language'] || req.headers['Accept-Language']));
757
861
  if (acceptLanguage) {
758
862
  const langPart = acceptLanguage.split(',')[0];
759
863
  const a = normalizeLocaleCandidate(langPart);
@@ -1062,10 +1166,10 @@ function mountPages(app, options) {
1062
1166
  }
1063
1167
  }
1064
1168
  // Same instance as createApp({ db }) / getAppContext().db — available to handler & route middleware
1065
- if (db != null) {
1169
+ if (db != null && !req.db) {
1066
1170
  req.db = db;
1067
1171
  }
1068
- if (serviceRegistry != null) {
1172
+ if (serviceRegistry != null && !req.service) {
1069
1173
  req.service = (name, input, opts) =>
1070
1174
  serviceRegistry.call(name, input, { req, res, db }, opts);
1071
1175
  }
@@ -1157,20 +1261,38 @@ function mountPages(app, options) {
1157
1261
  }
1158
1262
  }
1159
1263
  // Detect locale
1160
- const locale = detectLocale(req);
1161
- const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
1264
+ const supported = parseSupportedLocaleSet();
1265
+ const defaultLocale = getDefaultLocale(supported);
1266
+ const locale = detectLocale(req, supported, defaultLocale);
1162
1267
 
1163
1268
  // Load translations (primary + fallback)
1164
- const translations = loadI18n(absolutePagesDir, route.routeDir, locale, isDev);
1165
- const fallbackTranslations = (locale !== defaultLocale)
1166
- ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, isDev)
1167
- : EMPTY_OBJECT;
1168
-
1169
- const t = createTranslator(translations, {
1170
- locale,
1171
- fallbackTranslations,
1172
- fallbackLocale: defaultLocale,
1173
- });
1269
+ let t;
1270
+ if (!isDev) {
1271
+ const transKey = `${absolutePagesDir}::${route.routeDir}::${locale}::${defaultLocale}`;
1272
+ t = translatorCache.get(transKey);
1273
+ if (!t) {
1274
+ const translations = loadI18n(absolutePagesDir, route.routeDir, locale, false);
1275
+ const fallbackTranslations = (locale !== defaultLocale)
1276
+ ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, false)
1277
+ : EMPTY_OBJECT;
1278
+ t = createTranslator(translations, {
1279
+ locale,
1280
+ fallbackTranslations,
1281
+ fallbackLocale: defaultLocale,
1282
+ });
1283
+ translatorCache.set(transKey, t);
1284
+ }
1285
+ } else {
1286
+ const translations = loadI18n(absolutePagesDir, route.routeDir, locale, true);
1287
+ const fallbackTranslations = (locale !== defaultLocale)
1288
+ ? loadI18n(absolutePagesDir, route.routeDir, defaultLocale, true)
1289
+ : EMPTY_OBJECT;
1290
+ t = createTranslator(translations, {
1291
+ locale,
1292
+ fallbackTranslations,
1293
+ fallbackLocale: defaultLocale,
1294
+ });
1295
+ }
1174
1296
 
1175
1297
  // Load route config
1176
1298
  const config = isDev ? loadRouteConfig(route.configPath, true) : mountConfig;
@@ -1188,6 +1310,8 @@ function mountPages(app, options) {
1188
1310
  }
1189
1311
 
1190
1312
  const njkTpl = loadNjkRouteTemplate(route.fullPath, isDev);
1313
+ const metaTitle = t('meta.title');
1314
+ const metaDesc = t('meta.description');
1191
1315
 
1192
1316
  const ctx = {
1193
1317
  req,
@@ -1206,8 +1330,8 @@ function mountPages(app, options) {
1206
1330
  },
1207
1331
  data: { ...njkTpl.dataPatch },
1208
1332
  meta: {
1209
- title: t('meta.title') !== 'meta.title' ? t('meta.title') : null,
1210
- description: t('meta.description') !== 'meta.description' ? t('meta.description') : null,
1333
+ title: metaTitle !== 'meta.title' ? metaTitle : null,
1334
+ description: metaDesc !== 'meta.description' ? metaDesc : null,
1211
1335
  indexable: true,
1212
1336
  canonical: null,
1213
1337
  ...njkTpl.metaPatch,
@@ -1219,16 +1343,16 @@ function mountPages(app, options) {
1219
1343
  req.context = ctx;
1220
1344
 
1221
1345
  // Execute hooks: onRequest
1222
- await executeHook(globalHooks, 'onRequest', ctx);
1223
- await executeHook(routeHooks, 'onRequest', ctx);
1346
+ let hookPromise = runHooks(globalHooks, routeHooks, 'onRequest', ctx);
1347
+ if (hookPromise) await hookPromise;
1224
1348
 
1225
1349
  // Execute hooks: onRoute
1226
- await executeHook(globalHooks, 'onRoute', ctx);
1227
- await executeHook(routeHooks, 'onRoute', ctx);
1350
+ hookPromise = runHooks(globalHooks, routeHooks, 'onRoute', ctx);
1351
+ if (hookPromise) await hookPromise;
1228
1352
 
1229
1353
  // Execute hooks: beforeMiddleware
1230
- await executeHook(globalHooks, 'beforeMiddleware', ctx);
1231
- await executeHook(routeHooks, 'beforeMiddleware', ctx);
1354
+ hookPromise = runHooks(globalHooks, routeHooks, 'beforeMiddleware', ctx);
1355
+ if (hookPromise) await hookPromise;
1232
1356
 
1233
1357
  // Run route middleware (chain fixed at route registration; edit middleware in dev → restart)
1234
1358
  if (preResolvedPageMw.length) {
@@ -1243,8 +1367,8 @@ function mountPages(app, options) {
1243
1367
  }
1244
1368
 
1245
1369
  // Execute hooks: afterMiddleware
1246
- await executeHook(globalHooks, 'afterMiddleware', ctx);
1247
- await executeHook(routeHooks, 'afterMiddleware', ctx);
1370
+ hookPromise = runHooks(globalHooks, routeHooks, 'afterMiddleware', ctx);
1371
+ if (hookPromise) await hookPromise;
1248
1372
 
1249
1373
  // Execute hooks: beforeLoad
1250
1374
  if (pluginManager && db) {
@@ -1253,8 +1377,8 @@ function mountPages(app, options) {
1253
1377
  ctx.content = contentApi.getContentService(db);
1254
1378
  }
1255
1379
  }
1256
- await executeHook(globalHooks, 'beforeLoad', ctx);
1257
- await executeHook(routeHooks, 'beforeLoad', ctx);
1380
+ hookPromise = runHooks(globalHooks, routeHooks, 'beforeLoad', ctx);
1381
+ if (hookPromise) await hookPromise;
1258
1382
 
1259
1383
  // Run load function
1260
1384
  if (config?.load && typeof config.load === 'function') {
@@ -1265,8 +1389,8 @@ function mountPages(app, options) {
1265
1389
  }
1266
1390
 
1267
1391
  // Execute hooks: afterLoad
1268
- await executeHook(globalHooks, 'afterLoad', ctx);
1269
- await executeHook(routeHooks, 'afterLoad', ctx);
1392
+ hookPromise = runHooks(globalHooks, routeHooks, 'afterLoad', ctx);
1393
+ if (hookPromise) await hookPromise;
1270
1394
 
1271
1395
  // Run meta function
1272
1396
  if (config?.meta && typeof config.meta === 'function') {
@@ -1277,10 +1401,18 @@ function mountPages(app, options) {
1277
1401
  }
1278
1402
 
1279
1403
  // Execute hooks: beforeRender
1280
- await executeHook(globalHooks, 'beforeRender', ctx);
1281
- await executeHook(routeHooks, 'beforeRender', ctx);
1404
+ hookPromise = runHooks(globalHooks, routeHooks, 'beforeRender', ctx);
1405
+ if (hookPromise) await hookPromise;
1282
1406
 
1283
- const pageAssetBundle = applyPageAssetsToTemplateData(pageAssetsResolved, ctx.data);
1407
+ const pageAssetBundle = applyPageAssetsToTemplateData(
1408
+ pageAssetsResolved,
1409
+ ctx.data,
1410
+ {
1411
+ file: route.file,
1412
+ routePath: route.routePath,
1413
+ },
1414
+ ctx.fsy?.assetManager || getAssetManager()
1415
+ );
1284
1416
  ctx.data = pageAssetBundle.data;
1285
1417
  const renderContext = {
1286
1418
  ...ctx.data,
@@ -1314,10 +1446,24 @@ function mountPages(app, options) {
1314
1446
  });
1315
1447
  }
1316
1448
 
1317
- let html =
1318
- njkTpl.useStringRender && njkTpl.templateBody != null
1319
- ? nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath })
1320
- : nunjucks.render(templatePath, renderContext);
1449
+ let html;
1450
+ if (njkTpl.useStringRender && njkTpl.templateBody != null) {
1451
+ if (typeof nunjucks.Template === 'function') {
1452
+ let compiled = isDev ? null : compiledStringTemplateCache.get(route.fullPath);
1453
+ if (!compiled || (isDev && compiled._templateBody !== njkTpl.templateBody)) {
1454
+ compiled = new nunjucks.Template(njkTpl.templateBody, nunjucks, route.fullPath);
1455
+ compiled._templateBody = njkTpl.templateBody;
1456
+ compiledStringTemplateCache.set(route.fullPath, compiled);
1457
+ }
1458
+ html = compiled.render(renderContext);
1459
+ } else if (typeof nunjucks.renderString === 'function') {
1460
+ html = nunjucks.renderString(njkTpl.templateBody, renderContext, { path: route.fullPath });
1461
+ } else {
1462
+ html = nunjucks.render(templatePath, renderContext);
1463
+ }
1464
+ } else {
1465
+ html = nunjucks.render(templatePath, renderContext);
1466
+ }
1321
1467
 
1322
1468
  if (pluginManager) {
1323
1469
  const contentApi = pluginManager.getPluginAPI('content');
@@ -1328,8 +1474,8 @@ function mountPages(app, options) {
1328
1474
 
1329
1475
  // Execute hooks: afterRender
1330
1476
  ctx.html = html;
1331
- await executeHook(globalHooks, 'afterRender', ctx);
1332
- await executeHook(routeHooks, 'afterRender', ctx);
1477
+ hookPromise = runHooks(globalHooks, routeHooks, 'afterRender', ctx);
1478
+ if (hookPromise) await hookPromise;
1333
1479
 
1334
1480
  if (route.routePath === '/404' || route.file === '404.njk') {
1335
1481
  res.status(404);
@@ -1402,5 +1548,9 @@ module.exports = {
1402
1548
  frontmatterToPatches,
1403
1549
  loadNjkRouteTemplate,
1404
1550
  clearNjkFrontmatterCaches,
1551
+ compiledStringTemplateCache,
1552
+ clearCompiledStringTemplateCache,
1553
+ translatorCache,
1554
+ clearTranslatorCache,
1405
1555
  };
1406
1556