webspresso 0.0.101 → 0.0.103

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 (62) hide show
  1. package/.agents/AGENTS.md +1 -1
  2. package/.agents/ARCHITECTURE.md +11 -0
  3. package/.agents/CONVENTIONS.md +4 -0
  4. package/.agents/ORM.md +13 -4
  5. package/.agents/PLUGINS.md +32 -6
  6. package/.agents/ROUTING.md +99 -2
  7. package/.agents/skills/webspresso-framework/SKILL.md +32 -0
  8. package/.agents/skills/webspresso-orm-and-database/SKILL.md +4 -5
  9. package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
  10. package/AGENTS.md +6 -0
  11. package/README.md +20 -7
  12. package/bin/commands/add-tailwind.js +10 -0
  13. package/bin/commands/build.js +34 -0
  14. package/bin/commands/routes.js +8 -0
  15. package/core/applySchema.js +18 -1
  16. package/core/auth/jwt.js +10 -17
  17. package/core/auth/user-agent.js +61 -42
  18. package/core/compression/compression-stream.js +2 -2
  19. package/core/compression/middleware.js +1 -1
  20. package/core/compression/negotiate.js +11 -4
  21. package/core/events/dispatcher.js +56 -33
  22. package/core/openapi/orm-components.js +2 -3
  23. package/core/orm/cache/listeners.js +12 -2
  24. package/core/orm/json-fields.js +7 -1
  25. package/core/orm/model.js +99 -8
  26. package/core/orm/query-builder.js +71 -25
  27. package/core/orm/repository.js +42 -17
  28. package/core/orm/schema-helpers.js +49 -0
  29. package/core/orm/scopes.js +40 -9
  30. package/core/orm/types.js +12 -1
  31. package/core/queue/manager.js +5 -0
  32. package/core/validation/index.js +9 -3
  33. package/index.d.ts +178 -6
  34. package/index.js +71 -16
  35. package/package.json +6 -5
  36. package/plugins/admin-panel/index.js +12 -0
  37. package/plugins/audit-log/middleware.js +39 -4
  38. package/plugins/csrf/index.js +1 -1
  39. package/plugins/email/service.js +32 -1
  40. package/plugins/file-manager/api-handlers.js +10 -17
  41. package/plugins/file-manager/index.js +24 -9
  42. package/plugins/file-manager/security.js +18 -6
  43. package/plugins/index.js +2 -0
  44. package/plugins/lazy.js +258 -0
  45. package/plugins/site-analytics/bot-patterns.js +3 -0
  46. package/plugins/site-analytics/client-error-handler.js +3 -1
  47. package/plugins/site-analytics/index.js +10 -1
  48. package/plugins/site-analytics/tracking.js +245 -46
  49. package/src/api/api-loader.js +18 -2
  50. package/src/api/validation-i18n.js +231 -0
  51. package/src/discovery/route-manifest.js +197 -0
  52. package/src/discovery/scan-routes.js +84 -8
  53. package/src/file-router.js +215 -97
  54. package/src/helpers.js +139 -26
  55. package/src/njk-frontmatter.js +17 -1
  56. package/src/plugin-manager.js +46 -13
  57. package/src/routing/route-table.js +15 -5
  58. package/src/server.js +86 -15
  59. package/src/services/builtins/system.js +4 -6
  60. package/src/services/registry.js +12 -2
  61. package/src/services/validator.js +57 -32
  62. package/src/template/precompile.js +247 -0
@@ -5,9 +5,9 @@
5
5
 
6
6
  const fs = require('fs');
7
7
  const path = require('path');
8
- const { ZodError } = require('zod');
9
8
  const { compileSchema, invalidateSchema } = require('../core/compileSchema');
10
9
  const { applySchema } = require('../core/applySchema');
10
+ const { translateValidationIssues, getTranslatorForRequest } = require('./api/validation-i18n');
11
11
  const { createHelpers, getAssetManager } = require('./helpers');
