@gravito/cosmos 3.2.1 โ†’ 3.2.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 CHANGED
@@ -6,13 +6,28 @@
6
6
 
7
7
  ## โœจ Features
8
8
 
9
- - **๐Ÿš€ Performance-First**: Highly optimized translation resolution with internal caching.
10
- - **๐Ÿ›ก๏ธ Type-Safe**: Support for type-safe translation keys using TypeScript generics.
11
- - **๐Ÿ”„ Request-Scoped**: Clones i18n instances per request to maintain locale state without resource duplication.
12
- - **๐Ÿ“‚ Lazy Loading**: Load translation files from the filesystem only when needed.
13
- - **๐Ÿ”— Flexible Fallbacks**: Define custom fallback chains and missing key strategies.
14
- - **๐ŸŒ Pluralization**: Integrated support for `Intl.PluralRules` based pluralization.
15
- - **๐Ÿ“ก Auto-Detection**: Detects locale from Route Params, Query Strings, or `Accept-Language` headers.
9
+ - ๐Ÿช **Galaxy-Ready Globalization**: Native integration with PlanetCore for universal translation support across all Satellites.
10
+ - ๐Ÿš€ **Performance-First**: Highly optimized translation resolution with internal caching and lazy loading.
11
+ - ๐Ÿ›ก๏ธ **Type-Safe Keys**: End-to-end TypeScript support for translation keys using generics.
12
+ - ๐Ÿ”„ **Request-Scoped State**: Clones i18n instances per request to maintain locale consistency without overhead.
13
+ - ๐ŸŒ **Intl.PluralRules Support**: Native pluralization following international standards.
14
+ - ๐Ÿ“ก **Smart Auto-Detection**: Detects locale from Route Params, Query Strings, or `Accept-Language` headers.
15
+
16
+ ## ๐ŸŒŒ Role in Galaxy Architecture
17
+
18
+ In the **Gravito Galaxy Architecture**, Cosmos acts as the **Universal Translator (Linguistic Base)**.
19
+
20
+ - **Cross-Satellite Language**: Ensures that "Success" or "Error" messages are consistent and translated correctly, regardless of which Satellite generates the response.
21
+ - **Linguistic Context**: Propagates the user's preferred language from the `Photon` Sensing Layer deep into the business logic of every Satellite.
22
+ - **Dynamic Localization**: Works with `Atlas` or `Nebula` to load domain-specific translation files on demand, keeping the core Galaxy lean.
23
+
24
+ ```mermaid
25
+ graph LR
26
+ User([User]) -- "Accept-Language: zh-TW" --> Photon[Photon Engine]
27
+ Photon --> Cosmos{Cosmos Orbit}
28
+ Cosmos -->|Translate| Sat[Satellite: Shop]
29
+ Sat -->|Response| User
30
+ ```
16
31
 
17
32
  ## ๐Ÿ“ฆ Installation
18
33
 
@@ -88,6 +103,14 @@ app.get('/items', (c) => {
88
103
  });
89
104
  ```
90
105
 
106
+ ## ๐Ÿ“š Documentation
107
+
108
+ Detailed guides and references for the Galaxy Architecture:
109
+
110
+ - [๐Ÿ—๏ธ **Architecture Overview**](./README.md) โ€” Linguistic base and request-scoped state.
111
+ - [๐ŸŒ **Localization Strategy**](./doc/LOCALIZATION_STRATEGY.md) โ€” **NEW**: Managing translations across isolated Satellites.
112
+ - [๐Ÿ”— **Integration with Orbits**](#-quick-start) โ€” Using i18n in Signal and Flare.
113
+
91
114
  ## ๐Ÿ“š Core Concepts
92
115
 
93
116
  ### I18nManager
package/build.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import { spawn } from 'bun'
2
2
 
3
- console.log('Building @gravito/cosmos...')
3
+ const isDtsOnly = process.argv.includes('--dts-only')
4
+
5
+ console.log(isDtsOnly ? 'Building @gravito/cosmos DTS...' : 'Building @gravito/cosmos...')
4
6
 
5
7
  // Clean dist
6
8
  await Bun.$`rm -rf dist`
@@ -8,12 +10,14 @@ await Bun.$`rm -rf dist`
8
10
  // Use tsup for multi-format build
9
11
  const tsup = spawn(
10
12
  [
11
- 'npx',
13
+ 'bunx',
12
14
  'tsup',
13
15
  'src/index.ts',
14
16
  '--format',
15
- 'esm,cjs',
16
- '--dts',
17
+ isDtsOnly ? 'esm' : 'esm,cjs',
18
+ ...(isDtsOnly ? ['--dts', '--dts-only'] : ['--dts']),
19
+ '--tsconfig',
20
+ 'tsconfig.build.json',
17
21
  '--external',
18
22
  '@gravito/core,@gravito/photon',
19
23
  '--outDir',
@@ -27,9 +31,11 @@ const tsup = spawn(
27
31
 
28
32
  const tsupCode = await tsup.exited
29
33
  if (tsupCode !== 0) {
30
- console.error('โŒ tsup build failed')
34
+ console.error('\u274c tsup build failed')
31
35
  process.exit(1)
32
36
  }
33
37
 
34
- console.log('โœ… Build complete!')
38
+ // Type declaration generation is now handled by tsup --dts
39
+
40
+ console.log('\u2705 Build complete!')
35
41
  process.exit(0)
@@ -0,0 +1,89 @@
1
+ # Localization Strategy Guide
2
+
3
+ In a distributed **Galaxy Architecture**, maintaining consistent translations across isolated Satellites is a challenge. `@gravito/cosmos` provides the infrastructure to manage globalization at scale.
4
+
5
+ ## 1. Request-Scoped Locales
6
+
7
+ Cosmos automatically detects and sets the current locale for every incoming request. This state is maintained throughout the request lifecycle.
8
+
9
+ ```typescript
10
+ // The 'i18n' instance in context is pre-configured for the current user
11
+ app.get('/welcome', (c) => {
12
+ const t = c.get('i18n').t;
13
+ return c.text(t('messages.welcome'));
14
+ });
15
+ ```
16
+
17
+ ## 2. Distributed Translation Files
18
+
19
+ Instead of one giant translation file, each **Satellite** should manage its own linguistic assets.
20
+
21
+ ```
22
+ satellites/catalog/
23
+ โ””โ”€โ”€ lang/
24
+ โ”œโ”€โ”€ en.json
25
+ โ””โ”€โ”€ zh-TW.json
26
+ ```
27
+
28
+ Register satellite-specific translations during the `BOOT` phase:
29
+
30
+ ```typescript
31
+ // CatalogSatellite.ts
32
+ async boot(core: PlanetCore) {
33
+ const cosmos = core.container.resolve('cosmos');
34
+ cosmos.addTranslations('catalog', path.join(__dirname, '../lang'));
35
+ }
36
+ ```
37
+
38
+ ## 3. Translation Namespacing
39
+
40
+ To avoid collisions between Satellites, use namespaces when resolving keys.
41
+
42
+ ```typescript
43
+ // Explicit namespace
44
+ t('catalog::products.not_found');
45
+
46
+ // Fallback to global if namespace not found
47
+ t('common::errors.validation_failed');
48
+ ```
49
+
50
+ ## 4. Parameter Replacement & Pluralization
51
+
52
+ Cosmos follows the standard `:param` syntax for replacements and leverages `Intl.PluralRules` for complex pluralization logic.
53
+
54
+ ```json
55
+ {
56
+ "items_count": "Found :count {zero: items|one: item|other: items}"
57
+ }
58
+ ```
59
+
60
+ ```typescript
61
+ t('items_count', { count: 5 }); // Found 5 items
62
+ ```
63
+
64
+ ## 5. Dynamic Fallback Chains
65
+
66
+ Configure fallback languages based on regional requirements.
67
+
68
+ ```typescript
69
+ new OrbitCosmos({
70
+ fallback: {
71
+ fallbackChain: {
72
+ 'zh-HK': ['zh-TW', 'en'],
73
+ 'fr-CA': ['fr', 'en']
74
+ }
75
+ }
76
+ })
77
+ ```
78
+
79
+ ## 6. Integration with Signal (Mail)
80
+
81
+ When sending emails via `@gravito/signal`, the `Mailable` class automatically uses the current locale context provided by Cosmos.
82
+
83
+ ```typescript
84
+ export class WelcomeEmail extends Mailable {
85
+ build() {
86
+ return this.subject(this.t('emails.welcome_subject'));
87
+ }
88
+ }
89
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gravito/cosmos",
3
- "version": "3.2.1",
3
+ "version": "3.2.2",
4
4
  "description": "Internationalization orbit for Gravito framework",
