@objectstack/service-i18n 3.2.9 → 3.3.1

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.9 build /home/runner/work/spec/spec/packages/services/service-i18n
2
+ > @objectstack/service-i18n@3.3.1 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 6.41 KB
14
- ESM dist/index.js.map 16.79 KB
15
- ESM ⚡️ Build success in 77ms
16
- CJS dist/index.cjs 8.08 KB
17
- CJS dist/index.cjs.map 17.20 KB
18
- CJS ⚡️ Build success in 83ms
13
+ ESM dist/index.js 6.84 KB
14
+ ESM dist/index.js.map 18.02 KB
15
+ ESM ⚡️ Build success in 89ms
16
+ CJS dist/index.cjs 8.50 KB
17
+ CJS dist/index.cjs.map 18.42 KB
18
+ CJS ⚡️ Build success in 89ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 13417ms
20
+ DTS ⚡️ Build success in 14502ms
21
21
  DTS dist/index.d.ts 4.41 KB
22
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.3.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 772dc3f: fix i18n
8
+ - @objectstack/spec@3.3.1
9
+ - @objectstack/core@3.3.1
10
+
11
+ ## 3.3.0
12
+
13
+ ### Patch Changes
14
+
15
+ - @objectstack/spec@3.3.0
16
+ - @objectstack/core@3.3.0
17
+
3
18
  ## 3.2.9
4
19
 
5
20
  ### Patch Changes
package/dist/index.cjs CHANGED
@@ -47,6 +47,22 @@ function resolveKey(data, key) {
47
47
  }
48
48
  return typeof current === "string" ? current : void 0;
49
49
  }
