@objectstack/service-i18n 3.2.5 → 3.2.7

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/service-i18n@3.2.5 build /home/runner/work/spec/spec/packages/services/service-i18n
2
+ > @objectstack/service-i18n@3.2.7 build /home/runner/work/spec/spec/packages/services/service-i18n
3
3
  > tsup --config ../../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- ESM dist/index.js 3.19 KB
14
- ESM dist/index.js.map 9.54 KB
15
- ESM ⚡️ Build success in 77ms
16
- CJS dist/index.cjs 4.85 KB
17
- CJS dist/index.cjs.map 9.95 KB
18
- CJS ⚡️ Build success in 81ms
13
+ CJS dist/index.cjs 8.08 KB
14
+ CJS dist/index.cjs.map 17.20 KB
15
+ CJS ⚡️ Build success in 73ms
16
+ ESM dist/index.js 6.41 KB
17
+ ESM dist/index.js.map 16.79 KB
18
+ ESM ⚡️ Build success in 84ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 12649ms
21
- DTS dist/index.d.ts 3.29 KB
22
- DTS dist/index.d.cts 3.29 KB
20
+ DTS ⚡️ Build success in 14439ms
21
+ DTS dist/index.d.ts 4.41 KB
22
+ DTS dist/index.d.cts 4.41 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @objectstack/service-i18n
2
2
 
3
+ ## 3.2.7
4
+
5
+ ### Patch Changes
6
+
7
+ - @objectstack/spec@3.2.7
8
+ - @objectstack/core@3.2.7
9
+
10
+ ## 3.2.6
11
+
12
+ ### Patch Changes
13
+
14
+ - 83151bc: fix i18n
15
+ - @objectstack/spec@3.2.6
16
+ - @objectstack/core@3.2.6
17
+
3
18
  ## 3.2.5
4
19
 
5
20
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -124,6 +124,7 @@ var I18nServicePlugin = class {
124
124
  this.name = "com.objectstack.service.i18n";
125
125
  this.version = "1.0.0";
126
126
  this.type = "standard";
127
+ this.i18n = null;
127
128
  this.options = options;
128
129
  }
129
130
  async init(ctx) {
@@ -132,12 +133,101 @@ var I18nServicePlugin = class {
132
133
  localesDir: this.options.localesDir,
133
134
  fallbackLocale: this.options.fallbackLocale
134
135
  };
135
- const i18n = new FileI18nAdapter(adapterOptions);
136
- ctx.registerService("i18n", i18n);
136
+ this.i18n = new FileI18nAdapter(adapterOptions);
137
+ ctx.registerService("i18n", this.i18n);
137
138
  ctx.logger.info(
138
- `I18nServicePlugin: registered file-based i18n adapter (default: ${i18n.getDefaultLocale()})`
139
+ `I18nServicePlugin: registered file-based i18n adapter (default: ${this.i18n.getDefaultLocale?.() ?? "en"})`
139
140
  );
140
141
  }
142
+ async start(ctx) {
143
+ if (this.options.registerRoutes !== false) {
144
+ ctx.hook("kernel:ready", async () => {
145
+ let httpServer = null;
146
+ try {
147
+ httpServer = ctx.getService("http-server");
148
+ } catch {
149
+ }
150
+ if (httpServer) {
151
+ this.registerI18nRoutes(httpServer, ctx);
152
+ } else {
153
+ ctx.logger.warn(
154
+ 'No HTTP server available \u2014 i18n routes not registered. i18n service is still available programmatically via kernel.getService("i18n").'
155
+ );
156
+ }
157
+ });
158
+ }
159
+ }
160
+ /**
161
+ * Register i18n REST routes with the HTTP server.
162
+ *
163
+ * Routes:
164
+ * - GET /api/v1/i18n/locales → list available locales
165
+ * - GET /api/v1/i18n/translations/:locale → get translations for a locale
166
+ * - GET /api/v1/i18n/labels/:object/:locale → get field labels for an object
167
+ */
168
+ registerI18nRoutes(httpServer, ctx) {
169
+ if (!this.i18n) return;
170
+ const basePath = this.options.basePath || "/api/v1/i18n";
171
+ const i18n = this.i18n;
172
+ httpServer.get(`${basePath}/locales`, async (_req, res) => {
173
+ try {
174
+ const locales = i18n.getLocales();
175
+ const defaultLocale = i18n.getDefaultLocale?.() ?? "en";
176
+ res.json({
177
+ data: {
178
+ locales: locales.map((code) => ({
179
+ code,
180
+ label: code,
181
+ isDefault: code === defaultLocale
182
+ }))
183
+ }
184
+ });
185
+ } catch (error) {
186
+ res.status(500).json({ error: error.message });
187
+ }
188
+ });
189
+ httpServer.get(`${basePath}/translations/:locale`, async (req, res) => {
190
+ try {
191
+ const locale = req.params.locale;
192
+ if (!locale) {
193
+ res.status(400).json({ error: "Missing locale parameter" });
194
+ return;
195
+ }
196
+ const translations = i18n.getTranslations(locale);
197
+ res.json({ data: { locale, translations } });
198
+ } catch (error) {
199
+ res.status(500).json({ error: error.message });
200
+ }
201
+ });
202
+ httpServer.get(`${basePath}/labels/:object/:locale`, async (req, res) => {
203
+ try {
204
+ const objectName = req.params.object;
205
+ const locale = req.params.locale;
206
+ if (!objectName || !locale) {
207
+ res.status(400).json({ error: "Missing object or locale parameter" });
208
+ return;
209
+ }
210
+ const hasGetFieldLabels = "getFieldLabels" in i18n && typeof i18n["getFieldLabels"] === "function";
211
+ if (hasGetFieldLabels) {
212
+ const labels = i18n.getFieldLabels(objectName, locale);
213
+ res.json({ data: { object: objectName, locale, labels } });
214
+ } else {
215
+ const translations = i18n.getTranslations(locale);
216
+ const prefix = `o.${objectName}.fields.`;
217
+ const labels = {};
218
+ for (const [key, value] of Object.entries(translations)) {
219
+ if (key.startsWith(prefix)) {
220
+ labels[key.substring(prefix.length)] = value;
221
+ }
222
+ }
223
+ res.json({ data: { object: objectName, locale, labels } });
224
+ }
225
+ } catch (error) {
226
+ res.status(500).json({ error: error.message });
227
+ }
228
+ });
229
+ ctx.logger.info(`I18n routes registered: ${basePath}/locales, ${basePath}/translations/:locale, ${basePath}/labels/:object/:locale`);
230
+ }
141
231
  };
142
232
  // Annotate the CommonJS export names for ESM import in node:
