Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 9 additions & 7 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ the ecosystem for enterprise workloads.

### What Needs Building

12 of 25 service contracts are specification-only (no runtime implementation).
10 of 25 service contracts are specification-only (no runtime implementation).
These are the backbone of ObjectStack's enterprise capabilities.

### Minimal Implementation Strategy
Expand Down Expand Up @@ -96,7 +96,7 @@ This strategy ensures rapid iteration while maintaining a clear path to producti
| Exported Schemas | 1,100+ |
| `.describe()` Annotations | 7,111+ |
| Service Contracts | 25 |
| Contracts Implemented | 11 (44%) |
| Contracts Implemented | 13 (52%) |
| Test Files | 199 |
| Tests Passing | 5,468 / 5,468 |
| `@deprecated` Items | 3 |
Expand Down Expand Up @@ -280,8 +280,8 @@ The following renames are planned for packages that implement core service contr
| `ISearchService` | **P1** | `@objectstack/service-search` | In-memory search first, then Meilisearch driver |
| `INotificationService` | **P2** | `@objectstack/service-notification` | Email adapter (console logger in dev mode) |

- [ ] `service-i18n` — Implement `II18nService` with file-based locale loading
- [ ] `service-realtime` — Implement `IRealtimeService` with WebSocket + in-memory pub/sub
- [x] `service-i18n` — Implement `II18nService` with file-based locale loading
- [x] `service-realtime` — Implement `IRealtimeService` with WebSocket + in-memory pub/sub
- [ ] `service-search` — Implement `ISearchService` with in-memory search + Meilisearch adapter
- [ ] `service-notification` — Implement `INotificationService` with email adapter

Expand Down Expand Up @@ -543,20 +543,20 @@ Final polish and advanced features.
| 11 | Queue Service | `IQueueService` | ✅ | `@objectstack/service-queue` | Memory + BullMQ skeleton |
| 12 | Job Service | `IJobService` | ✅ | `@objectstack/service-job` | Interval + cron skeleton |
| 13 | Storage Service | `IStorageService` | ✅ | `@objectstack/service-storage` | Local FS + S3 skeleton |
| 14 | Realtime Service | `IRealtimeService` | | `@objectstack/service-realtime` (planned) | Spec only |
| 14 | Realtime Service | `IRealtimeService` | | `@objectstack/service-realtime` | In-memory pub/sub |
| 15 | Search Service | `ISearchService` | ❌ | `@objectstack/service-search` (planned) | Spec only |
| 16 | Notification Service | `INotificationService` | ❌ | `@objectstack/service-notification` (planned) | Spec only |
| 17 | AI Service | `IAIService` | ❌ | `@objectstack/service-ai` (planned) | Spec only |
| 18 | Automation Service | `IAutomationService` | ❌ | `@objectstack/service-automation` (planned) | Spec only |
| 19 | Workflow Service | `IWorkflowService` | ❌ | `@objectstack/service-workflow` (planned) | Spec only |
| 20 | GraphQL Service | `IGraphQLService` | ❌ | `@objectstack/service-graphql` (planned) | Spec only |
| 21 | i18n Service | `II18nService` | | `@objectstack/service-i18n` (planned) | Spec only |
| 21 | i18n Service | `II18nService` | | `@objectstack/service-i18n` | File-based locale loading |
| 22 | UI Service | `IUIService` | ⚠️ | — | **Deprecated** — merged into `IMetadataService` |
| 23 | Schema Driver | `ISchemaDriver` | ❌ | — | Spec only |
| 24 | Startup Orchestrator | `IStartupOrchestrator` | ❌ | — | Kernel handles basics |
| 25 | Plugin Validator | `IPluginValidator` | ❌ | — | Spec only |

**Summary:** 11 fully implemented · 2 partially implemented · 12 specification only
**Summary:** 13 fully implemented · 2 partially implemented · 10 specification only

---

