@gravito/cosmos 3.0.1 → 3.2.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.
Files changed (55) hide show
  1. package/MIGRATION.md +331 -0
  2. package/README.md +105 -45
  3. package/README.zh-TW.md +102 -22
  4. package/docs/plans/01-performance.md +187 -0
  5. package/docs/plans/02-architecture.md +309 -0
  6. package/docs/plans/03-api-enhancement.md +345 -0
  7. package/docs/plans/04-testing.md +431 -0
  8. package/docs/plans/README.md +47 -0
  9. package/ion/src/index.js +1179 -1138
  10. package/package.json +22 -6
  11. package/scripts/check-coverage.ts +64 -0
  12. package/src/HMRWatcher.ts +305 -0
  13. package/src/I18nService.ts +715 -91
  14. package/src/index.edge.ts +35 -0
  15. package/src/index.node.ts +20 -0
  16. package/src/index.ts +39 -6
  17. package/src/loader.ts +64 -14
  18. package/src/loaders/ChainedLoader.ts +117 -0
  19. package/src/loaders/CloudflareKVLoader.ts +194 -0
  20. package/src/loaders/EdgeKVLoader.ts +248 -0
  21. package/src/loaders/FileSystemLoader.ts +125 -0
  22. package/src/loaders/MemoryLoader.ts +161 -0
  23. package/src/loaders/RemoteLoader.ts +235 -0
  24. package/src/loaders/TranslationLoader.ts +98 -0
  25. package/src/loaders/VercelKVLoader.ts +192 -0
  26. package/src/runtime/detector.ts +97 -0
  27. package/src/runtime/path-utils.ts +169 -0
  28. package/tests/helpers/factory.ts +41 -0
  29. package/tests/performance/translate.bench.ts +27 -0
  30. package/tests/unit/api.test.ts +37 -0
  31. package/tests/unit/detector.test.ts +65 -0
  32. package/tests/unit/edge-kv-loader.test.ts +202 -0
  33. package/tests/unit/edge.test.ts +100 -0
  34. package/tests/unit/fallback.test.ts +66 -0
  35. package/tests/unit/hmr.test.ts +255 -0
  36. package/tests/unit/lazy.test.ts +35 -0
  37. package/tests/unit/loader.test.ts +72 -0
  38. package/tests/unit/loaders.test.ts +332 -0
  39. package/tests/{manager.test.ts → unit/manager.test.ts} +1 -1
  40. package/tests/unit/memory-loader.test.ts +130 -0
  41. package/tests/unit/path-utils.test.ts +135 -0
  42. package/tests/unit/plural.test.ts +58 -0
  43. package/tests/unit/runtime-detector.test.ts +86 -0
  44. package/tests/{service.test.ts → unit/service.test.ts} +2 -2
  45. package/tsconfig.json +12 -24
  46. package/.turbo/turbo-build.log +0 -20
  47. package/.turbo/turbo-test$colon$ci.log +0 -35
  48. package/.turbo/turbo-test$colon$coverage.log +0 -35
  49. package/.turbo/turbo-test.log +0 -27
  50. package/.turbo/turbo-typecheck.log +0 -2
  51. package/dist/index.cjs +0 -309
  52. package/dist/index.d.cts +0 -274
  53. package/dist/index.d.ts +0 -274
  54. package/dist/index.js +0 -277
  55. package/tests/loader.test.ts +0 -44
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { I18nManager } from '../../src/I18nService'
3
+
4
+ describe('Fallback Strategies', () => {
5
+ it('uses default fallback chain (defaultLocale)', () => {
6
+ const manager = new I18nManager({
7
+ defaultLocale: 'en',
8
+ supportedLocales: ['en', 'fr'],
9
+ translations: {
10
+ en: { hello: 'Hello' },
11
+ fr: {},
12
+ },
13
+ })
14
+ expect(manager.translate('fr', 'hello')).toBe('Hello')
15
+ })
16
+
17
+ it('uses configured fallback chain', () => {
18
+ const manager = new I18nManager({
19
+ defaultLocale: 'en',
20
+ supportedLocales: ['en', 'es', 'pt'],
21
+ translations: {
22
+ en: { hello: 'Hello' },
23
+ es: { hello: 'Hola' },
24
+ pt: {},
25
+ },
26
+ fallback: {
27
+ fallbackChain: {
28
+ pt: ['es', 'en'],
29
+ },
30
+ },
31
+ })
32
+ expect(manager.translate('pt', 'hello')).toBe('Hola')
33
+ })
34
+
35
+ it('handles missing key strategies', () => {
36
+ const manager = new I18nManager({
37
+ defaultLocale: 'en',
38
+ supportedLocales: ['en'],
39
+ translations: { en: {} },
40
+ fallback: {
41
+ onMissingKey: 'empty',
42
+ },
43
+ })
44
+ expect(manager.translate('en', 'missing')).toBe('')
45
+
46
+ const managerThrow = new I18nManager({
47
+ defaultLocale: 'en',
48
+ supportedLocales: ['en'],
49
+ translations: { en: {} },
50
+ fallback: {
51
+ onMissingKey: 'throw',
52
+ },
53
+ })
54
+ expect(() => managerThrow.translate('en', 'missing')).toThrow()
55
+
56
+ const managerCustom = new I18nManager({
57
+ defaultLocale: 'en',
58
+ supportedLocales: ['en'],
59
+ translations: { en: {} },
60
+ fallback: {
61
+ onMissingKey: (key, locale) => `Missing ${key} in ${locale}`,
62
+ },
63
+ })
64
+ expect(managerCustom.translate('en', 'missing')).toBe('Missing missing in en')
65
+ })
66
+ })
@@ -0,0 +1,255 @@
1
+ /**
2
+ * @file packages/cosmos/tests/unit/hmr.test.ts
3
+ * @description 測試 HMR (熱重載) 功能
4
+ */
5
+
6
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
7
+ import { mkdtempSync, writeFileSync } from 'node:fs'
8
+ import { rm, writeFile } from 'node:fs/promises'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { HMRWatcher } from '../../src/HMRWatcher'
12
+
13
+ describe('HMRWatcher', () => {
14
+ let tmpDir: string
15
+
16
+ beforeEach(() => {
17
+ tmpDir = mkdtempSync(join(tmpdir(), 'gravito-hmr-test-'))
18
+ })
19
+
20
+ afterEach(async () => {
21
+ await rm(tmpDir, { force: true, recursive: true })
22
+ })
23
+
24
+ it('should detect file changes', async () => {
25
+ const filePath = join(tmpDir, 'zh-TW.json')
26
+ writeFileSync(filePath, JSON.stringify({ test: 'initial' }))
27
+
28
+ const changeEvents: Array<{ locale: string; type: string }> = []
29
+
30
+ const watcher = new HMRWatcher({
31
+ enabled: true,
32
+ watchDirs: [tmpDir],
33
+ debounce: 100,
34
+ verbose: false,
35
+ })
36
+
37
+ watcher.onChange((event) => {
38
+ changeEvents.push({
39
+ locale: event.locale,
40
+ type: event.type,
41
+ })
42
+ })
43
+
44
+ watcher.start()
45
+
46
+ // 等待監視器啟動
47
+ await new Promise((resolve) => setTimeout(resolve, 100))
48
+
49
+ // 修改檔案
50
+ await writeFile(filePath, JSON.stringify({ test: 'updated' }))
51
+
52
+ // 等待防抖和事件處理
53
+ await new Promise((resolve) => setTimeout(resolve, 500))
54
+
55
+ watcher.stop()
56
+
57
+ expect(changeEvents.length).toBeGreaterThan(0)
58
+ expect(changeEvents[0].locale).toBe('zh-TW')
59
+ })
60
+
61
+ it('should not watch when disabled', async () => {
62
+ const watcher = new HMRWatcher({
63
+ enabled: false,
64
+ watchDirs: [tmpDir],
65
+ })
66
+
67
+ watcher.start()
68
+ expect(watcher.isActive()).toBe(false)
69
+ })
70
+
71
+ it('should filter by file extension', async () => {
72
+ const jsonFile = join(tmpDir, 'en.json')
73
+ const txtFile = join(tmpDir, 'en.txt')
74
+
75
+ writeFileSync(jsonFile, '{}')
76
+ writeFileSync(txtFile, 'text')
77
+
78
+ const changeEvents: string[] = []
79
+
80
+ const watcher = new HMRWatcher({
81
+ enabled: true,
82
+ watchDirs: [tmpDir],
83
+ extensions: ['.json'],
84
+ debounce: 100,
85
+ verbose: false,
86
+ })
87
+
88
+ watcher.onChange((event) => {
89
+ changeEvents.push(event.filePath)
90
+ })
91
+
92
+ watcher.start()
93
+ await new Promise((resolve) => setTimeout(resolve, 100))
94
+
95
+ // 修改 JSON 檔案 (應該被檢測到)
96
+ await writeFile(jsonFile, '{"updated": true}')
97
+ await new Promise((resolve) => setTimeout(resolve, 300))
98
+
99
+ // 修改 TXT 檔案 (應該被忽略)
100
+ await writeFile(txtFile, 'updated text')
101
+ await new Promise((resolve) => setTimeout(resolve, 300))
102
+
103
+ watcher.stop()
104
+
105
+ // 只有 JSON 檔案的變更應該被檢測到
106
+ expect(changeEvents.some((path) => path.includes('.json'))).toBe(true)
107
+ expect(changeEvents.some((path) => path.includes('.txt'))).toBe(false)
108
+ })
109
+
110
+ it('should support multiple listeners', async () => {
111
+ const filePath = join(tmpDir, 'test.json')
112
+ writeFileSync(filePath, '{}')
113
+
114
+ let listener1Called = false
115
+ let listener2Called = false
116
+
117
+ const watcher = new HMRWatcher({
118
+ enabled: true,
119
+ watchDirs: [tmpDir],
120
+ debounce: 100,
121
+ verbose: false,
122
+ })
123
+
124
+ watcher.onChange(() => {
125
+ listener1Called = true
126
+ })
127
+
128
+ watcher.onChange(() => {
129
+ listener2Called = true
130
+ })
131
+
132
+ watcher.start()
133
+ await new Promise((resolve) => setTimeout(resolve, 100))
134
+
135
+ await writeFile(filePath, '{"updated": true}')
136
+ await new Promise((resolve) => setTimeout(resolve, 300))
137
+
138
+ watcher.stop()
139
+
140
+ expect(listener1Called).toBe(true)
141
+ expect(listener2Called).toBe(true)
142
+ })
143
+
144
+ it('should allow removing listeners', async () => {
145
+ const filePath = join(tmpDir, 'test.json')
146
+ writeFileSync(filePath, '{}')
147
+
148
+ let callCount = 0
149
+ const listener = () => {
150
+ callCount++
151
+ }
152
+
153
+ const watcher = new HMRWatcher({
154
+ enabled: true,
155
+ watchDirs: [tmpDir],
156
+ debounce: 100,
157
+ verbose: false,
158
+ })
159
+
160
+ watcher.onChange(listener)
161
+ watcher.start()
162
+ await new Promise((resolve) => setTimeout(resolve, 100))
163
+
164
+ // 第一次變更
165
+ await writeFile(filePath, '{"v": 1}')
166
+ await new Promise((resolve) => setTimeout(resolve, 300))
167
+
168
+ const initialCallCount = callCount
169
+ expect(initialCallCount).toBeGreaterThan(0)
170
+
171
+ // 移除監聽器
172
+ watcher.removeListener(listener)
173
+
174
+ // 第二次變更 (不應該觸發)
175
+ await writeFile(filePath, '{"v": 2}')
176
+ await new Promise((resolve) => setTimeout(resolve, 300))
177
+
178
+ watcher.stop()
179
+
180
+ // 移除後,callCount 應該不再增加
181
+ expect(callCount).toBe(initialCallCount)
182
+ })
183
+
184
+ it('should debounce rapid changes', async () => {
185
+ const filePath = join(tmpDir, 'rapid.json')
186
+ writeFileSync(filePath, '{}')
187
+
188
+ let callCount = 0
189
+
190
+ const watcher = new HMRWatcher({
191
+ enabled: true,
192
+ watchDirs: [tmpDir],
193
+ debounce: 200,
194
+ verbose: false,
195
+ })
196
+
197
+ watcher.onChange(() => {
198
+ callCount++
199
+ })
200
+
201
+ watcher.start()
202
+ await new Promise((resolve) => setTimeout(resolve, 100))
203
+
204
+ // 快速連續修改多次
205
+ await writeFile(filePath, '{"v": 1}')
206
+ await new Promise((resolve) => setTimeout(resolve, 50))
207
+ await writeFile(filePath, '{"v": 2}')
208
+ await new Promise((resolve) => setTimeout(resolve, 50))
209
+ await writeFile(filePath, '{"v": 3}')
210
+
211
+ // 等待防抖完成
212
+ await new Promise((resolve) => setTimeout(resolve, 400))
213
+
214
+ watcher.stop()
215
+
216
+ // 由於防抖,應該只觸發一次或少數幾次,而不是每次修改都觸發
217
+ expect(callCount).toBeLessThan(3)
218
+ })
219
+
220
+ it('should handle errors in listeners gracefully', async () => {
221
+ const filePath = join(tmpDir, 'error-test.json')
222
+ writeFileSync(filePath, '{}')
223
+
224
+ const errorListener = () => {
225
+ throw new Error('Listener error')
226
+ }
227
+
228
+ let goodListenerCalled = false
229
+ const goodListener = () => {
230
+ goodListenerCalled = true
231
+ }
232
+
233
+ const watcher = new HMRWatcher({
234
+ enabled: true,
235
+ watchDirs: [tmpDir],
236
+ debounce: 100,
237
+ verbose: false,
238
+ })
239
+
240
+ // 註冊會拋出錯誤的監聽器和正常的監聽器
241
+ watcher.onChange(errorListener)
242
+ watcher.onChange(goodListener)
243
+
244
+ watcher.start()
245
+ await new Promise((resolve) => setTimeout(resolve, 100))
246
+
247
+ await writeFile(filePath, '{"updated": true}')
248
+ await new Promise((resolve) => setTimeout(resolve, 300))
249
+
250
+ watcher.stop()
251
+
252
+ // 即使有監聽器出錯,其他監聽器仍應該被呼叫
253
+ expect(goodListenerCalled).toBe(true)
254
+ })
255
+ })
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import { mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { I18nManager } from '../../src/I18nService'
7
+
8
+ describe('Lazy Loading', () => {
9
+ it('loads translations on demand', async () => {
10
+ const tmpDir = mkdtempSync(join(tmpdir(), 'gravito-i18n-lazy-'))
11
+
12
+ try {
13
+ writeFileSync(join(tmpDir, 'fr.json'), JSON.stringify({ greeting: 'Bonjour' }))
14
+
15
+ const manager = new I18nManager({
16
+ defaultLocale: 'en',
17
+ supportedLocales: ['en', 'fr'],
18
+ lazyLoad: {
19
+ baseDir: tmpDir,
20
+ },
21
+ })
22
+
23
+ expect(manager.translate('fr', 'greeting')).toBe('greeting')
24
+
25
+ await manager.ensureLocale('fr')
26
+
27
+ expect(manager.translate('fr', 'greeting')).toBe('Bonjour')
28
+
29
+ await manager.ensureLocale('fr')
30
+ expect(manager.translate('fr', 'greeting')).toBe('Bonjour')
31
+ } finally {
32
+ await rm(tmpDir, { force: true, recursive: true })
33
+ }
34
+ })
35
+ })
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it, mock, spyOn } from 'bun:test'
2
+ import { mkdtempSync, writeFileSync } from 'node:fs'
3
+ import { rm } from 'node:fs/promises'
4
+ import { tmpdir } from 'node:os'
5
+ import { join } from 'node:path'
6
+ import { I18nManager } from '../../src/I18nService'
7
+ import * as loader from '../../src/loader'
8
+
9
+ describe('I18n Loading', () => {
10
+ describe('Loading Coalescing', () => {
11
+ it('should coalesce concurrent load requests', async () => {
12
+ const mockLoader = mock(async () => {
13
+ await new Promise((resolve) => setTimeout(resolve, 50))
14
+ return { title: 'Loaded' }
15
+ })
16
+
17
+ const manager = new I18nManager({
18
+ defaultLocale: 'en',
19
+ supportedLocales: ['en'],
20
+ lazyLoad: {
21
+ baseDir: '/tmp',
22
+ loader: mockLoader,
23
+ },
24
+ })
25
+
26
+ const p1 = manager.ensureLocale('en')
27
+ const p2 = manager.ensureLocale('en')
28
+
29
+ await Promise.all([p1, p2])
30
+
31
+ expect(mockLoader).toHaveBeenCalledTimes(1)
32
+ })
33
+ })
34
+
35
+ describe('loadTranslations', () => {
36
+ it('loads json translation files from a directory', async () => {
37
+ const tmpDir = mkdtempSync(join(tmpdir(), 'gravito-i18n-'))
38
+
39
+ try {
40
+ writeFileSync(join(tmpDir, 'en.json'), JSON.stringify({ hello: 'Hello' }))
41
+ writeFileSync(join(tmpDir, 'zh.json'), JSON.stringify({ hello: '你好' }))
42
+
43
+ const translations = await loader.loadTranslations(tmpDir)
44
+
45
+ expect(translations.en.hello).toBe('Hello')
46
+ expect(translations.zh.hello).toBe('你好')
47
+ } finally {
48
+ await rm(tmpDir, { force: true, recursive: true })
49
+ }
50
+ })
51
+
52
+ it('skips invalid json files and returns empty for missing dir', async () => {
53
+ const tmpDir = mkdtempSync(join(tmpdir(), 'gravito-i18n-invalid-'))
54
+
55
+ const errorSpy = spyOn(console, 'error').mockImplementation(() => {})
56
+ const warnSpy = spyOn(console, 'warn').mockImplementation(() => {})
57
+
58
+ try {
59
+ writeFileSync(join(tmpDir, 'en.json'), '{invalid json}')
60
+ const translations = await loader.loadTranslations(tmpDir)
61
+ expect(translations.en).toBeUndefined()
62
+
63
+ const missing = await loader.loadTranslations(join(tmpDir, 'missing'))
64
+ expect(missing).toEqual({})
65
+ } finally {
66
+ errorSpy.mockRestore()
67
+ warnSpy.mockRestore()
68
+ await rm(tmpDir, { force: true, recursive: true })
69
+ }
70
+ })
71
+ })
72
+ })