@gravito/cosmos 1.0.0-alpha.2
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/README.md +66 -0
- package/dist/index.js +125 -0
- package/dist/src/index.js +125 -0
- package/package.json +35 -0
- package/src/I18nService.ts +139 -0
- package/src/index.ts +35 -0
- package/src/loader.ts +43 -0
- package/tests/manager.test.ts +93 -0
- package/tsconfig.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# @gravito/cosmos
|
|
2
|
+
|
|
3
|
+
> Lightweight Internationalization (i18n) Orbit for Gravito.
|
|
4
|
+
|
|
5
|
+
Provides simple JSON-based localization support for your Gravito application, with seamless integration into Hono context.
|
|
6
|
+
|
|
7
|
+
## 📦 Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bun add @gravito/cosmos
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## 🚀 Quick Start
|
|
14
|
+
|
|
15
|
+
1. **Register the Orbit**:
|
|
16
|
+
```typescript
|
|
17
|
+
import { PlanetCore } from 'gravito-core';
|
|
18
|
+
import { OrbitI18n } from '@gravito/cosmos';
|
|
19
|
+
|
|
20
|
+
const core = new PlanetCore();
|
|
21
|
+
|
|
22
|
+
core.boot({
|
|
23
|
+
orbits: [OrbitI18n],
|
|
24
|
+
config: {
|
|
25
|
+
i18n: {
|
|
26
|
+
defaultLocale: 'en',
|
|
27
|
+
fallbackLocale: 'en',
|
|
28
|
+
path: './lang' // Path to your locale JSON files
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
2. **Create Locale Files**:
|
|
35
|
+
Create `./lang/en.json` and `./lang/zh-TW.json`:
|
|
36
|
+
```json
|
|
37
|
+
// en.json
|
|
38
|
+
{
|
|
39
|
+
"welcome": "Welcome, {name}!"
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
3. **Use in Routes**:
|
|
44
|
+
```typescript
|
|
45
|
+
app.get('/', (c) => {
|
|
46
|
+
const t = c.get('t');
|
|
47
|
+
return c.text(t('welcome', { name: 'Carl' }));
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## ✨ Features
|
|
52
|
+
|
|
53
|
+
- **Context Injection**: Automatically injects `t()` helper into Hono context.
|
|
54
|
+
- **Parameter Replacement**: Supports `{key}` style parameter replacement.
|
|
55
|
+
- **Locale Detection**: Automatically detects locale from `Accept-Language` header or query parameter `?lang=`.
|
|
56
|
+
- **Fallback**: gracefully falls back to default locale if key is missing.
|
|
57
|
+
|
|
58
|
+
## 📚 API
|
|
59
|
+
|
|
60
|
+
### `t(key: string, params?: Record<string, any>): string`
|
|
61
|
+
|
|
62
|
+
Main translation helper function.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// src/I18nService.ts
|
|
2
|
+
class I18nManager {
|
|
3
|
+
config;
|
|
4
|
+
_locale;
|
|
5
|
+
translations = {};
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this._locale = config.defaultLocale;
|
|
9
|
+
if (config.translations) {
|
|
10
|
+
this.translations = config.translations;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
get locale() {
|
|
14
|
+
return this._locale;
|
|
15
|
+
}
|
|
16
|
+
set locale(value) {
|
|
17
|
+
this.setLocale(value);
|
|
18
|
+
}
|
|
19
|
+
setLocale(locale) {
|
|
20
|
+
if (this.config.supportedLocales.includes(locale)) {
|
|
21
|
+
this._locale = locale;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
getLocale() {
|
|
25
|
+
return this._locale;
|
|
26
|
+
}
|
|
27
|
+
addResource(locale, translations) {
|
|
28
|
+
this.translations[locale] = {
|
|
29
|
+
...this.translations[locale] || {},
|
|
30
|
+
...translations
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
t(key, replacements) {
|
|
34
|
+
const keys = key.split(".");
|
|
35
|
+
let value = this.translations[this._locale];
|
|
36
|
+
for (const k of keys) {
|
|
37
|
+
if (value && typeof value === "object" && k in value) {
|
|
38
|
+
value = value[k];
|
|
39
|
+
} else {
|
|
40
|
+
value = undefined;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (value === undefined && this._locale !== this.config.defaultLocale) {
|
|
45
|
+
let fallbackValue = this.translations[this.config.defaultLocale];
|
|
46
|
+
for (const k of keys) {
|
|
47
|
+
if (fallbackValue && typeof fallbackValue === "object" && k in fallbackValue) {
|
|
48
|
+
fallbackValue = fallbackValue[k];
|
|
49
|
+
} else {
|
|
50
|
+
fallbackValue = undefined;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
value = fallbackValue;
|
|
55
|
+
}
|
|
56
|
+
if (value === undefined || typeof value !== "string") {
|
|
57
|
+
return key;
|
|
58
|
+
}
|
|
59
|
+
if (replacements) {
|
|
60
|
+
for (const [search, replace] of Object.entries(replacements)) {
|
|
61
|
+
value = value.replace(new RegExp(`:${search}`, "g"), String(replace));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
has(key) {
|
|
67
|
+
return this.t(key) !== key;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
var localeMiddleware = (i18n) => {
|
|
71
|
+
return async (c, next) => {
|
|
72
|
+
const paramLocale = c.req.param("locale");
|
|
73
|
+
if (paramLocale) {
|
|
74
|
+
i18n.setLocale(paramLocale);
|
|
75
|
+
}
|
|
76
|
+
c.set("i18n", i18n);
|
|
77
|
+
await next();
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
// src/loader.ts
|
|
81
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
82
|
+
import { join, parse } from "node:path";
|
|
83
|
+
async function loadTranslations(directory) {
|
|
84
|
+
const translations = {};
|
|
85
|
+
try {
|
|
86
|
+
const files = await readdir(directory);
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
if (!file.endsWith(".json")) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const locale = parse(file).name;
|
|
92
|
+
const content = await readFile(join(directory, file), "utf-8");
|
|
93
|
+
try {
|
|
94
|
+
translations[locale] = JSON.parse(content);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`[Orbit-I18n] Failed to parse translation file: ${file}`, e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (_e) {
|
|
100
|
+
console.warn(`[Orbit-I18n] Could not load translations from ${directory}. Directory might not exist.`);
|
|
101
|
+
}
|
|
102
|
+
return translations;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/index.ts
|
|
106
|
+
class I18nOrbit {
|
|
107
|
+
config;
|
|
108
|
+
constructor(config) {
|
|
109
|
+
this.config = config;
|
|
110
|
+
}
|
|
111
|
+
install(core) {
|
|
112
|
+
const i18n = new I18nManager(this.config);
|
|
113
|
+
core.adapter.use("*", async (c, next) => {
|
|
114
|
+
c.set("i18n", i18n);
|
|
115
|
+
await next();
|
|
116
|
+
});
|
|
117
|
+
core.logger.info(`I18n Orbit initialized with locale: ${this.config.defaultLocale}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export {
|
|
121
|
+
localeMiddleware,
|
|
122
|
+
loadTranslations,
|
|
123
|
+
I18nOrbit,
|
|
124
|
+
I18nManager
|
|
125
|
+
};
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// src/I18nService.ts
|
|
2
|
+
class I18nManager {
|
|
3
|
+
config;
|
|
4
|
+
_locale;
|
|
5
|
+
translations = {};
|
|
6
|
+
constructor(config) {
|
|
7
|
+
this.config = config;
|
|
8
|
+
this._locale = config.defaultLocale;
|
|
9
|
+
if (config.translations) {
|
|
10
|
+
this.translations = config.translations;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
get locale() {
|
|
14
|
+
return this._locale;
|
|
15
|
+
}
|
|
16
|
+
set locale(value) {
|
|
17
|
+
this.setLocale(value);
|
|
18
|
+
}
|
|
19
|
+
setLocale(locale) {
|
|
20
|
+
if (this.config.supportedLocales.includes(locale)) {
|
|
21
|
+
this._locale = locale;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
getLocale() {
|
|
25
|
+
return this._locale;
|
|
26
|
+
}
|
|
27
|
+
addResource(locale, translations) {
|
|
28
|
+
this.translations[locale] = {
|
|
29
|
+
...this.translations[locale] || {},
|
|
30
|
+
...translations
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
t(key, replacements) {
|
|
34
|
+
const keys = key.split(".");
|
|
35
|
+
let value = this.translations[this._locale];
|
|
36
|
+
for (const k of keys) {
|
|
37
|
+
if (value && typeof value === "object" && k in value) {
|
|
38
|
+
value = value[k];
|
|
39
|
+
} else {
|
|
40
|
+
value = undefined;
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (value === undefined && this._locale !== this.config.defaultLocale) {
|
|
45
|
+
let fallbackValue = this.translations[this.config.defaultLocale];
|
|
46
|
+
for (const k of keys) {
|
|
47
|
+
if (fallbackValue && typeof fallbackValue === "object" && k in fallbackValue) {
|
|
48
|
+
fallbackValue = fallbackValue[k];
|
|
49
|
+
} else {
|
|
50
|
+
fallbackValue = undefined;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
value = fallbackValue;
|
|
55
|
+
}
|
|
56
|
+
if (value === undefined || typeof value !== "string") {
|
|
57
|
+
return key;
|
|
58
|
+
}
|
|
59
|
+
if (replacements) {
|
|
60
|
+
for (const [search, replace] of Object.entries(replacements)) {
|
|
61
|
+
value = value.replace(new RegExp(`:${search}`, "g"), String(replace));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
has(key) {
|
|
67
|
+
return this.t(key) !== key;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
var localeMiddleware = (i18n) => {
|
|
71
|
+
return async (c, next) => {
|
|
72
|
+
const paramLocale = c.req.param("locale");
|
|
73
|
+
if (paramLocale) {
|
|
74
|
+
i18n.setLocale(paramLocale);
|
|
75
|
+
}
|
|
76
|
+
c.set("i18n", i18n);
|
|
77
|
+
await next();
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
// src/loader.ts
|
|
81
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
82
|
+
import { join, parse } from "node:path";
|
|
83
|
+
async function loadTranslations(directory) {
|
|
84
|
+
const translations = {};
|
|
85
|
+
try {
|
|
86
|
+
const files = await readdir(directory);
|
|
87
|
+
for (const file of files) {
|
|
88
|
+
if (!file.endsWith(".json")) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const locale = parse(file).name;
|
|
92
|
+
const content = await readFile(join(directory, file), "utf-8");
|
|
93
|
+
try {
|
|
94
|
+
translations[locale] = JSON.parse(content);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(`[Orbit-I18n] Failed to parse translation file: ${file}`, e);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} catch (_e) {
|
|
100
|
+
console.warn(`[Orbit-I18n] Could not load translations from ${directory}. Directory might not exist.`);
|
|
101
|
+
}
|
|
102
|
+
return translations;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/index.ts
|
|
106
|
+
class I18nOrbit {
|
|
107
|
+
config;
|
|
108
|
+
constructor(config) {
|
|
109
|
+
this.config = config;
|
|
110
|
+
}
|
|
111
|
+
install(core) {
|
|
112
|
+
const i18n = new I18nManager(this.config);
|
|
113
|
+
core.app.use("*", async (c, next) => {
|
|
114
|
+
c.set("i18n", i18n);
|
|
115
|
+
await next();
|
|
116
|
+
});
|
|
117
|
+
core.logger.info(`I18n Orbit initialized with locale: ${this.config.defaultLocale}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
export {
|
|
121
|
+
localeMiddleware,
|
|
122
|
+
loadTranslations,
|
|
123
|
+
I18nOrbit,
|
|
124
|
+
I18nManager
|
|
125
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gravito/cosmos",
|
|
3
|
+
"version": "1.0.0-alpha.2",
|
|
4
|
+
"description": "Internationalization orbit for Gravito framework",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target node --external @gravito/core --external hono",
|
|
9
|
+
"test": "bun test"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"gravito",
|
|
13
|
+
"orbit",
|
|
14
|
+
"i18n",
|
|
15
|
+
"internationalization"
|
|
16
|
+
],
|
|
17
|
+
"author": "Carl Lee <carllee0520@gmail.com>",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"gravito-core": "1.0.0-beta.2",
|
|
21
|
+
"hono": "^4.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"bun-types": "latest"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/gravito-framework/gravito#readme",
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
33
|
+
"directory": "packages/cosmos"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from 'hono'
|
|
2
|
+
|
|
3
|
+
export interface I18nConfig {
|
|
4
|
+
defaultLocale: string
|
|
5
|
+
supportedLocales: string[]
|
|
6
|
+
// Path to translation files, or a Record of translations
|
|
7
|
+
// If undefined, it will look into `resources/lang` by default (conceptually, handled by loader)
|
|
8
|
+
translations?: Record<string, Record<string, string>>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface I18nService {
|
|
12
|
+
locale: string
|
|
13
|
+
setLocale(locale: string): void
|
|
14
|
+
getLocale(): string
|
|
15
|
+
t(key: string, replacements?: Record<string, string | number>): string
|
|
16
|
+
has(key: string): boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class I18nManager implements I18nService {
|
|
20
|
+
private _locale: string
|
|
21
|
+
private translations: Record<string, Record<string, string>> = {}
|
|
22
|
+
|
|
23
|
+
constructor(private config: I18nConfig) {
|
|
24
|
+
this._locale = config.defaultLocale
|
|
25
|
+
if (config.translations) {
|
|
26
|
+
this.translations = config.translations
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get locale(): string {
|
|
31
|
+
return this._locale
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
set locale(value: string) {
|
|
35
|
+
this.setLocale(value)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
setLocale(locale: string) {
|
|
39
|
+
if (this.config.supportedLocales.includes(locale)) {
|
|
40
|
+
this._locale = locale
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getLocale(): string {
|
|
45
|
+
return this._locale
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Add translations for a locale
|
|
50
|
+
*/
|
|
51
|
+
addResource(locale: string, translations: Record<string, string>) {
|
|
52
|
+
this.translations[locale] = {
|
|
53
|
+
...(this.translations[locale] || {}),
|
|
54
|
+
...translations,
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Translation helper
|
|
60
|
+
* t('messages.welcome', { name: 'Carl' })
|
|
61
|
+
* Supports nested keys via dot notation: t('auth.errors.invalid')
|
|
62
|
+
*/
|
|
63
|
+
t(key: string, replacements?: Record<string, string | number>): string {
|
|
64
|
+
const keys = key.split('.')
|
|
65
|
+
let value: any = this.translations[this._locale]
|
|
66
|
+
|
|
67
|
+
// Fallback to default locale if not found in current locale?
|
|
68
|
+
// Implementation: Try current locale, then fallback.
|
|
69
|
+
|
|
70
|
+
// 1. Try current locale
|
|
71
|
+
for (const k of keys) {
|
|
72
|
+
if (value && typeof value === 'object' && k in value) {
|
|
73
|
+
value = value[k]
|
|
74
|
+
} else {
|
|
75
|
+
value = undefined
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 2. If not found, try fallback (defaultLocale)
|
|
81
|
+
if (value === undefined && this._locale !== this.config.defaultLocale) {
|
|
82
|
+
let fallbackValue: any = this.translations[this.config.defaultLocale]
|
|
83
|
+
for (const k of keys) {
|
|
84
|
+
if (fallbackValue && typeof fallbackValue === 'object' && k in fallbackValue) {
|
|
85
|
+
fallbackValue = fallbackValue[k]
|
|
86
|
+
} else {
|
|
87
|
+
fallbackValue = undefined
|
|
88
|
+
break
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
value = fallbackValue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (value === undefined || typeof value !== 'string') {
|
|
95
|
+
return key // Return key if not found
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 3. Replacements
|
|
99
|
+
if (replacements) {
|
|
100
|
+
for (const [search, replace] of Object.entries(replacements)) {
|
|
101
|
+
value = value.replace(new RegExp(`:${search}`, 'g'), String(replace))
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
has(key: string): boolean {
|
|
109
|
+
// Simplistic check
|
|
110
|
+
return this.t(key) !== key
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Locale Middleware
|
|
116
|
+
*
|
|
117
|
+
* Detects locale from:
|
|
118
|
+
* 1. Route Parameter (e.g. /:locale/foo) - Recommended for SEO
|
|
119
|
+
* 2. Header (Accept-Language) - Recommended for APIs
|
|
120
|
+
*/
|
|
121
|
+
export const localeMiddleware = (i18n: I18nService): MiddlewareHandler => {
|
|
122
|
+
return async (c, next) => {
|
|
123
|
+
// 1. Check for route param 'locale'
|
|
124
|
+
const paramLocale = c.req.param('locale')
|
|
125
|
+
if (paramLocale) {
|
|
126
|
+
i18n.setLocale(paramLocale)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. Inject into context (using 'any' for now, or augment PlanetCore variables later)
|
|
130
|
+
c.set('i18n', i18n)
|
|
131
|
+
|
|
132
|
+
// 3. Share with View layer (if Orbit View is present)
|
|
133
|
+
// Assuming 'view' service might look at context variables or we explicitly pass it
|
|
134
|
+
// For Inertia, we might want to share it as a prop.
|
|
135
|
+
// This part depends on how the user sets up their detailed pipeline, but setting it in 'c' is the start.
|
|
136
|
+
|
|
137
|
+
await next()
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { GravitoOrbit, PlanetCore } from 'gravito-core'
|
|
2
|
+
import { type I18nConfig, I18nManager, type I18nService } from './I18nService'
|
|
3
|
+
|
|
4
|
+
declare module 'gravito-core' {
|
|
5
|
+
interface Variables {
|
|
6
|
+
i18n: I18nService
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class I18nOrbit implements GravitoOrbit {
|
|
11
|
+
constructor(private config: I18nConfig) {}
|
|
12
|
+
|
|
13
|
+
install(core: PlanetCore): void {
|
|
14
|
+
const i18n = new I18nManager(this.config)
|
|
15
|
+
|
|
16
|
+
// Register globally if needed, or just prepare it to be used.
|
|
17
|
+
// There isn't a global "services" container in PlanetCore yet other than 'Variables' injected via Config/Context.
|
|
18
|
+
// Ideally we attach it to the core instance or inject it into every request.
|
|
19
|
+
|
|
20
|
+
// Inject into every request
|
|
21
|
+
core.adapter.use('*', async (c, next) => {
|
|
22
|
+
c.set('i18n', i18n)
|
|
23
|
+
await next()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
// Register a helper if using Orbit View (View Rendering)
|
|
27
|
+
// We can check if 'view' exists or we can register a global view helper if that API exists.
|
|
28
|
+
// For now, context injection is sufficient.
|
|
29
|
+
|
|
30
|
+
core.logger.info(`I18n Orbit initialized with locale: ${this.config.defaultLocale}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export * from './I18nService'
|
|
35
|
+
export * from './loader'
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { join, parse } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Load translations from a directory
|
|
6
|
+
* Structure:
|
|
7
|
+
* /lang
|
|
8
|
+
* /en.json -> { "welcome": "Hello" }
|
|
9
|
+
* /zh.json -> { "welcome": "Hello" }
|
|
10
|
+
* /en/auth.json -> { "failed": "Login failed" } (Optional deep structure, maybe later)
|
|
11
|
+
*
|
|
12
|
+
* For now, we support flat JSON files per locale: en.json, zh.json
|
|
13
|
+
*/
|
|
14
|
+
export async function loadTranslations(
|
|
15
|
+
directory: string
|
|
16
|
+
): Promise<Record<string, Record<string, string>>> {
|
|
17
|
+
const translations: Record<string, Record<string, string>> = {}
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const files = await readdir(directory)
|
|
21
|
+
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
if (!file.endsWith('.json')) {
|
|
24
|
+
continue
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const locale = parse(file).name // 'en' from 'en.json'
|
|
28
|
+
const content = await readFile(join(directory, file), 'utf-8')
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
translations[locale] = JSON.parse(content)
|
|
32
|
+
} catch (e) {
|
|
33
|
+
console.error(`[Orbit-I18n] Failed to parse translation file: ${file}`, e)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
} catch (_e) {
|
|
37
|
+
console.warn(
|
|
38
|
+
`[Orbit-I18n] Could not load translations from ${directory}. Directory might not exist.`
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return translations
|
|
43
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { I18nManager } from '../src/index'
|
|
3
|
+
|
|
4
|
+
describe('Orbit I18n Manager', () => {
|
|
5
|
+
const _config = {
|
|
6
|
+
defaultLocale: 'en',
|
|
7
|
+
supportedLocales: ['en', 'zh', 'jp'],
|
|
8
|
+
translations: {
|
|
9
|
+
en: {
|
|
10
|
+
welcome: 'Welcome',
|
|
11
|
+
'auth.failed': 'Start Login Failed', // Nested key test mock
|
|
12
|
+
nested: 'Start Nested',
|
|
13
|
+
},
|
|
14
|
+
zh: {
|
|
15
|
+
welcome: 'æ¡è¿Ž',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Manually constructing nested object for test since our mock config above is flat strings
|
|
21
|
+
// But our I18nManager logic supports object traversal. Let's fix the mock data to be object structure if we want to test traversal properly,
|
|
22
|
+
// OR we fix our Loader to flatten everything?
|
|
23
|
+
// Gravito's Manager currently supports object traversal: value[k].
|
|
24
|
+
// Let's use proper object structure for 'auth' in 'en'.
|
|
25
|
+
// Redefining config for clarity.
|
|
26
|
+
const nestedConfig = {
|
|
27
|
+
defaultLocale: 'en',
|
|
28
|
+
supportedLocales: ['en', 'zh'],
|
|
29
|
+
translations: {
|
|
30
|
+
en: {
|
|
31
|
+
title: 'Hello',
|
|
32
|
+
auth: {
|
|
33
|
+
// @ts-expect-error
|
|
34
|
+
failed: 'Failed Login',
|
|
35
|
+
} as any,
|
|
36
|
+
},
|
|
37
|
+
zh: {
|
|
38
|
+
title: 'ä½ å¥½',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
test('it selects default locale initially', () => {
|
|
44
|
+
const i18n = new I18nManager(nestedConfig as any)
|
|
45
|
+
expect(i18n.locale).toBe('en')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('it can change locale', () => {
|
|
49
|
+
const i18n = new I18nManager(nestedConfig as any)
|
|
50
|
+
i18n.locale = 'zh'
|
|
51
|
+
expect(i18n.locale).toBe('zh')
|
|
52
|
+
|
|
53
|
+
// Unsupported locale should be ignored (based on our implementation logic?)
|
|
54
|
+
// Let's check implementation: if (supported.includes) ...
|
|
55
|
+
i18n.locale = 'fr'
|
|
56
|
+
expect(i18n.locale).toBe('zh')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('it translates simple keys', () => {
|
|
60
|
+
const i18n = new I18nManager(nestedConfig as any)
|
|
61
|
+
expect(i18n.t('title')).toBe('Hello')
|
|
62
|
+
|
|
63
|
+
i18n.locale = 'zh'
|
|
64
|
+
expect(i18n.t('title')).toBe('ä½ å¥½')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('it handles fallback to default locale', () => {
|
|
68
|
+
const i18n = new I18nManager(nestedConfig as any)
|
|
69
|
+
i18n.locale = 'zh'
|
|
70
|
+
|
|
71
|
+
// 'auth.failed' is only in EN
|
|
72
|
+
expect(i18n.t('auth.failed')).toBe('Failed Login')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('it returns key if not found', () => {
|
|
76
|
+
const i18n = new I18nManager(nestedConfig as any)
|
|
77
|
+
expect(i18n.t('missing.key')).toBe('missing.key')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('it supports replacements', () => {
|
|
81
|
+
const i18n = new I18nManager({
|
|
82
|
+
defaultLocale: 'en',
|
|
83
|
+
supportedLocales: ['en'],
|
|
84
|
+
translations: {
|
|
85
|
+
en: {
|
|
86
|
+
greet: 'Hello :name',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(i18n.t('greet', { name: 'Carl' })).toBe('Hello Carl')
|
|
92
|
+
})
|
|
93
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"baseUrl": ".",
|
|
6
|
+
"paths": {
|
|
7
|
+
"gravito-core": ["../../packages/core/src/index.ts"],
|
|
8
|
+
"@gravito/*": ["../../packages/*/src/index.ts"]
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"include": [
|
|
12
|
+
"src/**/*"
|
|
13
|
+
],
|
|
14
|
+
"exclude": [
|
|
15
|
+
"node_modules",
|
|
16
|
+
"dist",
|
|
17
|
+
"**/*.test.ts"
|
|
18
|
+
]
|
|
19
|
+
}
|