Expand All @@ -583,6 +583,8 @@ Final polish and advanced features.
| `@objectstack/service-queue` | 3.0.6 | 8 | ✅ Stable | 7/10 |
| `@objectstack/service-job` | 3.0.6 | 11 | ✅ Stable | 7/10 |
| `@objectstack/service-storage` | 3.0.6 | 8 | ✅ Stable | 7/10 |
| `@objectstack/service-i18n` | 3.0.7 | 20 | ✅ Stable | 7/10 |
| `@objectstack/service-realtime` | 3.0.7 | 14 | ✅ Stable | 7/10 |
| `@objectstack/nextjs` | 3.0.2 | ✅ | ✅ Stable | 10/10 |
| `@objectstack/nestjs` | 3.0.2 | ✅ | ✅ Stable | 10/10 |
| `@objectstack/hono` | 3.0.2 | ✅ | ✅ Stable | 10/10 |
Expand Down
29 changes: 29 additions & 0 deletions packages/services/service-i18n/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@objectstack/service-i18n",
"version": "3.0.7",
"license": "Apache-2.0",
"description": "I18n Service for ObjectStack — implements II18nService with file-based locale loading",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsup --config ../../../tsup.config.ts",
"test": "vitest run"
},
"dependencies": {
"@objectstack/core": "workspace:*",
"@objectstack/spec": "workspace:*"
},
"devDependencies": {
"typescript": "^5.0.0",
"vitest": "^4.0.18",
"@types/node": "^25.2.3"
}
}
185 changes: 185 additions & 0 deletions packages/services/service-i18n/src/file-i18n-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { FileI18nAdapter } from './file-i18n-adapter';
import type { II18nService } from '@objectstack/spec/contracts';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';

describe('FileI18nAdapter', () => {
it('should implement II18nService contract', () => {
const i18n: II18nService = new FileI18nAdapter();
expect(typeof i18n.t).toBe('function');
expect(typeof i18n.getTranslations).toBe('function');
expect(typeof i18n.loadTranslations).toBe('function');
expect(typeof i18n.getLocales).toBe('function');
expect(typeof i18n.getDefaultLocale).toBe('function');
expect(typeof i18n.setDefaultLocale).toBe('function');
});

it('should default to "en" locale', () => {
const i18n = new FileI18nAdapter();
expect(i18n.getDefaultLocale()).toBe('en');
});

it('should use custom default locale', () => {
const i18n = new FileI18nAdapter({ defaultLocale: 'zh-CN' });
expect(i18n.getDefaultLocale()).toBe('zh-CN');
});

it('should set and get default locale', () => {
const i18n = new FileI18nAdapter();
i18n.setDefaultLocale('ja');
expect(i18n.getDefaultLocale()).toBe('ja');
});

it('should return empty translations for unknown locale', () => {
const i18n = new FileI18nAdapter();
expect(i18n.getTranslations('fr')).toEqual({});
});

it('should return empty locales when no translations loaded', () => {
const i18n = new FileI18nAdapter();
expect(i18n.getLocales()).toEqual([]);
});

it('should load and retrieve translations', () => {
const i18n = new FileI18nAdapter();
i18n.loadTranslations('en', { greeting: 'Hello' });
i18n.loadTranslations('zh-CN', { greeting: '你好' });

expect(i18n.getLocales()).toContain('en');
expect(i18n.getLocales()).toContain('zh-CN');
expect(i18n.getTranslations('en')).toEqual({ greeting: 'Hello' });
expect(i18n.getTranslations('zh-CN')).toEqual({ greeting: '你好' });
});

it('should merge translations when loading into existing locale', () => {
const i18n = new FileI18nAdapter();
i18n.loadTranslations('en', { greeting: 'Hello' });
i18n.loadTranslations('en', { farewell: 'Goodbye' });

expect(i18n.getTranslations('en')).toEqual({
greeting: 'Hello',
farewell: 'Goodbye',
});
});

it('should translate a simple key', () => {
const i18n = new FileI18nAdapter();
i18n.loadTranslations('en', { greeting: 'Hello' });

expect(i18n.t('greeting', 'en')).toBe('Hello');
});

it('should return key when translation is missing', () => {
const i18n = new FileI18nAdapter();
expect(i18n.t('missing.key', 'en')).toBe('missing.key');
});

it('should resolve nested dot-notation keys', () => {
const i18n = new FileI18nAdapter();
i18n.loadTranslations('en', {
objects: {
account: {
label: 'Account',
},
},
});

expect(i18n.t('objects.account.label', 'en')).toBe('Account');
});

it('should interpolate parameters', () => {
const i18n = new FileI18nAdapter();
i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });

expect(i18n.t('greeting', 'en', { name: 'World' })).toBe('Hello, World!');
});

