@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 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",
3
+ "version": "0.0.5",
4
4
  "private": false,
5
- "description": "Rimelight i18n unified translation loader with optional KV backing",
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": "node -e \"process.exit(0)\""
34
+ "check": "pnpm audit --audit-level=moderate && vp check --fix"
19
35
  },
20
36
  "dependencies": {
21
37
  "@nanostores/i18n": "1.3.3",
22
- "astro-nanostores-i18n": "0.7.0",
23
- "nanostores": "1.3.0"
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": ">=6.0.0"
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 "astro-nanostores-i18n:runtime"
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: any, arg3?: any): any {
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 activeLocale = arg1?.currentLocale ?? "en"
30
- currentLocale.set(activeLocale)
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: any, arg3?: any): any {
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 activeLocale = arg1?.currentLocale ?? "en"
49
- currentLocale.set(activeLocale)
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 = currentLocale.get() || "en"
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
+ }
@@ -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): any {
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 ?? "./src/i18n/loader.ts"
16
+ const translationLoader = options?.translationLoader
18
17
 
19
- const nanostoresTranslations: Record<string, any> = {}
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
- if (locale === "pt") {
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
- return "\0" + id
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
- try {
88
- const { execSync } = await import("node:child_process")
89
- execSync(`npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"`, {
90
- stdio: "pipe",
91
- timeout: 30_000
92
- })
93
- logger.info(`KV "${kvBinding}" seeded with translations`)
94
- } catch {
95
- logger.warn(
96
- `could not seed KV automatically — run ` +
97
- `"npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"" ` +
98
- `to seed translations into KV`
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
  }
@@ -1,7 +1,7 @@
1
1
  import { setKVBinding } from "../index"
2
2
  import { env } from "cloudflare:workers"
3
3
 
4
- export const onRequest = async (context: any, next: any) => {
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
- if (parts[1]) {
17
- const qMatch = parts[1].match(/q=([0-9.]+)/)
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
- if (SUPPORTED_LOCALES.has(item.base)) {
31
- return item.base
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 (context: any, next: any) => {
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 "astro-nanostores-i18n:runtime" {
2
- export * from "astro-nanostores-i18n/runtime"
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
+ }