12
12
  const {
13
13
  loadNjkRouteTemplate,
@@ -190,6 +190,10 @@ const translatorCache = new Map();
190
190
 
191
191
  function clearTranslatorCache() {
192
192
  translatorCache.clear();
193
+ intlNumberFormatCache.clear();
194
+ intlDateTimeFormatCache.clear();
195
+ intlRelativeTimeFormatCache.clear();
196
+ filePathToRouteCache.clear();
193
197
  }
194
198
 
195
199
  // Cache for route configs in production
@@ -215,6 +219,48 @@ const methodFromFilenameCache = new Map();
215
219
  // Cache for translation param replacement regexes
216
220
  const paramRegexCache = new Map();
217
221
 
222
+ // Caches for file-to-route resolution and Intl formatters
223
+ const filePathToRouteCache = new Map();
224
+ const intlNumberFormatCache = new Map();
225
+ const intlDateTimeFormatCache = new Map();
226
+ const intlRelativeTimeFormatCache = new Map();
227
+
228
+ function getCachedNumberFormat(locale, opts) {
229
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
230
+ let fmt = intlNumberFormatCache.get(key);
231
+ if (!fmt) {
232
+ fmt = new Intl.NumberFormat(locale, opts);
233
+ if (intlNumberFormatCache.size < 500) {
234
+ intlNumberFormatCache.set(key, fmt);
235
+ }
236
+ }
237
+ return fmt;
238
+ }
239
+
240
+ function getCachedDateTimeFormat(locale, opts) {
241
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
242
+ let fmt = intlDateTimeFormatCache.get(key);
243
+ if (!fmt) {
244
+ fmt = new Intl.DateTimeFormat(locale, opts);
245
+ if (intlDateTimeFormatCache.size < 500) {
246
+ intlDateTimeFormatCache.set(key, fmt);
247
+ }
248
+ }
249
+ return fmt;
250
+ }
251
+
252
+ function getCachedRelativeTimeFormat(locale, opts) {
253
+ const key = opts ? `${locale}::${JSON.stringify(opts)}` : locale;
254
+ let fmt = intlRelativeTimeFormatCache.get(key);
255
+ if (!fmt) {
256
+ fmt = new Intl.RelativeTimeFormat(locale, opts);
257
+ if (intlRelativeTimeFormatCache.size < 500) {
258
+ intlRelativeTimeFormatCache.set(key, fmt);
259
+ }
260
+ }
261
+ return fmt;
262
+ }
263
+
218
264
  const MAX_LOCALE_LEN = 16;
219
265
 
220
266
  let cachedSupportedLocalesRaw = null;
@@ -404,6 +450,12 @@ function isApiRouteFile(file) {
404
450
  * @returns {string} Express route pattern
405
451
  */
406
452
  function filePathToRoute(filePath, ext) {
453
+ const cacheKey = filePath + '::' + ext;
454
+ const cached = filePathToRouteCache.get(cacheKey);
455
+ if (cached !== undefined) {
456
+ return cached;
457
+ }
458
+
407
459
  // Remove extension
408
460
  let route = filePath.replace(ext, '');
409
461
 
@@ -429,6 +481,10 @@ function filePathToRoute(filePath, ext) {
429
481
  route = '/' + route;
430
482
  }
431
483
 
484
+ if (filePathToRouteCache.size < 5000) {
485
+ filePathToRouteCache.set(cacheKey, route);
486
+ }
487
+
432
488
  return route;
433
489
  }
434
490
 
@@ -494,15 +550,15 @@ function routeRegistrationMeta(routePath) {
494
550
  * @param {{ routePath: string }} b
495
551
  */
496
552
  function compareRouteRegistrationOrder(a, b) {
497
- const ma = routeRegistrationMeta(a.routePath);
498
- const mb = routeRegistrationMeta(b.routePath);
553
+ const ma = a._regMeta || (a._regMeta = routeRegistrationMeta(a.routePath));
554
+ const mb = b._regMeta || (b._regMeta = routeRegistrationMeta(b.routePath));
499
555
  if (ma.tier !== mb.tier) return ma.tier - mb.tier;
500
556
  if (ma.literalSegCount !== mb.literalSegCount) {
501
557
  return mb.literalSegCount - ma.literalSegCount;
502
558
  }
503
559
  if (ma.depth !== mb.depth) return mb.depth - ma.depth;
504
560
  if (ma.paramSegCount !== mb.paramSegCount) return ma.paramSegCount - mb.paramSegCount;
505
- return ma.routePath.localeCompare(mb.routePath);
561
+ return ma.routePath < mb.routePath ? -1 : (ma.routePath > mb.routePath ? 1 : 0);
506
562
  }
507
563
 
508
564
  /**
@@ -886,8 +942,8 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
886
942
  return value;
887
943
  }
888
944
 
889
- // Replace params like {{name}} in the translation
890
- if (interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
945
+ // Replace params like {{name}} in the translation (skip if no placeholders in template)
946
+ if (value.includes('{{') && interpolationParams && interpolationParams !== EMPTY_OBJECT && Object.keys(interpolationParams).length > 0) {
891
947
  for (const [paramKey, paramValue] of Object.entries(interpolationParams)) {
892
948
  let regex = paramRegexCache.get(paramKey);
893
949
  if (regex === undefined) {
@@ -911,7 +967,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
911
967
  t.number = function formatNumber(num, numOpts) {
912
968
  if (num === null || num === undefined || Number.isNaN(Number(num))) return '';
913
969
  try {
914
- return new Intl.NumberFormat(locale, numOpts).format(Number(num));
970
+ return getCachedNumberFormat(locale, numOpts).format(Number(num));
915
971
  } catch {
916
972
  return String(num);
917
973
  }
@@ -921,7 +977,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
921
977
  t.currency = function formatCurrency(amount, currency = 'USD', currOpts = {}) {
922
978
  if (amount === null || amount === undefined || Number.isNaN(Number(amount))) return '';
923
979
  try {
924
- return new Intl.NumberFormat(locale, { style: 'currency', currency, ...currOpts }).format(Number(amount));
980
+ return getCachedNumberFormat(locale, { style: 'currency', currency, ...currOpts }).format(Number(amount));
925
981
  } catch {
926
982
  return `${amount} ${currency}`;
927
983
  }
@@ -933,7 +989,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
933
989
  try {
934
990
  const d = date instanceof Date ? date : new Date(date);
935
991
  if (Number.isNaN(d.getTime())) return '';
936
- return new Intl.DateTimeFormat(locale, dateOpts).format(d);
992
+ return getCachedDateTimeFormat(locale, dateOpts).format(d);
937
993
  } catch {
938
994
  return String(date);
939
995
  }
@@ -943,7 +999,7 @@ function createTranslator(translations = {}, options = EMPTY_OBJECT) {
943
999
  t.relativeTime = function formatRelativeTime(val, unit = 'day', relOpts = { numeric: 'auto' }) {
944
1000
  if (val === null || val === undefined || Number.isNaN(Number(val))) return '';
945
1001
  try {
946
- return new Intl.RelativeTimeFormat(locale, relOpts).format(Number(val), unit);
1002
+ return getCachedRelativeTimeFormat(locale, relOpts).format(Number(val), unit);
947
1003
  } catch {
948
1004
  return `${val} ${unit}`;
949
1005
  }
@@ -1133,86 +1189,92 @@ function mountPages(app, options) {
1133
1189
  // Get absolute path to pages directory
1134
1190
  const absolutePagesDir = path.resolve(pagesDir);
1135
1191
 
1136
- // Routing and file-system checks at startup
1137
- function checkRoutesAtStartup() {
1138
- function getGenericSegment(name) {
1139
- if (name.startsWith('[') && name.endsWith(']')) {
1140
- const inner = name.slice(1, -1);
1141
- if (inner.startsWith('...')) return '*';
1142
- return ':param';
1143
- }
1144
- return name;
1192
+ // Single-pass directory scanner with startup collision & case-sensitivity checks (0 statSync calls)
1193
+ function getGenericSegment(name) {
1194
+ if (name.startsWith('[') && name.endsWith(']')) {
1195
+ const inner = name.slice(1, -1);
1196
+ if (inner.startsWith('...')) return '*';
1197
+ return ':param';
1145
1198
  }
1199
+ return name;
1200
+ }
1146
1201
 
1147
- function checkDir(dirPath, relativeDir = '', isApi = false) {
1148
- if (!fs.existsSync(dirPath)) return;
1149
- const children = fs.readdirSync(dirPath);
1150
- const kept = [];
1151
-
1152
- for (const child of children) {
1153
- if (child.startsWith('_') || child === 'locales') continue;
1154
- const fullPath = path.join(dirPath, child);
1155
- const relPath = relativeDir ? path.join(relativeDir, child) : child;
1156
- const stats = fs.statSync(fullPath);
1202
+ function scanAndCheckDir(dirPath, relativeDir = '', isApi = false) {
1203
+ if (!fs.existsSync(dirPath)) return [];
1204
+ let children;
1205
+ try {
1206
+ children = fs.readdirSync(dirPath, { withFileTypes: true });
1207
+ } catch (_) {
1208
+ return [];
1209
+ }
1210
+ const kept = [];
1211
+ const files = [];
1212
+
1213
+ for (const entry of children) {
1214
+ const child = entry.name;
1215
+ if (child.startsWith('_') || child === 'locales') continue;
1216
+ const fullPath = path.join(dirPath, child);
1217
+ const relPath = relativeDir ? path.join(relativeDir, child) : child;
1218
+
1219
+ // 1. Check case-sensitivity issues
1220
+ if (child !== child.toLowerCase()) {
1221
+ console.warn(`[webspresso] WARNING: Path "pages/${relPath.split(path.sep).join('/')}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
1222
+ }
1157
1223
 
1158
- // 1. Check case-sensitivity issues
1159
- if (child !== child.toLowerCase()) {
1160
- console.warn(`[webspresso] WARNING: Path "pages/${relPath.split(path.sep).join('/')}" contains uppercase letters. Use lowercase to avoid routing issues on case-sensitive filesystems.`);
1224
+ if (entry.isDirectory()) {
1225
+ if (child === 'api' && relativeDir === '') {
1226
+ files.push(...scanAndCheckDir(fullPath, relPath, true));
1227
+ } else {
1228
+ files.push(...scanAndCheckDir(fullPath, relPath, isApi));
1229
+ if (!isApi) {
1230
+ kept.push({ name: child, isDir: true, original: child });
1231
+ }
1161
1232
  }
1162
-
1163
- if (stats.isDirectory()) {
1164
- if (child === 'api' && relativeDir === '') {
1165
- checkDir(fullPath, relPath, true);
1166
- } else {
1167
- checkDir(fullPath, relPath, isApi);
1168
- if (!isApi) {
1169
- kept.push({ name: child, isDir: true, original: child });
1170
- }
1233
+ } else if (entry.isFile()) {
1234
+ files.push(relPath);
1235
+ const ext = path.extname(child);
1236
+ if (isApi) {
1237
+ if (ext === '.js') {
1238
+ const base = path.basename(child, ext);
1239
+ kept.push({ name: base, isDir: false, original: child });
1171
1240
  }
1172
1241
  } else {
1173
- const ext = path.extname(child);
1174
- if (isApi) {
1175
- if (ext === '.js') {
1176
- const base = path.basename(child, ext);
1177
- kept.push({ name: base, isDir: false, original: child });
1178
- }
1179
- } else {
1180
- if (ext === '.njk') {
1181
- const base = path.basename(child, ext);
1182
- kept.push({ name: base, isDir: false, original: child });
1183
- }
1242
+ if (ext === '.njk') {
1243
+ const base = path.basename(child, ext);
1244
+ kept.push({ name: base, isDir: false, original: child });
1184
1245
  }
1185
1246
  }
1186
1247
  }
1248
+ }
1187
1249
 
1188
- // 2. Check dynamic route collisions
1189
- const groups = new Map();
1190
- for (const item of kept) {
1191
- const normalized = getGenericSegment(item.name);
1192
- if (!groups.has(normalized)) {
1193
- groups.set(normalized, []);
1194
- }
1195
- groups.get(normalized).push(item);
1250
+ // 2. Check dynamic route collisions
1251
+ const groups = new Map();
1252
+ for (const item of kept) {
1253
+ const normalized = getGenericSegment(item.name);
1254
+ if (!groups.has(normalized)) {
1255
+ groups.set(normalized, []);
1196
1256
  }
1257
+ groups.get(normalized).push(item);
1258
+ }
1197
1259
 
1198
- for (const [norm, items] of groups.entries()) {
1199
- if (items.length > 1) {
1200
- const namesList = items.map(item => `"${item.original}"`).join(' and ');
1201
- console.warn(`[webspresso] WARNING: Dynamic route collision in "pages/${relativeDir.split(path.sep).join('/')}": ${namesList} resolve to the same segment "${norm}" and will conflict at runtime.`);
1202
- }
1260
+ for (const [norm, items] of groups.entries()) {
1261
+ if (items.length > 1) {
1262
+ const namesList = items.map(item => `"${item.original}"`).join(' and ');
1263
+ console.warn(`[webspresso] WARNING: Dynamic route collision in "pages/${relativeDir.split(path.sep).join('/')}": ${namesList} resolve to the same segment "${norm}" and will conflict at runtime.`);
1203
1264
  }
1204
1265
  }
1205
1266
 
1206
- checkDir(absolutePagesDir, '');
1267
+ return files;
1207
1268
  }
1208
1269
 
1209
- checkRoutesAtStartup();
1210
-
1211
1270
  // Load global hooks
1212
1271
  const globalHooks = loadGlobalHooks(absolutePagesDir, isDev);
1213
1272
 
1214
- // Scan for files
1215
- const files = scanDirectory(absolutePagesDir);
1273
+ // Single-pass scan for files and checks (bypassed if pre-scanned files provided by discovery phase)
1274
+ const files = (Array.isArray(options.scannedFiles) && options.scannedFiles.length >= 0)
1275
+ ? options.scannedFiles
1276
+ : scanAndCheckDir(absolutePagesDir, '');
1277
+ const filesSet = new Set(files);
1216
1278
 
1217
1279
  // Separate SSR pages and API routes
1218
1280
  const ssrRoutes = [];
@@ -1239,7 +1301,9 @@ function mountPages(app, options) {
1239
1301
  } else if (ext === '.njk') {
1240
1302
  // SSR page
1241
1303
  const routePath = filePathToRoute(file, '.njk');
1242
- const configPath = path.join(absolutePagesDir, file.replace('.njk', '.js'));
1304
+ const configRel = file.replace(/\.njk$/, '.js');
1305
+ const hasCompanionConfig = filesSet.has(configRel);
1306
+ const configPath = path.join(absolutePagesDir, configRel);
1243
1307
  const routeDir = path.dirname(path.join(absolutePagesDir, file));
1244
1308
 
1245
1309
  ssrRoutes.push({
@@ -1247,6 +1311,7 @@ function mountPages(app, options) {
1247
1311
  routePath,
1248
1312
  fullPath: path.join(absolutePagesDir, file),
1249
1313
  configPath,
1314
+ hasCompanionConfig,
1250
1315
  routeDir
1251
1316
  });
1252
1317
  }
@@ -1280,21 +1345,27 @@ function mountPages(app, options) {
1280
1345
  if (options.skipExistingRoutes && options.skipExistingRoutes.has(route.method || 'GET', route.routePath)) {
1281
1346
  continue;
1282
1347
  }
1283
- const handler = require(route.fullPath);
1284
- const handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
1285
- const routeMiddleware = handler.middleware;
1286
-
1287
- const preResolvedMw = routeMiddleware
1288
- ? resolveMiddlewares(routeMiddleware, middlewares)
1289
- : EMPTY_ARRAY;
1290
-
1291
- if (typeof handlerFn !== 'function') {
1292
- console.warn(`API route ${route.file} does not export a function`);
1293
- continue;
1294
- }
1295
-
1348
+ let handlerFn = null;
1349
+ let preResolvedMw = null;
1296
1350
  let preCompiledSchema = null;
1351
+
1352
+ // In production, eager-load to compile schemas & fail fast at startup
1297
1353
  if (!isDev) {
1354
+ let handler;
1355
+ try {
1356
+ handler = require(route.fullPath);
1357
+ } catch (loadErr) {
1358
+ console.error(`Failed to load API route ${route.file}:`, loadErr);
1359
+ continue;
1360
+ }
1361
+ handlerFn = typeof handler === 'function' ? handler : handler.default || handler.handler;
1362
+ if (typeof handlerFn !== 'function') {
1363
+ console.warn(`API route ${route.file} does not export a function`);
1364
+ continue;
1365
+ }
1366
+ preResolvedMw = handler.middleware
1367
+ ? resolveMiddlewares(handler.middleware, middlewares)
1368
+ : EMPTY_ARRAY;
1298
1369
  try {
1299
1370
  preCompiledSchema = compileSchema(route.fullPath, handler);
1300
1371
  } catch (schemaErr) {
@@ -1329,11 +1400,29 @@ function mountPages(app, options) {
1329
1400
  if (require.cache[require.resolve(route.fullPath)]) {
1330
1401
  delete require.cache[require.resolve(route.fullPath)];
1331
1402
  }
1332
- const currentHandler = require(route.fullPath);
1403
+ let currentHandler;
1404
+ try {
1405
+ currentHandler = require(route.fullPath);
1406
+ } catch (err) {
1407
+ console.error(`Failed to load API route ${route.file}:`, err);
1408
+ return res.status(500).json({ error: 'Internal Server Error', message: `Failed to load API route: ${err.message}` });
1409
+ }
1333
1410
  fn = typeof currentHandler === 'function'
1334
1411
  ? currentHandler
1335
1412
  : currentHandler.default || currentHandler.handler;
1336
1413
 
1414
+ if (typeof fn !== 'function') {
1415
+ console.warn(`API route ${route.file} does not export a function`);
1416
+ return res.status(500).json({ error: 'Internal Server Error', message: `Route ${route.file} does not export a valid handler function` });
1417
+ }
1418
+
1419
+ // Stateful middlewares (e.g. rate limiters) must be resolved once and preserved across requests
1420
+ if (preResolvedMw === null) {
1421
+ preResolvedMw = currentHandler.middleware
1422
+ ? resolveMiddlewares(currentHandler.middleware, middlewares)
1423
+ : EMPTY_ARRAY;
1424
+ }
1425
+
1337
1426
  invalidateSchema(route.fullPath);
1338
1427
  try {
1339
1428
  compiledSchema = compileSchema(route.fullPath, currentHandler);
@@ -1344,13 +1433,21 @@ function mountPages(app, options) {
1344
1433
  }
1345
1434
  }
1346
1435
 
1436
+ const routeMw = preResolvedMw || EMPTY_ARRAY;
1437
+
1347
1438
  try {
1348
1439
  applySchema(req, compiledSchema);
1349
1440
  } catch (err) {
1350
- if (err instanceof ZodError) {
1441
+ if (err && (err.name === 'ZodError' || Array.isArray(err.issues))) {
1442
+ const translatedIssues = translateValidationIssues(err.issues, {
1443
+ req,
1444
+ pagesDir: absolutePagesDir,
1445
+ routeDir: route.routeDir || (route.fullPath ? path.dirname(route.fullPath) : ''),
1446
+ isDev,
1447
+ });
1351
1448
  return res.status(400).json({
1352
1449
  error: 'Validation Error',
1353
- issues: err.issues,
1450
+ issues: translatedIssues,
1354
1451
  });
1355
1452
  }
1356
1453
  throw err;
@@ -1363,11 +1460,18 @@ function mountPages(app, options) {
1363
1460
  if (res.headersSent) return;
1364
1461
  }
1365
1462
 
1366
- // Run middleware if defined (resolved at route registration — required for stateful middleware like express-rate-limit)
1367
- if (preResolvedMw.length) {
1368
- await runMiddlewareChain(preResolvedMw, req, res);
1463
+ // Run middleware if defined
1464
+ if (routeMw.length) {
1465
+ await runMiddlewareChain(routeMw, req, res);
1369
1466
  if (res.headersSent) return;
1370
1467
  }
1468
+
1469
+ const tr = getTranslatorForRequest(req, {
1470
+ pagesDir: absolutePagesDir,
1471
+ routeDir: route.routeDir || (route.fullPath ? path.dirname(route.fullPath) : ''),
1472
+ isDev,
1473
+ });
1474
+
1371
1475
  const apiCtx = Object.assign((err) => next(err), {
1372
1476
  req,
1373
1477
  res,
@@ -1376,6 +1480,8 @@ function mountPages(app, options) {
1376
1480
  query: req.query || {},
1377
1481
  body: req.body || {},
1378
1482
  input: req.input,
1483
+ locale: tr.locale,
1484
+ t: tr.t,
1379
1485
  service: req.service,
1380
1486
  db,
1381
1487
  sse: res.sse,
@@ -1404,10 +1510,12 @@ function mountPages(app, options) {
1404
1510
  if (options.skipExistingRoutes && options.skipExistingRoutes.has('GET', route.routePath)) {
1405
1511
  continue;
1406
1512
  }
1407
- const mountConfig = loadRouteConfig(route.configPath, isDev);
1408
- const preResolvedPageMw = mountConfig?.middleware
1513
+ const mountConfig = (!isDev && route.hasCompanionConfig)
1514
+ ? loadRouteConfig(route.configPath, false)
1515
+ : null;
1516
+ let preResolvedPageMw = mountConfig?.middleware
1409
1517
  ? resolveMiddlewares(mountConfig.middleware, middlewares)
1410
- : EMPTY_ARRAY;
1518
+ : (!isDev ? EMPTY_ARRAY : null);
1411
1519
  const staticRouteHooks = mountConfig?.hooks || EMPTY_OBJECT;
1412
1520
 
1413
1521
  app.get(route.routePath, async (req, res, next) => {
@@ -1455,8 +1563,16 @@ function mountPages(app, options) {
1455
1563
  }
1456
1564
 
1457
1565
  // Load route config
1458
- const config = isDev ? loadRouteConfig(route.configPath, true) : mountConfig;
1566
+ const config = isDev
1567
+ ? (route.hasCompanionConfig ? loadRouteConfig(route.configPath, true) : null)
1568
+ : mountConfig;
1459
1569
  const routeHooks = isDev ? (config?.hooks || EMPTY_OBJECT) : staticRouteHooks;
1570
+ if (preResolvedPageMw === null) {
1571
+ preResolvedPageMw = config?.middleware
1572
+ ? resolveMiddlewares(config.middleware, middlewares)
1573
+ : EMPTY_ARRAY;
1574
+ }
1575
+ const pageMw = preResolvedPageMw;
1460
1576
 
1461
1577
  // Create context with plugin helpers merged
1462
1578
  const baseHelpers = createHelpers({ req, res, locale, t });
@@ -1521,9 +1637,9 @@ function mountPages(app, options) {
1521
1637
  if (res.headersSent) return;
1522
1638
  }
1523
1639
 
1524
- // Run route middleware (chain fixed at route registration; edit middleware in dev → restart)
1525
- if (preResolvedPageMw.length) {
1526
- await runMiddlewareChain(preResolvedPageMw, req, res);
1640
+ // Run route middleware
1641
+ if (pageMw.length) {
1642
+ await runMiddlewareChain(pageMw, req, res);
1527
1643
  if (res.headersSent) return;
1528
1644
  }
1529
1645
 
@@ -1758,5 +1874,7 @@ module.exports = {
1758
1874
  clearCompiledStringTemplateCache,
1759
1875
  translatorCache,
1760
1876
  clearTranslatorCache,
1877
+ translateValidationIssues,
1878
+ getTranslatorForRequest,
1761
1879
  };
1762
1880