it('should keep placeholder when parameter is missing', () => {
const i18n = new FileI18nAdapter();
i18n.loadTranslations('en', { greeting: 'Hello, {{name}}!' });

expect(i18n.t('greeting', 'en', {})).toBe('Hello, {{name}}!');
});

it('should fallback to fallback locale when key not found', () => {
const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
i18n.loadTranslations('en', { greeting: 'Hello' });

expect(i18n.t('greeting', 'zh-CN')).toBe('Hello');
});

it('should not fallback when key exists in requested locale', () => {
const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
i18n.loadTranslations('en', { greeting: 'Hello' });
i18n.loadTranslations('zh-CN', { greeting: '你好' });

expect(i18n.t('greeting', 'zh-CN')).toBe('你好');
});

it('should return key when neither locale nor fallback has translation', () => {
const i18n = new FileI18nAdapter({ fallbackLocale: 'en' });
i18n.loadTranslations('en', { greeting: 'Hello' });

expect(i18n.t('missing.key', 'zh-CN')).toBe('missing.key');
});

describe('file-based loading', () => {
let tmpDir: string;

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'i18n-test-'));
});

afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});

it('should load translations from JSON files in a directory', () => {
fs.writeFileSync(
path.join(tmpDir, 'en.json'),
JSON.stringify({ greeting: 'Hello', objects: { account: { label: 'Account' } } }),
);
fs.writeFileSync(
path.join(tmpDir, 'zh-CN.json'),
JSON.stringify({ greeting: '你好', objects: { account: { label: '客户' } } }),
);

const i18n = new FileI18nAdapter({ localesDir: tmpDir });

expect(i18n.getLocales()).toContain('en');
expect(i18n.getLocales()).toContain('zh-CN');
expect(i18n.t('greeting', 'en')).toBe('Hello');
expect(i18n.t('greeting', 'zh-CN')).toBe('你好');
expect(i18n.t('objects.account.label', 'en')).toBe('Account');
expect(i18n.t('objects.account.label', 'zh-CN')).toBe('客户');
});

it('should ignore non-JSON files in the directory', () => {
fs.writeFileSync(path.join(tmpDir, 'en.json'), JSON.stringify({ greeting: 'Hello' }));
fs.writeFileSync(path.join(tmpDir, 'notes.txt'), 'not a translation file');

const i18n = new FileI18nAdapter({ localesDir: tmpDir });

expect(i18n.getLocales()).toEqual(['en']);
});

it('should skip malformed JSON files gracefully', () => {
fs.writeFileSync(path.join(tmpDir, 'en.json'), JSON.stringify({ greeting: 'Hello' }));
fs.writeFileSync(path.join(tmpDir, 'bad.json'), '{invalid json');

const i18n = new FileI18nAdapter({ localesDir: tmpDir });

expect(i18n.getLocales()).toEqual(['en']);
expect(i18n.t('greeting', 'en')).toBe('Hello');
});

it('should handle non-existent directory gracefully', () => {
const i18n = new FileI18nAdapter({ localesDir: '/nonexistent/path' });
expect(i18n.getLocales()).toEqual([]);
});
});
});
Loading