@objectstack/core 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/core",
3
- "version": "3.2.5",
3
+ "version": "3.2.7",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Microkernel Core for ObjectStack",
6
6
  "type": "module",
@@ -22,7 +22,7 @@
22
22
  "pino": "^10.3.1",
23
23
  "pino-pretty": "^13.1.3",
24
24
  "zod": "^4.3.6",
25
- "@objectstack/spec": "3.2.5"
25
+ "@objectstack/spec": "3.2.7"
26
26
  },
27
27
  "peerDependencies": {
28
28
  "pino": "^8.0.0"
@@ -135,6 +135,13 @@ export interface IHttpServer {
135
135
  * @returns Promise that resolves when server is ready
136
136
  */
137
137
  listen(port: number): Promise<void>;
138
+
139
+ /**
140
+ * Get the port the server is listening on.
141
+ * Returns the actual bound port after `listen()` resolves, or the
142
+ * configured port before that.
143
+ */
144
+ getPort?(): number;
138
145
 
139
146
  /**
140
147
  * Stop the HTTP server
@@ -2,11 +2,12 @@ import { describe, it, expect } from 'vitest';
2
2
  import { createMemoryCache } from './memory-cache';
3
3
  import { createMemoryQueue } from './memory-queue';
4
4
  import { createMemoryJob } from './memory-job';
5
+ import { createMemoryI18n, resolveLocale } from './memory-i18n';
5
6
  import { CORE_FALLBACK_FACTORIES } from './index';
6
7
 
7
8
  describe('CORE_FALLBACK_FACTORIES', () => {
8
- it('should have exactly 3 entries: cache, queue, job', () => {
9
- expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['cache', 'queue', 'job']);
9
+ it('should have exactly 4 entries: cache, queue, job, i18n', () => {
10
+ expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['cache', 'queue', 'job', 'i18n']);
10
11
  });
11
12
 
12
13
  it('should map to factory functions', () => {
@@ -156,3 +157,125 @@ describe('createMemoryJob', () => {
156
157
  expect(await job.getExecutions()).toEqual([]);
157
158
  });
158
159
  });
160
+
161
+ describe('createMemoryI18n', () => {
162
+ it('should return an object with _fallback: true', () => {
163
+ const i18n = createMemoryI18n();
164
+ expect(i18n._fallback).toBe(true);
165
+ expect(i18n._serviceName).toBe('i18n');
166
+ });
167
+
168
+ it('should return the key when no translations are loaded', () => {
169
+ const i18n = createMemoryI18n();
170
+ expect(i18n.t('objects.account.label', 'en')).toBe('objects.account.label');
171
+ });
172
+
173
+ it('should translate after loading translations', () => {
174
+ const i18n = createMemoryI18n();
175
+ i18n.loadTranslations('en', { objects: { account: { label: 'Account' } } });
176
+ expect(i18n.t('objects.account.label', 'en')).toBe('Account');
177
+ });
178
+
179
+ it('should interpolate parameters', () => {
180
+ const i18n = createMemoryI18n();
181
+ i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });
182
+ expect(i18n.t('greeting', 'en', { name: 'World' })).toBe('Hello, World!');
183
+ });
184
+
185
+ it('should fall back to default locale', () => {
186
+ const i18n = createMemoryI18n();
187
+ i18n.loadTranslations('en', { hello: 'Hello' });
188
+ // 'fr' has no translations, should fall back to default 'en'
189
+ expect(i18n.t('hello', 'fr')).toBe('Hello');
190
+ });
191
+
192
+ it('should get and set default locale', () => {
193
+ const i18n = createMemoryI18n();
194
+ expect(i18n.getDefaultLocale()).toBe('en');
195
+ i18n.setDefaultLocale('zh-CN');
196
+ expect(i18n.getDefaultLocale()).toBe('zh-CN');
197
+ });
198
+
199
+ it('should list loaded locales', () => {
200
+ const i18n = createMemoryI18n();
201
+ expect(i18n.getLocales()).toEqual([]);
202
+ i18n.loadTranslations('en', { hello: 'Hello' });
203
+ i18n.loadTranslations('zh-CN', { hello: '你好' });
204
+ expect(i18n.getLocales()).toEqual(['en', 'zh-CN']);
205
+ });
206
+
207
+ it('should get all translations for a locale', () => {
208
+ const i18n = createMemoryI18n();
209
+ i18n.loadTranslations('en', { hello: 'Hello', bye: 'Goodbye' });
210
+ expect(i18n.getTranslations('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' });
211
+ });
212
+
213
+ it('should return empty object for unknown locale', () => {
214
+ const i18n = createMemoryI18n();
215
+ expect(i18n.getTranslations('unknown')).toEqual({});
216
+ });
217
+
218
+ it('should merge translations on subsequent loads', () => {
219
+ const i18n = createMemoryI18n();
220
+ i18n.loadTranslations('en', { hello: 'Hello' });
221
+ i18n.loadTranslations('en', { bye: 'Goodbye' });
222
+ expect(i18n.getTranslations('en')).toEqual({ hello: 'Hello', bye: 'Goodbye' });
223
+ });
224
+ });
225
+
226
+ describe('resolveLocale', () => {
227
+ it('should return exact match', () => {
228
+ expect(resolveLocale('zh-CN', ['en', 'zh-CN', 'ja'])).toBe('zh-CN');
229
+ });
230
+
231
+ it('should return case-insensitive match', () => {
232
+ expect(resolveLocale('zh-cn', ['en', 'zh-CN'])).toBe('zh-CN');
233
+ expect(resolveLocale('EN-US', ['en-US', 'zh-CN'])).toBe('en-US');
234
+ });
235
+
236
+ it('should return base language match', () => {
237
+ expect(resolveLocale('zh-TW', ['en', 'zh'])).toBe('zh');
238
+ });
239
+
240
+ it('should return variant expansion (zh → zh-CN)', () => {
241
+ expect(resolveLocale('zh', ['en', 'zh-CN', 'zh-TW'])).toBe('zh-CN');
242
+ });
243
+
244
+ it('should return undefined for no match', () => {
245
+ expect(resolveLocale('fr', ['en', 'zh-CN'])).toBeUndefined();
246
+ });
247
+
248
+ it('should return undefined for empty available locales', () => {
249
+ expect(resolveLocale('en', [])).toBeUndefined();
250
+ });
251
+
252
+ it('should handle es → es-ES expansion', () => {
253
+ expect(resolveLocale('es', ['en', 'es-ES', 'fr'])).toBe('es-ES');
254
+ });
255
+ });
256
+
257
+ describe('createMemoryI18n locale fallback', () => {
258
+ it('should resolve translations via locale fallback (zh → zh-CN)', () => {
259
+ const i18n = createMemoryI18n();
260
+ i18n.loadTranslations('zh-CN', { hello: '你好' });
261
+ expect(i18n.getTranslations('zh')).toEqual({ hello: '你好' });
262
+ });
263
+
264
+ it('should resolve translations via case-insensitive fallback (zh-cn → zh-CN)', () => {
265
+ const i18n = createMemoryI18n();
266
+ i18n.loadTranslations('zh-CN', { hello: '你好' });
267
+ expect(i18n.getTranslations('zh-cn')).toEqual({ hello: '你好' });
268
+ });
269
+
270
+ it('should translate via locale fallback (zh → zh-CN)', () => {
271
+ const i18n = createMemoryI18n();
272
+ i18n.loadTranslations('zh-CN', { greeting: '你好世界' });
273
+ expect(i18n.t('greeting', 'zh')).toBe('你好世界');
274
+ });
275
+
276
+ it('should still fall back to default locale when no locale match at all', () => {
277
+ const i18n = createMemoryI18n();
278
+ i18n.loadTranslations('en', { hello: 'Hello' });
279
+ expect(i18n.t('hello', 'ja')).toBe('Hello');
280
+ });
281
+ });
@@ -3,10 +3,12 @@
3
3
  import { createMemoryCache } from './memory-cache.js';
4
4
  import { createMemoryQueue } from './memory-queue.js';
5
5
  import { createMemoryJob } from './memory-job.js';
6
+ import { createMemoryI18n } from './memory-i18n.js';
6
7
 
7
8
  export { createMemoryCache } from './memory-cache.js';
8
9
  export { createMemoryQueue } from './memory-queue.js';
9
10
  export { createMemoryJob } from './memory-job.js';
11
+ export { createMemoryI18n, resolveLocale } from './memory-i18n.js';
10
12
 
11
13
  /**
12
14
  * Map of core-criticality service names to their in-memory fallback factories.
@@ -17,4 +19,5 @@ export const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>>
17
19
  cache: createMemoryCache,
18
20
  queue: createMemoryQueue,
19
21
  job: createMemoryJob,
22
+ i18n: createMemoryI18n,
20
23
  };
@@ -0,0 +1,112 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ /**
4
+ * Resolve a locale code against available locales with fallback.
5
+ *
6
+ * Fallback chain:
7
+ * 1. Exact match (e.g. `zh-CN` → `zh-CN`)
8
+ * 2. Case-insensitive match (e.g. `zh-cn` → `zh-CN`)
9
+ * 3. Base language match (e.g. `zh-CN` → `zh`)
10
+ * 4. Variant expansion (e.g. `zh` → `zh-CN`)
11
+ *
12
+ * Returns the matched locale code, or `undefined` when no match is found.
13
+ */
14
+ export function resolveLocale(requestedLocale: string, availableLocales: string[]): string | undefined {
15
+ if (availableLocales.length === 0) return undefined;
16
+
17
+ // 1. Exact match
18
+ if (availableLocales.includes(requestedLocale)) return requestedLocale;
19
+
20
+ // 2. Case-insensitive match
21
+ const lower = requestedLocale.toLowerCase();
22
+ const caseMatch = availableLocales.find(l => l.toLowerCase() === lower);
23
+ if (caseMatch) return caseMatch;
24
+
25
+ // 3. Base language match (zh-CN → zh)
26
+ const baseLang = requestedLocale.split('-')[0].toLowerCase();
27
+ const baseMatch = availableLocales.find(l => l.toLowerCase() === baseLang);
28
+ if (baseMatch) return baseMatch;
29
+
30
+ // 4. Variant expansion (zh → zh-CN, zh-TW, etc. — first match wins)
31
+ const variantMatch = availableLocales.find(l => l.split('-')[0].toLowerCase() === baseLang);
32
+ if (variantMatch) return variantMatch;
33
+
34
+ return undefined;
35
+ }
36
+
37
+ /**
38
+ * In-memory i18n service fallback.
39
+ *
40
+ * Implements the II18nService contract with basic translate/load/getLocales
41
+ * operations. Used by ObjectKernel as an automatic fallback when no real
42
+ * i18n plugin (e.g. I18nServicePlugin) is registered.
43
+ *
44
+ * Supports runtime translation loading, locale management, and
45
+ * locale code fallback (e.g. `zh` → `zh-CN`).
46
+ * Does not load files from disk — operates purely in-memory.
47
+ */
48
+ export function createMemoryI18n() {
49
+ const translations = new Map<string, Record<string, unknown>>();
50
+ let defaultLocale = 'en';
51
+
52
+ /**
53
+ * Resolve a dot-notation key from a nested object.
54
+ */
55
+ function resolveKey(data: Record<string, unknown>, key: string): string | undefined {
56
+ const parts = key.split('.');
57
+ let current: unknown = data;
58
+ for (const part of parts) {
59
+ if (current == null || typeof current !== 'object') return undefined;
60
+ current = (current as Record<string, unknown>)[part];
61
+ }
62
+ return typeof current === 'string' ? current : undefined;
63
+ }
64
+
65
+ /**
66
+ * Find translation data for a locale, with fallback resolution.
67
+ */
68
+ function resolveTranslations(locale: string): Record<string, unknown> | undefined {
69
+ // Exact match
70
+ if (translations.has(locale)) return translations.get(locale);
71
+
72
+ // Locale fallback (zh → zh-CN, en-us → en-US, etc.)
73
+ const resolved = resolveLocale(locale, [...translations.keys()]);
74
+ if (resolved) return translations.get(resolved);
75
+
76
+ return undefined;
77
+ }
78
+
79
+ return {
80
+ _fallback: true, _serviceName: 'i18n',
81
+
82
+ t(key: string, locale: string, params?: Record<string, unknown>): string {
83
+ const data = resolveTranslations(locale) ?? translations.get(defaultLocale);
84
+ const value = data ? resolveKey(data, key) : undefined;
85
+ if (value == null) return key;
86
+ if (!params) return value;
87
+ // Interpolation format: {{paramName}} — matches FileI18nAdapter convention
88
+ return value.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params[name] ?? `{{${name}}}`));
89
+ },
90
+
91
+ getTranslations(locale: string): Record<string, unknown> {
92
+ return resolveTranslations(locale) ?? {};
93
+ },
94
+
95
+ loadTranslations(locale: string, data: Record<string, unknown>): void {
96
+ const existing = translations.get(locale) ?? {};
97
+ translations.set(locale, { ...existing, ...data });
98
+ },
99
+
100
+ getLocales(): string[] {
101
+ return [...translations.keys()];
102
+ },
103
+
104
+ getDefaultLocale(): string {
105
+ return defaultLocale;
106
+ },
107
+
108
+ setDefaultLocale(locale: string): void {
109
+ defaultLocale = locale;
110
+ },
111
+ };
112
+ }