5
5
  "main": "./dist/index.cjs",
6
6
  "module": "./dist/index.js",
@@ -26,9 +26,10 @@
26
26
  },
27
27
  "scripts": {
28
28
  "build": "bun run build.ts",
29
+ "build:dts": "bun run build.ts --dts-only",
29
30
  "test": "bun test --timeout=10000",
30
- "test:coverage": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
31
- "test:ci": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run --bun scripts/check-coverage.ts",
31
+ "test:coverage": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && COVERAGE_THRESHOLD=65 bun run --bun scripts/check-coverage.ts",
32
+ "test:ci": "bun test --timeout=10000 --coverage --coverage-reporter=lcov --coverage-dir coverage && COVERAGE_THRESHOLD=65 bun run --bun scripts/check-coverage.ts",
32
33
  "typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck",
33
34
  "test:unit": "bun test tests/ --timeout=10000",
34
35
  "test:integration": "test $(find tests -name '*.integration.test.ts' 2>/dev/null | wc -l) -gt 0 && find tests -name '*.integration.test.ts' -print0 | xargs -0 bun test --timeout=10000 || echo 'No integration tests found'"
@@ -42,10 +43,11 @@
42
43
  "author": "Carl Lee <carllee0520@gmail.com>",
43
44
  "license": "MIT",
44
45
  "peerDependencies": {
45
- "@gravito/core": "^1.6.1",
46
- "@gravito/photon": "^1.0.1"
46
+ "@gravito/core": "^2.0.0",
47
+ "@gravito/photon": "^1.1.0"
47
48
  },
48
49
  "devDependencies": {
50
+ "@gravito/core": "workspace:*",
49
51
  "bun-types": "latest",
50
52
  "mitata": "^1.0.34",
51
53
  "tsup": "^8.5.1",
package/src/HMRWatcher.ts CHANGED
@@ -141,7 +141,7 @@ export class HMRWatcher {
141
141
  enabled: false, // ๅผทๅˆถๅœ็”จ
142
142
  watchDirs: [],
143
143
  debounce: 300,
144
- extensions: ['.json'],
144
+ extensions: ['.json', '.json5'],
145
145
  verbose: false,
146
146
  }
147
147
  return
@@ -151,7 +151,7 @@ export class HMRWatcher {
151
151
  enabled: config.enabled,
152
152
  watchDirs: config.watchDirs,
153
153
  debounce: config.debounce ?? 300,
154
- extensions: config.extensions ?? ['.json'],
154
+ extensions: config.extensions ?? ['.json', '.json5'],
155
155
  verbose: config.verbose ?? true,
156
156
  }
157
157
  }
package/src/index.ts CHANGED
@@ -84,6 +84,7 @@ export * from './loaders/ChainedLoader'
84
84
  export * from './loaders/CloudflareKVLoader'
85
85
  export * from './loaders/EdgeKVLoader'
86
86
  export * from './loaders/FileSystemLoader'
87
+ export * from './loaders/Json5Loader'
87
88
  export * from './loaders/MemoryLoader'
88
89
  export * from './loaders/RemoteLoader'
89
90
  // Loaders
package/src/loader.ts CHANGED
@@ -22,13 +22,55 @@
22
22
  import { readdir, readFile } from 'node:fs/promises'
23
23
  import { join, parse } from 'node:path'
24
24
 
25
+ /**
26
+ * ๅตๆธฌ Bun ้‹่กŒๆ™‚ๆ˜ฏๅฆๅŽŸ็”Ÿๆ”ฏๆด JSON5 ่งฃๆž
27
+ *
28
+ * @returns ๆ˜ฏๅฆๅ…ทๅ‚™ Bun ๅŽŸ็”Ÿ JSON5 ่ƒฝๅŠ›
29
+ */
30
+ function hasBunJson5(): boolean {
31
+ return (
32
+ typeof globalThis.Bun !== 'undefined' &&
33
+ typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
34
+ )
35
+ }
36
+
37
+ /**
38
+ * ่งฃๆž JSON5 ๆ ผๅผๅญ—ไธฒ๏ผˆ้™็ดšๅˆฐ json5 npm ๅฅ—ไปถ๏ผ‰
39
+ *
40
+ * @param content - ่ฆ่งฃๆž็š„ JSON5 ๅญ—ไธฒ
41
+ * @returns ่งฃๆž็ตๆžœ
42
+ */
43
+ async function parseJson5(content: string): Promise<unknown> {
44
+ if (hasBunJson5()) {
45
+ const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
46
+ parse: (text: string) => unknown
47
+ }
48
+ return bunJson5.parse(content)
49
+ }
50
+
51
+ // ไฝฟ็”จ่ฎŠๆ•ธไฝœ็‚บ่ทฏๅพ‘ไปฅ้ฟๅ…้œๆ…‹ๅž‹ๅˆฅ่งฃๆž๏ผˆjson5 ็‚บๅฏ้ธ peer dependency๏ผ‰
52
+ try {
53
+ const pkgName = 'json5'
54
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
55
+ const json5Module = await (import(pkgName) as Promise<any>)
56
+ const json5 = json5Module.default ?? json5Module
57
+ return (json5 as { parse: (text: string) => unknown }).parse(content)
58
+ } catch {
59
+ throw new Error(
60
+ '[Cosmos] JSON5 parsing requires either Bun v1.2+ or the `json5` npm package. ' +
61
+ 'Please install it: bun add json5'
62
+ )
63
+ }
64
+ }
65
+
25
66
  /**
26
67
  * ๅพž็›ฎ้Œ„่ผ‰ๅ…ฅๆ‰€ๆœ‰็ฟป่ญฏๆช”ๆกˆ
27
68
  *
28
- * ๆŽƒๆๆŒ‡ๅฎš็›ฎ้Œ„ไธญ็š„ๆ‰€ๆœ‰ JSON ๆช”ๆกˆไธฆ่ผ‰ๅ…ฅ็‚บ็ฟป่ญฏ่ณ‡ๆบ
69
+ * ๆŽƒๆๆŒ‡ๅฎš็›ฎ้Œ„ไธญ็š„ๆ‰€ๆœ‰ JSON ่ˆ‡ JSON5 ๆช”ๆกˆไธฆ่ผ‰ๅ…ฅ็‚บ็ฟป่ญฏ่ณ‡ๆบ
70
+ * ่‹ฅๅŒไธ€่ชž่จ€ๅŒๆ™‚ๅญ˜ๅœจ `.json` ่ˆ‡ `.json5`๏ผŒไปฅ `.json5` ๅ„ชๅ…ˆ
29
71
  * ๆช”ๆกˆๅ็จฑ(ไธๅซๅ‰ฏๆช”ๅ)ๅฐ‡ไฝœ็‚บ่ชž่จ€ไปฃ็ขผ
30
72
  *
31
- * @deprecated ่‡ช v3.1.0 ่ตท,ๅปบ่ญฐไฝฟ็”จ FileSystemLoader
73
+ * @deprecated ่‡ช v3.1.0 ่ตท,ๅปบ่ญฐไฝฟ็”จ FileSystemLoader ๆˆ– Json5Loader
32
74
  * @param directory - ็ฟป่ญฏ็›ฎ้Œ„็š„็ต•ๅฐ่ทฏๅพ‘
33
75
  * @returns ่ชž่จ€ไปฃ็ขผๅˆฐ็ฟป่ญฏ่ณ‡ๆบ็š„ๅฐๆ‡‰่กจ
34
76
  * @public
@@ -37,7 +79,7 @@ import { join, parse } from 'node:path'
37
79
  * ```
38
80
  * /lang
39
81
  * /en.json -> { "welcome": "Hello" }
40
- * /zh-TW.json -> { "welcome": "ไฝ ๅฅฝ" }
82
+ * /zh-TW.json5 -> { "welcome": "ไฝ ๅฅฝ" } // ๆ”ฏๆด JSON5
41
83
  * ```
42
84
  */
