@rimelight/i18n 0.0.3 → 0.0.5
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 +19 -0
- package/package.json +31 -7
- package/src/env.d.ts +13 -0
- package/src/index.ts +46 -15
- package/src/integration.ts +102 -33
- package/src/middleware/kv.ts +1 -1
- package/src/middleware/routing.ts +22 -9
- package/src/middleware.ts +1 -1
- package/src/runtime.ts +316 -0
- package/src/utils.ts +3 -2
- package/src/virtual.d.ts +9 -2
package/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Rimelight Entertainment Workspace
|
|
2
|
+
|
|
3
|
+
## Structure
|
|
4
|
+
|
|
5
|
+
### Apps (`/packages`)
|
|
6
|
+
|
|
7
|
+
- **`rimelight.com`**: The main company website.
|
|
8
|
+
- **`starter.rimelight.com`**: Our standardized starter template for Astro websites.
|
|
9
|
+
|
|
10
|
+
### Packages (`/packages`)
|
|
11
|
+
|
|
12
|
+
- **`@rimelight/auth`**: Authentication and authorization utilities for the Rimelight ecosystem.
|
|
13
|
+
- **`@rimelight/cli`**: The command line interface for managing Rimelight projects.
|
|
14
|
+
- **`@rimelight/cms`**: Enterprise content management, block rendering, and wiki engine.
|
|
15
|
+
- **`@rimelight/docs`**: Documentation components and utilities.
|
|
16
|
+
- **`@rimelight/i18n`**: Internationalization and localization tools.
|
|
17
|
+
- **`@rimelight/security`**: Astro security integration (CSP, SRI, and more).
|
|
18
|
+
- **`@rimelight/seo`**: SEO utilities including sitemap, robots, and meta components.
|
|
19
|
+
- **`@rimelight/ui`**: Our component library used in all our web projects.
|
package/package.json
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rimelight/i18n",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Rimelight
|
|
5
|
+
"description": "Rimelight Entertainment's Internationalization Package",
|
|
6
|
+
"homepage": "https://rimelight.com/docs",
|
|
7
|
+
"bugs": {
|
|
8
|
+
"url": "https://github.com/Rimelight-Entertainment/rimelight/issues"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": {
|
|
12
|
+
"name": "Rimelight Entertainment"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/Rimelight-Entertainment/rimelight.git"
|
|
17
|
+
},
|
|
6
18
|
"files": [
|
|
7
19
|
"src"
|
|
8
20
|
],
|
|
@@ -11,23 +23,35 @@
|
|
|
11
23
|
".": "./src/index.ts",
|
|
12
24
|
"./integration": "./src/integration.ts",
|
|
13
25
|
"./middleware": "./src/middleware.ts",
|
|
26
|
+
"./runtime": "./src/runtime.ts",
|
|
14
27
|
"./types": "./src/types.ts",
|
|
15
28
|
"./utils": "./src/utils.ts"
|
|
16
29
|
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
17
33
|
"scripts": {
|
|
18
|
-
"check": "
|
|
34
|
+
"check": "pnpm audit --audit-level=moderate && vp check --fix"
|
|
19
35
|
},
|
|
20
36
|
"dependencies": {
|
|
21
37
|
"@nanostores/i18n": "1.3.3",
|
|
22
|
-
"
|
|
23
|
-
|
|
38
|
+
"nanostores": "1.5.1"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@rimelight/config": "workspace:*",
|
|
42
|
+
"astro": "7.2.2",
|
|
43
|
+
"typescript": "6.0.3"
|
|
24
44
|
},
|
|
25
45
|
"peerDependencies": {
|
|
26
|
-
"astro": ">=
|
|
46
|
+
"astro": ">=7.0.0"
|
|
27
47
|
},
|
|
28
48
|
"peerDependenciesMeta": {
|
|
29
49
|
"astro": {
|
|
30
50
|
"optional": true
|
|
31
51
|
}
|
|
32
|
-
}
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=26.7.0"
|
|
55
|
+
},
|
|
56
|
+
"packageManager": "pnpm@11.22.0"
|
|
33
57
|
}
|
package/src/env.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Enables importing .astro components inside .ts files (e.g., email rendering)
|
|
2
|
+
declare module "*.astro" {
|
|
3
|
+
export default {} as import("astro/runtime/server").AstroComponentFactory
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
declare module "astro:i18n" {
|
|
7
|
+
export function getRelativeLocaleUrl(locale: string, path?: string): string
|
|
8
|
+
export function getAbsoluteLocaleUrl(locale: string, path?: string): string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
declare module "@rimelight/i18n:runtime" {
|
|
12
|
+
export * from "./runtime"
|
|
13
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -3,63 +3,86 @@ import type { ComponentsJSON, KVNamespaceBinding } from "./types"
|
|
|
3
3
|
import {
|
|
4
4
|
useI18n as baseUseI18n,
|
|
5
5
|
useI18nAsync as baseUseI18nAsync,
|
|
6
|
+
t,
|
|
6
7
|
currentLocale,
|
|
7
8
|
useFormat,
|
|
8
9
|
clearCache,
|
|
9
10
|
getI18nInstance,
|
|
10
11
|
getFormatterInstance
|
|
11
|
-
} from "
|
|
12
|
+
} from "@rimelight/i18n:runtime"
|
|
12
13
|
import type { Translations } from "@nanostores/i18n"
|
|
13
14
|
|
|
14
|
-
export { currentLocale, useFormat, clearCache, getI18nInstance, getFormatterInstance }
|
|
15
|
+
export { t, currentLocale, useFormat, clearCache, getI18nInstance, getFormatterInstance }
|
|
15
16
|
|
|
17
|
+
export interface ComponentMessages {
|
|
18
|
+
[key: string]: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useI18n(componentName: string): ComponentMessages
|
|
16
22
|
export function useI18n<Body extends Translations>(
|
|
17
23
|
componentName: string,
|
|
18
24
|
baseTranslations: Body
|
|
19
|
-
): Body
|
|
25
|
+
): ComponentMessages & Body
|
|
26
|
+
export function useI18n(
|
|
27
|
+
astro: { currentLocale?: string | undefined; params?: Record<string, any> } | undefined | null,
|
|
28
|
+
componentName: string
|
|
29
|
+
): ComponentMessages
|
|
20
30
|
export function useI18n<Body extends Translations>(
|
|
21
|
-
astro: { currentLocale?: string | undefined } | undefined | null,
|
|
31
|
+
astro: { currentLocale?: string | undefined; params?: Record<string, any> } | undefined | null,
|
|
22
32
|
componentName: string,
|
|
23
33
|
baseTranslations: Body
|
|
24
|
-
): Body
|
|
25
|
-
export function useI18n(arg1: any, arg2
|
|
34
|
+
): ComponentMessages & Body
|
|
35
|
+
export function useI18n(arg1: any, arg2?: any, arg3?: any): any {
|
|
26
36
|
if (typeof arg1 === "string") {
|
|
27
37
|
return baseUseI18n(arg1, arg2)
|
|
28
38
|
} else {
|
|
29
|
-
const
|
|
30
|
-
|
|
39
|
+
const locale = arg1?.currentLocale || arg1?.params?.locale
|
|
40
|
+
if (locale) {
|
|
41
|
+
currentLocale.set(locale)
|
|
42
|
+
}
|
|
31
43
|
return baseUseI18n(arg2, arg3)
|
|
32
44
|
}
|
|
33
45
|
}
|
|
34
46
|
|
|
47
|
+
export function useI18nAsync(componentName: string): Promise<Record<string, string>>
|
|
35
48
|
export function useI18nAsync<Body extends Translations>(
|
|
36
49
|
componentName: string,
|
|
37
50
|
baseTranslations: Body
|
|
38
|
-
): Promise<Body>
|
|
51
|
+
): Promise<Record<string, string> & Body>
|
|
52
|
+
export function useI18nAsync(
|
|
53
|
+
astro: { currentLocale?: string | undefined; params?: Record<string, any> } | undefined | null,
|
|
54
|
+
componentName: string
|
|
55
|
+
): Promise<Record<string, string>>
|
|
39
56
|
export function useI18nAsync<Body extends Translations>(
|
|
40
|
-
astro: { currentLocale?: string | undefined } | undefined | null,
|
|
57
|
+
astro: { currentLocale?: string | undefined; params?: Record<string, any> } | undefined | null,
|
|
41
58
|
componentName: string,
|
|
42
59
|
baseTranslations: Body
|
|
43
|
-
): Promise<Body>
|
|
44
|
-
export function useI18nAsync(arg1: any, arg2
|
|
60
|
+
): Promise<Record<string, string> & Body>
|
|
61
|
+
export function useI18nAsync(arg1: any, arg2?: any, arg3?: any): any {
|
|
45
62
|
if (typeof arg1 === "string") {
|
|
46
63
|
return baseUseI18nAsync(arg1, arg2)
|
|
47
64
|
} else {
|
|
48
|
-
const
|
|
49
|
-
|
|
65
|
+
const locale = arg1?.currentLocale || arg1?.params?.locale
|
|
66
|
+
if (locale) {
|
|
67
|
+
currentLocale.set(locale)
|
|
68
|
+
}
|
|
50
69
|
return baseUseI18nAsync(arg2, arg3)
|
|
51
70
|
}
|
|
52
71
|
}
|
|
53
72
|
|
|
54
73
|
import { getRelativeLocaleUrl as astroGetRelativeLocaleUrl } from "astro:i18n"
|
|
55
74
|
|
|
75
|
+
export function getLocale(): string {
|
|
76
|
+
return currentLocale.get() || "en"
|
|
77
|
+
}
|
|
78
|
+
|
|
56
79
|
export function getRelativeLocaleUrl(path: string): string
|
|
57
80
|
export function getRelativeLocaleUrl(locale: string, path: string): string
|
|
58
81
|
export function getRelativeLocaleUrl(arg1: string, arg2?: string): string {
|
|
59
82
|
if (arg2 !== undefined) {
|
|
60
83
|
return astroGetRelativeLocaleUrl(arg1, arg2)
|
|
61
84
|
} else {
|
|
62
|
-
const locale =
|
|
85
|
+
const locale = getLocale()
|
|
63
86
|
return astroGetRelativeLocaleUrl(locale, arg1)
|
|
64
87
|
}
|
|
65
88
|
}
|
|
@@ -110,3 +133,11 @@ export function createTranslationLoader(
|
|
|
110
133
|
return Object.assign({}, ...results)
|
|
111
134
|
}
|
|
112
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Returns the plural category (e.g. 'one', 'few', 'many', 'other') for a given number and locale
|
|
139
|
+
* using the native browser Intl.PluralRules API.
|
|
140
|
+
*/
|
|
141
|
+
export function getPluralCategory(count: number, locale: string = "en"): Intl.LDMLPluralRule {
|
|
142
|
+
return new Intl.PluralRules(locale).select(count)
|
|
143
|
+
}
|
package/src/integration.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import nanostoresI18n from "astro-nanostores-i18n"
|
|
2
1
|
import type { AstroIntegration } from "astro"
|
|
3
2
|
import fs from "node:fs"
|
|
4
3
|
import path from "node:path"
|
|
@@ -10,23 +9,38 @@ export interface RimelightI18nOptions {
|
|
|
10
9
|
translationLoader?: string
|
|
11
10
|
}
|
|
12
11
|
|
|
13
|
-
export function rimelightI18n(options?: RimelightI18nOptions):
|
|
12
|
+
export function rimelightI18n(options?: RimelightI18nOptions): AstroIntegration[] {
|
|
14
13
|
const validateExtraction = options?.validateExtraction ?? true
|
|
15
14
|
const translations = options?.translations
|
|
16
15
|
const kvBinding = options?.kvBinding ?? "TRANSLATIONS_KV"
|
|
17
|
-
const translationLoader = options?.translationLoader
|
|
16
|
+
const translationLoader = options?.translationLoader
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
// Normalise translations passed in options
|
|
19
|
+
const normalisedTranslations: Record<string, any> = {}
|
|
20
20
|
if (translations) {
|
|
21
21
|
for (const [locale, val] of Object.entries(translations)) {
|
|
22
|
-
|
|
23
|
-
nanostoresTranslations["pt-br"] = val
|
|
24
|
-
} else {
|
|
25
|
-
nanostoresTranslations[locale] = val
|
|
26
|
-
}
|
|
22
|
+
normalisedTranslations[locale] = val
|
|
27
23
|
}
|
|
28
24
|
}
|
|
29
25
|
|
|
26
|
+
// ---------- Vite virtual module: @rimelight/i18n:runtime ----------
|
|
27
|
+
// This replicates what astro-nanostores-i18n integration.js used to do:
|
|
28
|
+
// it creates a virtual module that calls initializeI18n() once at module
|
|
29
|
+
// evaluation time and re-exports all runtime helpers.
|
|
30
|
+
const VIRTUAL_MODULE_ID = "@rimelight/i18n:runtime"
|
|
31
|
+
const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID
|
|
32
|
+
|
|
33
|
+
// Use the package export specifier so Vite resolves it through normal
|
|
34
|
+
// module resolution rather than an absolute filesystem path.
|
|
35
|
+
const runtimeSpecifier = "@rimelight/i18n/runtime"
|
|
36
|
+
|
|
37
|
+
const loaderImport = translationLoader
|
|
38
|
+
? `import translationLoader from ${JSON.stringify(translationLoader)};`
|
|
39
|
+
: ""
|
|
40
|
+
const loaderOption = translationLoader ? ", get: translationLoader" : ""
|
|
41
|
+
|
|
42
|
+
// ----------------------------------------------------------------
|
|
43
|
+
|
|
30
44
|
const rimelightI18nCore: AstroIntegration = {
|
|
31
45
|
name: "@rimelight/i18n",
|
|
32
46
|
hooks: {
|
|
@@ -34,22 +48,50 @@ export function rimelightI18n(options?: RimelightI18nOptions): any {
|
|
|
34
48
|
const locales = config.i18n?.locales ?? ["en"]
|
|
35
49
|
const defaultLocale = config.i18n?.defaultLocale ?? "en"
|
|
36
50
|
|
|
51
|
+
const virtualModuleContent = `\
|
|
52
|
+
import { initializeI18n, useFormat, useI18n, useI18nAsync, t,
|
|
53
|
+
currentLocale, getI18nInstance, getFormatterInstance, clearCache }
|
|
54
|
+
from ${JSON.stringify(runtimeSpecifier)};
|
|
55
|
+
${loaderImport}
|
|
56
|
+
|
|
57
|
+
initializeI18n({
|
|
58
|
+
defaultLocale: ${JSON.stringify(defaultLocale)},
|
|
59
|
+
translations: ${JSON.stringify(normalisedTranslations)}${loaderOption}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
export { useFormat, useI18n, useI18nAsync, t, currentLocale,
|
|
63
|
+
getI18nInstance, getFormatterInstance, clearCache };
|
|
64
|
+
`
|
|
65
|
+
|
|
37
66
|
addMiddleware({
|
|
38
67
|
entrypoint: "@rimelight/i18n/middleware",
|
|
39
68
|
order: "pre"
|
|
40
69
|
})
|
|
70
|
+
|
|
41
71
|
updateConfig({
|
|
42
72
|
vite: {
|
|
43
73
|
ssr: {
|
|
44
74
|
noExternal: ["@rimelight/i18n"]
|
|
45
75
|
},
|
|
46
76
|
plugins: [
|
|
77
|
+
// Virtual module: @rimelight/i18n:runtime
|
|
78
|
+
{
|
|
79
|
+
name: "vite-plugin-rimelight-i18n-runtime",
|
|
80
|
+
resolveId(id) {
|
|
81
|
+
if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID
|
|
82
|
+
return null
|
|
83
|
+
},
|
|
84
|
+
load(id) {
|
|
85
|
+
if (id === RESOLVED_VIRTUAL_MODULE_ID) return virtualModuleContent
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
// Virtual module: virtual:rimelight-i18n-config (locale metadata)
|
|
47
90
|
{
|
|
48
91
|
name: "vite-plugin-rimelight-i18n-config",
|
|
49
92
|
resolveId(id) {
|
|
50
|
-
if (id === "virtual:rimelight-i18n-config")
|
|
51
|
-
|
|
52
|
-
}
|
|
93
|
+
if (id === "virtual:rimelight-i18n-config") return "\0" + id
|
|
94
|
+
return null
|
|
53
95
|
},
|
|
54
96
|
load(id) {
|
|
55
97
|
if (id === "\0virtual:rimelight-i18n-config") {
|
|
@@ -58,15 +100,46 @@ export function rimelightI18n(options?: RimelightI18nOptions): any {
|
|
|
58
100
|
export const defaultLocale = ${JSON.stringify(defaultLocale)};
|
|
59
101
|
`
|
|
60
102
|
}
|
|
103
|
+
return null
|
|
61
104
|
}
|
|
62
105
|
}
|
|
63
106
|
]
|
|
64
107
|
}
|
|
65
108
|
})
|
|
109
|
+
|
|
66
110
|
if (validateExtraction) {
|
|
67
111
|
logger.info("i18n extraction validation active")
|
|
68
112
|
}
|
|
69
113
|
},
|
|
114
|
+
|
|
115
|
+
"astro:config:done": ({ injectTypes }) => {
|
|
116
|
+
// Inject ambient type declarations for the virtual module so TypeScript
|
|
117
|
+
// and editor tooling know the shape of @rimelight/i18n:runtime.
|
|
118
|
+
injectTypes({
|
|
119
|
+
filename: "rimelight-i18n.d.ts",
|
|
120
|
+
content: `\
|
|
121
|
+
declare module "@rimelight/i18n:runtime" {
|
|
122
|
+
import type { Translations } from '@nanostores/i18n';
|
|
123
|
+
export type { InitializeI18nOptions } from '@rimelight/i18n/runtime';
|
|
124
|
+
export const currentLocale: import('nanostores').PreinitializedWritableAtom<string> & object;
|
|
125
|
+
export declare function initializeI18n(options: import('@rimelight/i18n/runtime').InitializeI18nOptions): void;
|
|
126
|
+
export declare function useFormat(): import('@nanostores/i18n').Formatter;
|
|
127
|
+
export type ComponentMessages = Record<string, string>;
|
|
128
|
+
export declare function useI18n(componentName: string): ComponentMessages;
|
|
129
|
+
export declare function useI18n<Body extends Translations>(componentName: string, baseTranslations: Body): ComponentMessages & Body;
|
|
130
|
+
export declare function useI18nAsync(componentName: string): Promise<Record<string, string>>;
|
|
131
|
+
export declare function useI18nAsync<Body extends Translations>(componentName: string, baseTranslations: Body): Promise<Record<string, string> & Body>;
|
|
132
|
+
export declare function t(key: string, params?: Record<string, any>): string;
|
|
133
|
+
export declare function t(astro: { currentLocale?: string | undefined; params?: Record<string, any> } | undefined | null, key: string, params?: Record<string, any>): string;
|
|
134
|
+
export declare function getLocale(): string;
|
|
135
|
+
export declare function getI18nInstance(): ReturnType<typeof import('@nanostores/i18n').createI18n>;
|
|
136
|
+
export declare function getFormatterInstance(): ReturnType<typeof import('@nanostores/i18n').formatter>;
|
|
137
|
+
export declare function clearCache(locale?: string): void;
|
|
138
|
+
}
|
|
139
|
+
`
|
|
140
|
+
})
|
|
141
|
+
},
|
|
142
|
+
|
|
70
143
|
"astro:build:done": async ({ logger }) => {
|
|
71
144
|
if (translations) {
|
|
72
145
|
const seedData: { keys: { key: string; value: string }[] } = { keys: [] }
|
|
@@ -84,19 +157,22 @@ export function rimelightI18n(options?: RimelightI18nOptions): any {
|
|
|
84
157
|
fs.writeFileSync(seedFile, JSON.stringify(seedData, null, 2))
|
|
85
158
|
logger.info(`translations seed file written to ${seedFile}`)
|
|
86
159
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
`
|
|
99
|
-
|
|
160
|
+
const autoSeed = process.env.WRANGLER_SEED_KV === "true" || process.env.CI === "true"
|
|
161
|
+
if (autoSeed) {
|
|
162
|
+
try {
|
|
163
|
+
const { execSync } = await import("node:child_process")
|
|
164
|
+
execSync(`npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"`, {
|
|
165
|
+
stdio: "pipe",
|
|
166
|
+
timeout: 30_000
|
|
167
|
+
})
|
|
168
|
+
logger.info(`KV "${kvBinding}" seeded with translations`)
|
|
169
|
+
} catch {
|
|
170
|
+
logger.warn(
|
|
171
|
+
`could not seed KV automatically — run ` +
|
|
172
|
+
`"npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"" ` +
|
|
173
|
+
`to seed translations into KV`
|
|
174
|
+
)
|
|
175
|
+
}
|
|
100
176
|
}
|
|
101
177
|
} else {
|
|
102
178
|
logger.info("i18n build complete (no translations to seed)")
|
|
@@ -105,12 +181,5 @@ export function rimelightI18n(options?: RimelightI18nOptions): any {
|
|
|
105
181
|
}
|
|
106
182
|
}
|
|
107
183
|
|
|
108
|
-
return [
|
|
109
|
-
nanostoresI18n({
|
|
110
|
-
translationLoader,
|
|
111
|
-
addMiddleware: false,
|
|
112
|
-
translations: nanostoresTranslations
|
|
113
|
-
}),
|
|
114
|
-
rimelightI18nCore
|
|
115
|
-
]
|
|
184
|
+
return [rimelightI18nCore]
|
|
116
185
|
}
|
package/src/middleware/kv.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { setKVBinding } from "../index"
|
|
2
2
|
import { env } from "cloudflare:workers"
|
|
3
3
|
|
|
4
|
-
export const onRequest = async (
|
|
4
|
+
export const onRequest = async (_context: unknown, next: () => Promise<Response>) => {
|
|
5
5
|
const kv = Reflect.get(env, "TRANSLATIONS_KV")
|
|
6
6
|
if (kv) {
|
|
7
7
|
setKVBinding(kv)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { locales, defaultLocale } from "virtual:rimelight-i18n-config"
|
|
2
|
+
import { currentLocale } from "../runtime"
|
|
2
3
|
|
|
3
4
|
const SUPPORTED_LOCALES = new Set(locales)
|
|
4
5
|
const DEFAULT_LOCALE = defaultLocale
|
|
@@ -10,13 +11,14 @@ function getPreferredLocale(acceptLanguage: string | null): string {
|
|
|
10
11
|
.split(",")
|
|
11
12
|
.map((lang) => {
|
|
12
13
|
const parts = lang.split(";")
|
|
13
|
-
const code = parts[0].trim().toLowerCase()
|
|
14
|
-
const base = code.split("-")[0]
|
|
14
|
+
const code = (parts[0] ?? "").trim().toLowerCase()
|
|
15
|
+
const base = code.split("-")[0] ?? ""
|
|
15
16
|
let q = 1.0
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
const qualityPart = parts[1]
|
|
18
|
+
if (qualityPart) {
|
|
19
|
+
const qMatch = qualityPart.match(/q=([0-9.]+)/)
|
|
18
20
|
if (qMatch) {
|
|
19
|
-
q = parseFloat(qMatch[1])
|
|
21
|
+
q = parseFloat(qMatch[1] ?? "1")
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
return { code, base, q }
|
|
@@ -27,8 +29,9 @@ function getPreferredLocale(acceptLanguage: string | null): string {
|
|
|
27
29
|
if (SUPPORTED_LOCALES.has(item.code)) {
|
|
28
30
|
return item.code
|
|
29
31
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
+
const base: string = item.base
|
|
33
|
+
if (SUPPORTED_LOCALES.has(base)) {
|
|
34
|
+
return base
|
|
32
35
|
}
|
|
33
36
|
}
|
|
34
37
|
|
|
@@ -39,6 +42,17 @@ export const i18n = async (context: any, next: any) => {
|
|
|
39
42
|
const url = new URL(context.request.url)
|
|
40
43
|
const pathname = url.pathname
|
|
41
44
|
|
|
45
|
+
// Extract active locale from request context
|
|
46
|
+
const paramLocale = context.params?.locale
|
|
47
|
+
const firstSegment = pathname.split("/").find(Boolean)
|
|
48
|
+
const activeLocale =
|
|
49
|
+
(paramLocale && SUPPORTED_LOCALES.has(paramLocale) ? paramLocale : null) ||
|
|
50
|
+
(firstSegment && SUPPORTED_LOCALES.has(firstSegment) ? firstSegment : null) ||
|
|
51
|
+
context.currentLocale ||
|
|
52
|
+
DEFAULT_LOCALE
|
|
53
|
+
|
|
54
|
+
currentLocale.set(activeLocale)
|
|
55
|
+
|
|
42
56
|
// Only handle GET/HEAD requests
|
|
43
57
|
if (context.request.method !== "GET" && context.request.method !== "HEAD") {
|
|
44
58
|
return next()
|
|
@@ -50,8 +64,6 @@ export const i18n = async (context: any, next: any) => {
|
|
|
50
64
|
}
|
|
51
65
|
|
|
52
66
|
// Check if pathname starts with a supported locale prefix
|
|
53
|
-
const firstSegment = pathname.split("/").find(Boolean)
|
|
54
|
-
|
|
55
67
|
if (firstSegment && SUPPORTED_LOCALES.has(firstSegment)) {
|
|
56
68
|
return next()
|
|
57
69
|
}
|
|
@@ -59,6 +71,7 @@ export const i18n = async (context: any, next: any) => {
|
|
|
59
71
|
// Redirect to the preferred locale
|
|
60
72
|
const acceptLanguage = context.request.headers.get("accept-language")
|
|
61
73
|
const locale = getPreferredLocale(acceptLanguage)
|
|
74
|
+
currentLocale.set(locale)
|
|
62
75
|
const targetPath = `/${locale}${pathname}${url.search}`
|
|
63
76
|
|
|
64
77
|
return context.redirect(targetPath, 302)
|
package/src/middleware.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { env } from "cloudflare:workers"
|
|
2
2
|
import { setKVBinding } from "./index"
|
|
3
3
|
|
|
4
|
-
export const onRequest = async (
|
|
4
|
+
export const onRequest = async (_context: unknown, next: () => Promise<Response>) => {
|
|
5
5
|
const kv = Reflect.get(env, "TRANSLATIONS_KV")
|
|
6
6
|
if (kv) {
|
|
7
7
|
setKVBinding(kv)
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { createI18n, formatter, translationsLoading } from "@nanostores/i18n"
|
|
2
|
+
import type {
|
|
3
|
+
TranslationLoader,
|
|
4
|
+
Translations,
|
|
5
|
+
I18n,
|
|
6
|
+
Components,
|
|
7
|
+
ComponentsJSON
|
|
8
|
+
} from "@nanostores/i18n"
|
|
9
|
+
import { atom } from "nanostores"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A reactive store containing the current locale code. Set by middleware on each request, or
|
|
13
|
+
* manually via `currentLocale.set(locale)`.
|
|
14
|
+
*/
|
|
15
|
+
export const currentLocale = atom("")
|
|
16
|
+
|
|
17
|
+
type I18nInstance = I18n
|
|
18
|
+
type FormatterInstance = ReturnType<typeof formatter>
|
|
19
|
+
|
|
20
|
+
let i18nInstance: I18nInstance | undefined
|
|
21
|
+
let formatterInstance: FormatterInstance | undefined
|
|
22
|
+
let baseLocaleDefault: string = "en"
|
|
23
|
+
let rawTranslationsDict: Record<string, Components> = {}
|
|
24
|
+
|
|
25
|
+
function throwNotInitialized(): never {
|
|
26
|
+
throw new Error("i18n not initialized. Call initializeI18n first.")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wraps a TranslationLoader so that the returned object is guaranteed to contain a key for every
|
|
31
|
+
* requested component.
|
|
32
|
+
*
|
|
33
|
+
* The nanostores i18n library uses the keys of the object returned from `get` to clear its internal
|
|
34
|
+
* "requested" set. If a requested component is missing from the result (e.g. because the backend
|
|
35
|
+
* has no translations for it in the given locale yet), the internal loading atom is never set back
|
|
36
|
+
* to `false`, which causes useI18nAsync / translationsLoading to hang forever.
|
|
37
|
+
*
|
|
38
|
+
* This wrapper normalises the loader output so missing components are filled in with empty
|
|
39
|
+
* translation objects, falling back to the base translations defined at the call site.
|
|
40
|
+
*/
|
|
41
|
+
function wrapLoader(loader: TranslationLoader): TranslationLoader {
|
|
42
|
+
return async (code, components): Promise<ComponentsJSON> => {
|
|
43
|
+
const raw = await loader(code, components)
|
|
44
|
+
const normalised: ComponentsJSON = Array.isArray(raw)
|
|
45
|
+
? Object.assign({} as ComponentsJSON, ...raw)
|
|
46
|
+
: { ...raw }
|
|
47
|
+
for (const component of components) {
|
|
48
|
+
if (!(component in normalised)) normalised[component] = {}
|
|
49
|
+
}
|
|
50
|
+
return normalised
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface InitializeI18nOptions {
|
|
55
|
+
/**
|
|
56
|
+
* The default locale code (e.g. 'en').
|
|
57
|
+
*/
|
|
58
|
+
defaultLocale: string
|
|
59
|
+
/**
|
|
60
|
+
* Pre-loaded translations keyed by locale then component.
|
|
61
|
+
*/
|
|
62
|
+
translations: Record<string, Components>
|
|
63
|
+
/**
|
|
64
|
+
* Optional dynamic loader called when a locale is not in cache.
|
|
65
|
+
*/
|
|
66
|
+
get?: TranslationLoader
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Initializes the i18n system. Must be called once before any other i18n functions are used (the
|
|
71
|
+
* integration virtual module does this automatically).
|
|
72
|
+
*/
|
|
73
|
+
export function initializeI18n(options: InitializeI18nOptions): void {
|
|
74
|
+
const { defaultLocale, translations, get } = options
|
|
75
|
+
baseLocaleDefault = defaultLocale
|
|
76
|
+
rawTranslationsDict = translations || {}
|
|
77
|
+
|
|
78
|
+
if (!i18nInstance) {
|
|
79
|
+
currentLocale.set(defaultLocale)
|
|
80
|
+
|
|
81
|
+
// Convert plain JSON dictionaries to nanostores atoms expected by nanostores/i18n cache
|
|
82
|
+
const formattedCache: Record<string, Components> = {}
|
|
83
|
+
for (const [locale, components] of Object.entries(translations)) {
|
|
84
|
+
formattedCache[locale] = {}
|
|
85
|
+
for (const [compName, compBody] of Object.entries(components)) {
|
|
86
|
+
let bodyObj: Record<string, string> = {}
|
|
87
|
+
if (isStringRecord(compBody)) {
|
|
88
|
+
bodyObj = compBody
|
|
89
|
+
} else if (isStoreWithGet(compBody)) {
|
|
90
|
+
const res: unknown = compBody.get()
|
|
91
|
+
if (isStringRecord(res)) {
|
|
92
|
+
bodyObj = res
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
formattedCache[locale][compName] = atom(bodyObj)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
i18nInstance = createI18n(currentLocale, {
|
|
100
|
+
baseLocale: defaultLocale,
|
|
101
|
+
get: wrapLoader(get ?? (async (): Promise<ComponentsJSON> => ({}))),
|
|
102
|
+
cache: formattedCache,
|
|
103
|
+
isSSR: true
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
formatterInstance = formatter(currentLocale)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Returns the underlying nanostores/i18n instance. Throws if not initialized.
|
|
111
|
+
*/
|
|
112
|
+
export function getI18nInstance(): I18nInstance {
|
|
113
|
+
if (!i18nInstance) throwNotInitialized()
|
|
114
|
+
return i18nInstance
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Returns the formatter instance. Throws if not initialized.
|
|
119
|
+
*/
|
|
120
|
+
export function getFormatterInstance(): FormatterInstance {
|
|
121
|
+
if (!formatterInstance) throwNotInitialized()
|
|
122
|
+
return formatterInstance
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Returns a Formatter object for the current locale with methods for formatting numbers, dates, and
|
|
127
|
+
* relative times using the native Intl API.
|
|
128
|
+
*/
|
|
129
|
+
export function useFormat(): ReturnType<FormatterInstance["get"]> {
|
|
130
|
+
return getFormatterInstance().get()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Helper to safely extract raw translation JSON object from cache regardless of whether it is an
|
|
135
|
+
* atom or object. Checks exact locale code (e.g. 'zh-CN'), base language code (e.g. 'zh'), and
|
|
136
|
+
* default locale fallback.
|
|
137
|
+
*/
|
|
138
|
+
interface StoreWithGet {
|
|
139
|
+
get: () => unknown
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isStoreWithGet(obj: unknown): obj is StoreWithGet {
|
|
143
|
+
if (typeof obj !== "object" || obj === null) return false
|
|
144
|
+
const getFn = Reflect.get(obj, "get")
|
|
145
|
+
return typeof getFn === "function"
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function isStringRecord(obj: unknown): obj is Record<string, string> {
|
|
149
|
+
return typeof obj === "object" && obj !== null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getCachedComponent(
|
|
153
|
+
locale: string,
|
|
154
|
+
componentName: string
|
|
155
|
+
): Record<string, string> | undefined {
|
|
156
|
+
const normalizedLocale = locale.toLowerCase()
|
|
157
|
+
const baseLanguage = normalizedLocale.split("-")[0] ?? normalizedLocale
|
|
158
|
+
|
|
159
|
+
const candidates = [locale, normalizedLocale, baseLanguage, baseLocaleDefault]
|
|
160
|
+
|
|
161
|
+
for (const code of candidates) {
|
|
162
|
+
const rawComp: unknown = rawTranslationsDict[code]?.[componentName]
|
|
163
|
+
if (isStoreWithGet(rawComp)) {
|
|
164
|
+
const res: unknown = rawComp.get()
|
|
165
|
+
if (isStringRecord(res)) return res
|
|
166
|
+
} else if (isStringRecord(rawComp)) {
|
|
167
|
+
return rawComp
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!i18nInstance) return undefined
|
|
172
|
+
for (const code of candidates) {
|
|
173
|
+
const rawComp: unknown = i18nInstance.cache[code]?.[componentName]
|
|
174
|
+
if (isStoreWithGet(rawComp)) {
|
|
175
|
+
const res: unknown = rawComp.get()
|
|
176
|
+
if (isStringRecord(res)) return res
|
|
177
|
+
} else if (isStringRecord(rawComp)) {
|
|
178
|
+
return rawComp
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return undefined
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Returns the translated strings for a component in the current locale. Falls back to defaultLocale
|
|
187
|
+
* loaded translations or `baseTranslations` if provided.
|
|
188
|
+
*/
|
|
189
|
+
export interface ComponentMessages {
|
|
190
|
+
[key: string]: string
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function useI18n(componentName: string): ComponentMessages
|
|
194
|
+
export function useI18n<Body extends Translations>(
|
|
195
|
+
componentName: string,
|
|
196
|
+
baseTranslations: Body
|
|
197
|
+
): ComponentMessages & Body
|
|
198
|
+
export function useI18n(componentName: string, baseTranslations?: Translations): ComponentMessages {
|
|
199
|
+
const i18n = getI18nInstance()
|
|
200
|
+
const activeLocale = currentLocale.get() || baseLocaleDefault
|
|
201
|
+
const fallbackDict: Record<string, string> = isStringRecord(baseTranslations)
|
|
202
|
+
? baseTranslations
|
|
203
|
+
: {}
|
|
204
|
+
const baseDict =
|
|
205
|
+
getCachedComponent(activeLocale, componentName) ??
|
|
206
|
+
getCachedComponent(baseLocaleDefault, componentName) ??
|
|
207
|
+
fallbackDict
|
|
208
|
+
|
|
209
|
+
const store = i18n(componentName, baseDict)
|
|
210
|
+
const targetObj = store.get()
|
|
211
|
+
|
|
212
|
+
return new Proxy(targetObj, {
|
|
213
|
+
get(target: Record<string, string>, prop: string | symbol): string {
|
|
214
|
+
if (typeof prop === "symbol" || prop in Object.prototype) {
|
|
215
|
+
const val: unknown = Reflect.get(target, prop)
|
|
216
|
+
return typeof val === "string" ? val : ""
|
|
217
|
+
}
|
|
218
|
+
const val = target[prop] ?? baseDict[prop]
|
|
219
|
+
return val ?? prop
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Async version of useI18n that waits for translations to finish loading.
|
|
226
|
+
*/
|
|
227
|
+
export async function useI18nAsync(componentName: string): Promise<ComponentMessages>
|
|
228
|
+
export async function useI18nAsync<Body extends Translations>(
|
|
229
|
+
componentName: string,
|
|
230
|
+
baseTranslations: Body
|
|
231
|
+
): Promise<ComponentMessages & Body>
|
|
232
|
+
export async function useI18nAsync(
|
|
233
|
+
componentName: string,
|
|
234
|
+
baseTranslations?: Translations
|
|
235
|
+
): Promise<ComponentMessages> {
|
|
236
|
+
const i18n = getI18nInstance()
|
|
237
|
+
const activeLocale = currentLocale.get() || baseLocaleDefault
|
|
238
|
+
const cachedActive = getCachedComponent(activeLocale, componentName)
|
|
239
|
+
const cachedDefault = getCachedComponent(baseLocaleDefault, componentName)
|
|
240
|
+
let baseDict: Record<string, string> = cachedActive ?? cachedDefault ?? {}
|
|
241
|
+
if (!cachedActive && !cachedDefault && isStringRecord(baseTranslations)) {
|
|
242
|
+
baseDict = baseTranslations
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const store = i18n(componentName, baseDict)
|
|
246
|
+
const unsubscribe = store.listen(() => {})
|
|
247
|
+
await translationsLoading(i18n)
|
|
248
|
+
unsubscribe()
|
|
249
|
+
return store.get()
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Translates a single string key using dot-notation (e.g. `t("page_home.section_hero_title")`).
|
|
254
|
+
*/
|
|
255
|
+
export function t(key: string, params?: Record<string, any>): string
|
|
256
|
+
export function t(
|
|
257
|
+
astro: { currentLocale?: string | undefined; params?: Record<string, any> } | undefined | null,
|
|
258
|
+
key: string,
|
|
259
|
+
params?: Record<string, any>
|
|
260
|
+
): string
|
|
261
|
+
export function t(arg1: any, arg2?: any, arg3?: any): string {
|
|
262
|
+
let key: string
|
|
263
|
+
let params: Record<string, any> | undefined
|
|
264
|
+
|
|
265
|
+
if (typeof arg1 === "string") {
|
|
266
|
+
key = arg1
|
|
267
|
+
params = arg2
|
|
268
|
+
} else {
|
|
269
|
+
const locale = arg1?.currentLocale || arg1?.params?.locale
|
|
270
|
+
if (locale) {
|
|
271
|
+
currentLocale.set(locale)
|
|
272
|
+
}
|
|
273
|
+
key = arg2
|
|
274
|
+
params = arg3
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const dotIndex = key.indexOf(".")
|
|
278
|
+
if (dotIndex === -1) {
|
|
279
|
+
return key
|
|
280
|
+
}
|
|
281
|
+
const componentName = key.slice(0, dotIndex)
|
|
282
|
+
const keyName = key.slice(dotIndex + 1)
|
|
283
|
+
|
|
284
|
+
const activeLocale = currentLocale.get() || baseLocaleDefault
|
|
285
|
+
const componentDict =
|
|
286
|
+
getCachedComponent(activeLocale, componentName) ??
|
|
287
|
+
getCachedComponent(baseLocaleDefault, componentName) ??
|
|
288
|
+
{}
|
|
289
|
+
|
|
290
|
+
const value = componentDict[keyName] ?? key
|
|
291
|
+
|
|
292
|
+
if (typeof value === "function") {
|
|
293
|
+
return (value as Function)(params)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (params && typeof value === "string") {
|
|
297
|
+
return value.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return value
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Clears the translation cache.
|
|
305
|
+
*
|
|
306
|
+
* @param locale - If provided, clears only that locale's cache. Otherwise clears all.
|
|
307
|
+
*/
|
|
308
|
+
export function clearCache(locale?: string): void {
|
|
309
|
+
if (!i18nInstance) throwNotInitialized()
|
|
310
|
+
const cache = i18nInstance.cache
|
|
311
|
+
if (locale) {
|
|
312
|
+
cache[locale] = {}
|
|
313
|
+
} else {
|
|
314
|
+
for (const key in cache) cache[key] = {}
|
|
315
|
+
}
|
|
316
|
+
}
|
package/src/utils.ts
CHANGED
|
@@ -19,13 +19,14 @@ export function unflatten(flat: FlattenedTranslations): ComponentsJSON {
|
|
|
19
19
|
for (const [key, value] of Object.entries(flat)) {
|
|
20
20
|
const parts = key.split(".")
|
|
21
21
|
if (parts.length < 2) continue
|
|
22
|
-
const component = parts[0]
|
|
22
|
+
const component: string = parts[0] ?? ""
|
|
23
|
+
if (!component) continue
|
|
23
24
|
const translationKey = parts.slice(1).join(".")
|
|
24
25
|
|
|
25
26
|
if (!(component in result)) {
|
|
26
27
|
result[component] = {}
|
|
27
28
|
}
|
|
28
|
-
result[component][translationKey] = value
|
|
29
|
+
result[component]![translationKey] = value
|
|
29
30
|
}
|
|
30
31
|
return result
|
|
31
32
|
}
|
package/src/virtual.d.ts
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
declare module "
|
|
2
|
-
export * from "
|
|
1
|
+
declare module "@rimelight/i18n:runtime" {
|
|
2
|
+
export * from "@rimelight/i18n/runtime"
|
|
3
3
|
}
|
|
4
4
|
|
|
5
5
|
declare module "virtual:rimelight-i18n-config" {
|
|
6
6
|
export const locales: string[]
|
|
7
7
|
export const defaultLocale: string
|
|
8
8
|
}
|
|
9
|
+
|
|
10
|
+
declare module "cloudflare:workers" {
|
|
11
|
+
/**
|
|
12
|
+
* Ambient Cloudflare Workers env — populated at runtime by the CF runtime.
|
|
13
|
+
*/
|
|
14
|
+
export const env: Record<string, any>
|
|
15
|
+
}
|