@gravito/cosmos 3.1.0 → 3.2.1
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/CHANGELOG.md +15 -0
- package/MIGRATION.md +331 -0
- package/package.json +22 -7
- package/scripts/check-coverage.ts +64 -0
- package/src/HMRWatcher.ts +305 -0
- package/src/I18nService.ts +151 -10
- package/src/index.edge.ts +35 -0
- package/src/index.node.ts +20 -0
- package/src/index.ts +14 -0
- package/src/loader.ts +31 -14
- package/src/loaders/ChainedLoader.ts +117 -0
- package/src/loaders/CloudflareKVLoader.ts +194 -0
- package/src/loaders/EdgeKVLoader.ts +248 -0
- package/src/loaders/FileSystemLoader.ts +125 -0
- package/src/loaders/MemoryLoader.ts +161 -0
- package/src/loaders/RemoteLoader.ts +235 -0
- package/src/loaders/TranslationLoader.ts +98 -0
- package/src/loaders/VercelKVLoader.ts +192 -0
- package/src/runtime/detector.ts +97 -0
- package/src/runtime/path-utils.ts +169 -0
- package/tests/unit/detector.test.ts +1 -6
- package/tests/unit/edge-kv-loader.test.ts +202 -0
- package/tests/unit/hmr.test.ts +255 -0
- package/tests/unit/loader.test.ts +57 -29
- package/tests/unit/loaders.test.ts +332 -0
- package/tests/unit/memory-loader.test.ts +130 -0
- package/tests/unit/path-utils.test.ts +135 -0
- package/tests/unit/runtime-detector.test.ts +86 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/tests/unit/memory-loader.test.ts
|
|
3
|
+
* @description MemoryLoader 單元測試
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'bun:test'
|
|
7
|
+
import { MemoryLoader } from '../../src/loaders/MemoryLoader'
|
|
8
|
+
|
|
9
|
+
describe('MemoryLoader', () => {
|
|
10
|
+
it('should load translations from memory', async () => {
|
|
11
|
+
const loader = new MemoryLoader({
|
|
12
|
+
translations: {
|
|
13
|
+
en: { hello: 'Hello', welcome: 'Welcome' },
|
|
14
|
+
'zh-TW': { hello: '你好', welcome: '歡迎' },
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const en = await loader.load('en')
|
|
19
|
+
expect(en).toEqual({ hello: 'Hello', welcome: 'Welcome' })
|
|
20
|
+
|
|
21
|
+
const zhTW = await loader.load('zh-TW')
|
|
22
|
+
expect(zhTW).toEqual({ hello: '你好', welcome: '歡迎' })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should return null for missing locale', async () => {
|
|
26
|
+
const loader = new MemoryLoader({
|
|
27
|
+
translations: { en: { hello: 'Hello' } },
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const result = await loader.load('fr')
|
|
31
|
+
expect(result).toBeNull()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('should support fallback chain', async () => {
|
|
35
|
+
const primary = new MemoryLoader({
|
|
36
|
+
translations: { en: { hello: 'Hello' } },
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const fallback = new MemoryLoader({
|
|
40
|
+
translations: { fr: { hello: 'Bonjour' } },
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
primary.fallback(fallback)
|
|
44
|
+
|
|
45
|
+
const result = await primary.load('fr')
|
|
46
|
+
expect(result).toEqual({ hello: 'Bonjour' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should allow adding translations dynamically', async () => {
|
|
50
|
+
const loader = new MemoryLoader({
|
|
51
|
+
translations: { en: { hello: 'Hello' } },
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
loader.addTranslations('fr', { hello: 'Bonjour' })
|
|
55
|
+
|
|
56
|
+
const result = await loader.load('fr')
|
|
57
|
+
expect(result).toEqual({ hello: 'Bonjour' })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should merge when adding translations to existing locale', async () => {
|
|
61
|
+
const loader = new MemoryLoader({
|
|
62
|
+
translations: { en: { hello: 'Hello' } },
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
loader.addTranslations('en', { goodbye: 'Goodbye' })
|
|
66
|
+
|
|
67
|
+
const result = await loader.load('en')
|
|
68
|
+
expect(result).toEqual({ hello: 'Hello', goodbye: 'Goodbye' })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('should allow removing translations', async () => {
|
|
72
|
+
const loader = new MemoryLoader({
|
|
73
|
+
translations: {
|
|
74
|
+
en: { hello: 'Hello' },
|
|
75
|
+
fr: { hello: 'Bonjour' },
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
loader.removeTranslations('fr')
|
|
80
|
+
|
|
81
|
+
const result = await loader.load('fr')
|
|
82
|
+
expect(result).toBeNull()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should return loaded locales', () => {
|
|
86
|
+
const loader = new MemoryLoader({
|
|
87
|
+
translations: {
|
|
88
|
+
en: { hello: 'Hello' },
|
|
89
|
+
'zh-TW': { hello: '你好' },
|
|
90
|
+
fr: { hello: 'Bonjour' },
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
const locales = loader.getLoadedLocales()
|
|
95
|
+
expect(locales).toContain('en')
|
|
96
|
+
expect(locales).toContain('zh-TW')
|
|
97
|
+
expect(locales).toContain('fr')
|
|
98
|
+
expect(locales.length).toBe(3)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should check if locale exists', () => {
|
|
102
|
+
const loader = new MemoryLoader({
|
|
103
|
+
translations: { en: { hello: 'Hello' } },
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(loader.hasLocale('en')).toBe(true)
|
|
107
|
+
expect(loader.hasLocale('fr')).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should handle nested translations', async () => {
|
|
111
|
+
const loader = new MemoryLoader({
|
|
112
|
+
translations: {
|
|
113
|
+
en: {
|
|
114
|
+
auth: {
|
|
115
|
+
login: 'Login',
|
|
116
|
+
logout: 'Logout',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
const result = await loader.load('en')
|
|
123
|
+
expect(result).toEqual({
|
|
124
|
+
auth: {
|
|
125
|
+
login: 'Login',
|
|
126
|
+
logout: 'Logout',
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/tests/unit/path-utils.test.ts
|
|
3
|
+
* @description Path utils 單元測試
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'bun:test'
|
|
7
|
+
import { pathUtils } from '../../src/runtime/path-utils'
|
|
8
|
+
|
|
9
|
+
describe('Path Utils', () => {
|
|
10
|
+
describe('join', () => {
|
|
11
|
+
it('should join path segments', () => {
|
|
12
|
+
expect(pathUtils.join('foo', 'bar', 'baz.txt')).toBe('foo/bar/baz.txt')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('should handle leading slashes', () => {
|
|
16
|
+
expect(pathUtils.join('/foo', 'bar')).toBe('/foo/bar')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should handle trailing slashes', () => {
|
|
20
|
+
expect(pathUtils.join('foo/', 'bar/')).toBe('foo/bar')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('should filter empty segments', () => {
|
|
24
|
+
expect(pathUtils.join('', 'foo', '', 'bar')).toBe('foo/bar')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should handle multiple slashes', () => {
|
|
28
|
+
expect(pathUtils.join('foo//bar///baz')).toBe('foo/bar/baz')
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('basename', () => {
|
|
33
|
+
it('should return base name', () => {
|
|
34
|
+
expect(pathUtils.basename('/foo/bar/baz.txt')).toBe('baz.txt')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should remove extension if provided', () => {
|
|
38
|
+
expect(pathUtils.basename('/foo/bar/baz.txt', '.txt')).toBe('baz')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('should handle trailing slash', () => {
|
|
42
|
+
expect(pathUtils.basename('/foo/bar/')).toBe('bar')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should handle root path', () => {
|
|
46
|
+
expect(pathUtils.basename('/')).toBe('')
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('dirname', () => {
|
|
51
|
+
it('should return directory name', () => {
|
|
52
|
+
expect(pathUtils.dirname('/foo/bar/baz.txt')).toBe('/foo/bar')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should handle root path', () => {
|
|
56
|
+
expect(pathUtils.dirname('/foo')).toBe('/')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('should handle relative path', () => {
|
|
60
|
+
expect(pathUtils.dirname('foo/bar')).toBe('foo')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('should handle single segment', () => {
|
|
64
|
+
expect(pathUtils.dirname('foo')).toBe('.')
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('extname', () => {
|
|
69
|
+
it('should return file extension', () => {
|
|
70
|
+
expect(pathUtils.extname('index.html')).toBe('.html')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('should handle multiple dots', () => {
|
|
74
|
+
expect(pathUtils.extname('index.coffee.md')).toBe('.md')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should handle dot at end', () => {
|
|
78
|
+
expect(pathUtils.extname('index.')).toBe('.')
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should return empty for no extension', () => {
|
|
82
|
+
expect(pathUtils.extname('index')).toBe('')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should handle hidden files', () => {
|
|
86
|
+
// .gitignore 整個被視為副檔名
|
|
87
|
+
expect(pathUtils.extname('.gitignore')).toBe('.gitignore')
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('isAbsolute', () => {
|
|
92
|
+
it('should detect Unix absolute path', () => {
|
|
93
|
+
expect(pathUtils.isAbsolute('/foo/bar')).toBe(true)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should detect relative path', () => {
|
|
97
|
+
expect(pathUtils.isAbsolute('foo/bar')).toBe(false)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('should detect Windows absolute path', () => {
|
|
101
|
+
expect(pathUtils.isAbsolute('C:/foo/bar')).toBe(true)
|
|
102
|
+
expect(pathUtils.isAbsolute('D:\\foo\\bar')).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('normalize', () => {
|
|
107
|
+
it('should normalize path with dots', () => {
|
|
108
|
+
expect(pathUtils.normalize('/foo/bar//baz/./qux/../quux')).toBe('/foo/bar/baz/quux')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('should handle relative path with ..', () => {
|
|
112
|
+
expect(pathUtils.normalize('foo/../bar')).toBe('bar')
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('should preserve leading .. in relative paths', () => {
|
|
116
|
+
expect(pathUtils.normalize('../foo/bar')).toBe('../foo/bar')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('should remove . segments', () => {
|
|
120
|
+
expect(pathUtils.normalize('./foo/./bar/.')).toBe('foo/bar')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('should handle absolute path', () => {
|
|
124
|
+
expect(pathUtils.normalize('/foo/bar')).toBe('/foo/bar')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('should return . for empty normalized relative path', () => {
|
|
128
|
+
expect(pathUtils.normalize('foo/..')).toBe('.')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should return / for empty normalized absolute path', () => {
|
|
132
|
+
expect(pathUtils.normalize('/foo/..')).toBe('/')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file packages/cosmos/tests/unit/runtime-detector.test.ts
|
|
3
|
+
* @description Runtime detector 單元測試
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from 'bun:test'
|
|
7
|
+
import { detectRuntime, isEdge, isNode } from '../../src/runtime/detector'
|
|
8
|
+
|
|
9
|
+
describe('Runtime Detector', () => {
|
|
10
|
+
it('should detect node runtime', () => {
|
|
11
|
+
const runtime = detectRuntime()
|
|
12
|
+
// Bun 環境會被檢測為 node-compatible
|
|
13
|
+
expect(runtime).toBe('node')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('should return true for isNode()', () => {
|
|
17
|
+
expect(isNode()).toBe(true)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('should return false for isEdge()', () => {
|
|
21
|
+
expect(isEdge()).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// 以下測試模擬 Edge 環境 (需要 mock globalThis)
|
|
25
|
+
it('should detect Cloudflare Workers', () => {
|
|
26
|
+
const originalCaches = globalThis.caches
|
|
27
|
+
const originalWSP = (globalThis as any).WebSocketPair
|
|
28
|
+
|
|
29
|
+
// Mock Cloudflare Workers 環境
|
|
30
|
+
;(globalThis as any).caches = {}
|
|
31
|
+
;(globalThis as any).WebSocketPair = class {}
|
|
32
|
+
|
|
33
|
+
const runtime = detectRuntime()
|
|
34
|
+
|
|
35
|
+
// Restore
|
|
36
|
+
if (originalCaches) {
|
|
37
|
+
globalThis.caches = originalCaches
|
|
38
|
+
} else {
|
|
39
|
+
delete (globalThis as any).caches
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (originalWSP) {
|
|
43
|
+
;(globalThis as any).WebSocketPair = originalWSP
|
|
44
|
+
} else {
|
|
45
|
+
delete (globalThis as any).WebSocketPair
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
expect(runtime).toBe('edge')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should detect Vercel Edge Runtime', () => {
|
|
52
|
+
const originalEdgeRuntime = (globalThis as any).EdgeRuntime
|
|
53
|
+
|
|
54
|
+
// Mock Vercel Edge Runtime 環境
|
|
55
|
+
;(globalThis as any).EdgeRuntime = 'vercel'
|
|
56
|
+
|
|
57
|
+
const runtime = detectRuntime()
|
|
58
|
+
|
|
59
|
+
// Restore
|
|
60
|
+
if (originalEdgeRuntime !== undefined) {
|
|
61
|
+
;(globalThis as any).EdgeRuntime = originalEdgeRuntime
|
|
62
|
+
} else {
|
|
63
|
+
delete (globalThis as any).EdgeRuntime
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
expect(runtime).toBe('edge')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should detect Deno', () => {
|
|
70
|
+
const originalDeno = (globalThis as any).Deno
|
|
71
|
+
|
|
72
|
+
// Mock Deno 環境
|
|
73
|
+
;(globalThis as any).Deno = { version: { deno: '1.0.0' } }
|
|
74
|
+
|
|
75
|
+
const runtime = detectRuntime()
|
|
76
|
+
|
|
77
|
+
// Restore
|
|
78
|
+
if (originalDeno !== undefined) {
|
|
79
|
+
;(globalThis as any).Deno = originalDeno
|
|
80
|
+
} else {
|
|
81
|
+
delete (globalThis as any).Deno
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
expect(runtime).toBe('edge')
|
|
85
|
+
})
|
|
86
|
+
})
|