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
@@ -11,6 +11,66 @@ const { detectCountry } = require('./geo');
11
11
  const STATIC_EXT = /\.(js|css|png|jpg|jpeg|gif|svg|ico|woff2?|ttf|eot|map|webp|avif|mp4|webm)$/i;
12
12
  const DEFAULT_EXCLUDE = ['/_admin', '/api/', '/health', '/_next', '/__'];
13
13
 
14
+ /**
15
+ * Permanent / Fatal DB error codes that cannot be recovered by immediate retries.
16
+ */
17
+ const PERMANENT_ERROR_CODES = new Set([
18
+ 'SQLITE_FULL',
19
+ 'SQLITE_IOERR',
20
+ 'SQLITE_CORRUPT',
21
+ 'SQLITE_CANTOPEN',
22
+ 'SQLITE_NOTADB',
23
+ 'ER_DISK_FULL',
24
+ 'ENOSPC',
25
+ ]);
26
+
27
+ /**
28
+ * Patterns matching fatal disk full or corrupted database errors.
29
+ */
30
+ const PERMANENT_ERROR_PATTERNS = [
31
+ /database or disk is full/i,
32
+ /disk full/i,
33
+ /no space left on device/i,
34
+ /disk i\/o error/i,
35
+ /database disk image is malformed/i,
36
+ /file is not a database/i,
37
+ ];
38
+
39
+ /**
40
+ * Sanitize and format database error messages to prevent raw SQL query leakage in logs.
41
+ * Knex appends executed queries ("insert into ... - <actual error>") which can be 50-100 KB.
42
+ * @param {Error|Object|string} e
43
+ * @returns {string}
44
+ */
45
+ function formatDbError(e) {
46
+ if (!e) return 'Unknown error';
47
+
48
+ let msg;
49
+ if (typeof e.message === 'string' && e.message.includes(' - ')) {
50
+ msg = e.message.split(' - ').pop().trim();
51
+ } else if (typeof e.message === 'string') {
52
+ msg = e.message.trim();
53
+ } else if (typeof e === 'string') {
54
+ msg = e.trim();
55
+ } else {
56
+ msg = String(e.code || 'Database error');
57
+ }
58
+
59
+ return msg.replace(/[\r\n]+/g, ' ').trim().slice(0, 150);
60
+ }
61
+
62
+ /**
63
+ * Detect whether a database error is permanent/fatal (e.g. disk full, corruption).
64
+ * @param {Error|Object|string} e
65
+ * @returns {boolean}
66
+ */
67
+ function isPermanentDbError(e) {
68
+ if (!e) return false;
69
+ if (e.code && PERMANENT_ERROR_CODES.has(e.code)) return true;
70
+ const msg = typeof e.message === 'string' ? e.message : (typeof e === 'string' ? e : '');
71
+ return PERMANENT_ERROR_PATTERNS.some((pattern) => pattern.test(msg));
72
+ }
73
+
14
74
  /**
15
75
  * Hash a string with SHA-256 (truncated to 16 hex chars for compactness)
16
76
  */
