@c9up/rosetta 0.1.3
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/LICENSE +21 -0
- package/README.md +33 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +59 -0
- package/scripts/copy-napi.mjs +48 -0
- package/scripts/verify-napi.mjs +49 -0
- package/src/Rosetta.ts +569 -0
- package/src/RosettaProvider.ts +77 -0
- package/src/index.ts +19 -0
- package/src/loaders/FileSystemLoader.ts +191 -0
- package/src/native.ts +162 -0
- package/src/services/main.ts +42 -0
- package/wasm/rosetta_engine_wasm.d.ts +27 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 C9up
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @c9up/rosetta
|
|
2
|
+
|
|
3
|
+
> Dedicated i18n module for the Ream ecosystem — translations, pluralization, locale resolution.
|
|
4
|
+
|
|
5
|
+
Part of **[Ream](https://github.com/C9up/ream)** — a Rust-powered, AdonisJS-compatible Node.js framework. Independent, publishable package.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pnpm add @c9up/rosetta
|
|
11
|
+
ream configure @c9up/rosetta
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
Register the provider in your app, then configure it under `config/rosetta.ts`:
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
// reamrc.ts
|
|
20
|
+
providers: [
|
|
21
|
+
() => import('@c9up/rosetta/provider'),
|
|
22
|
+
]
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Entry points
|
|
26
|
+
|
|
27
|
+
- `@c9up/rosetta` — main API
|
|
28
|
+
- `@c9up/rosetta/provider` — Ream IoC provider
|
|
29
|
+
- `@c9up/rosetta/services/main` — container service accessor
|
|
30
|
+
|
|
31
|
+
## License
|
|
32
|
+
|
|
33
|
+
MIT
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@c9up/rosetta",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Rosetta — dedicated i18n module for the Ream ecosystem",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"files": [
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"README.md",
|
|
12
|
+
"dist",
|
|
13
|
+
"index.*.node",
|
|
14
|
+
"scripts",
|
|
15
|
+
"src",
|
|
16
|
+
"wasm"
|
|
17
|
+
],
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./dist/index.d.ts",
|
|
21
|
+
"import": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./provider": {
|
|
24
|
+
"types": "./dist/RosettaProvider.d.ts",
|
|
25
|
+
"import": "./dist/RosettaProvider.js"
|
|
26
|
+
},
|
|
27
|
+
"./services/main": {
|
|
28
|
+
"types": "./dist/services/main.d.ts",
|
|
29
|
+
"import": "./dist/services/main.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^22.19.15",
|
|
34
|
+
"typescript": "^6.0.2",
|
|
35
|
+
"vitest": "^4.1.2"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22.0.0"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"repository": {
|
|
44
|
+
"type": "git",
|
|
45
|
+
"url": "git+https://github.com/C9up/rosetta.git"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"build": "tsc -p tsconfig.build.json",
|
|
49
|
+
"build:rust": "cargo build --release",
|
|
50
|
+
"build:napi": "cargo build --release && node scripts/copy-napi.mjs",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"test:rust": "cargo test",
|
|
53
|
+
"test:napi": "node scripts/verify-napi.mjs",
|
|
54
|
+
"lint": "biome check src/ tests/",
|
|
55
|
+
"build:wasm": "wasm-pack build --target web crates/rosetta-engine-wasm --out-dir ../../wasm",
|
|
56
|
+
"test:coverage": "vitest run --coverage",
|
|
57
|
+
"typecheck": "tsc --noEmit"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { copyFileSync, existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { arch, env, platform } from 'node:process'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const root = join(here, '..')
|
|
8
|
+
const CRATE = 'rosetta_engine_napi'
|
|
9
|
+
const TAG = '[rosetta:napi]'
|
|
10
|
+
|
|
11
|
+
const tripleMap = {
|
|
12
|
+
'x86_64-unknown-linux-gnu': { suffix: 'linux-x64-gnu', os: 'linux' },
|
|
13
|
+
'aarch64-unknown-linux-gnu': { suffix: 'linux-arm64-gnu', os: 'linux' },
|
|
14
|
+
'x86_64-apple-darwin': { suffix: 'darwin-x64', os: 'darwin' },
|
|
15
|
+
'aarch64-apple-darwin': { suffix: 'darwin-arm64', os: 'darwin' },
|
|
16
|
+
'x86_64-pc-windows-msvc': { suffix: 'win32-x64-msvc', os: 'win32' },
|
|
17
|
+
}
|
|
18
|
+
const hostSuffixMap = {
|
|
19
|
+
'linux-x64': 'linux-x64-gnu', 'linux-arm64': 'linux-arm64-gnu',
|
|
20
|
+
'darwin-x64': 'darwin-x64', 'darwin-arm64': 'darwin-arm64', 'win32-x64': 'win32-x64-msvc',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const triple = env.CARGO_BUILD_TARGET ?? ''
|
|
24
|
+
let suffix, os, releaseDir
|
|
25
|
+
if (triple) {
|
|
26
|
+
const entry = tripleMap[triple]
|
|
27
|
+
if (!entry) throw new Error(`${TAG} unsupported CARGO_BUILD_TARGET: ${triple}`)
|
|
28
|
+
suffix = entry.suffix; os = entry.os
|
|
29
|
+
releaseDir = join(root, 'target', triple, 'release')
|
|
30
|
+
} else {
|
|
31
|
+
suffix = hostSuffixMap[`${platform}-${arch}`]; os = platform
|
|
32
|
+
releaseDir = join(root, 'target', 'release')
|
|
33
|
+
if (!suffix) throw new Error(`${TAG} unsupported platform/arch: ${platform}-${arch}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const candidates =
|
|
37
|
+
os === 'win32'
|
|
38
|
+
? [join(releaseDir, `${CRATE}.dll`), join(releaseDir, `lib${CRATE}.dll`)]
|
|
39
|
+
: os === 'darwin'
|
|
40
|
+
? [join(releaseDir, `lib${CRATE}.dylib`)]
|
|
41
|
+
: [join(releaseDir, `lib${CRATE}.so`)]
|
|
42
|
+
|
|
43
|
+
const source = candidates.find((candidate) => existsSync(candidate))
|
|
44
|
+
if (!source) throw new Error(`${TAG} native library not found. Looked for:\n${candidates.map((p) => `- ${p}`).join('\n')}`)
|
|
45
|
+
|
|
46
|
+
const target = join(root, `index.${suffix}.node`)
|
|
47
|
+
copyFileSync(source, target)
|
|
48
|
+
console.log(`${TAG} copied ${source} -> ${target}`)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { arch, platform } from 'node:process'
|
|
5
|
+
import { fileURLToPath } from 'node:url'
|
|
6
|
+
|
|
7
|
+
const here = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const root = join(here, '..')
|
|
9
|
+
|
|
10
|
+
const suffixMap = {
|
|
11
|
+
'linux-x64': 'linux-x64-gnu',
|
|
12
|
+
'linux-arm64': 'linux-arm64-gnu',
|
|
13
|
+
'darwin-x64': 'darwin-x64',
|
|
14
|
+
'darwin-arm64': 'darwin-arm64',
|
|
15
|
+
'win32-x64': 'win32-x64-msvc',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const suffix = suffixMap[`${platform}-${arch}`]
|
|
19
|
+
if (!suffix) {
|
|
20
|
+
throw new Error(`[rosetta:napi] unsupported platform/arch: ${platform}-${arch}`)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const binary = join(root, `index.${suffix}.node`)
|
|
24
|
+
if (!existsSync(binary)) {
|
|
25
|
+
throw new Error(`[rosetta:napi] binary missing: ${binary}`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const require2 = createRequire(import.meta.url)
|
|
29
|
+
const binding = require2(binary)
|
|
30
|
+
|
|
31
|
+
if (typeof binding.translate !== 'function' || typeof binding.has !== 'function') {
|
|
32
|
+
throw new Error('[rosetta:napi] invalid exports: expected translate() and has() functions')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const catalogs = JSON.stringify({ en: { hello: 'Hello {name}' } })
|
|
36
|
+
const chain = JSON.stringify(['en'])
|
|
37
|
+
const params = JSON.stringify({ name: 'Kaen' })
|
|
38
|
+
|
|
39
|
+
const translated = binding.translate(catalogs, 'hello', params, chain, undefined)
|
|
40
|
+
if (translated !== 'Hello Kaen') {
|
|
41
|
+
throw new Error(`[rosetta:napi] translate smoke test failed: got "${translated}"`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const hasKey = binding.has(catalogs, 'hello', chain)
|
|
45
|
+
if (hasKey !== true) {
|
|
46
|
+
throw new Error('[rosetta:napi] has smoke test failed')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log('[rosetta:napi] smoke test passed')
|
package/src/Rosetta.ts
ADDED
|
@@ -0,0 +1,569 @@
|
|
|
1
|
+
import { createNativeEngine, type NativeRosettaEngine } from "./native.js";
|
|
2
|
+
|
|
3
|
+
export type TranslationParams = Record<
|
|
4
|
+
string,
|
|
5
|
+
string | number | boolean | Date | null | undefined
|
|
6
|
+
>;
|
|
7
|
+
export type MessageValue = string | number | boolean | null;
|
|
8
|
+
export type MessageTree = { [key: string]: MessageValue | MessageTree };
|
|
9
|
+
export type MessageCatalog = Record<string, string>;
|
|
10
|
+
|
|
11
|
+
export interface TranslateOptions {
|
|
12
|
+
locale?: string;
|
|
13
|
+
defaultValue?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface LocaleResolverInput {
|
|
17
|
+
header?: string | null;
|
|
18
|
+
accepted?: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RosettaLoader {
|
|
22
|
+
load(
|
|
23
|
+
locale: string,
|
|
24
|
+
): Promise<MessageTree | MessageCatalog | null | undefined>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface RosettaOptions {
|
|
28
|
+
defaultLocale?: string;
|
|
29
|
+
supportedLocales?: string[];
|
|
30
|
+
fallbackLocale?: string;
|
|
31
|
+
fallbackLocales?: Record<string, string>;
|
|
32
|
+
messages?: Record<string, MessageTree | MessageCatalog>;
|
|
33
|
+
loaders?: RosettaLoader[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Numeric separators for a resolved locale. Returned by
|
|
38
|
+
* `Rosetta.getNumberFormatData()` so consumers (e.g. `@c9up/atom`'s
|
|
39
|
+
* `Decimal.parseLocale`) can honor Rosetta's `fallbackLocales`
|
|
40
|
+
* chain when extracting group / decimal separators — without
|
|
41
|
+
* importing `@c9up/rosetta` directly (structural typing).
|
|
42
|
+
*/
|
|
43
|
+
export interface NumberFormatData {
|
|
44
|
+
decimal: string;
|
|
45
|
+
group: string;
|
|
46
|
+
minus: string;
|
|
47
|
+
plusSign: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface StringNumberFormat extends Intl.NumberFormat {
|
|
51
|
+
format(value: number | bigint | string): string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const DEFAULT_LOCALE = "en";
|
|
55
|
+
|
|
56
|
+
export class RosettaLocale {
|
|
57
|
+
#manager: Rosetta;
|
|
58
|
+
#locale: string;
|
|
59
|
+
|
|
60
|
+
constructor(manager: Rosetta, locale: string) {
|
|
61
|
+
this.#manager = manager;
|
|
62
|
+
this.#locale = locale;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getLocale(): string {
|
|
66
|
+
return this.#locale;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
has(key: string): boolean {
|
|
70
|
+
return this.#manager.has(key, this.#locale);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
t(
|
|
74
|
+
key: string,
|
|
75
|
+
params?: TranslationParams,
|
|
76
|
+
options?: Omit<TranslateOptions, "locale">,
|
|
77
|
+
): string {
|
|
78
|
+
return this.#manager.t(key, params, { ...options, locale: this.#locale });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
formatNumber(value: number, options?: Intl.NumberFormatOptions): string {
|
|
82
|
+
return this.#manager.formatNumber(value, options, this.#locale);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
formatCurrency(
|
|
86
|
+
value: number,
|
|
87
|
+
currency: string,
|
|
88
|
+
options?: Intl.NumberFormatOptions,
|
|
89
|
+
): string {
|
|
90
|
+
return this.#manager.formatCurrency(value, currency, options, this.#locale);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
formatDate(
|
|
94
|
+
value: Date | number | string,
|
|
95
|
+
options?: Intl.DateTimeFormatOptions,
|
|
96
|
+
): string {
|
|
97
|
+
return this.#manager.formatDate(value, options, this.#locale);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
formatRelativeTime(
|
|
101
|
+
value: number,
|
|
102
|
+
unit: Intl.RelativeTimeFormatUnit,
|
|
103
|
+
options?: Intl.RelativeTimeFormatOptions,
|
|
104
|
+
): string {
|
|
105
|
+
return this.#manager.formatRelativeTime(value, unit, options, this.#locale);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
getNumberFormatData(): NumberFormatData {
|
|
109
|
+
return this.#manager.getNumberFormatData(this.#locale);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
formatNumberString(
|
|
113
|
+
value: string,
|
|
114
|
+
options?: Intl.NumberFormatOptions,
|
|
115
|
+
): string {
|
|
116
|
+
return this.#manager.formatNumberString(value, options, this.#locale);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Rosetta i18n manager.
|
|
122
|
+
*
|
|
123
|
+
* Framework-agnostic with request-scoped locale instances.
|
|
124
|
+
*/
|
|
125
|
+
export class Rosetta {
|
|
126
|
+
#messages: Map<string, MessageCatalog> = new Map();
|
|
127
|
+
#catalogsCache?: Record<string, MessageCatalog>;
|
|
128
|
+
#catalogsCacheDirty = true;
|
|
129
|
+
/** Stateful Rust engine (Story 37.9) — holds parsed catalogs in Rust memory. */
|
|
130
|
+
#nativeEngine: NativeRosettaEngine | null = createNativeEngine();
|
|
131
|
+
#locale: string;
|
|
132
|
+
#defaultLocale: string;
|
|
133
|
+
#fallbackLocale: string;
|
|
134
|
+
#fallbackLocales: Record<string, string>;
|
|
135
|
+
#supportedLocales?: Set<string>;
|
|
136
|
+
#loaders: RosettaLoader[];
|
|
137
|
+
#numberFormatDataCache: Map<string, NumberFormatData> = new Map();
|
|
138
|
+
|
|
139
|
+
constructor(options: RosettaOptions = {}) {
|
|
140
|
+
this.#defaultLocale = normalizeLocale(
|
|
141
|
+
options.defaultLocale ?? DEFAULT_LOCALE,
|
|
142
|
+
);
|
|
143
|
+
this.#locale = this.#defaultLocale;
|
|
144
|
+
this.#fallbackLocale = normalizeLocale(
|
|
145
|
+
options.fallbackLocale ?? this.#defaultLocale,
|
|
146
|
+
);
|
|
147
|
+
this.#fallbackLocales = normalizeFallbackMap(options.fallbackLocales ?? {});
|
|
148
|
+
this.#supportedLocales = options.supportedLocales
|
|
149
|
+
? new Set(options.supportedLocales.map(normalizeLocale))
|
|
150
|
+
: undefined;
|
|
151
|
+
this.#loaders = options.loaders ?? [];
|
|
152
|
+
|
|
153
|
+
if (options.messages) {
|
|
154
|
+
for (const [locale, catalog] of Object.entries(options.messages)) {
|
|
155
|
+
this.loadMessages(locale, catalog);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async boot(): Promise<void> {
|
|
161
|
+
if (!this.#supportedLocales || this.#loaders.length === 0) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
for (const locale of this.#supportedLocales) {
|
|
165
|
+
await this.#loadFromLoaders(locale);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
locale(locale: string): RosettaLocale {
|
|
170
|
+
return new RosettaLocale(this, normalizeLocale(locale));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
loadMessages(locale: string, messages: MessageTree | MessageCatalog): this {
|
|
174
|
+
const normalizedLocale = normalizeLocale(locale);
|
|
175
|
+
const existing = this.#messages.get(normalizedLocale) ?? {};
|
|
176
|
+
const flattened = flattenMessages(messages);
|
|
177
|
+
this.#messages.set(normalizedLocale, { ...existing, ...flattened });
|
|
178
|
+
this.#catalogsCacheDirty = true;
|
|
179
|
+
return this;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async loadLocale(locale: string): Promise<this> {
|
|
183
|
+
await this.#loadFromLoaders(normalizeLocale(locale));
|
|
184
|
+
return this;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
setLocale(locale: string): this {
|
|
188
|
+
this.#locale = normalizeLocale(locale);
|
|
189
|
+
return this;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
getLocale(): string {
|
|
193
|
+
return this.#locale;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
setDefaultLocale(locale: string): this {
|
|
197
|
+
this.#defaultLocale = normalizeLocale(locale);
|
|
198
|
+
// Invalidate the number-format-data cache: even though the
|
|
199
|
+
// cache is keyed by the *resolved* locale (so most entries
|
|
200
|
+
// stay correct across config changes), an entry produced by
|
|
201
|
+
// the previous "ultimate fallback" path may now mismatch.
|
|
202
|
+
this.#numberFormatDataCache.clear();
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
getDefaultLocale(): string {
|
|
207
|
+
return this.#defaultLocale;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setSupportedLocales(locales: string[]): this {
|
|
211
|
+
this.#supportedLocales = new Set(locales.map(normalizeLocale));
|
|
212
|
+
return this;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
getSupportedLocales(): string[] | undefined {
|
|
216
|
+
return this.#supportedLocales
|
|
217
|
+
? Array.from(this.#supportedLocales)
|
|
218
|
+
: undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
setFallbackLocale(locale: string): this {
|
|
222
|
+
this.#fallbackLocale = normalizeLocale(locale);
|
|
223
|
+
this.#numberFormatDataCache.clear();
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
getFallbackLocale(): string {
|
|
228
|
+
return this.#fallbackLocale;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setFallbackLocales(locales: Record<string, string>): this {
|
|
232
|
+
this.#fallbackLocales = normalizeFallbackMap(locales);
|
|
233
|
+
this.#numberFormatDataCache.clear();
|
|
234
|
+
return this;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
getFallbackLocales(): Record<string, string> {
|
|
238
|
+
return { ...this.#fallbackLocales };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
resolveLocale(input: string | LocaleResolverInput): string {
|
|
242
|
+
const requested =
|
|
243
|
+
typeof input === "string"
|
|
244
|
+
? parseAcceptLanguage(input)
|
|
245
|
+
: (input.accepted ?? parseAcceptLanguage(input.header ?? ""));
|
|
246
|
+
|
|
247
|
+
for (const candidate of requested) {
|
|
248
|
+
const normalized = normalizeLocale(candidate);
|
|
249
|
+
const supported = this.#pickFirstSupported(
|
|
250
|
+
this.#localeChainFor(normalized),
|
|
251
|
+
);
|
|
252
|
+
if (supported) {
|
|
253
|
+
return supported;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const fallback = this.#pickFirstSupported(
|
|
258
|
+
this.#localeChainFor(this.#defaultLocale),
|
|
259
|
+
);
|
|
260
|
+
return fallback ?? this.#defaultLocale;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
has(key: string, locale = this.#locale): boolean {
|
|
264
|
+
const normalizedLocale = normalizeLocale(locale);
|
|
265
|
+
const chain = this.#localeChainFor(normalizedLocale);
|
|
266
|
+
|
|
267
|
+
if (!this.#nativeEngine) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
"[ROSETTA_NAPI_REQUIRED] The Rust ICU engine is required. Build it with `cd packages/rosetta && pnpm build:napi`.",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
this.#syncNativeEngine();
|
|
273
|
+
return this.#nativeEngine.has(key, JSON.stringify(chain));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
t(
|
|
277
|
+
key: string,
|
|
278
|
+
params?: TranslationParams,
|
|
279
|
+
options?: TranslateOptions,
|
|
280
|
+
): string {
|
|
281
|
+
const requestedLocale = normalizeLocale(options?.locale ?? this.#locale);
|
|
282
|
+
const chain = this.#localeChainFor(requestedLocale);
|
|
283
|
+
|
|
284
|
+
if (!this.#nativeEngine) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
"[ROSETTA_NAPI_REQUIRED] The Rust ICU engine is required. Build it with `cd packages/rosetta && pnpm build:napi`.",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
this.#syncNativeEngine();
|
|
290
|
+
const paramsJson = params ? JSON.stringify(params) : undefined;
|
|
291
|
+
return this.#nativeEngine.translate(
|
|
292
|
+
key,
|
|
293
|
+
paramsJson,
|
|
294
|
+
JSON.stringify(chain),
|
|
295
|
+
options?.defaultValue,
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
formatNumber(
|
|
300
|
+
value: number,
|
|
301
|
+
options?: Intl.NumberFormatOptions,
|
|
302
|
+
locale = this.#locale,
|
|
303
|
+
): string {
|
|
304
|
+
return new Intl.NumberFormat(normalizeLocale(locale), options).format(
|
|
305
|
+
value,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
formatCurrency(
|
|
310
|
+
value: number,
|
|
311
|
+
currency: string,
|
|
312
|
+
options?: Intl.NumberFormatOptions,
|
|
313
|
+
locale = this.#locale,
|
|
314
|
+
): string {
|
|
315
|
+
// Spread `options` FIRST so explicit `style: "currency"` and
|
|
316
|
+
// `currency` always win — a caller's stray `{ style: "decimal" }`
|
|
317
|
+
// must not silently disable currency formatting.
|
|
318
|
+
return this.formatNumber(
|
|
319
|
+
value,
|
|
320
|
+
{ ...options, style: "currency", currency },
|
|
321
|
+
locale,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
formatDate(
|
|
326
|
+
value: Date | number | string,
|
|
327
|
+
options?: Intl.DateTimeFormatOptions,
|
|
328
|
+
locale = this.#locale,
|
|
329
|
+
): string {
|
|
330
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
331
|
+
return new Intl.DateTimeFormat(normalizeLocale(locale), options).format(
|
|
332
|
+
date,
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
formatRelativeTime(
|
|
337
|
+
value: number,
|
|
338
|
+
unit: Intl.RelativeTimeFormatUnit,
|
|
339
|
+
options?: Intl.RelativeTimeFormatOptions,
|
|
340
|
+
locale = this.#locale,
|
|
341
|
+
): string {
|
|
342
|
+
return new Intl.RelativeTimeFormat(normalizeLocale(locale), options).format(
|
|
343
|
+
value,
|
|
344
|
+
unit,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Resolve the numeric separators for `locale`, honoring the
|
|
350
|
+
* configured `fallbackLocales` chain. Returns the first locale
|
|
351
|
+
* in the chain that `Intl.NumberFormat` actually supports.
|
|
352
|
+
*
|
|
353
|
+
* Cached per resolved locale so the `formatToParts` work runs
|
|
354
|
+
* once per chain destination.
|
|
355
|
+
*/
|
|
356
|
+
getNumberFormatData(locale: string = this.#locale): NumberFormatData {
|
|
357
|
+
const resolved = this.#resolveNumberLocale(locale);
|
|
358
|
+
const cached = this.#numberFormatDataCache.get(resolved);
|
|
359
|
+
if (cached) return cached;
|
|
360
|
+
const formatter = new Intl.NumberFormat(resolved);
|
|
361
|
+
const negativeParts = formatter.formatToParts(-12345.6);
|
|
362
|
+
const positiveParts = new Intl.NumberFormat(resolved, {
|
|
363
|
+
signDisplay: "always",
|
|
364
|
+
}).formatToParts(1);
|
|
365
|
+
const data: NumberFormatData = {
|
|
366
|
+
decimal: negativeParts.find((p) => p.type === "decimal")?.value ?? ".",
|
|
367
|
+
group: negativeParts.find((p) => p.type === "group")?.value ?? ",",
|
|
368
|
+
minus: negativeParts.find((p) => p.type === "minusSign")?.value ?? "-",
|
|
369
|
+
plusSign: positiveParts.find((p) => p.type === "plusSign")?.value ?? "+",
|
|
370
|
+
};
|
|
371
|
+
this.#numberFormatDataCache.set(resolved, data);
|
|
372
|
+
return data;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Format a string-valued number through `Intl.NumberFormat`'s
|
|
377
|
+
* string-accepting overload — preserves precision on 18+-digit
|
|
378
|
+
* values that would otherwise truncate via `Number()`.
|
|
379
|
+
*
|
|
380
|
+
* Locale resolution uses the same fallback chain as
|
|
381
|
+
* `getNumberFormatData`.
|
|
382
|
+
*/
|
|
383
|
+
formatNumberString(
|
|
384
|
+
value: string,
|
|
385
|
+
options?: Intl.NumberFormatOptions,
|
|
386
|
+
locale = this.#locale,
|
|
387
|
+
): string {
|
|
388
|
+
const resolved = this.#resolveNumberLocale(locale);
|
|
389
|
+
const formatter = new Intl.NumberFormat(
|
|
390
|
+
resolved,
|
|
391
|
+
options,
|
|
392
|
+
) as StringNumberFormat;
|
|
393
|
+
return formatter.format(value);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#resolveNumberLocale(locale: string): string {
|
|
397
|
+
const chain = this.#localeChainFor(normalizeLocale(locale));
|
|
398
|
+
for (const candidate of chain) {
|
|
399
|
+
const supported = Intl.NumberFormat.supportedLocalesOf([candidate], {
|
|
400
|
+
localeMatcher: "lookup",
|
|
401
|
+
});
|
|
402
|
+
if (supported.length > 0) return supported[0];
|
|
403
|
+
}
|
|
404
|
+
// Last-resort: the configured #defaultLocale itself may not be
|
|
405
|
+
// Intl-supported (e.g. an invented private-use tag). Validate
|
|
406
|
+
// it once so the cache key is always a real Intl locale.
|
|
407
|
+
const defaultSupported = Intl.NumberFormat.supportedLocalesOf(
|
|
408
|
+
[this.#defaultLocale],
|
|
409
|
+
{ localeMatcher: "lookup" },
|
|
410
|
+
);
|
|
411
|
+
if (defaultSupported.length > 0) return defaultSupported[0];
|
|
412
|
+
// Truly unresolvable — fall back to "en", which every Intl
|
|
413
|
+
// implementation must support per ECMA-402.
|
|
414
|
+
return "en";
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async #loadFromLoaders(locale: string): Promise<void> {
|
|
418
|
+
for (const loader of this.#loaders) {
|
|
419
|
+
const messages = await loader.load(locale);
|
|
420
|
+
if (messages) {
|
|
421
|
+
this.loadMessages(locale, messages);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
#pickFirstSupported(candidates: string[]): string | undefined {
|
|
427
|
+
if (!this.#supportedLocales) {
|
|
428
|
+
return candidates[0];
|
|
429
|
+
}
|
|
430
|
+
return candidates.find((candidate) =>
|
|
431
|
+
this.#supportedLocales?.has(candidate),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
#localeChainFor(locale: string): string[] {
|
|
436
|
+
const chain: string[] = [];
|
|
437
|
+
const visited = new Set<string>();
|
|
438
|
+
const push = (value: string) => {
|
|
439
|
+
const normalized = normalizeLocale(value);
|
|
440
|
+
if (!normalized || visited.has(normalized)) return;
|
|
441
|
+
visited.add(normalized);
|
|
442
|
+
chain.push(normalized);
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
push(locale);
|
|
446
|
+
|
|
447
|
+
const localeParts = locale.split("-");
|
|
448
|
+
if (localeParts.length > 1) {
|
|
449
|
+
push(localeParts[0]);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const explicitFallback = this.#fallbackLocales[locale];
|
|
453
|
+
if (explicitFallback) {
|
|
454
|
+
push(explicitFallback);
|
|
455
|
+
const explicitParts = explicitFallback.split("-");
|
|
456
|
+
if (explicitParts.length > 1) {
|
|
457
|
+
push(explicitParts[0]);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
push(this.#fallbackLocale);
|
|
462
|
+
const fallbackParts = this.#fallbackLocale.split("-");
|
|
463
|
+
if (fallbackParts.length > 1) {
|
|
464
|
+
push(fallbackParts[0]);
|
|
465
|
+
}
|
|
466
|
+
push(this.#defaultLocale);
|
|
467
|
+
const defaultParts = this.#defaultLocale.split("-");
|
|
468
|
+
if (defaultParts.length > 1) {
|
|
469
|
+
push(defaultParts[0]);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return chain;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Sync the Rust-resident catalog with the TS-side `#messages` map. Only
|
|
477
|
+
* performs JSON.stringify when the dirty flag is set (a locale was loaded or
|
|
478
|
+
* edited). On subsequent `t()` / `has()` calls with no changes, this is a
|
|
479
|
+
* no-op — the Rust engine already holds the parsed catalog in memory.
|
|
480
|
+
*
|
|
481
|
+
* Story 37.9: this replaces the old `#catalogsJson()` which serialized the
|
|
482
|
+
* entire catalog on EVERY `t()` call.
|
|
483
|
+
*/
|
|
484
|
+
#syncNativeEngine(): void {
|
|
485
|
+
if (!this.#nativeEngine) return;
|
|
486
|
+
if (!this.#catalogsCacheDirty && this.#catalogsCache) return;
|
|
487
|
+
const cache: Record<string, MessageCatalog> = {};
|
|
488
|
+
for (const [locale, catalog] of this.#messages.entries()) {
|
|
489
|
+
cache[locale] = catalog;
|
|
490
|
+
}
|
|
491
|
+
// loadCatalogs may throw (malformed JSON, lock poisoned). The dirty flag
|
|
492
|
+
// is only cleared AFTER a successful load so a retry on the next t()
|
|
493
|
+
// call re-attempts instead of silently using a stale/empty catalog.
|
|
494
|
+
this.#nativeEngine.loadCatalogs(JSON.stringify(cache));
|
|
495
|
+
this.#catalogsCache = cache;
|
|
496
|
+
this.#catalogsCacheDirty = false;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function normalizeLocale(locale: string): string {
|
|
501
|
+
return locale.trim().replace(/_/g, "-").toLowerCase();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function normalizeFallbackMap(
|
|
505
|
+
map: Record<string, string>,
|
|
506
|
+
): Record<string, string> {
|
|
507
|
+
const normalized: Record<string, string> = {};
|
|
508
|
+
for (const [from, to] of Object.entries(map)) {
|
|
509
|
+
normalized[normalizeLocale(from)] = normalizeLocale(to);
|
|
510
|
+
}
|
|
511
|
+
return normalized;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function parseAcceptLanguage(header: string): string[] {
|
|
515
|
+
return header
|
|
516
|
+
.split(",")
|
|
517
|
+
.map((item) => item.trim())
|
|
518
|
+
.filter(Boolean)
|
|
519
|
+
.map((item) => {
|
|
520
|
+
const [localePart, ...rest] = item.split(";");
|
|
521
|
+
let quality = 1;
|
|
522
|
+
for (const part of rest) {
|
|
523
|
+
const trimmed = part.trim();
|
|
524
|
+
if (trimmed.startsWith("q=")) {
|
|
525
|
+
const n = Number(trimmed.slice(2));
|
|
526
|
+
if (Number.isFinite(n)) quality = n;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return { locale: normalizeLocale(localePart), quality };
|
|
530
|
+
})
|
|
531
|
+
.filter((entry) => entry.locale.length > 0)
|
|
532
|
+
.sort((a, b) => b.quality - a.quality)
|
|
533
|
+
.map((entry) => entry.locale);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function flattenMessages(
|
|
537
|
+
messages: MessageTree | MessageCatalog,
|
|
538
|
+
): MessageCatalog {
|
|
539
|
+
const out: MessageCatalog = {};
|
|
540
|
+
walkFlatten("", messages, out);
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function walkFlatten(
|
|
545
|
+
prefix: string,
|
|
546
|
+
value: MessageTree | MessageCatalog | MessageValue,
|
|
547
|
+
out: MessageCatalog,
|
|
548
|
+
): void {
|
|
549
|
+
if (typeof value === "string") {
|
|
550
|
+
out[prefix] = value;
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
554
|
+
out[prefix] = String(value);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
if (value === null || value === undefined) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
for (const [key, child] of Object.entries(value)) {
|
|
562
|
+
const next = prefix ? `${prefix}.${key}` : key;
|
|
563
|
+
walkFlatten(
|
|
564
|
+
next,
|
|
565
|
+
child as MessageTree | MessageCatalog | MessageValue,
|
|
566
|
+
out,
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { FileSystemLoader } from "./loaders/FileSystemLoader.js";
|
|
2
|
+
import { Rosetta, type RosettaOptions } from "./Rosetta.js";
|
|
3
|
+
import { setI18n } from "./services/main.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Duck-typed host context — rosetta stays publishable without
|
|
7
|
+
* importing `@c9up/ream`. Any framework that exposes a Container + a
|
|
8
|
+
* config store satisfies the contract.
|
|
9
|
+
*/
|
|
10
|
+
interface RosettaContainer {
|
|
11
|
+
singleton(token: unknown, factory: () => unknown): void;
|
|
12
|
+
resolve<T = unknown>(token: unknown): T;
|
|
13
|
+
}
|
|
14
|
+
interface RosettaConfigStore {
|
|
15
|
+
get<T = unknown>(key: string): T | undefined;
|
|
16
|
+
}
|
|
17
|
+
export interface RosettaAppContext {
|
|
18
|
+
container: RosettaContainer;
|
|
19
|
+
config: RosettaConfigStore;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface RosettaProviderConfig extends RosettaOptions {
|
|
23
|
+
/**
|
|
24
|
+
* If set, a `FileSystemLoader` reading from this directory is
|
|
25
|
+
* appended to `options.loaders`. Most apps only need this and the
|
|
26
|
+
* `supportedLocales` list — the provider's `boot()` calls
|
|
27
|
+
* `rosetta.boot()` so catalogs are warm before the first request.
|
|
28
|
+
*/
|
|
29
|
+
rootDir?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* RosettaProvider — registers a shared `Rosetta` instance under
|
|
34
|
+
* `Rosetta` + `"i18n"` tokens, then awaits its boot so the configured
|
|
35
|
+
* locale catalogs are loaded by the time the first request lands.
|
|
36
|
+
*
|
|
37
|
+
* // reamrc.ts
|
|
38
|
+
* providers: [() => import('@c9up/rosetta/provider')]
|
|
39
|
+
*
|
|
40
|
+
* // config/i18n.ts
|
|
41
|
+
* import { resolve } from 'node:path'
|
|
42
|
+
* export default {
|
|
43
|
+
* defaultLocale: 'en',
|
|
44
|
+
* supportedLocales: ['en', 'fr'],
|
|
45
|
+
* rootDir: resolve('./resources/lang'),
|
|
46
|
+
* }
|
|
47
|
+
*
|
|
48
|
+
* // anywhere
|
|
49
|
+
* import i18n from '@c9up/rosetta/services/main'
|
|
50
|
+
* const t = i18n.locale('fr').t('greeting', { name: 'Alice' })
|
|
51
|
+
*/
|
|
52
|
+
export default class RosettaProvider {
|
|
53
|
+
constructor(protected app: RosettaAppContext) {}
|
|
54
|
+
|
|
55
|
+
register(): void {
|
|
56
|
+
this.app.container.singleton(Rosetta, () => {
|
|
57
|
+
const config = this.app.config.get<RosettaProviderConfig>("i18n");
|
|
58
|
+
const options: RosettaOptions = { ...(config ?? {}) };
|
|
59
|
+
if (config?.rootDir) {
|
|
60
|
+
const fsLoader = new FileSystemLoader({ rootDir: config.rootDir });
|
|
61
|
+
options.loaders = [...(options.loaders ?? []), fsLoader];
|
|
62
|
+
}
|
|
63
|
+
return new Rosetta(options);
|
|
64
|
+
});
|
|
65
|
+
this.app.container.singleton("i18n", () =>
|
|
66
|
+
this.app.container.resolve<Rosetta>(Rosetta),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async boot(): Promise<void> {
|
|
71
|
+
const rosetta = this.app.container.resolve<Rosetta>(Rosetta);
|
|
72
|
+
await rosetta.boot();
|
|
73
|
+
setI18n(rosetta);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async shutdown(): Promise<void> {}
|
|
77
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @c9up/rosetta — framework-agnostic internationalization module.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type { FileSystemLoaderOptions } from "./loaders/FileSystemLoader.js";
|
|
6
|
+
export { FileSystemLoader } from "./loaders/FileSystemLoader.js";
|
|
7
|
+
export { isNativeAvailable } from "./native.js";
|
|
8
|
+
export type {
|
|
9
|
+
LocaleResolverInput,
|
|
10
|
+
MessageCatalog,
|
|
11
|
+
MessageTree,
|
|
12
|
+
NumberFormatData,
|
|
13
|
+
RosettaLoader,
|
|
14
|
+
RosettaLocale,
|
|
15
|
+
RosettaOptions,
|
|
16
|
+
TranslateOptions,
|
|
17
|
+
TranslationParams,
|
|
18
|
+
} from "./Rosetta.js";
|
|
19
|
+
export { Rosetta } from "./Rosetta.js";
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import * as fsp from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import type { MessageCatalog, MessageTree, RosettaLoader } from "../Rosetta.js";
|
|
4
|
+
|
|
5
|
+
/** IANA BCP 47 locale ID pattern — allows `en`, `en-US`, `zh-Hant-TW`, etc. */
|
|
6
|
+
const SAFE_LOCALE_PATTERN = /^[a-zA-Z]{2,8}(-[a-zA-Z0-9]{2,8})*$/;
|
|
7
|
+
|
|
8
|
+
export interface FileSystemLoaderOptions {
|
|
9
|
+
rootDir: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class FileSystemLoader implements RosettaLoader {
|
|
13
|
+
#rootDir: string;
|
|
14
|
+
|
|
15
|
+
constructor(options: FileSystemLoaderOptions) {
|
|
16
|
+
this.#rootDir = options.rootDir;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async load(locale: string): Promise<MessageTree | MessageCatalog | null> {
|
|
20
|
+
// Validate locale to prevent path traversal. A locale like
|
|
21
|
+
// `../../etc/passwd` would escape the rootDir without this check.
|
|
22
|
+
if (!SAFE_LOCALE_PATTERN.test(locale)) return null;
|
|
23
|
+
|
|
24
|
+
for (const ext of ["json", "yaml", "yml"]) {
|
|
25
|
+
const fullPath = path.join(this.#rootDir, `${locale}.${ext}`);
|
|
26
|
+
// Defense-in-depth: verify the resolved path is still inside rootDir
|
|
27
|
+
// even after the regex check (belt + suspenders).
|
|
28
|
+
if (!path.resolve(fullPath).startsWith(path.resolve(this.#rootDir)))
|
|
29
|
+
return null;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const raw = await fsp.readFile(fullPath, "utf8");
|
|
33
|
+
return ext === "json"
|
|
34
|
+
? (JSON.parse(raw) as MessageTree | MessageCatalog)
|
|
35
|
+
: parseYaml(raw);
|
|
36
|
+
} catch (err: unknown) {
|
|
37
|
+
// ENOENT = file doesn't exist → try next extension. Any other error
|
|
38
|
+
// (permission denied, invalid JSON, disk failure) is a real problem
|
|
39
|
+
// that must surface — silently swallowing it would turn a config bug
|
|
40
|
+
// into a "missing translation" ghost that's impossible to debug in prod.
|
|
41
|
+
const code = (err as NodeJS.ErrnoException)?.code;
|
|
42
|
+
if (code === "ENOENT") continue;
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Minimal YAML parser for locale files. Supports:
|
|
52
|
+
* - Nested keys via indentation
|
|
53
|
+
* - Quoted and unquoted scalar values
|
|
54
|
+
* - Colons in values (URLs like `https://example.com`)
|
|
55
|
+
* - Multiline block scalars: `|` (literal) and `>` (folded)
|
|
56
|
+
* - Comments (`#`)
|
|
57
|
+
*
|
|
58
|
+
* Does NOT support: anchors/aliases (`&`/`*`), flow sequences (`[a, b]`),
|
|
59
|
+
* flow mappings (`{a: 1}`), complex keys, merge keys (`<<`), or tags (`!!`).
|
|
60
|
+
* For production apps with complex locale files, replace with `js-yaml`.
|
|
61
|
+
*/
|
|
62
|
+
function parseYaml(input: string): MessageTree {
|
|
63
|
+
const root: MessageTree = {};
|
|
64
|
+
const stack: Array<{ indent: number; node: MessageTree }> = [
|
|
65
|
+
{ indent: -1, node: root },
|
|
66
|
+
];
|
|
67
|
+
const lines = input.split(/\r?\n/);
|
|
68
|
+
let i = 0;
|
|
69
|
+
|
|
70
|
+
while (i < lines.length) {
|
|
71
|
+
const rawLine = lines[i];
|
|
72
|
+
i++;
|
|
73
|
+
if (!rawLine.trim() || rawLine.trim().startsWith("#")) continue;
|
|
74
|
+
|
|
75
|
+
// Match `key:` with an optional value. The key stops at the first unquoted
|
|
76
|
+
// colon that is NOT inside a URL scheme (`://`). We use a simpler heuristic:
|
|
77
|
+
// split on the first `:` followed by a space or end-of-line. This correctly
|
|
78
|
+
// handles `url: https://example.com` because the `: ` after `url` is the
|
|
79
|
+
// first split point, and `://` inside the value has no preceding space.
|
|
80
|
+
const match = rawLine.match(/^(\s*)([^:#\s][^:]*?)\s*:\s*(.*)$/);
|
|
81
|
+
if (!match) continue;
|
|
82
|
+
|
|
83
|
+
const indent = match[1].length;
|
|
84
|
+
const key = match[2].trim();
|
|
85
|
+
let valueRaw = match[3].trim();
|
|
86
|
+
|
|
87
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
88
|
+
stack.pop();
|
|
89
|
+
}
|
|
90
|
+
const parent = stack[stack.length - 1].node;
|
|
91
|
+
|
|
92
|
+
// Block scalar: `|` (literal) or `>` (folded).
|
|
93
|
+
if (valueRaw === "|" || valueRaw === ">") {
|
|
94
|
+
const block = parseBlockScalar(lines, i, indent, valueRaw === ">");
|
|
95
|
+
parent[key] = block.value;
|
|
96
|
+
i = block.nextIndex;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!valueRaw) {
|
|
101
|
+
const child: MessageTree = {};
|
|
102
|
+
parent[key] = child;
|
|
103
|
+
stack.push({ indent, node: child });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Strip inline comments: `value # comment` → `value`. But only if the `#`
|
|
108
|
+
// is preceded by whitespace (to avoid stripping inside URLs like `#fragment`).
|
|
109
|
+
const commentIdx = valueRaw.search(/\s+#/);
|
|
110
|
+
if (commentIdx > 0) valueRaw = valueRaw.slice(0, commentIdx).trim();
|
|
111
|
+
|
|
112
|
+
parent[key] = parseScalar(valueRaw);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return root;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Consume a `|` (literal) or `>` (folded) block scalar starting at `startIndex`.
|
|
120
|
+
* Returns the joined value and the index of the first line past the block.
|
|
121
|
+
*/
|
|
122
|
+
function parseBlockScalar(
|
|
123
|
+
lines: string[],
|
|
124
|
+
startIndex: number,
|
|
125
|
+
indent: number,
|
|
126
|
+
fold: boolean,
|
|
127
|
+
): { value: string; nextIndex: number } {
|
|
128
|
+
const blockLines: string[] = [];
|
|
129
|
+
const blockIndent = indent + 2;
|
|
130
|
+
let i = startIndex;
|
|
131
|
+
while (i < lines.length) {
|
|
132
|
+
const bl = lines[i];
|
|
133
|
+
// A line with less indent (or empty after content) ends the block.
|
|
134
|
+
if (bl.trim() && bl.search(/\S/) < blockIndent) break;
|
|
135
|
+
blockLines.push(bl.slice(blockIndent) || "");
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
// Trim trailing empty lines.
|
|
139
|
+
while (blockLines.length > 0 && blockLines[blockLines.length - 1] === "") {
|
|
140
|
+
blockLines.pop();
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
value: fold ? foldParagraphs(blockLines) : blockLines.join("\n"),
|
|
144
|
+
nextIndex: i,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fold a `>` block scalar: consecutive non-blank lines join with a space, blank
|
|
150
|
+
* lines become hard newlines (paragraph breaks per the YAML spec).
|
|
151
|
+
*/
|
|
152
|
+
function foldParagraphs(blockLines: string[]): string {
|
|
153
|
+
const paragraphs: string[] = [];
|
|
154
|
+
let current: string[] = [];
|
|
155
|
+
for (const bl of blockLines) {
|
|
156
|
+
if (bl === "") {
|
|
157
|
+
if (current.length > 0) paragraphs.push(current.join(" "));
|
|
158
|
+
current = [];
|
|
159
|
+
paragraphs.push("");
|
|
160
|
+
} else {
|
|
161
|
+
current.push(bl);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (current.length > 0) paragraphs.push(current.join(" "));
|
|
165
|
+
return paragraphs.join("\n");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseScalar(value: string): string | number | boolean | null {
|
|
169
|
+
if (value.startsWith('"') && value.endsWith('"')) {
|
|
170
|
+
// Double-quoted: process YAML escape sequences (\n, \t, \\, \', \")
|
|
171
|
+
return value
|
|
172
|
+
.slice(1, -1)
|
|
173
|
+
.replace(/\\n/g, "\n")
|
|
174
|
+
.replace(/\\t/g, "\t")
|
|
175
|
+
.replace(/\\\\/g, "\\")
|
|
176
|
+
.replace(/\\'/g, "'")
|
|
177
|
+
.replace(/\\"/g, '"');
|
|
178
|
+
}
|
|
179
|
+
if (value.startsWith("'") && value.endsWith("'")) {
|
|
180
|
+
// Single-quoted: only '' escape (doubled apostrophe → single)
|
|
181
|
+
return value.slice(1, -1).replace(/''/g, "'");
|
|
182
|
+
}
|
|
183
|
+
if (value === "true") return true;
|
|
184
|
+
if (value === "false") return false;
|
|
185
|
+
if (value === "null") return null;
|
|
186
|
+
|
|
187
|
+
const n = Number(value);
|
|
188
|
+
if (Number.isFinite(n) && value !== "") return n;
|
|
189
|
+
|
|
190
|
+
return value;
|
|
191
|
+
}
|
package/src/native.ts
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Universal engine loader — auto-detects Node (NAPI) vs Browser (WASM).
|
|
3
|
+
*
|
|
4
|
+
* Node gets the stateful `RosettaEngine` class (resident catalog in Rust memory).
|
|
5
|
+
* Browser gets the stateless WASM functions (re-parse catalog per call — acceptable
|
|
6
|
+
* because browser apps typically have smaller catalogs than server-rendered i18n).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface NativeRosettaEngine {
|
|
10
|
+
loadCatalogs(catalogsJson: string): void;
|
|
11
|
+
translate(
|
|
12
|
+
key: string,
|
|
13
|
+
paramsJson: string | undefined,
|
|
14
|
+
chainJson: string,
|
|
15
|
+
defaultValue: string | undefined,
|
|
16
|
+
): string;
|
|
17
|
+
has(key: string, chainJson: string): boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface NativeModule {
|
|
21
|
+
RosettaEngine: new () => NativeRosettaEngine;
|
|
22
|
+
translate: (
|
|
23
|
+
catalogsJson: string,
|
|
24
|
+
key: string,
|
|
25
|
+
paramsJson: string | undefined,
|
|
26
|
+
chainJson: string,
|
|
27
|
+
defaultValue: string | undefined,
|
|
28
|
+
) => string;
|
|
29
|
+
has: (catalogsJson: string, key: string, chainJson: string) => boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let native: NativeModule | undefined;
|
|
33
|
+
interface WasmStatelessApi {
|
|
34
|
+
translate(
|
|
35
|
+
catalogsJson: string,
|
|
36
|
+
key: string,
|
|
37
|
+
paramsJson: string | undefined,
|
|
38
|
+
chainJson: string,
|
|
39
|
+
defaultValue: string | undefined,
|
|
40
|
+
): string;
|
|
41
|
+
has(catalogsJson: string, key: string, chainJson: string): boolean;
|
|
42
|
+
}
|
|
43
|
+
let wasmModule: WasmStatelessApi | undefined;
|
|
44
|
+
let loadError: unknown;
|
|
45
|
+
|
|
46
|
+
const isNode =
|
|
47
|
+
typeof globalThis.process !== "undefined" &&
|
|
48
|
+
typeof globalThis.process.versions?.node === "string";
|
|
49
|
+
|
|
50
|
+
if (isNode) {
|
|
51
|
+
try {
|
|
52
|
+
const { createRequire } = await import("node:module");
|
|
53
|
+
const { dirname, join } = await import("node:path");
|
|
54
|
+
const { fileURLToPath } = await import("node:url");
|
|
55
|
+
const { arch, platform } = await import("node:process");
|
|
56
|
+
|
|
57
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
58
|
+
const currentDir = dirname(fileURLToPath(import.meta.url));
|
|
59
|
+
|
|
60
|
+
const platformMap: Record<string, string> = {
|
|
61
|
+
"linux-x64": "linux-x64-gnu",
|
|
62
|
+
"linux-arm64": "linux-arm64-gnu",
|
|
63
|
+
"darwin-x64": "darwin-x64",
|
|
64
|
+
"darwin-arm64": "darwin-arm64",
|
|
65
|
+
"win32-x64": "win32-x64-msvc",
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const suffix = platformMap[`${platform}-${arch}`];
|
|
69
|
+
if (suffix) {
|
|
70
|
+
native = nodeRequire(join(currentDir, `../index.${suffix}.node`));
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
loadError = e;
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
try {
|
|
77
|
+
// The WASM glue generated by wasm-pack exports `translate` and `has` as
|
|
78
|
+
// top-level functions with the exact same signatures as `WasmStatelessApi`.
|
|
79
|
+
// We declare the import shape explicitly so no cast is needed.
|
|
80
|
+
const wasm: { default: () => Promise<unknown> } & WasmStatelessApi =
|
|
81
|
+
await import("../wasm/rosetta_engine_wasm.js");
|
|
82
|
+
await wasm.default();
|
|
83
|
+
wasmModule = wasm;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
loadError = e;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function isNativeAvailable(): boolean {
|
|
90
|
+
return native !== undefined || wasmModule !== undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Create a stateful engine (Node NAPI only). Returns null in browser (WASM is stateless). */
|
|
94
|
+
export function createNativeEngine(): NativeRosettaEngine | null {
|
|
95
|
+
if (!native) {
|
|
96
|
+
if (!wasmModule) return null;
|
|
97
|
+
const wasm = wasmModule;
|
|
98
|
+
let catalogsJson = "{}";
|
|
99
|
+
return {
|
|
100
|
+
loadCatalogs(json: string) {
|
|
101
|
+
catalogsJson = json;
|
|
102
|
+
},
|
|
103
|
+
translate(key, paramsJson, chainJson, defaultValue) {
|
|
104
|
+
return wasm.translate(
|
|
105
|
+
catalogsJson,
|
|
106
|
+
key,
|
|
107
|
+
paramsJson ?? undefined,
|
|
108
|
+
chainJson,
|
|
109
|
+
defaultValue ?? undefined,
|
|
110
|
+
) as string;
|
|
111
|
+
},
|
|
112
|
+
has(key, chainJson) {
|
|
113
|
+
return wasm.has(catalogsJson, key, chainJson) as boolean;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return new native.RosettaEngine();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Legacy stateless exports (used by tests)
|
|
121
|
+
export function nativeTranslate(
|
|
122
|
+
catalogsJson: string,
|
|
123
|
+
key: string,
|
|
124
|
+
paramsJson: string | undefined,
|
|
125
|
+
chainJson: string,
|
|
126
|
+
defaultValue: string | undefined,
|
|
127
|
+
): string {
|
|
128
|
+
if (native)
|
|
129
|
+
return native.translate(
|
|
130
|
+
catalogsJson,
|
|
131
|
+
key,
|
|
132
|
+
paramsJson,
|
|
133
|
+
chainJson,
|
|
134
|
+
defaultValue,
|
|
135
|
+
);
|
|
136
|
+
if (wasmModule)
|
|
137
|
+
return wasmModule.translate(
|
|
138
|
+
catalogsJson,
|
|
139
|
+
key,
|
|
140
|
+
paramsJson,
|
|
141
|
+
chainJson,
|
|
142
|
+
defaultValue,
|
|
143
|
+
) as string;
|
|
144
|
+
throw new Error(
|
|
145
|
+
`[ROSETTA_ENGINE_NOT_FOUND] ${loadError ?? "binary not found"}`,
|
|
146
|
+
{ cause: loadError },
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function nativeHas(
|
|
151
|
+
catalogsJson: string,
|
|
152
|
+
key: string,
|
|
153
|
+
chainJson: string,
|
|
154
|
+
): boolean {
|
|
155
|
+
if (native) return native.has(catalogsJson, key, chainJson);
|
|
156
|
+
if (wasmModule)
|
|
157
|
+
return wasmModule.has(catalogsJson, key, chainJson) as boolean;
|
|
158
|
+
throw new Error(
|
|
159
|
+
`[ROSETTA_ENGINE_NOT_FOUND] ${loadError ?? "binary not found"}`,
|
|
160
|
+
{ cause: loadError },
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default `Rosetta` singleton — mirror of Adonis's
|
|
3
|
+
* `import i18n from '@adonisjs/i18n/services/main'` shape.
|
|
4
|
+
*
|
|
5
|
+
* import i18n from '@c9up/rosetta/services/main'
|
|
6
|
+
*
|
|
7
|
+
* const locale = i18n.locale(i18n.resolveLocale({ header: req.headers['accept-language'] }))
|
|
8
|
+
* locale.t('greeting', { name: user.name })
|
|
9
|
+
*
|
|
10
|
+
* Populated by `RosettaProvider.boot()` or by the app directly through
|
|
11
|
+
* `setI18n(myRosetta)` when the i18n config has to be built outside
|
|
12
|
+
* the provider flow.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Rosetta } from "../Rosetta.js";
|
|
16
|
+
|
|
17
|
+
let instance: Rosetta | undefined;
|
|
18
|
+
|
|
19
|
+
/** @internal Bind the singleton (called by RosettaProvider or the app). */
|
|
20
|
+
export function setI18n(value: Rosetta): void {
|
|
21
|
+
instance = value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** @internal Read the singleton (or `undefined` pre-boot). */
|
|
25
|
+
export function getI18n(): Rosetta | undefined {
|
|
26
|
+
return instance;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const i18n: Rosetta = new Proxy({} as Rosetta, {
|
|
30
|
+
get(_target, prop) {
|
|
31
|
+
if (!instance) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"[rosetta] Rosetta singleton accessed before RosettaProvider.boot() ran " +
|
|
34
|
+
"or `setI18n(myRosetta)` was called. Wire one of them first.",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
const value = Reflect.get(instance, prop, instance);
|
|
38
|
+
return typeof value === "function" ? value.bind(instance) : value;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export default i18n;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Hand-written stub for the wasm-pack-generated glue file. Lets `tsc --noEmit`
|
|
2
|
+
// pass on a fresh checkout before `pnpm build:wasm` has been run. wasm-pack
|
|
3
|
+
// will overwrite this file with its real generated declarations on the next
|
|
4
|
+
// `build:wasm`; the runtime shape stays compatible.
|
|
5
|
+
//
|
|
6
|
+
// Story 52.1 review patch (2026-05-09): a) WASM artefact was previously
|
|
7
|
+
// emitted to `../../dist/wasm/` (sibling of the package, outside its
|
|
8
|
+
// tarball surface) while src/native.ts imported `../dist/wasm/...`. The
|
|
9
|
+
// artefact never landed in the published tarball — a browser consumer
|
|
10
|
+
// would hit `Cannot find module` at runtime. Same pattern as Epic 51 F3+F4
|
|
11
|
+
// (atom + chronos). Fixed: build:wasm out-dir is now `../../wasm` (lands
|
|
12
|
+
// inside the package), import path is `../wasm/...`, package.json files
|
|
13
|
+
// array now includes `wasm`. b) typecheck was also broken on a fresh
|
|
14
|
+
// checkout because tsc couldn't find this module before `build:wasm` had
|
|
15
|
+
// been run. This stub provides the minimum types needed.
|
|
16
|
+
|
|
17
|
+
export default function init(): Promise<unknown>;
|
|
18
|
+
|
|
19
|
+
export function translate(
|
|
20
|
+
catalogsJson: string,
|
|
21
|
+
key: string,
|
|
22
|
+
paramsJson: string | undefined,
|
|
23
|
+
chainJson: string,
|
|
24
|
+
defaultValue: string | undefined,
|
|
25
|
+
): string;
|
|
26
|
+
|
|
27
|
+
export function has(catalogsJson: string, key: string, chainJson: string): boolean;
|