143
233
  0 && (module.exports = {
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/file-i18n-adapter.ts","../src/i18n-service-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nexport { I18nServicePlugin } from './i18n-service-plugin.js';\nexport type { I18nServicePluginOptions } from './i18n-service-plugin.js';\nexport { FileI18nAdapter } from './file-i18n-adapter.js';\nexport type { FileI18nAdapterOptions } from './file-i18n-adapter.js';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { II18nService } from '@objectstack/spec/contracts';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n/**\n * Configuration options for FileI18nAdapter.\n */\nexport interface FileI18nAdapterOptions {\n /** Default locale (e.g. 'en') */\n defaultLocale?: string;\n /** Directory containing locale files (JSON). Each file should be named `{locale}.json`. */\n localesDir?: string;\n /** Fallback locale when a key is not found in the requested locale */\n fallbackLocale?: string;\n}\n\n/**\n * Resolve a nested key in a translations object using dot notation.\n *\n * @param data - Translation data object\n * @param key - Dot-separated key (e.g. 'objects.account.label')\n * @returns The resolved string value, or undefined if not found\n */\nfunction resolveKey(data: Record<string, unknown>, key: string): string | undefined {\n const parts = key.split('.');\n let current: unknown = data;\n for (const part of parts) {\n if (current == null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[part];\n }\n return typeof current === 'string' ? current : undefined;\n}\n\n/**\n * Interpolate parameters into a translated string.\n * Replaces `{{paramName}}` with the corresponding value from params.\n *\n * @param template - Template string with `{{key}}` placeholders\n * @param params - Parameter map\n * @returns Interpolated string\n */\nfunction interpolate(template: string, params: Record<string, unknown>): string {\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_match, key: string) => {\n return params[key] != null ? String(params[key]) : `{{${key}}}`;\n });\n}\n\n/**\n * File-based I18n adapter implementing II18nService.\n *\n * Loads JSON translation files from a directory on disk.\n * Each file should be named `{locale}.json` and contain a flat or nested\n * key-value map of translations.\n *\n * Supports:\n * - Dot-notation key resolution (e.g. 'objects.account.label')\n * - Parameter interpolation via `{{paramName}}` syntax\n * - Fallback locale for missing translations\n * - Runtime translation loading via loadTranslations()\n *\n * Suitable for server-side rendering, CLI tools, and development environments.\n *\n * @example\n * ```ts\n * const i18n = new FileI18nAdapter({\n * defaultLocale: 'en',\n * localesDir: './i18n',\n * fallbackLocale: 'en',\n * });\n *\n * i18n.t('objects.account.label', 'zh-CN'); // '客户'\n * i18n.t('greeting', 'en', { name: 'World' }); // 'Hello, World!'\n * ```\n */\nexport class FileI18nAdapter implements II18nService {\n private readonly translations = new Map<string, Record<string, unknown>>();\n private defaultLocale: string;\n private readonly fallbackLocale: string | undefined;\n\n constructor(options: FileI18nAdapterOptions = {}) {\n this.defaultLocale = options.defaultLocale ?? 'en';\n this.fallbackLocale = options.fallbackLocale;\n\n if (options.localesDir) {\n this.loadFromDirectory(options.localesDir);\n }\n }\n\n t(key: string, locale: string, params?: Record<string, unknown>): string {\n // Try requested locale\n let value = this.resolveFromLocale(key, locale);\n\n // Try fallback locale\n if (value === undefined && this.fallbackLocale && this.fallbackLocale !== locale) {\n value = this.resolveFromLocale(key, this.fallbackLocale);\n }\n\n // Return key if not found\n if (value === undefined) return key;\n\n // Interpolate parameters\n if (params && Object.keys(params).length > 0) {\n return interpolate(value, params);\n }\n\n return value;\n }\n\n getTranslations(locale: string): Record<string, unknown> {\n return this.translations.get(locale) ?? {};\n }\n\n loadTranslations(locale: string, translations: Record<string, unknown>): void {\n const existing = this.translations.get(locale);\n if (existing) {\n // Merge into existing translations\n this.translations.set(locale, { ...existing, ...translations });\n } else {\n this.translations.set(locale, { ...translations });\n }\n }\n\n getLocales(): string[] {\n return Array.from(this.translations.keys());\n }\n\n getDefaultLocale(): string {\n return this.defaultLocale;\n }\n\n setDefaultLocale(locale: string): void {\n this.defaultLocale = locale;\n }\n\n /**\n * Load all JSON translation files from a directory.\n * Each file should be named `{locale}.json`.\n */\n private loadFromDirectory(dir: string): void {\n if (!fs.existsSync(dir)) return;\n\n const files = fs.readdirSync(dir);\n for (const file of files) {\n if (!file.endsWith('.json')) continue;\n const locale = file.replace(/\\.json$/, '');\n const filePath = path.join(dir, file);\n try {\n const content = fs.readFileSync(filePath, 'utf-8');\n const data = JSON.parse(content) as Record<string, unknown>;\n this.translations.set(locale, data);\n } catch {\n // Skip files that can't be parsed\n }\n }\n }\n\n private resolveFromLocale(key: string, locale: string): string | undefined {\n const data = this.translations.get(locale);\n if (!data) return undefined;\n return resolveKey(data, key);\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { FileI18nAdapter } from './file-i18n-adapter.js';\nimport type { FileI18nAdapterOptions } from './file-i18n-adapter.js';\n\n/**\n * Configuration options for the I18nServicePlugin.\n */\nexport interface I18nServicePluginOptions {\n /** Default locale (default: 'en') */\n defaultLocale?: string;\n /** Directory containing locale JSON files */\n localesDir?: string;\n /** Fallback locale for missing translations */\n fallbackLocale?: string;\n}\n\n/**\n * I18nServicePlugin — Production II18nService implementation.\n *\n * Registers an i18n service with the kernel during the init phase.\n * Uses file-based locale loading with JSON files.\n *\n * @example\n * ```ts\n * import { ObjectKernel } from '@objectstack/core';\n * import { I18nServicePlugin } from '@objectstack/service-i18n';\n *\n * const kernel = new ObjectKernel();\n * kernel.use(new I18nServicePlugin({\n * defaultLocale: 'en',\n * localesDir: './i18n',\n * fallbackLocale: 'en',\n * }));\n * await kernel.bootstrap();\n *\n * const i18n = kernel.getService('i18n');\n * i18n.t('objects.account.label', 'en'); // 'Account'\n * ```\n */\nexport class I18nServicePlugin implements Plugin {\n name = 'com.objectstack.service.i18n';\n version = '1.0.0';\n type = 'standard';\n\n private readonly options: I18nServicePluginOptions;\n\n constructor(options: I18nServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const adapterOptions: FileI18nAdapterOptions = {\n defaultLocale: this.options.defaultLocale,\n localesDir: this.options.localesDir,\n fallbackLocale: this.options.fallbackLocale,\n };\n\n const i18n = new FileI18nAdapter(adapterOptions);\n ctx.registerService('i18n', i18n);\n ctx.logger.info(\n `I18nServicePlugin: registered file-based i18n adapter (default: ${i18n.getDefaultLocale()})`,\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/file-i18n-adapter.ts","../src/i18n-service-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nexport { I18nServicePlugin } from './i18n-service-plugin.js';\nexport type { I18nServicePluginOptions } from './i18n-service-plugin.js';\nexport { FileI18nAdapter } from './file-i18n-adapter.js';\nexport type { FileI18nAdapterOptions } from './file-i18n-adapter.js';\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { II18nService } from '@objectstack/spec/contracts';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n/**\n * Configuration options for FileI18nAdapter.\n */\nexport interface FileI18nAdapterOptions {\n /** Default locale (e.g. 'en') */\n defaultLocale?: string;\n /** Directory containing locale files (JSON). Each file should be named `{locale}.json`. */\n localesDir?: string;\n /** Fallback locale when a key is not found in the requested locale */\n fallbackLocale?: string;\n}\n\n/**\n * Resolve a nested key in a translations object using dot notation.\n *\n * @param data - Translation data object\n * @param key - Dot-separated key (e.g. 'objects.account.label')\n * @returns The resolved string value, or undefined if not found\n */\nfunction resolveKey(data: Record<string, unknown>, key: string): string | undefined {\n const parts = key.split('.');\n let current: unknown = data;\n for (const part of parts) {\n if (current == null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[part];\n }\n return typeof current === 'string' ? current : undefined;\n}\n\n/**\n * Interpolate parameters into a translated string.\n * Replaces `{{paramName}}` with the corresponding value from params.\n *\n * @param template - Template string with `{{key}}` placeholders\n * @param params - Parameter map\n * @returns Interpolated string\n */\nfunction interpolate(template: string, params: Record<string, unknown>): string {\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_match, key: string) => {\n return params[key] != null ? String(params[key]) : `{{${key}}}`;\n });\n}\n\n/**\n * File-based I18n adapter implementing II18nService.\n *\n * Loads JSON translation files from a directory on disk.\n * Each file should be named `{locale}.json` and contain a flat or nested\n * key-value map of translations.\n *\n * Supports:\n * - Dot-notation key resolution (e.g. 'objects.account.label')\n * - Parameter interpolation via `{{paramName}}` syntax\n * - Fallback locale for missing translations\n * - Runtime translation loading via loadTranslations()\n *\n * Suitable for server-side rendering, CLI tools, and development environments.\n *\n * @example\n * ```ts\n * const i18n = new FileI18nAdapter({\n * defaultLocale: 'en',\n * localesDir: './i18n',\n * fallbackLocale: 'en',\n * });\n *\n * i18n.t('objects.account.label', 'zh-CN'); // '客户'\n * i18n.t('greeting', 'en', { name: 'World' }); // 'Hello, World!'\n * ```\n */\nexport class FileI18nAdapter implements II18nService {\n private readonly translations = new Map<string, Record<string, unknown>>();\n private defaultLocale: string;\n private readonly fallbackLocale: string | undefined;\n\n constructor(options: FileI18nAdapterOptions = {}) {\n this.defaultLocale = options.defaultLocale ?? 'en';\n this.fallbackLocale = options.fallbackLocale;\n\n if (options.localesDir) {\n this.loadFromDirectory(options.localesDir);\n }\n }\n\n t(key: string, locale: string, params?: Record<string, unknown>): string {\n // Try requested locale\n let value = this.resolveFromLocale(key, locale);\n\n // Try fallback locale\n if (value === undefined && this.fallbackLocale && this.fallbackLocale !== locale) {\n value = this.resolveFromLocale(key, this.fallbackLocale);\n }\n\n // Return key if not found\n if (value === undefined) return key;\n\n // Interpolate parameters\n if (params && Object.keys(params).length > 0) {\n return interpolate(value, params);\n }\n\n return value;\n }\n\n getTranslations(locale: string): Record<string, unknown> {\n return this.translations.get(locale) ?? {};\n }\n\n loadTranslations(locale: string, translations: Record<string, unknown>): void {\n const existing = this.translations.get(locale);\n if (existing) {\n // Merge into existing translations\n this.translations.set(locale, { ...existing, ...translations });\n } else {\n this.translations.set(locale, { ...translations });\n }\n }\n\n getLocales(): string[] {\n return Array.from(this.translations.keys());\n }\n\n getDefaultLocale(): string {\n return this.defaultLocale;\n }\n\n setDefaultLocale(locale: string): void {\n this.defaultLocale = locale;\n }\n\n /**\n * Load all JSON translation files from a directory.\n * Each file should be named `{locale}.json`.\n */\n private loadFromDirectory(dir: string): void {\n if (!fs.existsSync(dir)) return;\n\n const files = fs.readdirSync(dir);\n for (const file of files) {\n if (!file.endsWith('.json')) continue;\n const locale = file.replace(/\\.json$/, '');\n const filePath = path.join(dir, file);\n try {\n const content = fs.readFileSync(filePath, 'utf-8');\n const data = JSON.parse(content) as Record<string, unknown>;\n this.translations.set(locale, data);\n } catch {\n // Skip files that can't be parsed\n }\n }\n }\n\n private resolveFromLocale(key: string, locale: string): string | undefined {\n const data = this.translations.get(locale);\n if (!data) return undefined;\n return resolveKey(data, key);\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IHttpServer, IHttpRequest, IHttpResponse } from '@objectstack/spec/contracts';\nimport type { II18nService } from '@objectstack/spec/contracts';\nimport { FileI18nAdapter } from './file-i18n-adapter.js';\nimport type { FileI18nAdapterOptions } from './file-i18n-adapter.js';\n\n/**\n * Configuration options for the I18nServicePlugin.\n */\nexport interface I18nServicePluginOptions {\n /** Default locale (default: 'en') */\n defaultLocale?: string;\n /** Directory containing locale JSON files */\n localesDir?: string;\n /** Fallback locale for missing translations */\n fallbackLocale?: string;\n /**\n * Whether to automatically register i18n REST routes with the HTTP server.\n * When true (default), the plugin registers `/api/v1/i18n/*` endpoints\n * via the `kernel:ready` hook. When false or no HTTP server is available,\n * routes are skipped but the i18n service is still available via the kernel.\n * @default true\n */\n registerRoutes?: boolean;\n /**\n * Base path for i18n REST routes.\n * @default '/api/v1/i18n'\n */\n basePath?: string;\n}\n\n/**\n * I18nServicePlugin — Production II18nService implementation.\n *\n * Registers an i18n service with the kernel during the init phase,\n * and self-registers REST endpoints (`/api/v1/i18n/*`) with the HTTP\n * server during the `kernel:ready` hook.\n *\n * REST route self-registration follows the same autonomous plugin pattern\n * used by AuthPlugin, WorkflowPlugin, and other service plugins — RestServer\n * is not involved.\n *\n * @example\n * ```ts\n * import { ObjectKernel } from '@objectstack/core';\n * import { I18nServicePlugin } from '@objectstack/service-i18n';\n *\n * const kernel = new ObjectKernel();\n * kernel.use(new I18nServicePlugin({\n * defaultLocale: 'en',\n * localesDir: './i18n',\n * fallbackLocale: 'en',\n * }));\n * await kernel.bootstrap();\n *\n * const i18n = kernel.getService('i18n');\n * i18n.t('objects.account.label', 'en'); // 'Account'\n * ```\n */\nexport class I18nServicePlugin implements Plugin {\n name = 'com.objectstack.service.i18n';\n version = '1.0.0';\n type = 'standard';\n\n private readonly options: I18nServicePluginOptions;\n private i18n: II18nService | null = null;\n\n constructor(options: I18nServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const adapterOptions: FileI18nAdapterOptions = {\n defaultLocale: this.options.defaultLocale,\n localesDir: this.options.localesDir,\n fallbackLocale: this.options.fallbackLocale,\n };\n\n this.i18n = new FileI18nAdapter(adapterOptions);\n ctx.registerService('i18n', this.i18n);\n ctx.logger.info(\n `I18nServicePlugin: registered file-based i18n adapter (default: ${this.i18n.getDefaultLocale?.() ?? 'en'})`,\n );\n }\n\n async start(ctx: PluginContext): Promise<void> {\n // Defer HTTP route registration to kernel:ready hook.\n // This ensures all plugins (including HonoServerPlugin) have completed\n // their init and start phases before we attempt to look up the\n // http-server service — making I18nServicePlugin resilient to plugin\n // loading order.\n if (this.options.registerRoutes !== false) {\n ctx.hook('kernel:ready', async () => {\n let httpServer: IHttpServer | null = null;\n try {\n httpServer = ctx.getService<IHttpServer>('http-server');\n } catch {\n // Service not found — expected in MSW/mock mode\n }\n\n if (httpServer) {\n this.registerI18nRoutes(httpServer, ctx);\n } else {\n ctx.logger.warn(\n 'No HTTP server available — i18n routes not registered. ' +\n 'i18n service is still available programmatically via kernel.getService(\"i18n\").'\n );\n }\n });\n }\n }\n\n /**\n * Register i18n REST routes with the HTTP server.\n *\n * Routes:\n * - GET /api/v1/i18n/locales → list available locales\n * - GET /api/v1/i18n/translations/:locale → get translations for a locale\n * - GET /api/v1/i18n/labels/:object/:locale → get field labels for an object\n */\n private registerI18nRoutes(httpServer: IHttpServer, ctx: PluginContext): void {\n if (!this.i18n) return;\n\n const basePath = this.options.basePath || '/api/v1/i18n';\n const i18n = this.i18n;\n\n // GET /i18n/locales\n httpServer.get(`${basePath}/locales`, async (_req: IHttpRequest, res: IHttpResponse) => {\n try {\n const locales = i18n.getLocales();\n const defaultLocale = i18n.getDefaultLocale?.() ?? 'en';\n res.json({\n data: {\n locales: locales.map((code) => ({\n code,\n label: code,\n isDefault: code === defaultLocale,\n })),\n },\n });\n } catch (error: any) {\n res.status(500).json({ error: error.message });\n }\n });\n\n // GET /i18n/translations/:locale\n httpServer.get(`${basePath}/translations/:locale`, async (req: IHttpRequest, res: IHttpResponse) => {\n try {\n const locale = req.params.locale;\n if (!locale) {\n res.status(400).json({ error: 'Missing locale parameter' });\n return;\n }\n const translations = i18n.getTranslations(locale);\n res.json({ data: { locale, translations } });\n } catch (error: any) {\n res.status(500).json({ error: error.message });\n }\n });\n\n // GET /i18n/labels/:object/:locale\n httpServer.get(`${basePath}/labels/:object/:locale`, async (req: IHttpRequest, res: IHttpResponse) => {\n try {\n const objectName = req.params.object;\n const locale = req.params.locale;\n if (!objectName || !locale) {\n res.status(400).json({ error: 'Missing object or locale parameter' });\n return;\n }\n // Some implementations may provide a dedicated getFieldLabels method\n const hasGetFieldLabels = 'getFieldLabels' in i18n\n && typeof (i18n as Record<string, unknown>)['getFieldLabels'] === 'function';\n if (hasGetFieldLabels) {\n const labels = (i18n as II18nService & { getFieldLabels(obj: string, loc: string): Record<string, string> })\n .getFieldLabels(objectName, locale);\n res.json({ data: { object: objectName, locale, labels } });\n } else {\n // Fallback: derive field labels from full translation bundle\n const translations = i18n.getTranslations(locale);\n const prefix = `o.${objectName}.fields.`;\n const labels: Record<string, string> = {};\n for (const [key, value] of Object.entries(translations)) {\n if (key.startsWith(prefix)) {\n labels[key.substring(prefix.length)] = value as string;\n }\n }\n res.json({ data: { object: objectName, locale, labels } });\n }\n } catch (error: any) {\n res.status(500).json({ error: error.message });\n }\n });\n\n ctx.logger.info(`I18n routes registered: ${basePath}/locales, ${basePath}/translations/:locale, ${basePath}/labels/:object/:locale`);\n }\n}\n"],"mappings":"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","names":[]}
package/dist/index.d.cts CHANGED
@@ -11,12 +11,30 @@ interface I18nServicePluginOptions {
11
11
  localesDir?: string;
12
12
  /** Fallback locale for missing translations */
13
13
  fallbackLocale?: string;
14
+ /**
15
+ * Whether to automatically register i18n REST routes with the HTTP server.
16
+ * When true (default), the plugin registers `/api/v1/i18n/*` endpoints
17
+ * via the `kernel:ready` hook. When false or no HTTP server is available,
18
+ * routes are skipped but the i18n service is still available via the kernel.
19
+ * @default true
20
+ */
21
+ registerRoutes?: boolean;
22
+ /**
23
+ * Base path for i18n REST routes.
24
+ * @default '/api/v1/i18n'
25
+ */
26
+ basePath?: string;
14
27
  }
15
28
  /**
16
29
  * I18nServicePlugin — Production II18nService implementation.
17
30
  *
18
- * Registers an i18n service with the kernel during the init phase.
19
- * Uses file-based locale loading with JSON files.
31
+ * Registers an i18n service with the kernel during the init phase,
32
+ * and self-registers REST endpoints (`/api/v1/i18n/*`) with the HTTP
33
+ * server during the `kernel:ready` hook.
34
+ *
35
+ * REST route self-registration follows the same autonomous plugin pattern
36
+ * used by AuthPlugin, WorkflowPlugin, and other service plugins — RestServer
37
+ * is not involved.
20
38
  *
21
39
  * @example
22
40
  * ```ts
@@ -40,8 +58,19 @@ declare class I18nServicePlugin implements Plugin {
40
58
  version: string;
41
59
  type: string;
42
60
  private readonly options;
61
+ private i18n;
43
62
  constructor(options?: I18nServicePluginOptions);
44
63
  init(ctx: PluginContext): Promise<void>;
64
+ start(ctx: PluginContext): Promise<void>;
65
+ /**
66
+ * Register i18n REST routes with the HTTP server.
67
+ *
68
+ * Routes:
69
+ * - GET /api/v1/i18n/locales → list available locales
70
+ * - GET /api/v1/i18n/translations/:locale → get translations for a locale
71
+ * - GET /api/v1/i18n/labels/:object/:locale → get field labels for an object
72
+ */
73
+ private registerI18nRoutes;
45
74
  }
46
75
 
47
76
  /**
package/dist/index.d.ts CHANGED
@@ -11,12 +11,30 @@ interface I18nServicePluginOptions {
11
11
  localesDir?: string;
12
12
  /** Fallback locale for missing translations */
13
13
  fallbackLocale?: string;
14
+ /**
15
+ * Whether to automatically register i18n REST routes with the HTTP server.
16
+ * When true (default), the plugin registers `/api/v1/i18n/*` endpoints
17
+ * via the `kernel:ready` hook. When false or no HTTP server is available,
18
+ * routes are skipped but the i18n service is still available via the kernel.
19
+ * @default true
20
+ */
21
+ registerRoutes?: boolean;
22
+ /**
23
+ * Base path for i18n REST routes.
24
+ * @default '/api/v1/i18n'
25
+ */
26
+ basePath?: string;
14
27
  }
15
28
  /**
16
29
  * I18nServicePlugin — Production II18nService implementation.
17
30
  *
18
- * Registers an i18n service with the kernel during the init phase.
19
- * Uses file-based locale loading with JSON files.
31
+ * Registers an i18n service with the kernel during the init phase,
32
+ * and self-registers REST endpoints (`/api/v1/i18n/*`) with the HTTP
33
+ * server during the `kernel:ready` hook.
34
+ *
35
+ * REST route self-registration follows the same autonomous plugin pattern
36
+ * used by AuthPlugin, WorkflowPlugin, and other service plugins — RestServer
37
+ * is not involved.
20
38
  *
21
39
  * @example
22
40
  * ```ts
@@ -40,8 +58,19 @@ declare class I18nServicePlugin implements Plugin {
40
58
  version: string;
41
59
  type: string;
42
60
  private readonly options;
61
+ private i18n;
43
62
  constructor(options?: I18nServicePluginOptions);
44
63
  init(ctx: PluginContext): Promise<void>;
64
+ start(ctx: PluginContext): Promise<void>;
65
+ /**
66
+ * Register i18n REST routes with the HTTP server.
67
+ *
68
+ * Routes:
69
+ * - GET /api/v1/i18n/locales → list available locales
70
+ * - GET /api/v1/i18n/translations/:locale → get translations for a locale
71
+ * - GET /api/v1/i18n/labels/:object/:locale → get field labels for an object
72
+ */
73
+ private registerI18nRoutes;
45
74
  }