50
+ function deepMerge(target, source) {
51
+ const result = { ...target };
52
+ for (const key of Object.keys(source)) {
53
+ const tVal = target[key];
54
+ const sVal = source[key];
55
+ if (tVal && sVal && typeof tVal === "object" && !Array.isArray(tVal) && typeof sVal === "object" && !Array.isArray(sVal)) {
56
+ result[key] = deepMerge(
57
+ tVal,
58
+ sVal
59
+ );
60
+ } else {
61
+ result[key] = sVal;
62
+ }
63
+ }
64
+ return result;
65
+ }
50
66
  function interpolate(template, params) {
51
67
  return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
52
68
  return params[key] != null ? String(params[key]) : `{{${key}}}`;
@@ -78,7 +94,7 @@ var FileI18nAdapter = class {
78
94
  loadTranslations(locale, translations) {
79
95
  const existing = this.translations.get(locale);
80
96
  if (existing) {
81
- this.translations.set(locale, { ...existing, ...translations });
97
+ this.translations.set(locale, deepMerge(existing, translations));
82
98
  } else {
83
99
  this.translations.set(locale, { ...translations });
84
100
  }
@@ -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 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":[]}
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 * Deep-merge two plain objects recursively.\n * Arrays and non-plain-object values from `source` overwrite those in `target`.\n */\nfunction deepMerge(\n target: Record<string, unknown>,\n source: Record<string, unknown>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = { ...target };\n for (const key of Object.keys(source)) {\n const tVal = target[key];\n const sVal = source[key];\n if (\n tVal && sVal\n && typeof tVal === 'object' && !Array.isArray(tVal)\n && typeof sVal === 'object' && !Array.isArray(sVal)\n ) {\n result[key] = deepMerge(\n tVal as Record<string, unknown>,\n sVal as Record<string, unknown>,\n );\n } else {\n result[key] = sVal;\n }\n }\n return result;\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 // Deep-merge so multiple plugins can contribute to the same nested keys\n // (e.g. each plugin adds its own objects under `objects.*`)\n this.translations.set(locale, deepMerge(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.js CHANGED
@@ -10,6 +10,22 @@ function resolveKey(data, key) {
10
10
  }
11
11
  return typeof current === "string" ? current : void 0;
12
12
  }
13
+ function deepMerge(target, source) {
14
+ const result = { ...target };
15
+ for (const key of Object.keys(source)) {
16
+ const tVal = target[key];
17
+ const sVal = source[key];
18
+ if (tVal && sVal && typeof tVal === "object" && !Array.isArray(tVal) && typeof sVal === "object" && !Array.isArray(sVal)) {
19
+ result[key] = deepMerge(
20
+ tVal,
21
+ sVal
22
+ );
23
+ } else {
24
+ result[key] = sVal;
25
+ }
26
+ }
27
+ return result;
28
+ }
13
29
  function interpolate(template, params) {
14
30
  return template.replace(/\{\{(\w+)\}\}/g, (_match, key) => {
15
31
  return params[key] != null ? String(params[key]) : `{{${key}}}`;
@@ -41,7 +57,7 @@ var FileI18nAdapter = class {
41
57
  loadTranslations(locale, translations) {
42
58
  const existing = this.translations.get(locale);
43
59
  if (existing) {
44
- this.translations.set(locale, { ...existing, ...translations });
60
+ this.translations.set(locale, deepMerge(existing, translations));
45
61
  } else {
46
62
  this.translations.set(locale, { ...translations });
47
63
  }
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 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":[]}
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 * Deep-merge two plain objects recursively.\n * Arrays and non-plain-object values from `source` overwrite those in `target`.\n */\nfunction deepMerge(\n target: Record<string, unknown>,\n source: Record<string, unknown>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = { ...target };\n for (const key of Object.keys(source)) {\n const tVal = target[key];\n const sVal = source[key];\n if (\n tVal && sVal\n && typeof tVal === 'object' && !Array.isArray(tVal)\n && typeof sVal === 'object' && !Array.isArray(sVal)\n ) {\n result[key] = deepMerge(\n tVal as Record<string, unknown>,\n sVal as Record<string, unknown>,\n );\n } else {\n result[key] = sVal;\n }\n }\n return result;\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 // Deep-merge so multiple plugins can contribute to the same nested keys\n // (e.g. each plugin adds its own objects under `objects.*`)\n this.translations.set(locale, deepMerge(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.9",
3
+ "version": "3.3.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading",
6
6
  "type": "module",
@@ -14,8 +14,8 @@
14
14
  }
15
15
  },
16
16
  "dependencies": {
17
- "@objectstack/core": "3.2.9",
18
- "@objectstack/spec": "3.2.9"
17
+ "@objectstack/core": "3.3.1",
18
+ "@objectstack/spec": "3.3.1"
19
19
  },
20
20
  "devDependencies": {
21
21
  "typescript": "^5.0.0",
@@ -127,6 +127,42 @@ describe('FileI18nAdapter', () => {
127
127
  expect(i18n.t('missing.key', 'zh-CN')).toBe('missing.key');
128
128
  });
129
129
 
130
+ it('should deep-merge nested objects from multiple plugins', () => {
131
+ const i18n = new FileI18nAdapter();
132
+
133
+ // Plugin 1: CRM objects
134
+ i18n.loadTranslations('en', {
135
+ objects: {
136
+ account: { label: 'Account', fields: { name: { label: 'Account Name' } } },
137
+ contact: { label: 'Contact' },
138
+ },
139
+ apps: { crm: { label: 'CRM' } },
140
+ });
141
+
142
+ // Plugin 2: HR objects
143
+ i18n.loadTranslations('en', {
144
+ objects: {
145
+ department: { label: 'Department', fields: { name: { label: 'Department Name' } } },
146
+ employee: { label: 'Employee' },
147
+ },
148
+ apps: { hr: { label: 'HR' } },
149
+ });
150
+
151
+ const data = i18n.getTranslations('en');
152
+
153
+ // Both plugins' objects must be preserved (not overwritten)
154
+ expect(i18n.t('objects.account.label', 'en')).toBe('Account');
155
+ expect(i18n.t('objects.contact.label', 'en')).toBe('Contact');
156
+ expect(i18n.t('objects.department.label', 'en')).toBe('Department');
157
+ expect(i18n.t('objects.employee.label', 'en')).toBe('Employee');
158
+ expect(i18n.t('objects.account.fields.name.label', 'en')).toBe('Account Name');
159
+ expect(i18n.t('objects.department.fields.name.label', 'en')).toBe('Department Name');
160
+
161
+ // Both plugins' apps must be preserved
162
+ expect((data as any).apps.crm.label).toBe('CRM');
163
+ expect((data as any).apps.hr.label).toBe('HR');
164
+ });
165
+
130
166
  describe('file-based loading', () => {
131
167
  let tmpDir: string;
132
168
 
@@ -33,6 +33,34 @@ function resolveKey(data: Record<string, unknown>, key: string): string | undefi
33
33
  return typeof current === 'string' ? current : undefined;
34
34
  }
35
35
 
36
+ /**
37
+ * Deep-merge two plain objects recursively.
38
+ * Arrays and non-plain-object values from `source` overwrite those in `target`.
39
+ */
40
+ function deepMerge(
41
+ target: Record<string, unknown>,
42
+ source: Record<string, unknown>,
43
+ ): Record<string, unknown> {
44
+ const result: Record<string, unknown> = { ...target };
45
+ for (const key of Object.keys(source)) {
46
+ const tVal = target[key];
47
+ const sVal = source[key];
48
+ if (
49
+ tVal && sVal
50
+ && typeof tVal === 'object' && !Array.isArray(tVal)
51
+ && typeof sVal === 'object' && !Array.isArray(sVal)
52
+ ) {
53
+ result[key] = deepMerge(
54
+ tVal as Record<string, unknown>,
55
+ sVal as Record<string, unknown>,
56
+ );
57
+ } else {
58
+ result[key] = sVal;
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
36
64
  /**
37
65
  * Interpolate parameters into a translated string.
38
66
  * Replaces `{{paramName}}` with the corresponding value from params.
@@ -115,8 +143,9 @@ export class FileI18nAdapter implements II18nService {
115
143
  loadTranslations(locale: string, translations: Record<string, unknown>): void {
116
144
  const existing = this.translations.get(locale);
117
145
  if (existing) {
118
- // Merge into existing translations
119
- this.translations.set(locale, { ...existing, ...translations });
146
+ // Deep-merge so multiple plugins can contribute to the same nested keys
147
+ // (e.g. each plugin adds its own objects under `objects.*`)
148
+ this.translations.set(locale, deepMerge(existing, translations));
120
149
  } else {
121
150
  this.translations.set(locale, { ...translations });
122
151
  }