webspresso 0.0.101 → 0.0.102
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +11 -0
- package/.agents/CONVENTIONS.md +4 -0
- package/.agents/PLUGINS.md +32 -6
- package/.agents/ROUTING.md +99 -2
- package/.agents/skills/webspresso-framework/SKILL.md +32 -0
- package/.agents/skills/webspresso-routing-and-api/SKILL.md +4 -4
- package/AGENTS.md +6 -0
- package/README.md +18 -5
- package/bin/commands/add-tailwind.js +10 -0
- package/bin/commands/build.js +34 -0
- package/bin/commands/routes.js +8 -0
- package/core/applySchema.js +18 -1
- package/index.d.ts +156 -1
- package/index.js +71 -16
- package/package.json +5 -5
- package/plugins/admin-panel/index.js +12 -0
- package/plugins/csrf/index.js +1 -1
- package/plugins/file-manager/index.js +13 -0
- package/plugins/index.js +2 -0
- package/plugins/lazy.js +258 -0
- package/src/api/api-loader.js +17 -1
- package/src/api/validation-i18n.js +231 -0
- package/src/discovery/route-manifest.js +197 -0
- package/src/discovery/scan-routes.js +70 -4
- package/src/file-router.js +153 -91
- package/src/njk-frontmatter.js +17 -1
- package/src/plugin-manager.js +12 -0
- package/src/routing/route-table.js +1 -1
- package/src/server.js +63 -8
- package/src/services/builtins/system.js +4 -6
- package/src/services/registry.js +12 -2
- package/src/template/precompile.js +247 -0
package/plugins/lazy.js
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso On-Demand (Lazy) Plugin Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Defers execution of heavy plugin initialization, filesystem scans, route mounting,
|
|
5
|
+
* and dependency loading until the plugin is actually accessed via:
|
|
6
|
+
* 1. HTTP request matching its configured URL prefix (e.g. /_admin)
|
|
7
|
+
* 2. Service execution (e.g. ctx.service('email.send'))
|
|
8
|
+
* 3. Direct inter-plugin API access (e.g. ctx.usePlugin('admin-panel'))
|
|
9
|
+
* 4. Template helper execution (e.g. fsy.adminHelper())
|
|
10
|
+
* 5. Explicit programmatic hydration (lazyPlugin.hydrate())
|
|
11
|
+
*
|
|
12
|
+
* @module plugins/lazy
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const express = require('express');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Wraps a plugin definition or factory function into an on-demand lazy plugin.
|
|
19
|
+
*
|
|
20
|
+
* @param {Function|Object} pluginFactory - Factory `() => plugin` or plugin definition object
|
|
21
|
+
* @param {Object} [lazyOptions={}]
|
|
22
|
+
* @param {string} [lazyOptions.name] - Plugin name (inferred if not provided)
|
|
23
|
+
* @param {string|string[]} [lazyOptions.prefix] - URL path prefix(es) that trigger on-demand hydration (e.g. '/_admin')
|
|
24
|
+
* @param {string[]} [lazyOptions.services=[]] - Service names that trigger on-demand hydration
|
|
25
|
+
* @param {string[]} [lazyOptions.helpers=[]] - Template helpers that trigger on-demand hydration
|
|
26
|
+
* @param {string[]} [lazyOptions.events=[]] - Event names that trigger on-demand hydration
|
|
27
|
+
* @param {Object} [lazyOptions.dependencies] - Plugin dependencies
|
|
28
|
+
* @param {Object} [lazyOptions.csp] - Content Security Policy requirements
|
|
29
|
+
* @param {Function} [lazyOptions.onHydrate] - Callback invoked when hydration finishes
|
|
30
|
+
* @returns {Object} Webspresso Lazy Plugin definition
|
|
31
|
+
*/
|
|
32
|
+
function lazyPlugin(pluginFactory, lazyOptions = {}) {
|
|
33
|
+
let realPlugin = null;
|
|
34
|
+
let isHydrated = false;
|
|
35
|
+
let isHydrating = false;
|
|
36
|
+
|
|
37
|
+
let regCtx = null;
|
|
38
|
+
let routesCtx = null;
|
|
39
|
+
const router = express.Router();
|
|
40
|
+
|
|
41
|
+
const name =
|
|
42
|
+
lazyOptions.name ||
|
|
43
|
+
(typeof pluginFactory === 'object' && pluginFactory ? pluginFactory.name : null) ||
|
|
44
|
+
'anonymous-lazy-plugin';
|
|
45
|
+
|
|
46
|
+
const rawPrefixes = lazyOptions.prefix
|
|
47
|
+
? (Array.isArray(lazyOptions.prefix) ? lazyOptions.prefix : [lazyOptions.prefix])
|
|
48
|
+
: [];
|
|
49
|
+
// Normalize prefixes: ensure leading slash, remove trailing slash (except root '/')
|
|
50
|
+
const prefixes = rawPrefixes.map(p => {
|
|
51
|
+
let norm = String(p).trim();
|
|
52
|
+
if (!norm.startsWith('/')) norm = '/' + norm;
|
|
53
|
+
if (norm.length > 1 && norm.endsWith('/')) norm = norm.slice(0, -1);
|
|
54
|
+
return norm;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const lazyServices = Array.isArray(lazyOptions.services) ? lazyOptions.services : [];
|
|
58
|
+
const lazyHelpers = Array.isArray(lazyOptions.helpers) ? lazyOptions.helpers : [];
|
|
59
|
+
const lazyEvents = Array.isArray(lazyOptions.events) ? lazyOptions.events : [];
|
|
60
|
+
|
|
61
|
+
function hydrate() {
|
|
62
|
+
if (isHydrated) return realPlugin;
|
|
63
|
+
if (isHydrating) return realPlugin;
|
|
64
|
+
isHydrating = true;
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
realPlugin = typeof pluginFactory === 'function' ? pluginFactory() : pluginFactory;
|
|
68
|
+
if (!realPlugin) {
|
|
69
|
+
throw new Error(`[lazyPlugin] Factory for plugin "${name}" did not return a valid plugin object`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 1. Call setup hook if present
|
|
73
|
+
if (typeof realPlugin.setup === 'function' && regCtx?.app) {
|
|
74
|
+
const disposer = realPlugin.setup(regCtx.app);
|
|
75
|
+
if (typeof disposer === 'function' && regCtx.onDispose) {
|
|
76
|
+
regCtx.onDispose(disposer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. Call register hook
|
|
81
|
+
if (typeof realPlugin.register === 'function' && regCtx) {
|
|
82
|
+
const disposer = realPlugin.register(regCtx);
|
|
83
|
+
if (typeof disposer === 'function' && regCtx.onDispose) {
|
|
84
|
+
regCtx.onDispose(disposer);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// 3. Register real routes onto the lazy router
|
|
89
|
+
if (typeof realPlugin.onRoutesReady === 'function' && routesCtx) {
|
|
90
|
+
const customRoutesCtx = {
|
|
91
|
+
...routesCtx,
|
|
92
|
+
addRoute(method, routePath, ...handlers) {
|
|
93
|
+
const m = String(method).toLowerCase();
|
|
94
|
+
let matchedPrefix = null;
|
|
95
|
+
for (const p of prefixes) {
|
|
96
|
+
if (routePath === p || routePath.startsWith(p + '/')) {
|
|
97
|
+
matchedPrefix = p;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (matchedPrefix) {
|
|
103
|
+
// Convert absolute route path to router-relative subpath
|
|
104
|
+
let subPath = routePath.slice(matchedPrefix.length);
|
|
105
|
+
if (!subPath || !subPath.startsWith('/')) {
|
|
106
|
+
subPath = '/' + (subPath || '');
|
|
107
|
+
}
|
|
108
|
+
if (router[m]) {
|
|
109
|
+
router[m](subPath, ...handlers);
|
|
110
|
+
} else if (router.all) {
|
|
111
|
+
router.all(subPath, ...handlers);
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// Route is outside configured prefix: mount directly on app
|
|
115
|
+
if (routesCtx.addRoute) {
|
|
116
|
+
routesCtx.addRoute(method, routePath, ...handlers);
|
|
117
|
+
} else if (routesCtx.app && routesCtx.app[m]) {
|
|
118
|
+
routesCtx.app[m](routePath, ...handlers);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const disposer = realPlugin.onRoutesReady(customRoutesCtx);
|
|
125
|
+
if (typeof disposer === 'function' && routesCtx.onDispose) {
|
|
126
|
+
routesCtx.onDispose(disposer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. Update exposed API
|
|
131
|
+
if (realPlugin.api && lazyWrapper.api) {
|
|
132
|
+
Object.assign(lazyWrapper.api, realPlugin.api);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 5. Fire optional callback
|
|
136
|
+
if (typeof lazyOptions.onHydrate === 'function') {
|
|
137
|
+
try {
|
|
138
|
+
lazyOptions.onHydrate(realPlugin);
|
|
139
|
+
} catch (_) {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
isHydrated = true;
|
|
143
|
+
return realPlugin;
|
|
144
|
+
} finally {
|
|
145
|
+
isHydrating = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Create API Proxy that auto-hydrates on property access
|
|
150
|
+
const apiProxy = new Proxy({}, {
|
|
151
|
+
get(target, prop, receiver) {
|
|
152
|
+
if (prop === '_isLazyProxy') return true;
|
|
153
|
+
if (prop === 'hydrate') return hydrate;
|
|
154
|
+
if (prop === 'isHydrated') return isHydrated;
|
|
155
|
+
hydrate();
|
|
156
|
+
if (realPlugin && realPlugin.api) {
|
|
157
|
+
const val = Reflect.get(realPlugin.api, prop, receiver);
|
|
158
|
+
return typeof val === 'function' ? val.bind(realPlugin.api) : val;
|
|
159
|
+
}
|
|
160
|
+
return Reflect.get(target, prop, receiver);
|
|
161
|
+
},
|
|
162
|
+
has(target, prop) {
|
|
163
|
+
hydrate();
|
|
164
|
+
return realPlugin && realPlugin.api ? Reflect.has(realPlugin.api, prop) : Reflect.has(target, prop);
|
|
165
|
+
},
|
|
166
|
+
set(target, prop, value, receiver) {
|
|
167
|
+
hydrate();
|
|
168
|
+
if (realPlugin && realPlugin.api) {
|
|
169
|
+
return Reflect.set(realPlugin.api, prop, value, receiver);
|
|
170
|
+
}
|
|
171
|
+
return Reflect.set(target, prop, value, receiver);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const lazyWrapper = {
|
|
176
|
+
name,
|
|
177
|
+
version: lazyOptions.version || (typeof pluginFactory === 'object' && pluginFactory?.version) || '1.0.0',
|
|
178
|
+
dependencies: lazyOptions.dependencies || (typeof pluginFactory === 'object' && pluginFactory?.dependencies) || {},
|
|
179
|
+
csp: lazyOptions.csp || (typeof pluginFactory === 'object' && pluginFactory?.csp) || null,
|
|
180
|
+
isLazy: true,
|
|
181
|
+
get isHydrated() { return isHydrated; },
|
|
182
|
+
hydrate,
|
|
183
|
+
api: apiProxy,
|
|
184
|
+
|
|
185
|
+
register(ctx) {
|
|
186
|
+
regCtx = ctx;
|
|
187
|
+
|
|
188
|
+
// Register proxy services in app service registry
|
|
189
|
+
if (ctx.app && ctx.app.serviceRegistry && lazyServices.length > 0) {
|
|
190
|
+
for (const svcName of lazyServices) {
|
|
191
|
+
if (!ctx.app.serviceRegistry.has(svcName)) {
|
|
192
|
+
const proxyHandler = async (input, serviceCtx) => {
|
|
193
|
+
hydrate();
|
|
194
|
+
const realDef = ctx.app.serviceRegistry.get(svcName);
|
|
195
|
+
if (realDef && typeof realDef.handler === 'function' && realDef.handler !== proxyHandler) {
|
|
196
|
+
return typeof realDef.rawHandler === 'function'
|
|
197
|
+
? realDef.rawHandler(input, serviceCtx)
|
|
198
|
+
: realDef.handler(input, serviceCtx);
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
ctx.app.serviceRegistry.register(
|
|
204
|
+
svcName,
|
|
205
|
+
{ handler: proxyHandler },
|
|
206
|
+
{ overwrite: true }
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Register proxy helpers in template engine
|
|
213
|
+
if (typeof ctx.addHelper === 'function' && lazyHelpers.length > 0) {
|
|
214
|
+
for (const helperName of lazyHelpers) {
|
|
215
|
+
ctx.addHelper(helperName, (...args) => {
|
|
216
|
+
hydrate();
|
|
217
|
+
const helpers = regCtx?.pluginManager?.getHelpers ? regCtx.pluginManager.getHelpers() : {};
|
|
218
|
+
if (typeof helpers[helperName] === 'function') {
|
|
219
|
+
return helpers[helperName](...args);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Wire event triggers if eventDispatcher is available
|
|
226
|
+
if (ctx.app?.events && lazyEvents.length > 0) {
|
|
227
|
+
for (const eventName of lazyEvents) {
|
|
228
|
+
ctx.app.events.on(eventName, () => {
|
|
229
|
+
hydrate();
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
onRoutesReady(ctx) {
|
|
236
|
+
routesCtx = ctx;
|
|
237
|
+
|
|
238
|
+
if (!ctx.app) return;
|
|
239
|
+
|
|
240
|
+
// Mount lazy trigger router for each prefix
|
|
241
|
+
for (const prefix of prefixes) {
|
|
242
|
+
ctx.app.use(prefix, (req, res, next) => {
|
|
243
|
+
if (!isHydrated) {
|
|
244
|
+
hydrate();
|
|
245
|
+
}
|
|
246
|
+
router(req, res, next);
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
return lazyWrapper;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
module.exports = {
|
|
256
|
+
lazyPlugin,
|
|
257
|
+
defineLazyPlugin: lazyPlugin,
|
|
258
|
+
};
|
package/src/api/api-loader.js
CHANGED
|
@@ -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
|
|
@@ -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:
|
|
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
|
+
};
|