46
75
 
47
76
  /**
package/dist/index.js CHANGED
@@ -87,6 +87,7 @@ var I18nServicePlugin = class {
87
87
  this.name = "com.objectstack.service.i18n";
88
88
  this.version = "1.0.0";
89
89
  this.type = "standard";
90
+ this.i18n = null;
90
91
  this.options = options;
91
92
  }
92
93
  async init(ctx) {
@@ -95,12 +96,101 @@ var I18nServicePlugin = class {
95
96
  localesDir: this.options.localesDir,
96
97
  fallbackLocale: this.options.fallbackLocale
97
98
  };
98
- const i18n = new FileI18nAdapter(adapterOptions);
99
- ctx.registerService("i18n", i18n);
99
+ this.i18n = new FileI18nAdapter(adapterOptions);
100
+ ctx.registerService("i18n", this.i18n);
100
101
  ctx.logger.info(
101
- `I18nServicePlugin: registered file-based i18n adapter (default: ${i18n.getDefaultLocale()})`
102
+ `I18nServicePlugin: registered file-based i18n adapter (default: ${this.i18n.getDefaultLocale?.() ?? "en"})`
102
103
  );
103
104
  }
105
+ async start(ctx) {
106
+ if (this.options.registerRoutes !== false) {
107
+ ctx.hook("kernel:ready", async () => {
108
+ let httpServer = null;
109
+ try {
110
+ httpServer = ctx.getService("http-server");
111
+ } catch {
112
+ }
113
+ if (httpServer) {
114
+ this.registerI18nRoutes(httpServer, ctx);
115
+ } else {
116
+ ctx.logger.warn(
117
+ 'No HTTP server available \u2014 i18n routes not registered. i18n service is still available programmatically via kernel.getService("i18n").'
118
+ );
119
+ }
120
+ });
121
+ }
122
+ }
123
+ /**
124
+ * Register i18n REST routes with the HTTP server.
125
+ *
126
+ * Routes:
127
+ * - GET /api/v1/i18n/locales → list available locales
128
+ * - GET /api/v1/i18n/translations/:locale → get translations for a locale
129
+ * - GET /api/v1/i18n/labels/:object/:locale → get field labels for an object
130
+ */
131
+ registerI18nRoutes(httpServer, ctx) {
132
+ if (!this.i18n) return;
133
+ const basePath = this.options.basePath || "/api/v1/i18n";
134
+ const i18n = this.i18n;
135
+ httpServer.get(`${basePath}/locales`, async (_req, res) => {
136
+ try {
137
+ const locales = i18n.getLocales();
138
+ const defaultLocale = i18n.getDefaultLocale?.() ?? "en";
139
+ res.json({
140
+ data: {
141
+ locales: locales.map((code) => ({
142
+ code,
143
+ label: code,
144
+ isDefault: code === defaultLocale
145
+ }))
146
+ }
147
+ });
148
+ } catch (error) {
149
+ res.status(500).json({ error: error.message });
150
+ }
151
+ });
152
+ httpServer.get(`${basePath}/translations/:locale`, async (req, res) => {
153
+ try {
154
+ const locale = req.params.locale;
155
+ if (!locale) {
156
+ res.status(400).json({ error: "Missing locale parameter" });
157
+ return;
158
+ }
159
+ const translations = i18n.getTranslations(locale);
160
+ res.json({ data: { locale, translations } });
161
+ } catch (error) {
162
+ res.status(500).json({ error: error.message });
163
+ }
164
+ });
165
+ httpServer.get(`${basePath}/labels/:object/:locale`, async (req, res) => {
166
+ try {
167
+ const objectName = req.params.object;
168
+ const locale = req.params.locale;
169
+ if (!objectName || !locale) {
170
+ res.status(400).json({ error: "Missing object or locale parameter" });
171
+ return;
172
+ }
173
+ const hasGetFieldLabels = "getFieldLabels" in i18n && typeof i18n["getFieldLabels"] === "function";
174
+ if (hasGetFieldLabels) {
175
+ const labels = i18n.getFieldLabels(objectName, locale);
176
+ res.json({ data: { object: objectName, locale, labels } });
177
+ } else {
178
+ const translations = i18n.getTranslations(locale);
179
+ const prefix = `o.${objectName}.fields.`;
180
+ const labels = {};
181
+ for (const [key, value] of Object.entries(translations)) {
182
+ if (key.startsWith(prefix)) {
183
+ labels[key.substring(prefix.length)] = value;
184
+ }
185
+ }
186
+ res.json({ data: { object: objectName, locale, labels } });
187
+ }
188
+ } catch (error) {
189
+ res.status(500).json({ error: error.message });
190
+ }
191
+ });
192
+ ctx.logger.info(`I18n routes registered: ${basePath}/locales, ${basePath}/translations/:locale, ${basePath}/labels/:object/:locale`);
193
+ }
104
194
  };