43
85
  export async function loadTranslations(
@@ -48,13 +90,29 @@ export async function loadTranslations(
48
90
  try {
49
91
  const files = await readdir(directory)
50
92
 
93
+ // ๆ”ถ้›†ๆ‰€ๆœ‰ๆ”ฏๆดๆ ผๅผ็š„็ฟป่ญฏๆช”ๆกˆ๏ผŒไปฅ locale ็‚บ้ต๏ผŒ้ฟๅ…้‡่ค‡่™•็†
94
+ const localeFiles = new Map<string, string>()
95
+
51
96
  for (const file of files) {
52
- if (!file.endsWith('.json')) {
97
+ if (!file.endsWith('.json') && !file.endsWith('.json5')) {
53
98
  continue
54
99
  }
55
100
 
56
- const locale = parse(file).name // 'en' from 'en.json'
57
- const translationsForLocale = await loadLocale(directory, locale)
101
+ const locale = parse(file).name
102
+ const existing = localeFiles.get(locale)
103
+
104
+ // json5 ๅ„ชๅ…ˆ๏ผš่‹ฅๅŒไธ€ locale ๅทฒๆœ‰ .json๏ผŒjson5 ๅฏ่ฆ†่“‹๏ผ›ๅไน‹ไธ่ฆ†่“‹
105
+ if (!existing || file.endsWith('.json5')) {
106
+ localeFiles.set(locale, file)
107
+ }
108
+ }
109
+
110
+ for (const [locale, file] of localeFiles) {
111
+ const translationsForLocale = await loadLocale(
112
+ directory,
113
+ locale,
114
+ parse(file).ext as '.json' | '.json5'
115
+ )
58
116
  if (translationsForLocale) {
59
117
  translations[locale] = translationsForLocale
60
118
  }
@@ -71,21 +129,26 @@ export async function loadTranslations(
71
129
  /**
72
130
  * ่ผ‰ๅ…ฅๆŒ‡ๅฎš่ชž่จ€็š„็ฟป่ญฏ่ณ‡ๆบ
73
131
  *
74
- * ๆœŸๆœ›ๅœจๆŒ‡ๅฎš็›ฎ้Œ„ไธญๆ‰พๅˆฐ `{locale}.json` ๆ ผๅผ็š„ๆช”ๆกˆ
132
+ * ๆœŸๆœ›ๅœจๆŒ‡ๅฎš็›ฎ้Œ„ไธญๆ‰พๅˆฐ `{locale}.json` ๆˆ– `{locale}.json5` ๆ ผๅผ็š„ๆช”ๆกˆ
75
133
  *
76
- * @deprecated ่‡ช v3.1.0 ่ตท,ๅปบ่ญฐไฝฟ็”จ FileSystemLoader
134
+ * @deprecated ่‡ช v3.1.0 ่ตท,ๅปบ่ญฐไฝฟ็”จ FileSystemLoader ๆˆ– Json5Loader
77
135
  * @param directory - ๅŒ…ๅซ็ฟป่ญฏๆช”ๆกˆ็š„็›ฎ้Œ„
78
136
  * @param locale - ่ฆ่ผ‰ๅ…ฅ็š„่ชž่จ€ไปฃ็ขผ
137
+ * @param extension - ๆช”ๆกˆๅ‰ฏๆช”ๅ๏ผŒ้ ่จญ `.json`๏ผŒๅฏๅ‚ณๅ…ฅ `.json5`
79
138
  * @returns ็ฟป่ญฏ่ณ‡ๆบ,่ผ‰ๅ…ฅๅคฑๆ•—ๅ‰‡่ฟ”ๅ›ž null
80
139
  * @public
81
140
  */
82
141
  export async function loadLocale(
83
142
  directory: string,
84
- locale: string
143
+ locale: string,
144
+ extension: '.json' | '.json5' = '.json'
85
145
  ): Promise<Record<string, string> | null> {
86
- const filePath = join(directory, `${locale}.json`)
146
+ const filePath = join(directory, `${locale}${extension}`)
87
147
  try {
88
148
  const content = await readFile(filePath, 'utf-8')
149
+ if (extension === '.json5') {
150
+ return (await parseJson5(content)) as Record<string, string>
151
+ }
89
152
  return JSON.parse(content)
90
153
  } catch {
91
154
  return null
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @file packages/cosmos/src/loaders/FileSystemLoader.ts
3
3
  * @module @gravito/cosmos/loaders
4
- * @description ๅพžๆช”ๆกˆ็ณป็ตฑ่ผ‰ๅ…ฅ็ฟป่ญฏ่ณ‡ๆบ็š„ๅฏฆ็พ
4
+ * @description ๅพžๆช”ๆกˆ็ณป็ตฑ่ผ‰ๅ…ฅ็ฟป่ญฏ่ณ‡ๆบ็š„ๅฏฆ็พ๏ผŒๆ”ฏๆด JSON ่ˆ‡ JSON5 ๆ ผๅผ
5
5
  *
6
6
  * โš ๏ธ **Node.js Only**
7
7
  *
@@ -22,6 +22,20 @@ import type { TranslationMap } from '../I18nService'
22
22
  import { detectRuntime } from '../runtime/detector'
23
23
  import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
24
24
 
25
+ /**
26
+ * ๅตๆธฌ Bun ้‹่กŒๆ™‚ๆ˜ฏๅฆๅŽŸ็”Ÿๆ”ฏๆด JSON5 ่งฃๆž
27
+ *
28
+ * Bun v1.2+ ๆไพ› `Bun.JSON5` ๅ…จๅŸŸ็‰ฉไปถ
29
+ *
30
+ * @returns ๆ˜ฏๅฆๅ…ทๅ‚™ Bun ๅŽŸ็”Ÿ JSON5 ่ƒฝๅŠ›
31
+ */
32
+ function hasBunJson5(): boolean {
33
+ return (
34
+ typeof globalThis.Bun !== 'undefined' &&
35
+ typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
36
+ )
37
+ }
38
+
25
39
  /**
26
40
  * ๆช”ๆกˆ็ณป็ตฑ่ผ‰ๅ…ฅๅ™จ้…็ฝฎ
27
41
  *
@@ -39,6 +53,8 @@ export interface FileSystemLoaderConfig extends LoaderConfig {
39
53
  /**
40
54
  * ๆช”ๆกˆๅ‰ฏๆช”ๅ
41
55
  *
56
+ * ๆ”ฏๆด `.json` ่ˆ‡ `.json5` ๅ…ฉ็จฎๆ ผๅผ
57
+ *
42
58
  * @default '.json'
43
59
  */
44
60
  extension?: string
@@ -94,6 +110,8 @@ export class FileSystemLoader implements TranslationLoaderChain {
94
110
  /**
95
111
  * ่ผ‰ๅ…ฅๆŒ‡ๅฎš่ชž่จ€็š„็ฟป่ญฏ่ณ‡ๆบ
96
112
  *
113
+ * ๆ นๆ“š extension ่‡ชๅ‹•ๅˆคๆ–ทไฝฟ็”จ JSON ๆˆ– JSON5 ่งฃๆžๅ™จ
114
+ *
97
115
  * @param locale - ่ชž่จ€ไปฃ็ขผ
98
116
  * @returns ็ฟป่ญฏ่ณ‡ๆบ,่ผ‰ๅ…ฅๅคฑๆ•—ๅ‰‡่ฟ”ๅ›ž null
99
117
  */
@@ -101,7 +119,10 @@ export class FileSystemLoader implements TranslationLoaderChain {
101
119
  try {
102
120
  const filePath = join(this.baseDir, `${locale}${this.extension}`)
103
121
  const content = await readFile(filePath, 'utf-8')
104
- const translations = JSON.parse(content) as TranslationMap
122
+ const translations =
123
+ this.extension === '.json5'
124
+ ? await this.parseJson5(content)
125
+ : (JSON.parse(content) as TranslationMap)
105
126
  return translations
106
127
  } catch (_error) {
107
128
  // ๅฆ‚ๆžœๆœ‰ๅ‚™็”จ่ผ‰ๅ…ฅๅ™จ,ๅ˜—่ฉฆไฝฟ็”จๅ‚™็”จ่ผ‰ๅ…ฅๅ™จ
@@ -112,6 +133,41 @@ export class FileSystemLoader implements TranslationLoaderChain {
112
133
  }
113
134
  }
114
135
 
136
+ /**
137
+ * ่งฃๆž JSON5 ๅ…งๅฎน
138
+ *
139
+ * ๅ„ชๅ…ˆไฝฟ็”จ Bun ๅŽŸ็”Ÿ JSON5 ่งฃๆžๅ™จ๏ผˆBun v1.2+๏ผ‰๏ผŒ
140
+ * ่‹ฅไธๅฏ็”จๅ‰‡ๅ˜—่ฉฆๅ‹•ๆ…‹่ผ‰ๅ…ฅ `json5` npm ๅฅ—ไปถไฝœ็‚บ้™็ดšๆ–นๆกˆ
141
+ *
142
+ * @param content - ่ฆ่งฃๆž็š„ JSON5 ๅญ—ไธฒ
143
+ * @returns ่งฃๆžๅพŒ็š„็ฟป่ญฏ่ณ‡ๆบ
144
+ * @throws {Error} ่‹ฅ Bun ๅŽŸ็”Ÿ่ˆ‡ json5 ๅฅ—ไปถๅ‡ไธๅฏ็”จๆ™‚
145
+ */
146
+ private async parseJson5(content: string): Promise<TranslationMap> {
147
+ // ๅ„ชๅ…ˆไฝฟ็”จ Bun ๅŽŸ็”Ÿ JSON5 ่งฃๆžๅ™จ
148
+ if (hasBunJson5()) {
149
+ const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
150
+ parse: (text: string) => unknown
151
+ }
152
+ return bunJson5.parse(content) as TranslationMap
153
+ }
154
+
155
+ // ้™็ดšๆ–นๆกˆ๏ผšๅ‹•ๆ…‹่ผ‰ๅ…ฅ json5 npm ๅฅ—ไปถ
156
+ // ไฝฟ็”จ่ฎŠๆ•ธไฝœ็‚บ่ทฏๅพ‘ไปฅ้ฟๅ…้œๆ…‹ๅž‹ๅˆฅ่งฃๆž๏ผˆjson5 ็‚บๅฏ้ธ peer dependency๏ผ‰
157
+ try {
158
+ const pkgName = 'json5'
159
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
160
+ const json5Module = await (import(pkgName) as Promise<any>)
161
+ const json5 = json5Module.default ?? json5Module
162
+ return (json5 as { parse: (text: string) => unknown }).parse(content) as TranslationMap
163
+ } catch {
164
+ throw new Error(
165
+ '[FileSystemLoader] JSON5 parsing requires either Bun v1.2+ (native support) ' +
166
+ 'or the `json5` npm package. Please install it: bun add json5'
167
+ )
168
+ }
169
+ }
170
+
115
171
  /**
116
172
  * ่จญๅฎšๅ‚™็”จ่ผ‰ๅ…ฅๅ™จ
117
173
  *
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @file packages/cosmos/src/loaders/Json5Loader.ts
3
+ * @module @gravito/cosmos/loaders
4
+ * @description ๆ”ฏๆด JSON5 ๆ ผๅผ็ฟป่ญฏๆช”ๆกˆ็š„ๆททๅˆ่ผ‰ๅ…ฅๅ™จ
5
+ *
6
+ * โš ๏ธ **Node.js Only**
7
+ *
8
+ * ๆญค่ผ‰ๅ…ฅๅ™จไพ่ณด Node.js ็š„ fs ๆจก็ต„๏ผŒ็„กๆณ•ๅœจ Edge Runtime ไฝฟ็”จ
9
+ *
10
+ * ๆ”ฏๆดๆททๅˆ่ผ‰ๅ…ฅ `.json5` ่ˆ‡ `.json` ๆ ผๅผ็š„็ฟป่ญฏๆช”ๆกˆ๏ผŒ
11
+ * ๅฏ้…็ฝฎ่ผ‰ๅ…ฅๅ„ชๅ…ˆ็ดšไปฅๅฝˆๆ€งๆ‡‰ๅฐไธๅŒๅ ดๆ™ฏ
12
+ *
13
+ * @since 3.2.0
14
+ * @platform node
15
+ */
16
+
17
+ import { readFile } from 'node:fs/promises'
18
+ import { join } from 'node:path'
19
+ import type { TranslationMap } from '../I18nService'
20
+ import { detectRuntime } from '../runtime/detector'
21
+ import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
22
+
23
+ /**
24
+ * Json5Loader ๅ„ชๅ…ˆ็ดš็ญ–็•ฅ
25
+ *
26
+ * - `json5-first`: ๅ„ชๅ…ˆๅ˜—่ฉฆ `.json5`๏ผŒๅคฑๆ•—ๅพŒ้€€ๅˆฐ `.json`
27
+ * - `json-first`: ๅ„ชๅ…ˆๅ˜—่ฉฆ `.json`๏ผŒๅคฑๆ•—ๅพŒ้€€ๅˆฐ `.json5`
28
+ * - `json5-only`: ๅช่ผ‰ๅ…ฅ `.json5`๏ผŒไธ้™็ดš
29
+ * - `json-only`: ๅช่ผ‰ๅ…ฅ `.json`๏ผŒไธ้™็ดš
30
+ *
31
+ * @public
32
+ * @since 3.2.0
33
+ */
34
+ export type Json5LoaderPriority = 'json5-first' | 'json-first' | 'json5-only' | 'json-only'
35
+
36
+ /**
37
+ * Json5Loader ้…็ฝฎ
38
+ *
39
+ * @public
40
+ * @since 3.2.0
41
+ */
42
+ export interface Json5LoaderConfig extends LoaderConfig {
43
+ /**
44
+ * ็ฟป่ญฏๆช”ๆกˆๆ‰€ๅœจ็š„ๅŸบ็คŽ็›ฎ้Œ„
45
+ *
46
+ * @example '/app/lang'
47
+ */
48
+ baseDir: string
49
+
50
+ /**
51
+ * ่ผ‰ๅ…ฅๅ„ชๅ…ˆ็ดš็ญ–็•ฅ
52
+ *
53
+ * @default 'json5-first'
54
+ */
55
+ priority?: Json5LoaderPriority
56
+ }
57
+
58
+ /**
59
+ * ๅตๆธฌ Bun ้‹่กŒๆ™‚ๆ˜ฏๅฆๅŽŸ็”Ÿๆ”ฏๆด JSON5 ่งฃๆž
60
+ *
61
+ * Bun v1.2+ ๆไพ› `Bun.JSON5` ๅ…จๅŸŸ็‰ฉไปถ
62
+ *
63
+ * @returns ๆ˜ฏๅฆๅ…ทๅ‚™ Bun ๅŽŸ็”Ÿ JSON5 ่ƒฝๅŠ›
64
+ */
65
+ function hasBunJson5(): boolean {
66
+ return (
67
+ typeof globalThis.Bun !== 'undefined' &&
68
+ typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
69
+ )
70
+ }
71
+
72
+ /**
73
+ * ่งฃๆž JSON5 ๆ ผๅผๅญ—ไธฒ
74
+ *
75
+ * ๅ„ชๅ…ˆไฝฟ็”จ Bun ๅŽŸ็”Ÿ JSON5 ่งฃๆžๅ™จ๏ผˆBun v1.2+๏ผ‰๏ผŒ
76
+ * ้™็ดš่‡ณ `json5` npm ๅฅ—ไปถ
77
+ *
78
+ * @param content - ่ฆ่งฃๆž็š„ JSON5 ๅญ—ไธฒ
79
+ * @returns ่งฃๆž็ตๆžœ
80
+ * @throws {Error} ่‹ฅๅ…ฉ็จฎๆ–นๅผๅ‡ไธๅฏ็”จ
81
+ */
82
+ async function parseJson5Content(content: string): Promise<unknown> {
83
+ if (hasBunJson5()) {
84
+ const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
85
+ parse: (text: string) => unknown
86
+ }
87
+ return bunJson5.parse(content)
88
+ }
89
+
90
+ // ไฝฟ็”จ่ฎŠๆ•ธไฝœ็‚บ่ทฏๅพ‘ไปฅ้ฟๅ…้œๆ…‹ๅž‹ๅˆฅ่งฃๆž๏ผˆjson5 ็‚บๅฏ้ธ peer dependency๏ผ‰
91
+ try {
92
+ const pkgName = 'json5'
93
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
94
+ const json5Module = await (import(pkgName) as Promise<any>)
95
+ const json5 = json5Module.default ?? json5Module
96
+ return (json5 as { parse: (text: string) => unknown }).parse(content)
97
+ } catch {
98
+ throw new Error(
99
+ '[Json5Loader] JSON5 parsing requires either Bun v1.2+ (native support) ' +
100
+ 'or the `json5` npm package. Please install it: bun add json5'
101
+ )
102
+ }
103
+ }
104
+
105
+ /**
106
+ * JSON5 ๆททๅˆ็ฟป่ญฏ่ผ‰ๅ…ฅๅ™จ
107
+ *
108
+ * ๆ”ฏๆดๆททๅˆ่ผ‰ๅ…ฅ `.json5` ่ˆ‡ `.json` ๆ ผๅผ็š„็ฟป่ญฏๆช”ๆกˆใ€‚
109
+ * ้€้Žๅ„ชๅ…ˆ็ดš้…็ฝฎๅฝˆๆ€งๆŽงๅˆถ่ผ‰ๅ…ฅ็ญ–็•ฅ๏ผŒไธฆๆ”ฏๆด fallback ้™็ดš้ˆใ€‚
110
+ *
111
+ * @public
112
+ * @since 3.2.0
113
+ *
114
+ * @example json5-first ๅ„ชๅ…ˆ๏ผˆ้ ่จญ๏ผ‰
115
+ * ```typescript
116
+ * const loader = new Json5Loader({
117
+ * baseDir: '/app/lang',
118
+ * priority: 'json5-first'
119
+ * })
120
+ * // ๅ˜—่ฉฆ zh-TW.json5๏ผŒ่‹ฅไธๅญ˜ๅœจๅ‰‡้€€ๅ›ž zh-TW.json
121
+ * const translations = await loader.load('zh-TW')
122
+ * ```
123
+ *
124
+ * @example ๆญ้… fallback ้ˆ
125
+ * ```typescript
126
+ * const loader = new Json5Loader({ baseDir: '/app/lang' })
127
+ * .fallback(new MemoryLoader({ en: { welcome: 'Hello' } }))
128
+ *
129
+ * const translations = await loader.load('en')
130
+ * ```
131
+ */
132
+ export class Json5Loader implements TranslationLoaderChain {
133
+ public readonly name: string
134
+ private readonly baseDir: string
135
+ private readonly priority: Json5LoaderPriority
136
+ private fallbackLoader?: TranslationLoader
137
+
138
+ /**
139
+ * ๅปบ็ซ‹ Json5Loader ๅฏฆไพ‹
140
+ *
141
+ * @param config - ่ผ‰ๅ…ฅๅ™จ้…็ฝฎ
142
+ * @throws {Error} ๅฆ‚ๆžœๅœจ Edge Runtime ็’ฐๅขƒไธญไฝฟ็”จ
143
+ */
144
+ constructor(config: Json5LoaderConfig) {
145
+ // ้‹่กŒๆ™‚ๆชขๆŸฅ
146
+ const runtime = detectRuntime()
147
+ if (runtime === 'edge') {
148
+ throw new Error(
149
+ '[Json5Loader] This loader requires Node.js and cannot run in Edge Runtime. ' +
150
+ 'Use MemoryLoader, RemoteLoader, or EdgeKVLoader instead.'
151
+ )
152
+ }
153
+
154
+ this.name = config.name ?? 'Json5Loader'
155
+ this.baseDir = config.baseDir
156
+ this.priority = config.priority ?? 'json5-first'
157
+ }
158
+
159
+ /**
160
+ * ่ผ‰ๅ…ฅๆŒ‡ๅฎš่ชž่จ€็š„็ฟป่ญฏ่ณ‡ๆบ
161
+ *
162
+ * ๆ นๆ“šๅ„ชๅ…ˆ็ดš็ญ–็•ฅๅ˜—่ฉฆไธๅŒๅ‰ฏๆช”ๅ็š„ๆช”ๆกˆ
163
+ *
164
+ * @param locale - ่ชž่จ€ไปฃ็ขผ
165
+ * @returns ็ฟป่ญฏ่ณ‡ๆบ๏ผŒๆ‰€ๆœ‰ๅ˜—่ฉฆๅ‡ๅคฑๆ•—ๅ‰‡่ฟ”ๅ›ž null
166
+ */
167
+ async load(locale: string): Promise<TranslationMap | null> {
168
+ const result = await this.loadByPriority(locale)
169
+
170
+ if (result !== null) {
171
+ return result
172
+ }
173
+
174
+ // ๅ˜—่ฉฆๅค–้ƒจ fallback ่ผ‰ๅ…ฅๅ™จ
175
+ if (this.fallbackLoader) {
176
+ return this.fallbackLoader.load(locale)
177
+ }
178
+
179
+ return null
180
+ }
181
+
182
+ /**
183
+ * ่จญๅฎšๅ‚™็”จ่ผ‰ๅ…ฅๅ™จ
184
+ *
185
+ * @param loader - ๅ‚™็”จ่ผ‰ๅ…ฅๅ™จ
186
+ * @returns ็•ถๅ‰ๅฏฆไพ‹๏ผŒๆ”ฏๆด้ˆๅผ่ชฟ็”จ
187
+ */
188
+ fallback(loader: TranslationLoader): TranslationLoaderChain {
189
+ this.fallbackLoader = loader
190
+ return this
191
+ }
192
+
193
+ /**
194
+ * ๅ–ๅพ—็•ถๅ‰ๅ„ชๅ…ˆ็ดš็ญ–็•ฅ
195
+ *
196
+ * @returns ๅ„ชๅ…ˆ็ดš็ญ–็•ฅๅญ—ไธฒ
197
+ */
198
+ getPriority(): Json5LoaderPriority {
199
+ return this.priority
200
+ }
201
+
202
+ /**
203
+ * ๆ นๆ“šๅ„ชๅ…ˆ็ดš็ญ–็•ฅไพๅบๅ˜—่ฉฆ่ผ‰ๅ…ฅ็ฟป่ญฏๆช”ๆกˆ
204
+ *
205
+ * @param locale - ่ชž่จ€ไปฃ็ขผ
206
+ * @returns ็ฟป่ญฏ่ณ‡ๆบ๏ผŒๆˆ– null
207
+ */
208
+ private async loadByPriority(locale: string): Promise<TranslationMap | null> {
209
+ switch (this.priority) {
210
+ case 'json5-first':
211
+ return (await this.tryLoadJson5(locale)) ?? (await this.tryLoadJson(locale))
212
+ case 'json-first':
213
+ return (await this.tryLoadJson(locale)) ?? (await this.tryLoadJson5(locale))
214
+ case 'json5-only':
215
+ return this.tryLoadJson5(locale)
216
+ case 'json-only':
217
+ return this.tryLoadJson(locale)
218
+ }
219
+ }
220
+
221
+ /**
222
+ * ๅ˜—่ฉฆ่ผ‰ๅ…ฅ `.json5` ๆ ผๅผ็š„็ฟป่ญฏๆช”ๆกˆ
223
+ *
224
+ * @param locale - ่ชž่จ€ไปฃ็ขผ
225
+ * @returns ็ฟป่ญฏ่ณ‡ๆบ๏ผŒๆˆ– null
226
+ */
227
+ private async tryLoadJson5(locale: string): Promise<TranslationMap | null> {
228
+ try {
229
+ const filePath = join(this.baseDir, `${locale}.json5`)
230
+ const content = await readFile(filePath, 'utf-8')
231
+ return (await parseJson5Content(content)) as TranslationMap
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
237
+ /**
238
+ * ๅ˜—่ฉฆ่ผ‰ๅ…ฅ `.json` ๆ ผๅผ็š„็ฟป่ญฏๆช”ๆกˆ
239
+ *
240
+ * @param locale - ่ชž่จ€ไปฃ็ขผ
241
+ * @returns ็ฟป่ญฏ่ณ‡ๆบ๏ผŒๆˆ– null
242
+ */
243
+ private async tryLoadJson(locale: string): Promise<TranslationMap | null> {
244
+ try {
245
+ const filePath = join(this.baseDir, `${locale}.json`)
246
+ const content = await readFile(filePath, 'utf-8')
247
+ return JSON.parse(content) as TranslationMap
248
+ } catch {
249
+ return null
250
+ }
251
+ }
252
+ }
@@ -124,16 +124,12 @@ export class RemoteLoader implements TranslationLoaderChain {
124
124
  * @returns ็ฟป่ญฏ่ณ‡ๆบ,่ผ‰ๅ…ฅๅคฑๆ•—ๅ‰‡่ฟ”ๅ›ž null
125
125
  */
126
126
  async load(locale: string): Promise<TranslationMap | null> {
127
- let _lastError: Error | null = null
128
-
129
127
  // ้‡่ฉฆ้‚่ผฏ
130
128
  for (let attempt = 0; attempt <= this.retries; attempt++) {
131
129
  try {
132
130
  const result = await this.fetchWithTimeout(locale)
133
131
  return result
134
- } catch (error) {
135
- _lastError = error as Error
136
-
132
+ } catch {
137
133
  // ๅฆ‚ๆžœไธๆ˜ฏๆœ€ๅพŒไธ€ๆฌกๅ˜—่ฉฆ,็ญ‰ๅพ…ๅพŒ้‡่ฉฆ
138
134
  if (attempt < this.retries) {
139
135
  const delay = this.retryDelay * 2 ** attempt
@@ -160,6 +156,7 @@ export class RemoteLoader implements TranslationLoaderChain {
160
156
  const url = this.url.replace(':locale', locale)
161
157
  const controller = new AbortController()
162
158
  const timeoutId = setTimeout(() => controller.abort(), this.timeout)
159
+ timeoutId.unref?.()
163
160
 
164
161
  try {
165
162
  const headers: Record<string, string> = { ...this.headers }
@@ -181,6 +178,10 @@ export class RemoteLoader implements TranslationLoaderChain {
181
178
  if (cached) {
182
179
  return cached
183
180
  }
181
+
182
+ // ETag ่ˆ‡ๆœฌๅœฐ็ฟป่ญฏๅฟซๅ–่„ซ้‰คๆ™‚๏ผŒไธŸๆฃ„ stale ETag ๅพŒ้‡ๆ–ฐๆŠ“ๅ–ๅฎŒๆ•ดๅ…งๅฎนใ€‚
183
+ this.etagMap.delete(locale)
184
+ return this.fetchWithTimeout(locale)
184
185
  }
185
186
 
186
187
  // ่™•็†้Œฏ่ชค็‹€ๆ…‹็ขผ
@@ -159,6 +159,52 @@ describe('TranslationLoaders', () => {
159
159
  expect(callCount).toBe(2)
160
160
  })
161
161
 
162
+ it('should refetch when server returns 304 but translation cache is missing', async () => {
163
+ let callCount = 0
164
+ globalThis.fetch = mock((_url: string, options?: any) => {
165
+ callCount++
166
+ const ifNoneMatch = options?.headers?.['If-None-Match']
167
+
168
+ if (!ifNoneMatch && callCount === 1) {
169
+ return Promise.resolve({
170
+ ok: true,
171
+ status: 200,
172
+ headers: new Map([['ETag', '"v1"']]),
173
+ json: async () => ({ version: '1.0' }),
174
+ })
175
+ }
176
+
177
+ if (callCount === 2) {
178
+ return Promise.resolve({
179
+ ok: true,
180
+ status: 304,
181
+ headers: new Map([['ETag', '"v1"']]),
182
+ })
183
+ }
184
+
185
+ return Promise.resolve({
186
+ ok: true,
187
+ status: 200,
188
+ headers: new Map([['ETag', '"v2"']]),
189
+ json: async () => ({ version: '2.0' }),
190
+ })
191
+ }) as any
192
+
193
+ const loader = new RemoteLoader({
194
+ url: 'https://api.example.com/i18n/:locale',
195
+ etagCache: true,
196
+ })
197
+
198
+ const translations1 = await loader.load('en')
199
+ expect(translations1?.version).toBe('1.0')
200
+
201
+ ;(loader as any).translationCache.clear()
202
+
203
+ const translations2 = await loader.load('en')
204
+ expect(translations2?.version).toBe('2.0')
205
+ expect(callCount).toBe(3)
206
+ })
207
+
162
208
  it('should retry on failure', async () => {
163
209
  let attemptCount = 0
164
210
  globalThis.fetch = mock(() => {
@@ -213,6 +259,39 @@ describe('TranslationLoaders', () => {
213
259
  expect(receivedHeaders['X-Custom']).toBe('value')
214
260
  })
215
261
 
262
+ it('should unref request timeout timers', async () => {
263
+ const originalSetTimeout = global.setTimeout
264
+ const handle = {
265
+ unrefCalled: false,
266
+ unref() {
267
+ this.unrefCalled = true
268
+ },
269
+ }
270
+
271
+ globalThis.fetch = mock(() =>
272
+ Promise.resolve({
273
+ ok: true,
274
+ status: 200,
275
+ headers: new Map(),
276
+ json: async () => ({}),
277
+ })
278
+ ) as any
279
+
280
+ global.setTimeout = ((_fn: () => void) => handle as any) as unknown as typeof setTimeout
281
+
282
+ try {
283
+ const loader = new RemoteLoader({
284
+ url: 'https://api.example.com/i18n/:locale',
285
+ timeout: 100,
286
+ })
287
+ await loader.load('en')
288
+
289
+ expect(handle.unrefCalled).toBe(true)
290
+ } finally {
291
+ global.setTimeout = originalSetTimeout
292
+ }
293
+ })
294
+
216
295
  it('should support fallback loader when all retries fail', async () => {
217
296
  globalThis.fetch = mock(() => Promise.reject(new Error('Network error'))) as any
218
297
 
@@ -0,0 +1,27 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "noEmit": false,
7
+ "emitDeclarationOnly": true,
8
+ "declaration": true,
9
+ "declarationMap": false,
10
+ "outDir": "./dist",
11
+ "rootDir": "src",
12
+ "skipLibCheck": true,
13
+ "baseUrl": ".",
14
+ "jsx": "react-jsx",
15
+ "experimentalDecorators": true,
16
+ "emitDecoratorMetadata": true,
17
+ "types": ["bun-types"],
18
+ "paths": {
19
+ "@gravito/cosmos": ["./src/index.ts"],
20
+ "@gravito/core": ["../../packages/core/dist/index.d.ts"],
21
+ "@gravito/core/*": ["../../packages/core/dist/*"],
22
+ "@gravito/*": ["../../packages/*/dist/index.d.ts"]
23
+ }
24
+ },
25
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
26
+ "exclude": ["node_modules", "dist", "tests", "**/*.test.ts", "**/*.test.tsx"]
27
+ }
package/tsconfig.json CHANGED
@@ -2,11 +2,6 @@
2
2
  "extends": "../../tsconfig.json",
3
3
  "compilerOptions": {
4
4
  "outDir": "./dist",
5
- "baseUrl": ".",
6
- "paths": {
7
- "@gravito/core": ["../../packages/core/src/index.ts"],
8
- "@gravito/*": ["../../packages/*/src/index.ts"]
9
- },
10
5
  "types": ["bun-types"]
11
6
  },
12
7
  "include": ["src/**/*"],