@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 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
+ }