@gravito/cosmos 3.2.1 → 4.0.0

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": "4.0.0",
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": "^3.0.0",
47
+ "@gravito/photon": "^2.0.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
  }
@@ -7,6 +7,8 @@
7
7
  import type { GravitoMiddleware } from '@gravito/core'
8
8
  import { LRUCache } from 'lru-cache'
9
9
  import { type HMRConfig, HMRWatcher } from './HMRWatcher'
10
+ import { CosmosError } from './errors/CosmosError'
11
+ import { CosmosErrorCodes } from './errors/codes'
10
12
  import { loadLocale } from './loader'
11
13
  import type { TranslationLoader } from './loaders/TranslationLoader'
12
14
 
@@ -816,7 +818,9 @@ export class I18nManager<Schema = TranslationMap> implements I18nService<Schema>
816
818
  case 'empty':
817
819
  return ''
818
820
  case 'throw':
819
- throw new Error(`Missing translation: ${key}`)
821
+ throw new CosmosError(500, CosmosErrorCodes.MISSING_TRANSLATION, {
822
+ message: `Missing translation: ${key}`,
823
+ })
820
824
  default:
821
825
  return key
822
826
  }
@@ -0,0 +1,20 @@
1
+ import { SystemException, type ExceptionOptions } from '@gravito/core'
2
+ import type { CosmosErrorCode } from './codes'
3
+
4
+ /**
5
+ * Base error class for @gravito/cosmos i18n operations.
6
+ * Extends SystemException as cosmos is a general system utility (not a domain/auth module).
7
+ *
8
+ * @public
9
+ */
10
+ export class CosmosError extends SystemException {
11
+ constructor(
12
+ status: number,
13
+ code: CosmosErrorCode,
14
+ options: ExceptionOptions = {}
15
+ ) {
16
+ super(status, code, options)
17
+ this.name = 'CosmosError'
18
+ Object.setPrototypeOf(this, new.target.prototype)
19
+ }
20
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Structured error codes for @gravito/cosmos i18n operations.
3
+ * Follows the dot-separated namespace convention used across Gravito.
4
+ *
5
+ * @public
6
+ */
7
+ export const CosmosErrorCodes = {
8
+ // Translation errors
9
+ MISSING_TRANSLATION: 'cosmos.missing_translation',
10
+
11
+ // Loader errors
12
+ LOADER_FAILED: 'cosmos.loader_failed',
13
+ UNSUPPORTED_FORMAT: 'cosmos.unsupported_format',
14
+
15
+ // Filesystem loader errors
16
+ FILE_NOT_FOUND: 'cosmos.file_not_found',
17
+ FILE_READ_FAILED: 'cosmos.file_read_failed',
18
+
19
+ // Remote loader errors
20
+ HTTP_ERROR: 'cosmos.http_error',
21
+
22
+ // Runtime errors
23
+ EDGE_RUNTIME_UNSUPPORTED: 'cosmos.edge_runtime_unsupported',
24
+
25
+ // KV storage errors
26
+ KV_PUT_UNSUPPORTED: 'cosmos.kv_put_unsupported',
27
+ KV_DELETE_UNSUPPORTED: 'cosmos.kv_delete_unsupported',
28
+ } as const
29
+
30
+ export type CosmosErrorCode = (typeof CosmosErrorCodes)[keyof typeof CosmosErrorCodes]
@@ -0,0 +1,2 @@
1
+ export { CosmosError } from './CosmosError'
2
+ export { CosmosErrorCodes, type CosmosErrorCode } from './codes'
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
@@ -93,3 +94,6 @@ export * from './loaders/VercelKVLoader'
93
94
  // Runtime utilities
94
95
  export * from './runtime/detector'
95
96
  export * from './runtime/path-utils'
97
+
98
+ // Errors
99
+ export * from './errors'
package/src/loader.ts CHANGED
@@ -21,14 +21,59 @@
21
21
 
22
22
  import { readdir, readFile } from 'node:fs/promises'
23
23
  import { join, parse } from 'node:path'
24
+ import { CosmosError } from './errors/CosmosError'
25
+ import { CosmosErrorCodes } from './errors/codes'
26
+
27
+ /**
28
+ * 偵測 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
+
39
+ /**
40
+ * 解析 JSON5 格式字串(降級到 json5 npm 套件)
41
+ *
42
+ * @param content - 要解析的 JSON5 字串
43
+ * @returns 解析結果
44
+ */
45
+ async function parseJson5(content: string): Promise<unknown> {
46
+ if (hasBunJson5()) {
47
+ const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
48
+ parse: (text: string) => unknown
49
+ }
50
+ return bunJson5.parse(content)
51
+ }
52
+
53
+ // 使用變數作為路徑以避免靜態型別解析(json5 為可選 peer dependency)
54
+ try {
55
+ const pkgName = 'json5'
56
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
57
+ const json5Module = await (import(pkgName) as Promise<any>)
58
+ const json5 = json5Module.default ?? json5Module
59
+ return (json5 as { parse: (text: string) => unknown }).parse(content)
60
+ } catch {
61
+ throw new CosmosError(500, CosmosErrorCodes.UNSUPPORTED_FORMAT, {
62
+ message:
63
+ '[Cosmos] JSON5 parsing requires either Bun v1.2+ or the `json5` npm package. ' +
64
+ 'Please install it: bun add json5',
65
+ })
66
+ }
67
+ }
24
68
 