105
195
  export {
106
196
  FileI18nAdapter,
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/file-i18n-adapter.ts","../src/i18n-service-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { II18nService } from '@objectstack/spec/contracts';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n/**\n * Configuration options for FileI18nAdapter.\n */\nexport interface FileI18nAdapterOptions {\n /** Default locale (e.g. 'en') */\n defaultLocale?: string;\n /** Directory containing locale files (JSON). Each file should be named `{locale}.json`. */\n localesDir?: string;\n /** Fallback locale when a key is not found in the requested locale */\n fallbackLocale?: string;\n}\n\n/**\n * Resolve a nested key in a translations object using dot notation.\n *\n * @param data - Translation data object\n * @param key - Dot-separated key (e.g. 'objects.account.label')\n * @returns The resolved string value, or undefined if not found\n */\nfunction resolveKey(data: Record<string, unknown>, key: string): string | undefined {\n const parts = key.split('.');\n let current: unknown = data;\n for (const part of parts) {\n if (current == null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[part];\n }\n return typeof current === 'string' ? current : undefined;\n}\n\n/**\n * Interpolate parameters into a translated string.\n * Replaces `{{paramName}}` with the corresponding value from params.\n *\n * @param template - Template string with `{{key}}` placeholders\n * @param params - Parameter map\n * @returns Interpolated string\n */\nfunction interpolate(template: string, params: Record<string, unknown>): string {\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_match, key: string) => {\n return params[key] != null ? String(params[key]) : `{{${key}}}`;\n });\n}\n\n/**\n * File-based I18n adapter implementing II18nService.\n *\n * Loads JSON translation files from a directory on disk.\n * Each file should be named `{locale}.json` and contain a flat or nested\n * key-value map of translations.\n *\n * Supports:\n * - Dot-notation key resolution (e.g. 'objects.account.label')\n * - Parameter interpolation via `{{paramName}}` syntax\n * - Fallback locale for missing translations\n * - Runtime translation loading via loadTranslations()\n *\n * Suitable for server-side rendering, CLI tools, and development environments.\n *\n * @example\n * ```ts\n * const i18n = new FileI18nAdapter({\n * defaultLocale: 'en',\n * localesDir: './i18n',\n * fallbackLocale: 'en',\n * });\n *\n * i18n.t('objects.account.label', 'zh-CN'); // '客户'\n * i18n.t('greeting', 'en', { name: 'World' }); // 'Hello, World!'\n * ```\n */\nexport class FileI18nAdapter implements II18nService {\n private readonly translations = new Map<string, Record<string, unknown>>();\n private defaultLocale: string;\n private readonly fallbackLocale: string | undefined;\n\n constructor(options: FileI18nAdapterOptions = {}) {\n this.defaultLocale = options.defaultLocale ?? 'en';\n this.fallbackLocale = options.fallbackLocale;\n\n if (options.localesDir) {\n this.loadFromDirectory(options.localesDir);\n }\n }\n\n t(key: string, locale: string, params?: Record<string, unknown>): string {\n // Try requested locale\n let value = this.resolveFromLocale(key, locale);\n\n // Try fallback locale\n if (value === undefined && this.fallbackLocale && this.fallbackLocale !== locale) {\n value = this.resolveFromLocale(key, this.fallbackLocale);\n }\n\n // Return key if not found\n if (value === undefined) return key;\n\n // Interpolate parameters\n if (params && Object.keys(params).length > 0) {\n return interpolate(value, params);\n }\n\n return value;\n }\n\n getTranslations(locale: string): Record<string, unknown> {\n return this.translations.get(locale) ?? {};\n }\n\n loadTranslations(locale: string, translations: Record<string, unknown>): void {\n const existing = this.translations.get(locale);\n if (existing) {\n // Merge into existing translations\n this.translations.set(locale, { ...existing, ...translations });\n } else {\n this.translations.set(locale, { ...translations });\n }\n }\n\n getLocales(): string[] {\n return Array.from(this.translations.keys());\n }\n\n getDefaultLocale(): string {\n return this.defaultLocale;\n }\n\n setDefaultLocale(locale: string): void {\n this.defaultLocale = locale;\n }\n\n /**\n * Load all JSON translation files from a directory.\n * Each file should be named `{locale}.json`.\n */\n private loadFromDirectory(dir: string): void {\n if (!fs.existsSync(dir)) return;\n\n const files = fs.readdirSync(dir);\n for (const file of files) {\n if (!file.endsWith('.json')) continue;\n const locale = file.replace(/\\.json$/, '');\n const filePath = path.join(dir, file);\n try {\n const content = fs.readFileSync(filePath, 'utf-8');\n const data = JSON.parse(content) as Record<string, unknown>;\n this.translations.set(locale, data);\n } catch {\n // Skip files that can't be parsed\n }\n }\n }\n\n private resolveFromLocale(key: string, locale: string): string | undefined {\n const data = this.translations.get(locale);\n if (!data) return undefined;\n return resolveKey(data, key);\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport { FileI18nAdapter } from './file-i18n-adapter.js';\nimport type { FileI18nAdapterOptions } from './file-i18n-adapter.js';\n\n/**\n * Configuration options for the I18nServicePlugin.\n */\nexport interface I18nServicePluginOptions {\n /** Default locale (default: 'en') */\n defaultLocale?: string;\n /** Directory containing locale JSON files */\n localesDir?: string;\n /** Fallback locale for missing translations */\n fallbackLocale?: string;\n}\n\n/**\n * I18nServicePlugin — Production II18nService implementation.\n *\n * Registers an i18n service with the kernel during the init phase.\n * Uses file-based locale loading with JSON files.\n *\n * @example\n * ```ts\n * import { ObjectKernel } from '@objectstack/core';\n * import { I18nServicePlugin } from '@objectstack/service-i18n';\n *\n * const kernel = new ObjectKernel();\n * kernel.use(new I18nServicePlugin({\n * defaultLocale: 'en',\n * localesDir: './i18n',\n * fallbackLocale: 'en',\n * }));\n * await kernel.bootstrap();\n *\n * const i18n = kernel.getService('i18n');\n * i18n.t('objects.account.label', 'en'); // 'Account'\n * ```\n */\nexport class I18nServicePlugin implements Plugin {\n name = 'com.objectstack.service.i18n';\n version = '1.0.0';\n type = 'standard';\n\n private readonly options: I18nServicePluginOptions;\n\n constructor(options: I18nServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const adapterOptions: FileI18nAdapterOptions = {\n defaultLocale: this.options.defaultLocale,\n localesDir: this.options.localesDir,\n fallbackLocale: this.options.fallbackLocale,\n };\n\n const i18n = new FileI18nAdapter(adapterOptions);\n ctx.registerService('i18n', i18n);\n ctx.logger.info(\n `I18nServicePlugin: registered file-based i18n adapter (default: ${i18n.getDefaultLocale()})`,\n );\n }\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/file-i18n-adapter.ts","../src/i18n-service-plugin.ts"],"sourcesContent":["// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { II18nService } from '@objectstack/spec/contracts';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\n\n/**\n * Configuration options for FileI18nAdapter.\n */\nexport interface FileI18nAdapterOptions {\n /** Default locale (e.g. 'en') */\n defaultLocale?: string;\n /** Directory containing locale files (JSON). Each file should be named `{locale}.json`. */\n localesDir?: string;\n /** Fallback locale when a key is not found in the requested locale */\n fallbackLocale?: string;\n}\n\n/**\n * Resolve a nested key in a translations object using dot notation.\n *\n * @param data - Translation data object\n * @param key - Dot-separated key (e.g. 'objects.account.label')\n * @returns The resolved string value, or undefined if not found\n */\nfunction resolveKey(data: Record<string, unknown>, key: string): string | undefined {\n const parts = key.split('.');\n let current: unknown = data;\n for (const part of parts) {\n if (current == null || typeof current !== 'object') return undefined;\n current = (current as Record<string, unknown>)[part];\n }\n return typeof current === 'string' ? current : undefined;\n}\n\n/**\n * Interpolate parameters into a translated string.\n * Replaces `{{paramName}}` with the corresponding value from params.\n *\n * @param template - Template string with `{{key}}` placeholders\n * @param params - Parameter map\n * @returns Interpolated string\n */\nfunction interpolate(template: string, params: Record<string, unknown>): string {\n return template.replace(/\\{\\{(\\w+)\\}\\}/g, (_match, key: string) => {\n return params[key] != null ? String(params[key]) : `{{${key}}}`;\n });\n}\n\n/**\n * File-based I18n adapter implementing II18nService.\n *\n * Loads JSON translation files from a directory on disk.\n * Each file should be named `{locale}.json` and contain a flat or nested\n * key-value map of translations.\n *\n * Supports:\n * - Dot-notation key resolution (e.g. 'objects.account.label')\n * - Parameter interpolation via `{{paramName}}` syntax\n * - Fallback locale for missing translations\n * - Runtime translation loading via loadTranslations()\n *\n * Suitable for server-side rendering, CLI tools, and development environments.\n *\n * @example\n * ```ts\n * const i18n = new FileI18nAdapter({\n * defaultLocale: 'en',\n * localesDir: './i18n',\n * fallbackLocale: 'en',\n * });\n *\n * i18n.t('objects.account.label', 'zh-CN'); // '客户'\n * i18n.t('greeting', 'en', { name: 'World' }); // 'Hello, World!'\n * ```\n */\nexport class FileI18nAdapter implements II18nService {\n private readonly translations = new Map<string, Record<string, unknown>>();\n private defaultLocale: string;\n private readonly fallbackLocale: string | undefined;\n\n constructor(options: FileI18nAdapterOptions = {}) {\n this.defaultLocale = options.defaultLocale ?? 'en';\n this.fallbackLocale = options.fallbackLocale;\n\n if (options.localesDir) {\n this.loadFromDirectory(options.localesDir);\n }\n }\n\n t(key: string, locale: string, params?: Record<string, unknown>): string {\n // Try requested locale\n let value = this.resolveFromLocale(key, locale);\n\n // Try fallback locale\n if (value === undefined && this.fallbackLocale && this.fallbackLocale !== locale) {\n value = this.resolveFromLocale(key, this.fallbackLocale);\n }\n\n // Return key if not found\n if (value === undefined) return key;\n\n // Interpolate parameters\n if (params && Object.keys(params).length > 0) {\n return interpolate(value, params);\n }\n\n return value;\n }\n\n getTranslations(locale: string): Record<string, unknown> {\n return this.translations.get(locale) ?? {};\n }\n\n loadTranslations(locale: string, translations: Record<string, unknown>): void {\n const existing = this.translations.get(locale);\n if (existing) {\n // Merge into existing translations\n this.translations.set(locale, { ...existing, ...translations });\n } else {\n this.translations.set(locale, { ...translations });\n }\n }\n\n getLocales(): string[] {\n return Array.from(this.translations.keys());\n }\n\n getDefaultLocale(): string {\n return this.defaultLocale;\n }\n\n setDefaultLocale(locale: string): void {\n this.defaultLocale = locale;\n }\n\n /**\n * Load all JSON translation files from a directory.\n * Each file should be named `{locale}.json`.\n */\n private loadFromDirectory(dir: string): void {\n if (!fs.existsSync(dir)) return;\n\n const files = fs.readdirSync(dir);\n for (const file of files) {\n if (!file.endsWith('.json')) continue;\n const locale = file.replace(/\\.json$/, '');\n const filePath = path.join(dir, file);\n try {\n const content = fs.readFileSync(filePath, 'utf-8');\n const data = JSON.parse(content) as Record<string, unknown>;\n this.translations.set(locale, data);\n } catch {\n // Skip files that can't be parsed\n }\n }\n }\n\n private resolveFromLocale(key: string, locale: string): string | undefined {\n const data = this.translations.get(locale);\n if (!data) return undefined;\n return resolveKey(data, key);\n }\n}\n","// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.\n\nimport type { Plugin, PluginContext } from '@objectstack/core';\nimport type { IHttpServer, IHttpRequest, IHttpResponse } from '@objectstack/spec/contracts';\nimport type { II18nService } from '@objectstack/spec/contracts';\nimport { FileI18nAdapter } from './file-i18n-adapter.js';\nimport type { FileI18nAdapterOptions } from './file-i18n-adapter.js';\n\n/**\n * Configuration options for the I18nServicePlugin.\n */\nexport interface I18nServicePluginOptions {\n /** Default locale (default: 'en') */\n defaultLocale?: string;\n /** Directory containing locale JSON files */\n localesDir?: string;\n /** Fallback locale for missing translations */\n fallbackLocale?: string;\n /**\n * Whether to automatically register i18n REST routes with the HTTP server.\n * When true (default), the plugin registers `/api/v1/i18n/*` endpoints\n * via the `kernel:ready` hook. When false or no HTTP server is available,\n * routes are skipped but the i18n service is still available via the kernel.\n * @default true\n */\n registerRoutes?: boolean;\n /**\n * Base path for i18n REST routes.\n * @default '/api/v1/i18n'\n */\n basePath?: string;\n}\n\n/**\n * I18nServicePlugin — Production II18nService implementation.\n *\n * Registers an i18n service with the kernel during the init phase,\n * and self-registers REST endpoints (`/api/v1/i18n/*`) with the HTTP\n * server during the `kernel:ready` hook.\n *\n * REST route self-registration follows the same autonomous plugin pattern\n * used by AuthPlugin, WorkflowPlugin, and other service plugins — RestServer\n * is not involved.\n *\n * @example\n * ```ts\n * import { ObjectKernel } from '@objectstack/core';\n * import { I18nServicePlugin } from '@objectstack/service-i18n';\n *\n * const kernel = new ObjectKernel();\n * kernel.use(new I18nServicePlugin({\n * defaultLocale: 'en',\n * localesDir: './i18n',\n * fallbackLocale: 'en',\n * }));\n * await kernel.bootstrap();\n *\n * const i18n = kernel.getService('i18n');\n * i18n.t('objects.account.label', 'en'); // 'Account'\n * ```\n */\nexport class I18nServicePlugin implements Plugin {\n name = 'com.objectstack.service.i18n';\n version = '1.0.0';\n type = 'standard';\n\n private readonly options: I18nServicePluginOptions;\n private i18n: II18nService | null = null;\n\n constructor(options: I18nServicePluginOptions = {}) {\n this.options = options;\n }\n\n async init(ctx: PluginContext): Promise<void> {\n const adapterOptions: FileI18nAdapterOptions = {\n defaultLocale: this.options.defaultLocale,\n localesDir: this.options.localesDir,\n fallbackLocale: this.options.fallbackLocale,\n };\n\n this.i18n = new FileI18nAdapter(adapterOptions);\n ctx.registerService('i18n', this.i18n);\n ctx.logger.info(\n `I18nServicePlugin: registered file-based i18n adapter (default: ${this.i18n.getDefaultLocale?.() ?? 'en'})`,\n );\n }\n\n async start(ctx: PluginContext): Promise<void> {\n // Defer HTTP route registration to kernel:ready hook.\n // This ensures all plugins (including HonoServerPlugin) have completed\n // their init and start phases before we attempt to look up the\n // http-server service — making I18nServicePlugin resilient to plugin\n // loading order.\n if (this.options.registerRoutes !== false) {\n ctx.hook('kernel:ready', async () => {\n let httpServer: IHttpServer | null = null;\n try {\n httpServer = ctx.getService<IHttpServer>('http-server');\n } catch {\n // Service not found — expected in MSW/mock mode\n }\n\n if (httpServer) {\n this.registerI18nRoutes(httpServer, ctx);\n } else {\n ctx.logger.warn(\n 'No HTTP server available — i18n routes not registered. ' +\n 'i18n service is still available programmatically via kernel.getService(\"i18n\").'\n );\n }\n });\n }\n }\n\n /**\n * Register i18n REST routes with the HTTP server.\n *\n * Routes:\n * - GET /api/v1/i18n/locales → list available locales\n * - GET /api/v1/i18n/translations/:locale → get translations for a locale\n * - GET /api/v1/i18n/labels/:object/:locale → get field labels for an object\n */\n private registerI18nRoutes(httpServer: IHttpServer, ctx: PluginContext): void {\n if (!this.i18n) return;\n\n const basePath = this.options.basePath || '/api/v1/i18n';\n const i18n = this.i18n;\n\n // GET /i18n/locales\n httpServer.get(`${basePath}/locales`, async (_req: IHttpRequest, res: IHttpResponse) => {\n try {\n const locales = i18n.getLocales();\n const defaultLocale = i18n.getDefaultLocale?.() ?? 'en';\n res.json({\n data: {\n locales: locales.map((code) => ({\n code,\n label: code,\n isDefault: code === defaultLocale,\n })),\n },\n });\n } catch (error: any) {\n res.status(500).json({ error: error.message });\n }\n });\n\n // GET /i18n/translations/:locale\n httpServer.get(`${basePath}/translations/:locale`, async (req: IHttpRequest, res: IHttpResponse) => {\n try {\n const locale = req.params.locale;\n if (!locale) {\n res.status(400).json({ error: 'Missing locale parameter' });\n return;\n }\n const translations = i18n.getTranslations(locale);\n res.json({ data: { locale, translations } });\n } catch (error: any) {\n res.status(500).json({ error: error.message });\n }\n });\n\n // GET /i18n/labels/:object/:locale\n httpServer.get(`${basePath}/labels/:object/:locale`, async (req: IHttpRequest, res: IHttpResponse) => {\n try {\n const objectName = req.params.object;\n const locale = req.params.locale;\n if (!objectName || !locale) {\n res.status(400).json({ error: 'Missing object or locale parameter' });\n return;\n }\n // Some implementations may provide a dedicated getFieldLabels method\n const hasGetFieldLabels = 'getFieldLabels' in i18n\n && typeof (i18n as Record<string, unknown>)['getFieldLabels'] === 'function';\n if (hasGetFieldLabels) {\n const labels = (i18n as II18nService & { getFieldLabels(obj: string, loc: string): Record<string, string> })\n .getFieldLabels(objectName, locale);\n res.json({ data: { object: objectName, locale, labels } });\n } else {\n // Fallback: derive field labels from full translation bundle\n const translations = i18n.getTranslations(locale);\n const prefix = `o.${objectName}.fields.`;\n const labels: Record<string, string> = {};\n for (const [key, value] of Object.entries(translations)) {\n if (key.startsWith(prefix)) {\n labels[key.substring(prefix.length)] = value as string;\n }\n }\n res.json({ data: { object: objectName, locale, labels } });\n }\n } catch (error: any) {\n res.status(500).json({ error: error.message });\n }\n });\n\n ctx.logger.info(`I18n routes registered: ${basePath}/locales, ${basePath}/translations/:locale, ${basePath}/labels/:object/:locale`);\n }\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/service-i18n",
3
- "version": "3.2.5",
3
+ "version": "3.2.7",
4
4
  "license": "Apache-2.0",
5
5
  "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading",
6
6
  "type": "module",
@@ -9,13 +9,13 @@
9
9
  "exports": {
10
10
  ".": {
11
11
  "types": "./dist/index.d.ts",
12
- "import": "./dist/index.mjs",
13
- "require": "./dist/index.js"
12
+ "import": "./dist/index.js",
13
+ "require": "./dist/index.cjs"
14
14
  }
15
15
  },
16
16
  "dependencies": {
17
- "@objectstack/core": "3.2.5",
18
- "@objectstack/spec": "3.2.5"
17
+ "@objectstack/core": "3.2.7",
18
+ "@objectstack/spec": "3.2.7"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^5.0.0",
@@ -0,0 +1,280 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
4
+ import { I18nServicePlugin } from './i18n-service-plugin';
5
+ import type { IHttpRequest, IHttpResponse, RouteHandler } from '@objectstack/spec/contracts';
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Mocks
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function createMockHttpServer() {
12
+ const routes = new Map<string, RouteHandler>();
13
+ return {
14
+ get: vi.fn((path: string, handler: RouteHandler) => { routes.set(`GET:${path}`, handler); }),
15
+ post: vi.fn(),
16
+ put: vi.fn(),
17
+ delete: vi.fn(),
18
+ patch: vi.fn(),
19
+ use: vi.fn(),
20
+ listen: vi.fn().mockResolvedValue(undefined),
21
+ close: vi.fn().mockResolvedValue(undefined),
22
+ /** Test helper: retrieve a registered handler */
23
+ _getHandler(method: string, path: string): RouteHandler | undefined {
24
+ return routes.get(`${method}:${path}`);
25
+ },
26
+ };
27
+ }
28
+
29
+ function createMockPluginContext(services: Record<string, any> = {}) {
30
+ const hooks = new Map<string, Array<(...args: any[]) => Promise<void>>>();
31
+ return {
32
+ registerService: vi.fn(),
33
+ getService: vi.fn((name: string) => {
34
+ if (services[name]) return services[name];
35
+ throw new Error(`Service '${name}' not found`);
36
+ }),
37
+ getServices: vi.fn(() => new Map(Object.entries(services))),
38
+ hook: vi.fn((name: string, handler: (...args: any[]) => Promise<void>) => {
39
+ if (!hooks.has(name)) hooks.set(name, []);
40
+ hooks.get(name)!.push(handler);
41
+ }),
42
+ trigger: vi.fn(async (name: string, ...args: any[]) => {
43
+ const handlers = hooks.get(name) ?? [];
44
+ for (const h of handlers) await h(...args);
45
+ }),
46
+ logger: {
47
+ info: vi.fn(),
48
+ warn: vi.fn(),
49
+ error: vi.fn(),
50
+ debug: vi.fn(),
51
+ },
52
+ getKernel: vi.fn(),
53
+ replaceService: vi.fn(),
54
+ };
55
+ }
56
+
57
+ function createMockReq(overrides: Partial<IHttpRequest> = {}): IHttpRequest {
58
+ return {
59
+ params: {},
60
+ query: {},
61
+ headers: {},
62
+ method: 'GET',
63
+ path: '/',
64
+ ...overrides,
65
+ };
66
+ }
67
+
68
+ function createMockRes(): IHttpResponse & { _data: any; _status: number } {
69
+ const res: any = {
70
+ _data: null,
71
+ _status: 200,
72
+ json(data: any) { res._data = data; },
73
+ send(data: string) { res._data = data; },
74
+ status(code: number) { res._status = code; return res; },
75
+ header() { return res; },
76
+ };
77
+ return res;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Tests
82
+ // ---------------------------------------------------------------------------
83
+
84
+ describe('I18nServicePlugin', () => {
85
+ let httpServer: ReturnType<typeof createMockHttpServer>;
86
+ let ctx: ReturnType<typeof createMockPluginContext>;
87
+
88
+ beforeEach(() => {
89
+ httpServer = createMockHttpServer();
90
+ ctx = createMockPluginContext({ 'http-server': httpServer });
91
+ });
92
+
93
+ // -- Service registration -------------------------------------------------
94
+
95
+ describe('init', () => {
96
+ it('should register i18n service during init', async () => {
97
+ const plugin = new I18nServicePlugin();
98
+ await plugin.init!(ctx as any);
99
+
100
+ expect(ctx.registerService).toHaveBeenCalledWith('i18n', expect.any(Object));
101
+ });
102
+
103
+ it('should pass options to the FileI18nAdapter', async () => {
104
+ const plugin = new I18nServicePlugin({ defaultLocale: 'zh-CN' });
105
+ await plugin.init!(ctx as any);
106
+
107
+ const registeredService = ctx.registerService.mock.calls[0][1];
108
+ expect(registeredService.getDefaultLocale()).toBe('zh-CN');
109
+ });
110
+ });
111
+
112
+ // -- Route self-registration ----------------------------------------------
113
+
114
+ describe('route self-registration', () => {
115
+ it('should register a kernel:ready hook during start', async () => {
116
+ const plugin = new I18nServicePlugin();
117
+ await plugin.init!(ctx as any);
118
+ await plugin.start!(ctx as any);
119
+
120
+ expect(ctx.hook).toHaveBeenCalledWith('kernel:ready', expect.any(Function));
121
+ });
122
+
123
+ it('should register i18n routes when http-server is available', async () => {
124
+ const plugin = new I18nServicePlugin();
125
+ await plugin.init!(ctx as any);
126
+ await plugin.start!(ctx as any);
127
+
128
+ // Simulate kernel:ready
129
+ await ctx.trigger('kernel:ready');
130
+
131
+ expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/locales', expect.any(Function));
132
+ expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/translations/:locale', expect.any(Function));
133
+ expect(httpServer.get).toHaveBeenCalledWith('/api/v1/i18n/labels/:object/:locale', expect.any(Function));
134
+ });
135
+
136
+ it('should respect custom basePath', async () => {
137
+ const plugin = new I18nServicePlugin({ basePath: '/custom/i18n' });
138
+ await plugin.init!(ctx as any);
139
+ await plugin.start!(ctx as any);
140
+
141
+ await ctx.trigger('kernel:ready');
142
+
143
+ expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/locales', expect.any(Function));
144
+ expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/translations/:locale', expect.any(Function));
145
+ expect(httpServer.get).toHaveBeenCalledWith('/custom/i18n/labels/:object/:locale', expect.any(Function));
146
+ });
147
+
148
+ it('should skip route registration when registerRoutes is false', async () => {
149
+ const plugin = new I18nServicePlugin({ registerRoutes: false });
150
+ await plugin.init!(ctx as any);
151
+ await plugin.start!(ctx as any);
152
+
153
+ expect(ctx.hook).not.toHaveBeenCalled();
154
+ });
155
+
156
+ it('should gracefully skip routes when http-server is not available', async () => {
157
+ const ctxNoHttp = createMockPluginContext({}); // no http-server
158
+ const plugin = new I18nServicePlugin();
159
+ await plugin.init!(ctxNoHttp as any);
160
+ await plugin.start!(ctxNoHttp as any);
161
+
162
+ await ctxNoHttp.trigger('kernel:ready');
163
+
164
+ expect(ctxNoHttp.logger.warn).toHaveBeenCalledWith(
165
+ expect.stringContaining('No HTTP server available'),
166
+ );
167
+ });
168
+ });
169
+
170
+ // -- Route handler behavior -----------------------------------------------
171
+
172
+ describe('route handlers', () => {
173
+ async function setupPlugin(options: ConstructorParameters<typeof I18nServicePlugin>[0] = {}) {
174
+ const plugin = new I18nServicePlugin(options);
175
+ await plugin.init!(ctx as any);
176
+ // Load some translations after init so the service has data
177
+ const i18n = ctx.registerService.mock.calls[0][1];
178
+ i18n.loadTranslations('en', { greeting: 'Hello', 'o.account.fields.name': 'Account Name' });
179
+ i18n.loadTranslations('zh-CN', { greeting: '你好', 'o.account.fields.name': '账户名称' });
180
+ await plugin.start!(ctx as any);
181
+ await ctx.trigger('kernel:ready');
182
+ return { plugin, i18n };
183
+ }
184
+
185
+ it('GET /locales should return all available locales', async () => {
186
+ await setupPlugin();
187
+
188
+ const handler = httpServer._getHandler('GET', '/api/v1/i18n/locales')!;
189
+ expect(handler).toBeDefined();
190
+
191
+ const req = createMockReq();
192
+ const res = createMockRes();
193
+ await handler(req, res);
194
+
195
+ expect(res._data).toEqual({
196
+ data: {
197
+ locales: [
198
+ { code: 'en', label: 'en', isDefault: true },
199
+ { code: 'zh-CN', label: 'zh-CN', isDefault: false },
200
+ ],
201
+ },
202
+ });
203
+ });
204
+
205
+ it('GET /translations/:locale should return translations for the given locale', async () => {
206
+ await setupPlugin();
207
+
208
+ const handler = httpServer._getHandler('GET', '/api/v1/i18n/translations/:locale')!;
209
+ expect(handler).toBeDefined();
210
+
211
+ const req = createMockReq({ params: { locale: 'en' } });
212
+ const res = createMockRes();
213
+ await handler(req, res);
214
+
215
+ expect(res._data).toEqual({
216
+ data: {
217
+ locale: 'en',
218
+ translations: { greeting: 'Hello', 'o.account.fields.name': 'Account Name' },
219
+ },
220
+ });
221
+ });
222
+
223
+ it('GET /translations/:locale should return 400 when locale is missing', async () => {
224
+ await setupPlugin();
225
+
226
+ const handler = httpServer._getHandler('GET', '/api/v1/i18n/translations/:locale')!;
227
+ const req = createMockReq({ params: {} });
228
+ const res = createMockRes();
229
+ await handler(req, res);
230
+
231
+ expect(res._status).toBe(400);
232
+ expect(res._data).toEqual({ error: 'Missing locale parameter' });
233
+ });
234
+
235
+ it('GET /labels/:object/:locale should derive field labels from translation bundle', async () => {
236
+ await setupPlugin();
237
+
238
+ const handler = httpServer._getHandler('GET', '/api/v1/i18n/labels/:object/:locale')!;
239
+ expect(handler).toBeDefined();
240
+
241
+ const req = createMockReq({ params: { object: 'account', locale: 'en' } });
242
+ const res = createMockRes();
243
+ await handler(req, res);
244
+
245
+ expect(res._data).toEqual({
246
+ data: {
247
+ object: 'account',
248
+ locale: 'en',
249
+ labels: { name: 'Account Name' },
250
+ },
251
+ });
252
+ });
253
+
254
+ it('GET /labels/:object/:locale should return 400 when params are missing', async () => {
255
+ await setupPlugin();
256
+
257
+ const handler = httpServer._getHandler('GET', '/api/v1/i18n/labels/:object/:locale')!;
258
+ const req = createMockReq({ params: {} });
259
+ const res = createMockRes();
260
+ await handler(req, res);
261
+
262
+ expect(res._status).toBe(400);
263
+ expect(res._data).toEqual({ error: 'Missing object or locale parameter' });
264
+ });
265
+ });
266
+
267
+ // -- Plugin metadata -------------------------------------------------------
268
+
269
+ describe('plugin metadata', () => {
270
+ it('should have correct plugin name', () => {
271
+ const plugin = new I18nServicePlugin();
272
+ expect(plugin.name).toBe('com.objectstack.service.i18n');
273
+ });
274
+
275
+ it('should have version', () => {
276
+ const plugin = new I18nServicePlugin();
277
+ expect(plugin.version).toBe('1.0.0');
278
+ });
279
+ });
280
+ });
@@ -1,6 +1,8 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import type { Plugin, PluginContext } from '@objectstack/core';
4
+ import type { IHttpServer, IHttpRequest, IHttpResponse } from '@objectstack/spec/contracts';
5
+ import type { II18nService } from '@objectstack/spec/contracts';
4
6
  import { FileI18nAdapter } from './file-i18n-adapter.js';
5
7
  import type { FileI18nAdapterOptions } from './file-i18n-adapter.js';
6
8
 
@@ -14,13 +16,31 @@ export interface I18nServicePluginOptions {
14
16
  localesDir?: string;
15
17
  /** Fallback locale for missing translations */
16
18
  fallbackLocale?: string;
19
+ /**
20
+ * Whether to automatically register i18n REST routes with the HTTP server.
21
+ * When true (default), the plugin registers `/api/v1/i18n/*` endpoints
22
+ * via the `kernel:ready` hook. When false or no HTTP server is available,
23
+ * routes are skipped but the i18n service is still available via the kernel.
24
+ * @default true
25
+ */
26
+ registerRoutes?: boolean;
27
+ /**
28
+ * Base path for i18n REST routes.
29
+ * @default '/api/v1/i18n'
30
+ */
31
+ basePath?: string;
17
32
  }
18
33
 
19
34
  /**
20
35
  * I18nServicePlugin — Production II18nService implementation.
21
36
  *
22
- * Registers an i18n service with the kernel during the init phase.
23
- * Uses file-based locale loading with JSON files.
37
+ * Registers an i18n service with the kernel during the init phase,
38
+ * and self-registers REST endpoints (`/api/v1/i18n/*`) with the HTTP
39
+ * server during the `kernel:ready` hook.
40
+ *
41
+ * REST route self-registration follows the same autonomous plugin pattern
42
+ * used by AuthPlugin, WorkflowPlugin, and other service plugins — RestServer
43
+ * is not involved.
24
44
  *
25
45
  * @example
26
46
  * ```ts
@@ -45,6 +65,7 @@ export class I18nServicePlugin implements Plugin {
45
65
  type = 'standard';
46
66
 
47
67
  private readonly options: I18nServicePluginOptions;
68
+ private i18n: II18nService | null = null;
48
69
 
49
70
  constructor(options: I18nServicePluginOptions = {}) {
50
71
  this.options = options;
@@ -57,10 +78,121 @@ export class I18nServicePlugin implements Plugin {
57
78
  fallbackLocale: this.options.fallbackLocale,
58
79
  };
59
80
 
60
- const i18n = new FileI18nAdapter(adapterOptions);
61
- ctx.registerService('i18n', i18n);
81
+ this.i18n = new FileI18nAdapter(adapterOptions);
82
+ ctx.registerService('i18n', this.i18n);
62
83
  ctx.logger.info(
63
- `I18nServicePlugin: registered file-based i18n adapter (default: ${i18n.getDefaultLocale()})`,
84
+ `I18nServicePlugin: registered file-based i18n adapter (default: ${this.i18n.getDefaultLocale?.() ?? 'en'})`,
64
85
  );
65
86
  }
87
+
88
+ async start(ctx: PluginContext): Promise<void> {
89
+ // Defer HTTP route registration to kernel:ready hook.
90
+ // This ensures all plugins (including HonoServerPlugin) have completed
91
+ // their init and start phases before we attempt to look up the
92
+ // http-server service — making I18nServicePlugin resilient to plugin
93
+ // loading order.
94
+ if (this.options.registerRoutes !== false) {
95
+ ctx.hook('kernel:ready', async () => {
96
+ let httpServer: IHttpServer | null = null;
97
+ try {
98
+ httpServer = ctx.getService<IHttpServer>('http-server');
99
+ } catch {
100
+ // Service not found — expected in MSW/mock mode
101
+ }
102
+
103
+ if (httpServer) {
104
+ this.registerI18nRoutes(httpServer, ctx);
105
+ } else {
106
+ ctx.logger.warn(
107
+ 'No HTTP server available — i18n routes not registered. ' +
108
+ 'i18n service is still available programmatically via kernel.getService("i18n").'
109
+ );
110
+ }
111
+ });
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Register i18n REST routes with the HTTP server.
117
+ *
118
+ * Routes:
119
+ * - GET /api/v1/i18n/locales → list available locales
120
+ * - GET /api/v1/i18n/translations/:locale → get translations for a locale
121
+ * - GET /api/v1/i18n/labels/:object/:locale → get field labels for an object
122
+ */
123
+ private registerI18nRoutes(httpServer: IHttpServer, ctx: PluginContext): void {
124
+ if (!this.i18n) return;
125
+
126
+ const basePath = this.options.basePath || '/api/v1/i18n';
127
+ const i18n = this.i18n;
128
+
129
+ // GET /i18n/locales
130
+ httpServer.get(`${basePath}/locales`, async (_req: IHttpRequest, res: IHttpResponse) => {
131
+ try {
132
+ const locales = i18n.getLocales();
133
+ const defaultLocale = i18n.getDefaultLocale?.() ?? 'en';
134
+ res.json({
135
+ data: {
136
+ locales: locales.map((code) => ({
137
+ code,
138
+ label: code,
139
+ isDefault: code === defaultLocale,
140
+ })),
141
+ },
142
+ });
143
+ } catch (error: any) {
144
+ res.status(500).json({ error: error.message });
145
+ }
146
+ });
147
+
148
+ // GET /i18n/translations/:locale
149
+ httpServer.get(`${basePath}/translations/:locale`, async (req: IHttpRequest, res: IHttpResponse) => {
150
+ try {
151
+ const locale = req.params.locale;
152
+ if (!locale) {
153
+ res.status(400).json({ error: 'Missing locale parameter' });
154
+ return;
155
+ }
156
+ const translations = i18n.getTranslations(locale);
157
+ res.json({ data: { locale, translations } });
158
+ } catch (error: any) {
159
+ res.status(500).json({ error: error.message });
160
+ }
161
+ });
162
+
163
+ // GET /i18n/labels/:object/:locale
164
+ httpServer.get(`${basePath}/labels/:object/:locale`, async (req: IHttpRequest, res: IHttpResponse) => {
165
+ try {
166
+ const objectName = req.params.object;
167
+ const locale = req.params.locale;
168
+ if (!objectName || !locale) {
169
+ res.status(400).json({ error: 'Missing object or locale parameter' });
170
+ return;
171
+ }
172
+ // Some implementations may provide a dedicated getFieldLabels method
173
+ const hasGetFieldLabels = 'getFieldLabels' in i18n
174
+ && typeof (i18n as Record<string, unknown>)['getFieldLabels'] === 'function';
175
+ if (hasGetFieldLabels) {
176
+ const labels = (i18n as II18nService & { getFieldLabels(obj: string, loc: string): Record<string, string> })
177
+ .getFieldLabels(objectName, locale);
178
+ res.json({ data: { object: objectName, locale, labels } });
179
+ } else {
180
+ // Fallback: derive field labels from full translation bundle
181
+ const translations = i18n.getTranslations(locale);
182
+ const prefix = `o.${objectName}.fields.`;
183
+ const labels: Record<string, string> = {};
184
+ for (const [key, value] of Object.entries(translations)) {
185
+ if (key.startsWith(prefix)) {
186
+ labels[key.substring(prefix.length)] = value as string;
187
+ }
188
+ }
189
+ res.json({ data: { object: objectName, locale, labels } });
190
+ }
191
+ } catch (error: any) {
192
+ res.status(500).json({ error: error.message });
193
+ }
194
+ });
195
+
196
+ ctx.logger.info(`I18n routes registered: ${basePath}/locales, ${basePath}/translations/:locale, ${basePath}/labels/:object/:locale`);
197
+ }
66
198
  }