@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 +33 -0
- package/src/index.ts +112 -0
- package/src/integration.ts +95 -0
- package/src/middleware/kv.ts +10 -0
- package/src/middleware/routing.ts +63 -0
- package/src/middleware.ts +12 -0
- package/src/types.ts +18 -0
- package/src/utils.ts +43 -0
- package/src/virtual.d.ts +3 -0
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,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
|
+
}
|
package/src/virtual.d.ts
ADDED