@rimelight/i18n 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@rimelight/i18n",
3
+ "version": "0.0.1",
4
+ "private": false,
5
+ "description": "Rimelight i18n — unified translation loader with optional KV backing",
6
+ "files": [
7
+ "src"
8
+ ],
9
+ "type": "module",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./integration": "./src/integration.ts",
13
+ "./middleware": "./src/middleware.ts",
14
+ "./types": "./src/types.ts",
15
+ "./utils": "./src/utils.ts"
16
+ },
17
+ "scripts": {
18
+ "check": "node -e \"process.exit(0)\""
19
+ },
20
+ "dependencies": {
21
+ "@nanostores/i18n": "1.3.3",
22
+ "astro-nanostores-i18n": "0.7.0",
23
+ "nanostores": "1.3.0"
24
+ },
25
+ "peerDependencies": {
26
+ "astro": ">=6.0.0"
27
+ },
28
+ "peerDependenciesMeta": {
29
+ "astro": {
30
+ "optional": true
31
+ }
32
+ }
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,112 @@
1
+ import type { TranslationLoader } from "@nanostores/i18n"
2
+ import type { ComponentsJSON, KVNamespaceBinding } from "./types"
3
+ import {
4
+ useI18n as baseUseI18n,
5
+ useI18nAsync as baseUseI18nAsync,
6
+ currentLocale,
7
+ useFormat,
8
+ clearCache,
9
+ getI18nInstance,
10
+ getFormatterInstance
11
+ } from "astro-nanostores-i18n:runtime"
12
+ import type { Translations } from "@nanostores/i18n"
13
+
14
+ export { currentLocale, useFormat, clearCache, getI18nInstance, getFormatterInstance }
15
+
16
+ export function useI18n<Body extends Translations>(
17
+ componentName: string,
18
+ baseTranslations: Body
19
+ ): Body
20
+ export function useI18n<Body extends Translations>(
21
+ astro: { currentLocale?: string | undefined } | undefined | null,
22
+ componentName: string,
23
+ baseTranslations: Body
24
+ ): Body
25
+ export function useI18n(arg1: any, arg2: any, arg3?: any): any {
26
+ if (typeof arg1 === "string") {
27
+ return baseUseI18n(arg1, arg2)
28
+ } else {
29
+ const activeLocale = arg1?.currentLocale ?? "en"
30
+ currentLocale.set(activeLocale)
31
+ return baseUseI18n(arg2, arg3)
32
+ }
33
+ }
34
+
35
+ export function useI18nAsync<Body extends Translations>(
36
+ componentName: string,
37
+ baseTranslations: Body
38
+ ): Promise<Body>
39
+ export function useI18nAsync<Body extends Translations>(
40
+ astro: { currentLocale?: string | undefined } | undefined | null,
41
+ componentName: string,
42
+ baseTranslations: Body
43
+ ): Promise<Body>
44
+ export function useI18nAsync(arg1: any, arg2: any, arg3?: any): any {
45
+ if (typeof arg1 === "string") {
46
+ return baseUseI18nAsync(arg1, arg2)
47
+ } else {
48
+ const activeLocale = arg1?.currentLocale ?? "en"
49
+ currentLocale.set(activeLocale)
50
+ return baseUseI18nAsync(arg2, arg3)
51
+ }
52
+ }
53
+
54
+ import { getRelativeLocaleUrl as astroGetRelativeLocaleUrl } from "astro:i18n"
55
+
56
+ export function getRelativeLocaleUrl(path: string): string
57
+ export function getRelativeLocaleUrl(locale: string, path: string): string
58
+ export function getRelativeLocaleUrl(arg1: string, arg2?: string): string {
59
+ if (arg2 !== undefined) {
60
+ return astroGetRelativeLocaleUrl(arg1, arg2)
61
+ } else {
62
+ const locale = currentLocale.get() || "en"
63
+ return astroGetRelativeLocaleUrl(locale, arg1)
64
+ }
65
+ }
66
+
67
+ let kvBinding: KVNamespaceBinding | null = null
68
+
69
+ export function setKVBinding(kv: KVNamespaceBinding) {
70
+ kvBinding = kv
71
+ }
72
+
73
+ export function createTranslationLoader(
74
+ translations: Record<string, ComponentsJSON>
75
+ ): TranslationLoader {
76
+ return async (locale, components) => {
77
+ const localeTranslations = translations[locale] || {}
78
+
79
+ // If components is not provided, is not an array, or is empty, return the entire locale's translations.
80
+ if (!components || !Array.isArray(components) || components.length === 0) {
81
+ return localeTranslations
82
+ }
83
+
84
+ const activeKv = kvBinding
85
+ if (activeKv) {
86
+ try {
87
+ const results = await Promise.all(
88
+ components.map(async (name) => {
89
+ const key = `locale:${locale}:${name}`
90
+ const data = await activeKv.get(key, "json")
91
+ // Fall back to bundled translation for this component if missing in KV
92
+ if (data == null) {
93
+ return { [name]: localeTranslations[name] ?? {} }
94
+ }
95
+ return { [name]: data }
96
+ })
97
+ )
98
+ return Object.assign({}, ...results)
99
+ } catch {
100
+ // KV read failed — fall through to static
101
+ }
102
+ }
103
+
104
+ const results = await Promise.all(
105
+ components.map(async (name) => {
106
+ const data = localeTranslations[name]
107
+ return { [name]: data ?? {} }
108
+ })
109
+ )
110
+ return Object.assign({}, ...results)
111
+ }
112
+ }
@@ -0,0 +1,95 @@
1
+ import nanostoresI18n from "astro-nanostores-i18n"
2
+ import type { AstroIntegration } from "astro"
3
+ import fs from "node:fs"
4
+ import path from "node:path"
5
+
6
+ export interface RimelightI18nOptions {
7
+ validateExtraction?: boolean
8
+ translations?: Record<string, Record<string, any>>
9
+ kvBinding?: string
10
+ translationLoader?: string
11
+ }
12
+
13
+ export function rimelightI18n(options?: RimelightI18nOptions): any {
14
+ const validateExtraction = options?.validateExtraction ?? true
15
+ const translations = options?.translations
16
+ const kvBinding = options?.kvBinding ?? "TRANSLATIONS_KV"
17
+ const translationLoader = options?.translationLoader ?? "./src/i18n/loader.ts"
18
+
19
+ const nanostoresTranslations: Record<string, any> = {}
20
+ if (translations) {
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
+ }
27
+ }
28
+ }
29
+
30
+ const rimelightI18nCore: AstroIntegration = {
31
+ name: "@rimelight/i18n",
32
+ hooks: {
33
+ "astro:config:setup": ({ addMiddleware, updateConfig, logger }) => {
34
+ addMiddleware({
35
+ entrypoint: "@rimelight/i18n/middleware",
36
+ order: "pre"
37
+ })
38
+ updateConfig({
39
+ vite: {
40
+ ssr: {
41
+ noExternal: ["@rimelight/i18n"]
42
+ }
43
+ }
44
+ })
45
+ if (validateExtraction) {
46
+ logger.info("i18n extraction validation active")
47
+ }
48
+ },
49
+ "astro:build:done": async ({ logger }) => {
50
+ if (translations) {
51
+ const seedData: { keys: { key: string; value: string }[] } = { keys: [] }
52
+ for (const [locale, localeData] of Object.entries(translations)) {
53
+ for (const [component, componentData] of Object.entries(localeData)) {
54
+ seedData.keys.push({
55
+ key: `locale:${locale}:${component}`,
56
+ value: JSON.stringify(componentData)
57
+ })
58
+ }
59
+ }
60
+ const outDir = process.env.WRANGLER_OUT_DIR ?? "dist"
61
+ const seedFile = path.join(outDir, "_translations-seed.json")
62
+ fs.mkdirSync(path.dirname(seedFile), { recursive: true })
63
+ fs.writeFileSync(seedFile, JSON.stringify(seedData, null, 2))
64
+ logger.info(`translations seed file written to ${seedFile}`)
65
+
66
+ try {
67
+ const { execSync } = await import("node:child_process")
68
+ execSync(`npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"`, {
69
+ stdio: "pipe",
70
+ timeout: 30_000
71
+ })
72
+ logger.info(`KV "${kvBinding}" seeded with translations`)
73
+ } catch {
74
+ logger.warn(
75
+ `could not seed KV automatically — run ` +
76
+ `"npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"" ` +
77
+ `to seed translations into KV`
78
+ )
79
+ }
80
+ } else {
81
+ logger.info("i18n build complete (no translations to seed)")
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ return [
88
+ nanostoresI18n({
89
+ translationLoader,
90
+ addMiddleware: true,
91
+ translations: nanostoresTranslations
92
+ }),
93
+ rimelightI18nCore
94
+ ]
95
+ }
@@ -0,0 +1,10 @@
1
+ import { setKVBinding } from "../index"
2
+ import { env } from "cloudflare:workers"
3
+
4
+ export const onRequest = async (context: any, next: any) => {
5
+ const kv = Reflect.get(env, "TRANSLATIONS_KV")
6
+ if (kv) {
7
+ setKVBinding(kv)
8
+ }
9
+ return next()
10
+ }
@@ -0,0 +1,63 @@
1
+ const SUPPORTED_LOCALES = new Set(["en", "pt", "es"])
2
+ const DEFAULT_LOCALE = "en"
3
+
4
+ function getPreferredLocale(acceptLanguage: string | null): string {
5
+ if (!acceptLanguage) return DEFAULT_LOCALE
6
+
7
+ const parsed = acceptLanguage
8
+ .split(",")
9
+ .map((lang) => {
10
+ const parts = lang.split(";")
11
+ const code = parts[0].trim().toLowerCase()
12
+ const base = code.split("-")[0]
13
+ let q = 1.0
14
+ if (parts[1]) {
15
+ const qMatch = parts[1].match(/q=([0-9.]+)/)
16
+ if (qMatch) {
17
+ q = parseFloat(qMatch[1])
18
+ }
19
+ }
20
+ return { code, base, q }
21
+ })
22
+ .toSorted((a, b) => b.q - a.q)
23
+
24
+ for (const item of parsed) {
25
+ if (SUPPORTED_LOCALES.has(item.code)) {
26
+ return item.code
27
+ }
28
+ if (SUPPORTED_LOCALES.has(item.base)) {
29
+ return item.base
30
+ }
31
+ }
32
+
33
+ return DEFAULT_LOCALE
34
+ }
35
+
36
+ export const i18n = async (context: any, next: any) => {
37
+ const url = new URL(context.request.url)
38
+ const pathname = url.pathname
39
+
40
+ // Only handle GET/HEAD requests
41
+ if (context.request.method !== "GET" && context.request.method !== "HEAD") {
42
+ return next()
43
+ }
44
+
45
+ // Skip API routes, internal Astro routes, and files with extensions
46
+ if (pathname.startsWith("/api") || pathname.startsWith("/_") || pathname.includes(".")) {
47
+ return next()
48
+ }
49
+
50
+ // Check if pathname starts with a supported locale prefix
51
+ const firstSegment = pathname.split("/").find(Boolean)
52
+
53
+ if (firstSegment && SUPPORTED_LOCALES.has(firstSegment)) {
54
+ return next()
55
+ }
56
+
57
+ // Redirect to the preferred locale
58
+ const acceptLanguage = context.request.headers.get("accept-language")
59
+ const locale = getPreferredLocale(acceptLanguage)
60
+ const targetPath = `/${locale}${pathname}${url.search}`
61
+
62
+ return context.redirect(targetPath, 302)
63
+ }
@@ -0,0 +1,12 @@
1
+ import { env } from "cloudflare:workers"
2
+ import { setKVBinding } from "./index"
3
+
4
+ export const onRequest = async (context: any, next: any) => {
5
+ const kv = Reflect.get(env, "TRANSLATIONS_KV")
6
+ if (kv) {
7
+ setKVBinding(kv)
8
+ }
9
+ return next()
10
+ }
11
+
12
+ export { i18n } from "./middleware/routing"
package/src/types.ts ADDED
@@ -0,0 +1,18 @@
1
+ import type { ComponentsJSON } from "@nanostores/i18n"
2
+
3
+ export type { ComponentsJSON }
4
+
5
+ export interface FlattenedTranslations {
6
+ [key: string]: string
7
+ }
8
+
9
+ export interface LocaleFile {
10
+ [component: string]: {
11
+ [key: string]: string
12
+ }
13
+ }
14
+
15
+ export interface KVNamespaceBinding {
16
+ get(key: string, type: "json"): Promise<Record<string, string> | null>
17
+ put(key: string, value: string): Promise<void>
18
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { ComponentsJSON, FlattenedTranslations } from "./types"
2
+
3
+ export function flatten(obj: ComponentsJSON): FlattenedTranslations {
4
+ const result: FlattenedTranslations = {}
5
+ for (const [component, translations] of Object.entries(obj)) {
6
+ if (translations && typeof translations === "object") {
7
+ for (const [key, value] of Object.entries(translations)) {
8
+ if (typeof value === "string") {
9
+ result[`${component}.${key}`] = value
10
+ }
11
+ }
12
+ }
13
+ }
14
+ return result
15
+ }
16
+
17
+ export function unflatten(flat: FlattenedTranslations): ComponentsJSON {
18
+ const result: ComponentsJSON = {}
19
+ for (const [key, value] of Object.entries(flat)) {
20
+ const parts = key.split(".")
21
+ if (parts.length < 2) continue
22
+ const component = parts[0]
23
+ const translationKey = parts.slice(1).join(".")
24
+
25
+ if (!(component in result)) {
26
+ result[component] = {}
27
+ }
28
+ result[component][translationKey] = value
29
+ }
30
+ return result
31
+ }
32
+
33
+ export function extractKeys(source: ComponentsJSON): string[] {
34
+ const keys: string[] = []
35
+ for (const [component, translations] of Object.entries(source)) {
36
+ if (translations && typeof translations === "object") {
37
+ for (const key of Object.keys(translations)) {
38
+ keys.push(`${component}.${key}`)
39
+ }
40
+ }
41
+ }
42
+ return keys
43
+ }
@@ -0,0 +1,3 @@
1
+ declare module "astro-nanostores-i18n:runtime" {
2
+ export * from "astro-nanostores-i18n/runtime"
3
+ }