25
69
  /**
26
70
  * 從目錄載入所有翻譯檔案
27
71
  *
28
- * 掃描指定目錄中的所有 JSON 檔案並載入為翻譯資源
72
+ * 掃描指定目錄中的所有 JSON 與 JSON5 檔案並載入為翻譯資源
73
+ * 若同一語言同時存在 `.json` 與 `.json5`,以 `.json5` 優先
29
74
  * 檔案名稱(不含副檔名)將作為語言代碼
30
75
  *
31
- * @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader
76
+ * @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader 或 Json5Loader
32
77
  * @param directory - 翻譯目錄的絕對路徑
33
78
  * @returns 語言代碼到翻譯資源的對應表
34
79
  * @public
@@ -37,7 +82,7 @@ import { join, parse } from 'node:path'
37
82
  * ```
38
83
  * /lang
39
84
  * /en.json -> { "welcome": "Hello" }
40
- * /zh-TW.json -> { "welcome": "你好" }
85
+ * /zh-TW.json5 -> { "welcome": "你好" } // 支援 JSON5
41
86
  * ```
42
87
  */
43
88
  export async function loadTranslations(
@@ -48,13 +93,29 @@ export async function loadTranslations(
48
93
  try {
49
94
  const files = await readdir(directory)
50
95
 
96
+ // 收集所有支援格式的翻譯檔案,以 locale 為鍵,避免重複處理
97
+ const localeFiles = new Map<string, string>()
98
+
51
99
  for (const file of files) {
52
- if (!file.endsWith('.json')) {
100
+ if (!file.endsWith('.json') && !file.endsWith('.json5')) {
53
101
  continue
54
102
  }
55
103
 
56
- const locale = parse(file).name // 'en' from 'en.json'
57
- const translationsForLocale = await loadLocale(directory, locale)
104
+ const locale = parse(file).name
105
+ const existing = localeFiles.get(locale)
106
+
107
+ // json5 優先:若同一 locale 已有 .json,json5 可覆蓋;反之不覆蓋
108
+ if (!existing || file.endsWith('.json5')) {
109
+ localeFiles.set(locale, file)
110
+ }
111
+ }
112
+
113
+ for (const [locale, file] of localeFiles) {
114
+ const translationsForLocale = await loadLocale(
115
+ directory,
116
+ locale,
117
+ parse(file).ext as '.json' | '.json5'
118
+ )
58
119
  if (translationsForLocale) {
59
120
  translations[locale] = translationsForLocale
60
121
  }
@@ -71,21 +132,26 @@ export async function loadTranslations(
71
132
  /**
72
133
  * 載入指定語言的翻譯資源
73
134
  *
74
- * 期望在指定目錄中找到 `{locale}.json` 格式的檔案
135
+ * 期望在指定目錄中找到 `{locale}.json` 或 `{locale}.json5` 格式的檔案
75
136
  *
76
- * @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader
137
+ * @deprecated 自 v3.1.0 起,建議使用 FileSystemLoader 或 Json5Loader
77
138
  * @param directory - 包含翻譯檔案的目錄
78
139
  * @param locale - 要載入的語言代碼
140
+ * @param extension - 檔案副檔名,預設 `.json`,可傳入 `.json5`
79
141
  * @returns 翻譯資源,載入失敗則返回 null
80
142
  * @public
81
143
  */
82
144
  export async function loadLocale(
83
145
  directory: string,
84
- locale: string
146
+ locale: string,
147
+ extension: '.json' | '.json5' = '.json'
85
148
  ): Promise<Record<string, string> | null> {
86
- const filePath = join(directory, `${locale}.json`)
149
+ const filePath = join(directory, `${locale}${extension}`)
87
150
  try {
88
151
  const content = await readFile(filePath, 'utf-8')
152
+ if (extension === '.json5') {
153
+ return (await parseJson5(content)) as Record<string, string>
154
+ }
89
155
  return JSON.parse(content)
90
156
  } catch {
91
157
  return null
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import type { TranslationMap } from '../I18nService'
8
+ import { CosmosError } from '../errors/CosmosError'
9
+ import { CosmosErrorCodes } from '../errors/codes'
8
10
  import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
9
11
 
10
12
  /**
@@ -219,7 +221,9 @@ export class EdgeKVLoader implements TranslationLoaderChain {
219
221
  */
220
222
  async put(locale: string, translations: TranslationMap): Promise<void> {
221
223
  if (!this.storage.put) {
222
- throw new Error('[EdgeKVLoader] Storage does not support put operation')
224
+ throw new CosmosError(500, CosmosErrorCodes.KV_PUT_UNSUPPORTED, {
225
+ message: '[EdgeKVLoader] Storage does not support put operation',
226
+ })
223
227
  }
224
228
 
225
229
  const key = this.buildKey(locale)
@@ -239,7 +243,9 @@ export class EdgeKVLoader implements TranslationLoaderChain {
239
243
  */
240
244
  async delete(locale: string): Promise<void> {
241
245
  if (!this.storage.delete) {
242
- throw new Error('[EdgeKVLoader] Storage does not support delete operation')
246
+ throw new CosmosError(500, CosmosErrorCodes.KV_DELETE_UNSUPPORTED, {
247
+ message: '[EdgeKVLoader] Storage does not support delete operation',
248
+ })
243
249
  }
244
250
 
245
251
  const key = this.buildKey(locale)
@@ -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
  *
@@ -19,9 +19,25 @@
19
19
  import { readFile } from 'node:fs/promises'
20
20
  import { join } from 'node:path'
21
21
  import type { TranslationMap } from '../I18nService'
22
+ import { CosmosError } from '../errors/CosmosError'
23
+ import { CosmosErrorCodes } from '../errors/codes'
22
24
  import { detectRuntime } from '../runtime/detector'
23
25
  import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
24
26
 
27
+ /**
28
+ * 偵測 Bun 運行時是否原生支援 JSON5 解析
29
+ *
30
+ * Bun v1.2+ 提供 `Bun.JSON5` 全域物件
31
+ *
32
+ * @returns 是否具備 Bun 原生 JSON5 能力
33
+ */
34
+ function hasBunJson5(): boolean {
35
+ return (
36
+ typeof globalThis.Bun !== 'undefined' &&
37
+ typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
38
+ )
39
+ }
40
+
25
41
  /**
26
42
  * 檔案系統載入器配置
27
43
  *
@@ -39,6 +55,8 @@ export interface FileSystemLoaderConfig extends LoaderConfig {
39
55
  /**
40
56
  * 檔案副檔名
41
57
  *
58
+ * 支援 `.json` 與 `.json5` 兩種格式
59
+ *
42
60
  * @default '.json'
43
61
  */
44
62
  extension?: string
@@ -80,10 +98,11 @@ export class FileSystemLoader implements TranslationLoaderChain {
80
98
  // 運行時檢查
81
99
  const runtime = detectRuntime()
82
100
  if (runtime === 'edge') {
83
- throw new Error(
84
- '[FileSystemLoader] This loader requires Node.js and cannot run in Edge Runtime. ' +
85
- 'Use MemoryLoader, RemoteLoader, or EdgeKVLoader instead.'
86
- )
101
+ throw new CosmosError(500, CosmosErrorCodes.EDGE_RUNTIME_UNSUPPORTED, {
102
+ message:
103
+ '[FileSystemLoader] This loader requires Node.js and cannot run in Edge Runtime. ' +
104
+ 'Use MemoryLoader, RemoteLoader, or EdgeKVLoader instead.',
105
+ })
87
106
  }
88
107
 
89
108
  this.name = config.name || 'FileSystemLoader'
@@ -94,6 +113,8 @@ export class FileSystemLoader implements TranslationLoaderChain {
94
113
  /**
95
114
  * 載入指定語言的翻譯資源
96
115
  *
116
+ * 根據 extension 自動判斷使用 JSON 或 JSON5 解析器
117
+ *
97
118
  * @param locale - 語言代碼
98
119
  * @returns 翻譯資源,載入失敗則返回 null
99
120
  */
@@ -101,7 +122,10 @@ export class FileSystemLoader implements TranslationLoaderChain {
101
122
  try {
102
123
  const filePath = join(this.baseDir, `${locale}${this.extension}`)
103
124
  const content = await readFile(filePath, 'utf-8')
104
- const translations = JSON.parse(content) as TranslationMap
125
+ const translations =
126
+ this.extension === '.json5'
127
+ ? await this.parseJson5(content)
128
+ : (JSON.parse(content) as TranslationMap)
105
129
  return translations
106
130
  } catch (_error) {
107
131
  // 如果有備用載入器,嘗試使用備用載入器
@@ -112,6 +136,42 @@ export class FileSystemLoader implements TranslationLoaderChain {
112
136
  }
113
137
  }
114
138
 
139
+ /**
140
+ * 解析 JSON5 內容
141
+ *
142
+ * 優先使用 Bun 原生 JSON5 解析器(Bun v1.2+),
143
+ * 若不可用則嘗試動態載入 `json5` npm 套件作為降級方案
144
+ *
145
+ * @param content - 要解析的 JSON5 字串
146
+ * @returns 解析後的翻譯資源
147
+ * @throws {Error} 若 Bun 原生與 json5 套件均不可用時
148
+ */
149
+ private async parseJson5(content: string): Promise<TranslationMap> {
150
+ // 優先使用 Bun 原生 JSON5 解析器
151
+ if (hasBunJson5()) {
152
+ const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
153
+ parse: (text: string) => unknown
154
+ }
155
+ return bunJson5.parse(content) as TranslationMap
156
+ }
157
+
158
+ // 降級方案:動態載入 json5 npm 套件
159
+ // 使用變數作為路徑以避免靜態型別解析(json5 為可選 peer dependency)
160
+ try {
161
+ const pkgName = 'json5'
162
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
163
+ const json5Module = await (import(pkgName) as Promise<any>)
164
+ const json5 = json5Module.default ?? json5Module
165
+ return (json5 as { parse: (text: string) => unknown }).parse(content) as TranslationMap
166
+ } catch {
167
+ throw new CosmosError(500, CosmosErrorCodes.UNSUPPORTED_FORMAT, {
168
+ message:
169
+ '[FileSystemLoader] JSON5 parsing requires either Bun v1.2+ (native support) ' +
170
+ 'or the `json5` npm package. Please install it: bun add json5',
171
+ })
172
+ }
173
+ }
174
+
115
175
  /**
116
176
  * 設定備用載入器
117
177
  *
@@ -0,0 +1,256 @@
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 { CosmosError } from '../errors/CosmosError'
21
+ import { CosmosErrorCodes } from '../errors/codes'
22
+ import { detectRuntime } from '../runtime/detector'
23
+ import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
24
+
25
+ /**
26
+ * Json5Loader 優先級策略
27
+ *
28
+ * - `json5-first`: 優先嘗試 `.json5`,失敗後退到 `.json`
29
+ * - `json-first`: 優先嘗試 `.json`,失敗後退到 `.json5`
30
+ * - `json5-only`: 只載入 `.json5`,不降級
31
+ * - `json-only`: 只載入 `.json`,不降級
32
+ *
33
+ * @public
34
+ * @since 3.2.0
35
+ */
36
+ export type Json5LoaderPriority = 'json5-first' | 'json-first' | 'json5-only' | 'json-only'
37
+
38
+ /**
39
+ * Json5Loader 配置
40
+ *
41
+ * @public
42
+ * @since 3.2.0
43
+ */
44
+ export interface Json5LoaderConfig extends LoaderConfig {
45
+ /**
46
+ * 翻譯檔案所在的基礎目錄
47
+ *
48
+ * @example '/app/lang'
49
+ */
50
+ baseDir: string
51
+
52
+ /**
53
+ * 載入優先級策略
54
+ *
55
+ * @default 'json5-first'
56
+ */
57
+ priority?: Json5LoaderPriority
58
+ }
59
+
60
+ /**
61
+ * 偵測 Bun 運行時是否原生支援 JSON5 解析
62
+ *
63
+ * Bun v1.2+ 提供 `Bun.JSON5` 全域物件
64
+ *
65
+ * @returns 是否具備 Bun 原生 JSON5 能力
66
+ */
67
+ function hasBunJson5(): boolean {
68
+ return (
69
+ typeof globalThis.Bun !== 'undefined' &&
70
+ typeof (globalThis.Bun as Record<string, unknown>).JSON5 === 'object'
71
+ )
72
+ }
73
+
74
+ /**
75
+ * 解析 JSON5 格式字串
76
+ *
77
+ * 優先使用 Bun 原生 JSON5 解析器(Bun v1.2+),
78
+ * 降級至 `json5` npm 套件
79
+ *
80
+ * @param content - 要解析的 JSON5 字串
81
+ * @returns 解析結果
82
+ * @throws {Error} 若兩種方式均不可用
83
+ */
84
+ async function parseJson5Content(content: string): Promise<unknown> {
85
+ if (hasBunJson5()) {
86
+ const bunJson5 = (globalThis.Bun as Record<string, unknown>).JSON5 as {
87
+ parse: (text: string) => unknown
88
+ }
89
+ return bunJson5.parse(content)
90
+ }
91
+
92
+ // 使用變數作為路徑以避免靜態型別解析(json5 為可選 peer dependency)
93
+ try {
94
+ const pkgName = 'json5'
95
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
96
+ const json5Module = await (import(pkgName) as Promise<any>)
97
+ const json5 = json5Module.default ?? json5Module
98
+ return (json5 as { parse: (text: string) => unknown }).parse(content)
99
+ } catch {
100
+ throw new CosmosError(500, CosmosErrorCodes.UNSUPPORTED_FORMAT, {
101
+ message:
102
+ '[Json5Loader] JSON5 parsing requires either Bun v1.2+ (native support) ' +
103
+ 'or the `json5` npm package. Please install it: bun add json5',
104
+ })
105
+ }
106
+ }
107
+
108
+ /**
109
+ * JSON5 混合翻譯載入器
110
+ *
111
+ * 支援混合載入 `.json5` 與 `.json` 格式的翻譯檔案。
112
+ * 透過優先級配置彈性控制載入策略,並支援 fallback 降級鏈。
113
+ *
114
+ * @public
115
+ * @since 3.2.0
116
+ *
117
+ * @example json5-first 優先(預設)
118
+ * ```typescript
119
+ * const loader = new Json5Loader({
120
+ * baseDir: '/app/lang',
121
+ * priority: 'json5-first'
122
+ * })
123
+ * // 嘗試 zh-TW.json5,若不存在則退回 zh-TW.json
124
+ * const translations = await loader.load('zh-TW')
125
+ * ```
126
+ *
127
+ * @example 搭配 fallback 鏈
128
+ * ```typescript
129
+ * const loader = new Json5Loader({ baseDir: '/app/lang' })
130
+ * .fallback(new MemoryLoader({ en: { welcome: 'Hello' } }))
131
+ *
132
+ * const translations = await loader.load('en')
133
+ * ```
134
+ */
135
+ export class Json5Loader implements TranslationLoaderChain {
136
+ public readonly name: string
137
+ private readonly baseDir: string
138
+ private readonly priority: Json5LoaderPriority
139
+ private fallbackLoader?: TranslationLoader
140
+
141
+ /**
142
+ * 建立 Json5Loader 實例
143
+ *
144
+ * @param config - 載入器配置
145
+ * @throws {Error} 如果在 Edge Runtime 環境中使用
146
+ */
147
+ constructor(config: Json5LoaderConfig) {
148
+ // 運行時檢查
149
+ const runtime = detectRuntime()
150
+ if (runtime === 'edge') {
151
+ throw new CosmosError(500, CosmosErrorCodes.EDGE_RUNTIME_UNSUPPORTED, {
152
+ message:
153
+ '[Json5Loader] This loader requires Node.js and cannot run in Edge Runtime. ' +
154
+ 'Use MemoryLoader, RemoteLoader, or EdgeKVLoader instead.',
155
+ })
156
+ }
157
+
158
+ this.name = config.name ?? 'Json5Loader'
159
+ this.baseDir = config.baseDir
160
+ this.priority = config.priority ?? 'json5-first'
161
+ }
162
+
163
+ /**
164
+ * 載入指定語言的翻譯資源
165
+ *
166
+ * 根據優先級策略嘗試不同副檔名的檔案
167
+ *
168
+ * @param locale - 語言代碼
169
+ * @returns 翻譯資源,所有嘗試均失敗則返回 null
170
+ */
171
+ async load(locale: string): Promise<TranslationMap | null> {
172
+ const result = await this.loadByPriority(locale)
173
+
174
+ if (result !== null) {
175
+ return result
176
+ }
177
+
178
+ // 嘗試外部 fallback 載入器
179
+ if (this.fallbackLoader) {
180
+ return this.fallbackLoader.load(locale)
181
+ }
182
+
183
+ return null
184
+ }
185
+
186
+ /**
187
+ * 設定備用載入器
188
+ *
189
+ * @param loader - 備用載入器
190
+ * @returns 當前實例,支援鏈式調用
191
+ */
192
+ fallback(loader: TranslationLoader): TranslationLoaderChain {
193
+ this.fallbackLoader = loader
194
+ return this
195
+ }
196
+
197
+ /**
198
+ * 取得當前優先級策略
199
+ *
200
+ * @returns 優先級策略字串
201
+ */
202
+ getPriority(): Json5LoaderPriority {
203
+ return this.priority
204
+ }
205
+
206
+ /**
207
+ * 根據優先級策略依序嘗試載入翻譯檔案
208
+ *
209
+ * @param locale - 語言代碼
210
+ * @returns 翻譯資源,或 null
211
+ */
212
+ private async loadByPriority(locale: string): Promise<TranslationMap | null> {
213
+ switch (this.priority) {
214
+ case 'json5-first':
215
+ return (await this.tryLoadJson5(locale)) ?? (await this.tryLoadJson(locale))
216
+ case 'json-first':
217
+ return (await this.tryLoadJson(locale)) ?? (await this.tryLoadJson5(locale))
218
+ case 'json5-only':
219
+ return this.tryLoadJson5(locale)
220
+ case 'json-only':
221
+ return this.tryLoadJson(locale)
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 嘗試載入 `.json5` 格式的翻譯檔案
227
+ *
228
+ * @param locale - 語言代碼
229
+ * @returns 翻譯資源,或 null
230
+ */
231
+ private async tryLoadJson5(locale: string): Promise<TranslationMap | null> {
232
+ try {
233
+ const filePath = join(this.baseDir, `${locale}.json5`)
234
+ const content = await readFile(filePath, 'utf-8')
235
+ return (await parseJson5Content(content)) as TranslationMap
236
+ } catch {
237
+ return null
238
+ }
239
+ }
240
+
241
+ /**
242
+ * 嘗試載入 `.json` 格式的翻譯檔案
243
+ *
244
+ * @param locale - 語言代碼
245
+ * @returns 翻譯資源,或 null
246
+ */
247
+ private async tryLoadJson(locale: string): Promise<TranslationMap | null> {
248
+ try {
249
+ const filePath = join(this.baseDir, `${locale}.json`)
250
+ const content = await readFile(filePath, 'utf-8')
251
+ return JSON.parse(content) as TranslationMap
252
+ } catch {
253
+ return null
254
+ }
255
+ }
256
+ }
@@ -5,6 +5,8 @@
5
5
  */
6
6
 
7
7
  import type { TranslationMap } from '../I18nService'
8
+ import { CosmosError } from '../errors/CosmosError'
9
+ import { CosmosErrorCodes } from '../errors/codes'
8
10
  import type { LoaderConfig, TranslationLoader, TranslationLoaderChain } from './TranslationLoader'
9
11
 
10
12
  /**
@@ -124,16 +126,12 @@ export class RemoteLoader implements TranslationLoaderChain {
124
126
  * @returns 翻譯資源,載入失敗則返回 null
125
127
  */
126
128
  async load(locale: string): Promise<TranslationMap | null> {
127
- let _lastError: Error | null = null
128
-
129
129
  // 重試邏輯
130
130
  for (let attempt = 0; attempt <= this.retries; attempt++) {
131
131
  try {
132
132
  const result = await this.fetchWithTimeout(locale)
133
133
  return result
134
- } catch (error) {
135
- _lastError = error as Error
136
-
134
+ } catch {
137
135
  // 如果不是最後一次嘗試,等待後重試
138
136
  if (attempt < this.retries) {
139
137
  const delay = this.retryDelay * 2 ** attempt
@@ -160,6 +158,7 @@ export class RemoteLoader implements TranslationLoaderChain {
160
158
  const url = this.url.replace(':locale', locale)
161
159
  const controller = new AbortController()
162
160
  const timeoutId = setTimeout(() => controller.abort(), this.timeout)
161
+ timeoutId.unref?.()
163
162
 
164
163
  try {
165
164
  const headers: Record<string, string> = { ...this.headers }
@@ -181,11 +180,17 @@ export class RemoteLoader implements TranslationLoaderChain {
181
180
  if (cached) {
182
181
  return cached
183
182
  }
183
+
184
+ // ETag 與本地翻譯快取脫鉤時,丟棄 stale ETag 後重新抓取完整內容。
185
+ this.etagMap.delete(locale)
186
+ return this.fetchWithTimeout(locale)
184
187
  }
185
188
 
186
189
  // 處理錯誤狀態碼
187
190
  if (!response.ok) {
188
- throw new Error(`HTTP error! status: ${response.status}`)
191
+ throw new CosmosError(response.status, CosmosErrorCodes.HTTP_ERROR, {
192
+ message: `HTTP error! status: ${response.status}`,
193
+ })
189
194
  }
190
195
 
191
196
  const data = (await response.json()) as TranslationMap
@@ -0,0 +1,73 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { GravitoException, SystemException } from '@gravito/core'
3
+ import { CosmosError } from '../../src/errors/CosmosError'
4
+ import { CosmosErrorCodes } from '../../src/errors/codes'
5
+
6
+ describe('CosmosError contract', () => {
7
+ it('satisfies GravitoException contract', () => {
8
+ const err = new CosmosError(500, CosmosErrorCodes.MISSING_TRANSLATION, {
9
+ message: 'Missing translation: common.welcome',
10
+ })
11
+
12
+ expect(err).toBeInstanceOf(Error)
13
+ expect(err).toBeInstanceOf(GravitoException)
14
+ expect(err).toBeInstanceOf(SystemException)
15
+ expect(err).toBeInstanceOf(CosmosError)
16
+
17
+ expect(err.code).toBe('cosmos.missing_translation')
18
+ expect(err.status).toBe(500)
19
+ expect(typeof err.code).toBe('string')
20
+ expect(typeof err.status).toBe('number')
21
+ expect(err.name).toBe('CosmosError')
22
+ expect(err.name).not.toBe('Error')
23
+ })
24
+
25
+ it('extends SystemException (not DomainException or AuthException)', () => {
26
+ const err = new CosmosError(500, CosmosErrorCodes.LOADER_FAILED)
27
+ expect(err).toBeInstanceOf(SystemException)
28
+ })
29
+
30
+ it('preserves message', () => {
31
+ const err = new CosmosError(500, CosmosErrorCodes.MISSING_TRANSLATION, {
32
+ message: 'Missing translation: test.key',
33
+ })
34
+ expect(err.message).toBe('Missing translation: test.key')
35
+ })
36
+
37
+ it('preserves cause', () => {
38
+ const cause = new Error('original error')
39
+ const err = new CosmosError(500, CosmosErrorCodes.LOADER_FAILED, { cause })
40
+ expect(err.cause).toBe(cause)
41
+ })
42
+
43
+ it('instanceof chain holds for all error codes', () => {
44
+ const codes = Object.values(CosmosErrorCodes)
45
+ for (const code of codes) {
46
+ const err = new CosmosError(500, code)
47
+ expect(err).toBeInstanceOf(CosmosError)
48
+ expect(err).toBeInstanceOf(SystemException)
49
+ expect(err).toBeInstanceOf(GravitoException)
50
+ expect(err).toBeInstanceOf(Error)
51
+ expect(err.code).toBe(code)
52
+ }
53
+ })
54
+
55
+ it('all error codes are cosmos.* namespaced', () => {
56
+ const codes = Object.values(CosmosErrorCodes)
57
+ for (const code of codes) {
58
+ expect(code).toMatch(/^cosmos\./)
59
+ }
60
+ })
61
+
62
+ it('CosmosErrorCodes covers expected domains', () => {
63
+ expect(CosmosErrorCodes.MISSING_TRANSLATION).toBe('cosmos.missing_translation')
64
+ expect(CosmosErrorCodes.LOADER_FAILED).toBe('cosmos.loader_failed')
65
+ expect(CosmosErrorCodes.UNSUPPORTED_FORMAT).toBe('cosmos.unsupported_format')
66
+ expect(CosmosErrorCodes.FILE_NOT_FOUND).toBe('cosmos.file_not_found')
67
+ expect(CosmosErrorCodes.FILE_READ_FAILED).toBe('cosmos.file_read_failed')
68
+ expect(CosmosErrorCodes.HTTP_ERROR).toBe('cosmos.http_error')
69
+ expect(CosmosErrorCodes.EDGE_RUNTIME_UNSUPPORTED).toBe('cosmos.edge_runtime_unsupported')
70
+ expect(CosmosErrorCodes.KV_PUT_UNSUPPORTED).toBe('cosmos.kv_put_unsupported')
71
+ expect(CosmosErrorCodes.KV_DELETE_UNSUPPORTED).toBe('cosmos.kv_delete_unsupported')
72
+ })
73
+ })
@@ -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/**/*"],