@@ -38,6 +98,10 @@ function getClientIp(req) {
38
98
  * @param {string} [options.tableName='analytics_page_views'] - DB table name
39
99
  * @param {number} [options.batchSize=20] - Flush when queue reaches this size
40
100
  * @param {number} [options.flushIntervalMs=3000] - Flush interval for low traffic
101
+ * @param {number} [options.maxRetries=3] - Maximum retry attempts per batch item before dropping
102
+ * @param {number} [options.maxBackoffMs=60000] - Max exponential backoff cooldown
103
+ * @param {number} [options.maxQueueSize=5000] - Queue memory cap to prevent OOM
104
+ * @param {number} [options.errorLogIntervalMs=60000] - Rate limit window for batch failure logs
41
105
  */
42
106
  function createTrackingMiddleware(options) {
43
107
  const {
@@ -47,12 +111,26 @@ function createTrackingMiddleware(options) {
47
111
  tableName = 'analytics_page_views',
48
112
  batchSize = 20,
49
113
  flushIntervalMs = 3000,
114
+ maxRetries = 3,
115
+ maxBackoffMs = 60000,
116
+ maxQueueSize = 5000,
117
+ errorLogIntervalMs = 60000,
50
118
  } = options;
51
119
 
52
120
  const allExcludes = [...DEFAULT_EXCLUDE, ...excludePaths];
53
121
  let tableReady = false;
54
122
  const queue = [];
55
- let flushScheduled = false;
123
+
124
+ // Circuit breaker & backoff state
125
+ let failureCount = 0;
126
+ let nextAllowedFlushTime = 0;
127
+ let isFlushing = false;
128
+ let flushPromise = null;
129
+ let flushTimeout = null;
130
+
131
+ // Log throttling state
132
+ let lastErrorLogTime = 0;
133
+ let suppressedErrorCount = 0;
56
134
 
57
135
  async function ensureTable() {
58
136
  if (tableReady) return;
@@ -75,7 +153,26 @@ function createTrackingMiddleware(options) {
75
153
  }
76
154
  tableReady = true;
77
155
  } catch (e) {
78
- console.error('[site-analytics] Failed to ensure table:', e.message);
156
+ logError(e, 'Failed to ensure table');
157
+ throw e;
158
+ }
159
+ }
160
+
161
+ function logError(e, prefix = 'Batch insert failed') {
162
+ const now = Date.now();
163
+ const formatted = formatDbError(e);
164
+
165
+ if (lastErrorLogTime === 0 || now - lastErrorLogTime >= errorLogIntervalMs) {
166
+ const elapsedSec = lastErrorLogTime > 0 ? Math.round((now - lastErrorLogTime) / 1000) : 0;
167
+ if (suppressedErrorCount > 0) {
168
+ console.error(`[site-analytics] ${prefix} (suppressed ${suppressedErrorCount} occurrences in last ${elapsedSec}s): ${formatted}`);
169
+ } else {
170
+ console.error(`[site-analytics] ${prefix}: ${formatted}`);
171
+ }
172
+ lastErrorLogTime = now;
173
+ suppressedErrorCount = 0;
174
+ } else {
175
+ suppressedErrorCount++;
79
176
  }
80
177
  }
81
178
 
@@ -83,17 +180,17 @@ function createTrackingMiddleware(options) {
83
180
  const sessionMap = new Map();
84
181
  const SESSION_TIMEOUT = 30 * 60 * 1000; // 30 minutes
85
182
  const MAX_SESSIONS = 10000;
86
- const MAX_QUEUE_SIZE = 5000;
87
183
 
88
184
  // Periodic session cleanup
89
- setInterval(() => {
185
+ const sessionCleanupInterval = setInterval(() => {
90
186
  const now = Date.now();
91
187
  for (const [vid, data] of sessionMap) {
92
188
  if (now - data.lastSeen > SESSION_TIMEOUT * 2) {
93
189
  sessionMap.delete(vid);
94
190
  }
95
191
  }
96
- }, 5 * 60 * 1000).unref();
192
+ }, 5 * 60 * 1000);
193
+ if (sessionCleanupInterval.unref) sessionCleanupInterval.unref();
97
194
 
98
195
  function getSessionId(visitorId) {
99
196
  const now = Date.now();
@@ -115,44 +212,93 @@ function createTrackingMiddleware(options) {
115
212
  }
116
213
 
117
214
  async function flushQueue() {
215
+ if (flushPromise) return flushPromise;
118
216
  if (queue.length === 0) return;
119
- const batch = queue.splice(0, queue.length);
120
217
 
121
- try {
122
- await ensureTable();
123
- for (const row of batch) {
124
- row.created_at = knex.fn.now();
125
- }
126
- await knex(tableName).insert(batch);
127
- } catch (e) {
128
- console.error('[site-analytics] Batch insert failed:', e.message);
129
- // Re-queue failed items with max size limit to prevent memory leak
130
- if (queue.length + batch.length <= MAX_QUEUE_SIZE) {
131
- queue.unshift(...batch);
132
- } else {
133
- const available = Math.max(0, MAX_QUEUE_SIZE - queue.length);
134
- if (available > 0) {
135
- queue.unshift(...batch.slice(batch.length - available));
218
+ const now = Date.now();
219
+ if (now < nextAllowedFlushTime) {
220
+ scheduleFlush(nextAllowedFlushTime - now);
221
+ return;
222
+ }
223
+
224
+ isFlushing = true;
225
+ flushPromise = (async () => {
226
+ const batch = queue.splice(0, queue.length);
227
+
228
+ try {
229
+ await ensureTable();
230
+ const dbBatch = batch.map(({ _retries, ...row }) => ({
231
+ ...row,
232
+ created_at: knex.fn.now(),
233
+ }));
234
+ await knex(tableName).insert(dbBatch);
235
+
236
+ // Successfully inserted: reset circuit breaker & failure counters
237
+ failureCount = 0;
238
+ nextAllowedFlushTime = 0;
239
+ lastErrorLogTime = 0;
240
+ suppressedErrorCount = 0;
241
+ } catch (e) {
242
+ failureCount++;
243
+ const backoffDelay = Math.min(flushIntervalMs * Math.pow(2, failureCount), maxBackoffMs);
244
+ nextAllowedFlushTime = Date.now() + backoffDelay;
245
+
246
+ // Rate-limited sanitized logging (no raw SQL query leakage)
247
+ logError(e, 'Batch insert failed');
248
+
249
+ // Schedule next attempt respecting backoff
250
+ scheduleFlush(backoffDelay);
251
+
252
+ // Drop policy: If fatal/permanent error (disk full, corrupted DB, etc.), drop entire batch
253
+ if (!isPermanentDbError(e)) {
254
+ const retryable = [];
255
+ for (const item of batch) {
256
+ item._retries = (item._retries || 0) + 1;
257
+ if (item._retries < maxRetries) {
258
+ retryable.push(item);
259
+ }
260
+ }
261
+
262
+ // Re-queue items that haven't exceeded retry limits, respecting maxQueueSize
263
+ if (retryable.length > 0) {
264
+ const available = Math.max(0, maxQueueSize - queue.length);
265
+ if (available > 0) {
266
+ queue.unshift(...retryable.slice(0, available));
267
+ }
268
+ }
136
269
  }
270
+ } finally {
271
+ isFlushing = false;
272
+ flushPromise = null;
137
273
  }
138
- }
274
+ })();
275
+
276
+ return flushPromise;
139
277
  }
140
278
 
141
- function scheduleFlush() {
142
- if (flushScheduled || queue.length === 0) return;
143
- flushScheduled = true;
144
- setTimeout(() => {
145
- flushScheduled = false;
279
+ function scheduleFlush(customDelay) {
280
+ if (flushTimeout || queue.length === 0) return;
281
+ const now = Date.now();
282
+ const delay = customDelay !== undefined
283
+ ? customDelay
284
+ : Math.max(flushIntervalMs, nextAllowedFlushTime - now, 0);
285
+
286
+ flushTimeout = setTimeout(() => {
287
+ flushTimeout = null;
146
288
  flushQueue().catch(() => {});
147
- }, flushIntervalMs);
289
+ }, delay);
290
+ if (flushTimeout.unref) flushTimeout.unref();
148
291
  }
149
292
 
150
293
  // Periodic flush (catches low-traffic case)
151
- setInterval(() => {
152
- if (queue.length > 0) flushQueue().catch(() => {});
153
- }, flushIntervalMs).unref();
294
+ const periodicFlushInterval = setInterval(() => {
295
+ if (queue.length > 0 && Date.now() >= nextAllowedFlushTime && !isFlushing) {
296
+ flushQueue().catch(() => {});
297
+ }
298
+ }, flushIntervalMs);
299
+ if (periodicFlushInterval.unref) periodicFlushInterval.unref();
154
300
 
155
- return async function trackingMiddleware(req, res, next) {
301
+ async function trackingMiddleware(req, res, next) {
156
302
  next();
157
303
 
158
304
  if (req.method !== 'GET') return;
@@ -168,33 +314,86 @@ function createTrackingMiddleware(options) {
168
314
 
169
315
  if (isBot && !trackBots) return;
170
316
 
317
+ // Protect queue under pressure or during active database failures:
318
+ // Drop bots immediately to preserve queue capacity and avoid wasting DB I/O
319
+ if (isBot && (failureCount > 0 || queue.length >= maxQueueSize * 0.7)) {
320
+ return;
321
+ }
322
+
171
323
  const ipHash = quickHash(ip);
172
324
  const visitorId = quickHash(ip + '|' + userAgent);
173
- const sessionId = getSessionId(visitorId);
325
+
326
+ // Do not thrash human sessionMap with bot/crawler visits
327
+ const sessionId = isBot
328
+ ? quickHash('bot-' + (botName || 'crawler') + '-' + visitorId)
329
+ : getSessionId(visitorId);
330
+
174
331
  const country = detectCountry(req);
175
332
  const referrer = req.headers['referer'] || req.headers['referrer'] || null;
176
333
 
177
- queue.push({
178
- session_id: sessionId,
179
- visitor_id: visitorId,
180
- path,
181
- referrer: referrer ? referrer.slice(0, 500) : null,
182
- user_agent: userAgent.slice(0, 1000),
183
- ip_hash: ipHash,
184
- country,
185
- is_bot: isBot,
186
- bot_name: botName,
187
- });
334
+ if (queue.length < maxQueueSize) {
335
+ queue.push({
336
+ session_id: sessionId,
337
+ visitor_id: visitorId,
338
+ path,
339
+ referrer: referrer ? referrer.slice(0, 500) : null,
340
+ user_agent: userAgent.slice(0, 1000),
341
+ ip_hash: ipHash,
342
+ country,
343
+ is_bot: isBot,
344
+ bot_name: botName,
345
+ });
346
+ }
188
347
 
189
348
  if (queue.length >= batchSize) {
190
- flushQueue().catch(() => {});
349
+ if (Date.now() >= nextAllowedFlushTime) {
350
+ flushQueue().catch(() => {});
351
+ } else {
352
+ scheduleFlush();
353
+ }
191
354
  } else {
192
355
  scheduleFlush();
193
356
  }
194
357
  } catch (e) {
195
358
  // Non-blocking: don't let tracking errors affect the app
196
359
  }
360
+ }
361
+
362
+ // Diagnostic / testing accessors
363
+ trackingMiddleware.flush = () => flushQueue();
364
+ trackingMiddleware.getQueue = () => queue;
365
+ trackingMiddleware.getFailureCount = () => failureCount;
366
+ trackingMiddleware.getNextAllowedFlushTime = () => nextAllowedFlushTime;
367
+ trackingMiddleware.resetState = (opts = {}) => {
368
+ failureCount = 0;
369
+ nextAllowedFlushTime = 0;
370
+ const resetLogs = typeof opts === 'boolean' ? opts : !!opts.resetLogs;
371
+ if (resetLogs) {
372
+ lastErrorLogTime = 0;
373
+ suppressedErrorCount = 0;
374
+ }
375
+ queue.length = 0;
376
+ if (flushTimeout) {
377
+ clearTimeout(flushTimeout);
378
+ flushTimeout = null;
379
+ }
197
380
  };
381
+ trackingMiddleware.stop = () => {
382
+ if (flushTimeout) {
383
+ clearTimeout(flushTimeout);
384
+ flushTimeout = null;
385
+ }
386
+ clearInterval(sessionCleanupInterval);
387
+ clearInterval(periodicFlushInterval);
388
+ };
389
+
390
+ return trackingMiddleware;
198
391
  }
199
392
 
200
- module.exports = { createTrackingMiddleware };
393
+ module.exports = {
394
+ createTrackingMiddleware,
395
+ formatDbError,
396
+ isPermanentDbError,
397
+ PERMANENT_ERROR_CODES,
398
+ PERMANENT_ERROR_PATTERNS,
399
+ };
@@ -6,9 +6,11 @@
6
6
  * @module src/api/api-loader
7
7
  */
8
8
 
9
+ const path = require('path');
9
10
  const { ZodError } = require('zod');
10
11
  const { z: zForApi } = require('../../core/validation');
11
12
  const { resolveMiddlewares } = require('../file-router');
13
+ const { translateValidationIssues, getTranslatorForRequest } = require('./validation-i18n');
12
14
 
13
15
  /**
14
16
  * Compiles a schema definition from an API module
@@ -93,7 +95,7 @@ function createApiHandler(descriptor, context) {
93
95
  // 1. Reload module in development
94
96
  if (isDev) {
95
97
  try {
96
- const resolvedPath = require.resolve(descriptor.file);
98
+ const resolvedPath = descriptor._resolvedPath || (descriptor._resolvedPath = require.resolve(descriptor.file));
97
99
  if (require.cache[resolvedPath]) {
98
100
  delete require.cache[resolvedPath];
99
101
  cachedMiddlewares = null;
@@ -147,9 +149,17 @@ function createApiHandler(descriptor, context) {
147
149
  validateRequestInput(req, compiledSchema);
148
150
  } catch (err) {
149
151
  if (err instanceof ZodError || err.name === 'ZodError' || Array.isArray(err?.issues)) {
152
+ const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
153
+ const routeDir = descriptor.file ? path.dirname(descriptor.file) : '';
154
+ const translatedIssues = translateValidationIssues(err.issues, {
155
+ req,
156
+ pagesDir,
157
+ routeDir,
158
+ isDev,
159
+ });
150
160
  return res.status(400).json({
151
161
  error: 'Validation Error',
152
- issues: err.issues,
162
+ issues: translatedIssues,
153
163
  });
154
164
  }
155
165
  throw err;
@@ -185,6 +195,10 @@ function createApiHandler(descriptor, context) {
185
195
  }
186
196
 
187
197
  // 5. Execute handler
198
+ const pagesDir = context.pagesDir || appOptions.pagesDir || 'pages';
199
+ const routeDir = descriptor.file ? path.dirname(descriptor.file) : '';
200
+ const tr = getTranslatorForRequest(req, { pagesDir, routeDir, isDev });
201
+
188
202
  const apiCtx = {
189
203
  req,
190
204
  res,
@@ -192,6 +206,8 @@ function createApiHandler(descriptor, context) {
192
206
  query: req.query || {},
193
207
  body: req.body || {},
194
208
  input: req.input,
209
+ locale: tr.locale,
210
+ t: tr.t,
195
211
  service: (name, input, opts) =>
196
212
  serviceRegistry ? serviceRegistry.call(name, input, { req, res, db }, opts) : null,
197
213
  module: descriptor.module || null,
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Webspresso API Validation i18n & Error Translator
5
+ * Automatically localizes Zod validation error messages and keys based on request locale
6
+ * @module src/api/validation-i18n
7
+ */
8
+
9
+ const path = require('path');
10
+
11
+ const EMPTY_OBJECT = Object.freeze({});
12
+
13
+ let _fileRouter = null;
14
+ function getFileRouter() {
15
+ if (!_fileRouter) {
16
+ _fileRouter = require('../file-router');
17
+ }
18
+ return _fileRouter;
19
+ }
20
+
21
+ /**
22
+ * Resolves or creates a cached translator function for the given request and directory context
23
+ * @param {import('express').Request} req - Express request
24
+ * @param {Object} [options]
25
+ * @param {string} [options.pagesDir] - Pages directory path
26
+ * @param {string} [options.routeDir] - Route-specific directory
27
+ * @param {boolean} [options.isDev] - Is development environment
28
+ * @returns {{ t: Function, locale: string }}
29
+ */
30
+ function getTranslatorForRequest(req, options = {}) {
31
+ if (req && req.t && req.locale) {
32
+ return { t: req.t, locale: req.locale };
33
+ }
34
+
35
+ const {
36
+ loadI18n,
37
+ createTranslator,
38
+ detectLocale,
39
+ translatorCache,
40
+ } = getFileRouter();
41
+
42
+ const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
43
+ const locale = (req && req.locale) ? req.locale : (req ? detectLocale(req) : defaultLocale);
44
+
45
+ const rawPagesDir = options.pagesDir || (req?.app?.get?.('pagesDir')) || 'pages';
46
+ const pagesDir = path.resolve(rawPagesDir);
47
+ const routeDir = options.routeDir ? path.resolve(options.routeDir) : '';
48
+ const isDev = options.isDev !== undefined ? options.isDev : process.env.NODE_ENV !== 'production';
49
+
50
+ let t;
51
+ if (!isDev && translatorCache) {
52
+ const transKey = `${pagesDir}::${routeDir}::${locale}::${defaultLocale}`;
53
+ t = translatorCache.get(transKey);
54
+ if (!t) {
55
+ const translations = loadI18n(pagesDir, routeDir, locale, false);
56
+ const fallbackTranslations = (locale !== defaultLocale)
57
+ ? loadI18n(pagesDir, routeDir, defaultLocale, false)
58
+ : EMPTY_OBJECT;
59
+ t = createTranslator(translations, {
60
+ locale,
61
+ fallbackTranslations,
62
+ fallbackLocale: defaultLocale,
63
+ });
64
+ translatorCache.set(transKey, t);
65
+ }
66
+ } else {
67
+ const translations = loadI18n(pagesDir, routeDir, locale, true);
68
+ const fallbackTranslations = (locale !== defaultLocale)
69
+ ? loadI18n(pagesDir, routeDir, defaultLocale, true)
70
+ : EMPTY_OBJECT;
71
+ t = createTranslator(translations, {
72
+ locale,
73
+ fallbackTranslations,
74
+ fallbackLocale: defaultLocale,
75
+ });
76
+ }
77
+
78
+ if (req) {
79
+ req.locale = locale;
80
+ req.t = t;
81
+ }
82
+
83
+ return { t, locale };
84
+ }
85
+
86
+ /**
87
+ * Translates a single Zod issue using candidate keys and parameter interpolation
88
+ * @param {Object} issue - Zod issue
89
+ * @param {Function} t - Translator function
90
+ * @returns {Object} Translated issue clone
91
+ */
92
+ function translateSingleIssue(issue, t) {
93
+ if (!issue || typeof issue !== 'object') return issue;
94
+
95
+ const pathKey = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path.join('.') : '';
96
+ const field = Array.isArray(issue.path) && issue.path.length > 0 ? issue.path[issue.path.length - 1] : '';
97
+ const isRequired = (issue.code === 'invalid_type' && (issue.received === 'undefined' || issue.message === 'Required'))
98
+ || issue.message === 'Required';
99
+
100
+ const params = Object.assign(
101
+ {
102
+ path: pathKey,
103
+ field,
104
+ min: issue.minimum !== undefined ? issue.minimum : undefined,
105
+ max: issue.maximum !== undefined ? issue.maximum : undefined,
106
+ minimum: issue.minimum !== undefined ? issue.minimum : undefined,
107
+ maximum: issue.maximum !== undefined ? issue.maximum : undefined,
108
+ limit: issue.minimum !== undefined ? issue.minimum : (issue.maximum !== undefined ? issue.maximum : undefined),
109
+ expected: issue.expected,
110
+ received: issue.received,
111
+ type: issue.type,
112
+ validation: issue.validation,
113
+ },
114
+ issue
115
+ );
116
+
117
+ const candidates = [];
118
+
119
+ // 1. Explicit key in custom message (e.g. z.string({ message: 'auth.email_required' }))
120
+ if (typeof issue.message === 'string' && typeof t.has === 'function' && t.has(issue.message)) {
121
+ candidates.push(issue.message);
122
+ } else if (typeof issue.message === 'string' && issue.message.includes('{{')) {
123
+ const interpolated = issue.message.replace(/{{\s*(\w+)\s*}}/g, (_, k) =>
124
+ params[k] !== undefined ? String(params[k]) : ''
125
+ );
126
+ return Object.assign({}, issue, { message: interpolated });
127
+ }
128
+
129
+ // 2. Field-specific overrides (e.g. validation.fields.email.required or validation.email.required)
130
+ if (pathKey) {
131
+ if (isRequired) {
132
+ candidates.push(`validation.fields.${pathKey}.required`);
133
+ candidates.push(`validation.${pathKey}.required`);
134
+ candidates.push(`validation.fields.${pathKey}`);
135
+ candidates.push(`validation.${pathKey}`);
136
+ } else {
137
+ if (issue.validation) {
138
+ candidates.push(`validation.fields.${pathKey}.${issue.validation}`);
139
+ candidates.push(`validation.${pathKey}.${issue.validation}`);
140
+ }
141
+ candidates.push(`validation.fields.${pathKey}.${issue.code}`);
142
+ candidates.push(`validation.${pathKey}.${issue.code}`);
143
+ candidates.push(`validation.fields.${pathKey}`);
144
+ candidates.push(`validation.${pathKey}`);
145
+ }
146
+ }
147
+
148
+ // 3. General validation code translations
149
+ if (isRequired) {
150
+ candidates.push('validation.required');
151
+ candidates.push('zod.required');
152
+ candidates.push('validation.invalid_type');
153
+ candidates.push('zod.invalid_type');
154
+ } else if (issue.code === 'invalid_string' && issue.validation) {
155
+ candidates.push(`validation.invalid_string.${issue.validation}`);
156
+ candidates.push(`validation.${issue.validation}`);
157
+ candidates.push(`zod.invalid_string.${issue.validation}`);
158
+ candidates.push(`zod.${issue.validation}`);
159
+ candidates.push('validation.invalid_string');
160
+ candidates.push('zod.invalid_string');
161
+ } else {
162
+ candidates.push(`validation.${issue.code}`);
163
+ candidates.push(`zod.${issue.code}`);
164
+ }
165
+
166
+ let matchedKey = null;
167
+ if (typeof t.has === 'function') {
168
+ for (const key of candidates) {
169
+ if (t.has(key)) {
170
+ matchedKey = key;
171
+ break;
172
+ }
173
+ }
174
+ }
175
+
176
+ if (matchedKey) {
177
+ return Object.assign({}, issue, {
178
+ message: t(matchedKey, params),
179
+ key: matchedKey,
180
+ });
181
+ }
182
+
183
+ // If no translation found, interpolate params in custom message if it contains placeholders
184
+ if (typeof issue.message === 'string' && issue.message.includes('{{')) {
185
+ const interpolated = issue.message.replace(/{{\s*(\w+)\s*}}/g, (_, k) =>
186
+ params[k] !== undefined ? String(params[k]) : ''
187
+ );
188
+ return Object.assign({}, issue, { message: interpolated });
189
+ }
190
+
191
+ return Object.assign({}, issue);
192
+ }
193
+
194
+ /**
195
+ * Translates an array of Zod validation issues using the active request locale
196
+ * @param {Array<Object>} issues - Zod error issues array
197
+ * @param {Object} [options]
198
+ * @param {import('express').Request} [options.req] - Express request
199
+ * @param {Function} [options.t] - Optional direct translator
200
+ * @param {string} [options.locale] - Optional direct locale
201
+ * @param {string} [options.pagesDir] - Pages directory path
202
+ * @param {string} [options.routeDir] - Route directory path
203
+ * @param {boolean} [options.isDev] - Is development environment
204
+ * @returns {Array<Object>} Translated issues
205
+ */
206
+ function translateValidationIssues(issues, options = {}) {
207
+ if (!Array.isArray(issues) || issues.length === 0) {
208
+ return issues;
209
+ }
210
+
211
+ let t = options.t || options.req?.t;
212
+ let locale = options.locale || options.req?.locale;
213
+
214
+ if (!t && options.req) {
215
+ const tr = getTranslatorForRequest(options.req, options);
216
+ t = tr.t;
217
+ locale = tr.locale;
218
+ }
219
+
220
+ if (!t) {
221
+ return issues;
222
+ }
223
+
224
+ return issues.map((issue) => translateSingleIssue(issue, t));
225
+ }
226
+
227
+ module.exports = {
228
+ getTranslatorForRequest,
229
+ translateSingleIssue,
230
+ translateValidationIssues,
231
+ };