@rimelight/i18n 0.0.7 → 0.0.8

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.
@@ -0,0 +1,8 @@
1
+ //#region src/hono.d.ts
2
+ /**
3
+ * Hono middleware for Rimelight i18n. Handles locale routing, redirection, currentLocale store
4
+ * updates, and Cloudflare KV binding setup.
5
+ */
6
+ declare function i18n(): (c: any, next: any) => Promise<any>;
7
+ //#endregion
8
+ export { i18n };
package/dist/hono.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { currentLocale } from "./runtime.mjs";
2
+ import { setKVBinding } from "./index.mjs";
3
+ import { defaultLocale, locales, prefixDefaultLocale } from "virtual:rimelight-i18n-config";
4
+ import { env } from "cloudflare:workers";
5
+ //#region src/hono.ts
6
+ const SUPPORTED_LOCALES = new Set(locales);
7
+ const DEFAULT_LOCALE = defaultLocale;
8
+ function getPreferredLocale(acceptLanguage) {
9
+ if (!acceptLanguage) return DEFAULT_LOCALE;
10
+ const parsed = acceptLanguage.split(",").map((lang) => {
11
+ const parts = lang.split(";");
12
+ const code = (parts[0] ?? "").trim().toLowerCase();
13
+ const base = code.split("-")[0] ?? "";
14
+ let q = 1;
15
+ const qualityPart = parts[1];
16
+ if (qualityPart) {
17
+ const qMatch = qualityPart.match(/q=([0-9.]+)/);
18
+ if (qMatch) q = parseFloat(qMatch[1] ?? "1");
19
+ }
20
+ return {
21
+ code,
22
+ base,
23
+ q
24
+ };
25
+ }).toSorted((a, b) => b.q - a.q);
26
+ for (const item of parsed) {
27
+ if (SUPPORTED_LOCALES.has(item.code)) return item.code;
28
+ const base = item.base;
29
+ if (SUPPORTED_LOCALES.has(base)) return base;
30
+ }
31
+ return DEFAULT_LOCALE;
32
+ }
33
+ /**
34
+ * Hono middleware for Rimelight i18n. Handles locale routing, redirection, currentLocale store
35
+ * updates, and Cloudflare KV binding setup.
36
+ */
37
+ function i18n() {
38
+ return async (c, next) => {
39
+ try {
40
+ const kv = Reflect.get(env, "TRANSLATIONS_KV") || c.env?.TRANSLATIONS_KV;
41
+ if (kv) setKVBinding(kv);
42
+ } catch {}
43
+ const url = new URL(c.req.url);
44
+ const pathname = url.pathname;
45
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
46
+ const paramLocale = c.req.param("locale");
47
+ const firstSegment = pathname.split("/").find(Boolean);
48
+ const activeLocale = (paramLocale && SUPPORTED_LOCALES.has(paramLocale) ? paramLocale : null) || (firstSegment && SUPPORTED_LOCALES.has(firstSegment) ? firstSegment : null) || DEFAULT_LOCALE;
49
+ currentLocale.set(activeLocale);
50
+ return next();
51
+ }
52
+ if (pathname.startsWith("/api") || pathname.startsWith("/_") || pathname.includes(".")) return next();
53
+ const firstSegment = pathname.split("/").find(Boolean);
54
+ const firstIsLocale = firstSegment !== void 0 && SUPPORTED_LOCALES.has(firstSegment);
55
+ if (prefixDefaultLocale) {
56
+ const paramLocale = c.req.param("locale");
57
+ const activeLocale = (paramLocale && SUPPORTED_LOCALES.has(paramLocale) ? paramLocale : null) || (firstIsLocale ? firstSegment : null) || DEFAULT_LOCALE;
58
+ currentLocale.set(activeLocale);
59
+ if (firstIsLocale) return next();
60
+ const locale = getPreferredLocale(c.req.header("accept-language") || null);
61
+ currentLocale.set(locale);
62
+ const targetPath = `/${locale}${pathname}${url.search}`;
63
+ return c.redirect(targetPath, 302);
64
+ } else {
65
+ if (firstIsLocale) {
66
+ if (firstSegment === DEFAULT_LOCALE) {
67
+ const rest = pathname.slice(DEFAULT_LOCALE.length + 1) || "/";
68
+ currentLocale.set(DEFAULT_LOCALE);
69
+ return c.redirect(`${rest}${url.search}`, 302);
70
+ }
71
+ currentLocale.set(firstSegment);
72
+ return next();
73
+ }
74
+ currentLocale.set(DEFAULT_LOCALE);
75
+ return next();
76
+ }
77
+ };
78
+ }
79
+ //#endregion
80
+ export { i18n };
package/dist/index.d.mts CHANGED
@@ -1,39 +1,37 @@
1
- import { ComponentsJSON, KVNamespaceBinding } from "./types.mjs";
2
- import { clearCache, currentLocale, getFormatterInstance, getI18nInstance, t, useFormat } from "@rimelight/i18n:runtime";
3
- import { TranslationLoader, Translations } from "@nanostores/i18n";
1
+ import { ComponentsJSON, CustomTranslations, KVNamespaceBinding, NestedTranslationKeys, TranslationKey } from "./types.mjs";
2
+ import { clearCache, currentLocale, getFormatterInstance, getI18nInstance, getPluralCategory, initializeI18n, useFormat, useI18n, useI18nAsync } from "./runtime.mjs";
3
+ import { RimelightI18nOptions, RimelightI18nPlugins, i18n } from "./plugin.mjs";
4
+ import { TranslationLoader } from "@nanostores/i18n";
5
+ import { defaultLocale, locales, prefixDefaultLocale } from "virtual:rimelight-i18n-config";
4
6
  //#region src/index.d.ts
5
- interface ComponentMessages {
6
- [key: string]: string;
7
- }
8
- declare function useI18n(componentName: string): ComponentMessages;
9
- declare function useI18n<Body extends Translations>(componentName: string, baseTranslations: Body): ComponentMessages & Body;
10
- declare function useI18n(astro: {
11
- currentLocale?: string | undefined;
12
- params?: Record<string, any>;
13
- } | undefined | null, componentName: string): ComponentMessages;
14
- declare function useI18n<Body extends Translations>(astro: {
15
- currentLocale?: string | undefined;
16
- params?: Record<string, any>;
17
- } | undefined | null, componentName: string, baseTranslations: Body): ComponentMessages & Body;
18
- declare function useI18nAsync(componentName: string): Promise<Record<string, string>>;
19
- declare function useI18nAsync<Body extends Translations>(componentName: string, baseTranslations: Body): Promise<Record<string, string> & Body>;
20
- declare function useI18nAsync(astro: {
21
- currentLocale?: string | undefined;
22
- params?: Record<string, any>;
23
- } | undefined | null, componentName: string): Promise<Record<string, string>>;
24
- declare function useI18nAsync<Body extends Translations>(astro: {
25
- currentLocale?: string | undefined;
26
- params?: Record<string, any>;
27
- } | undefined | null, componentName: string, baseTranslations: Body): Promise<Record<string, string> & Body>;
7
+ /**
8
+ * Translates a single string key using dot-notation (e.g. `t("page_home.section_hero_title")`).
9
+ * When augmented via CustomTranslations, keys are strictly type-checked and autocompleted.
10
+ */
11
+ declare function t<K extends string = TranslationKey>(key: K | TranslationKey, params?: Record<string, any>): string;
28
12
  declare function getLocale(): string;
13
+ /**
14
+ * Returns a standard BCP 47 / RFC 5646 language tag suitable for HTML `<html lang="...">`. Maps
15
+ * 2-letter codes like 'en' to 'en-US', 'pt' to 'pt-BR', 'es' to 'es-ES', etc.
16
+ */
17
+ declare function getHtmlLang(): string;
18
+ /**
19
+ * Returns an OpenGraph-compliant locale tag (e.g. 'en_US', 'pt_BR', 'es_ES').
20
+ */
21
+ declare function getOgLocale(): string;
29
22
  declare function getRelativeLocaleUrl(path: string): string;
30
23
  declare function getRelativeLocaleUrl(locale: string, path: string): string;
31
- declare function setKVBinding(kv: KVNamespaceBinding): void;
32
- declare function createTranslationLoader(translations: Record<string, ComponentsJSON>): TranslationLoader;
24
+ declare function getRelativeLocaleUrlList(path?: string): string[];
25
+ interface LanguageAlternate {
26
+ hreflang: string;
27
+ href: string;
28
+ }
33
29
  /**
34
- * Returns the plural category (e.g. 'one', 'few', 'many', 'other') for a given number and locale
35
- * using the native browser Intl.PluralRules API.
30
+ * Generates an array of hreflang alternates for SEO tags (<link rel="alternate" hreflang="...">).
31
+ * Automatically includes all supported locales and 'x-default'.
36
32
  */
37
- declare function getPluralCategory(count: number, locale?: string): Intl.LDMLPluralRule;
33
+ declare function getLanguageAlternates(pathname: string, siteUrl?: string): LanguageAlternate[];
34
+ declare function setKVBinding(kv: KVNamespaceBinding): void;
35
+ declare function createTranslationLoader(translations: Record<string, ComponentsJSON>): TranslationLoader;
38
36
  //#endregion
39
- export { ComponentMessages, clearCache, createTranslationLoader, currentLocale, getFormatterInstance, getI18nInstance, getLocale, getPluralCategory, getRelativeLocaleUrl, setKVBinding, t, useFormat, useI18n, useI18nAsync };
37
+ export { type CustomTranslations, LanguageAlternate, type NestedTranslationKeys, type RimelightI18nOptions, type RimelightI18nPlugins, type TranslationKey, clearCache, createTranslationLoader, currentLocale, defaultLocale, getFormatterInstance, getHtmlLang, getI18nInstance, getLanguageAlternates, getLocale, getOgLocale, getPluralCategory, getRelativeLocaleUrl, getRelativeLocaleUrlList, i18n, i18n as rimelightI18n, initializeI18n, locales, prefixDefaultLocale, setKVBinding, t, useFormat, useI18n, useI18nAsync };
package/dist/index.mjs CHANGED
@@ -1,31 +1,85 @@
1
- import { clearCache, currentLocale, getFormatterInstance, getI18nInstance, t, useFormat, useI18n as useI18n$1, useI18nAsync as useI18nAsync$1 } from "@rimelight/i18n:runtime";
2
- import { getRelativeLocaleUrl as getRelativeLocaleUrl$1 } from "astro:i18n";
1
+ import { clearCache, currentLocale, getFormatterInstance, getI18nInstance, getPluralCategory, initializeI18n, t as t$1, useFormat, useI18n, useI18nAsync } from "./runtime.mjs";
2
+ import { i18n } from "./plugin.mjs";
3
+ import { defaultLocale, locales, prefixDefaultLocale, translations } from "virtual:rimelight-i18n-config";
3
4
  //#region src/index.ts
4
- function useI18n(arg1, arg2, arg3) {
5
- if (typeof arg1 === "string") return useI18n$1(arg1, arg2);
6
- else {
7
- const locale = arg1?.currentLocale || arg1?.params?.locale;
8
- if (locale) currentLocale.set(locale);
9
- return useI18n$1(arg2, arg3);
10
- }
11
- }
12
- function useI18nAsync(arg1, arg2, arg3) {
13
- if (typeof arg1 === "string") return useI18nAsync$1(arg1, arg2);
14
- else {
15
- const locale = arg1?.currentLocale || arg1?.params?.locale;
16
- if (locale) currentLocale.set(locale);
17
- return useI18nAsync$1(arg2, arg3);
18
- }
5
+ if (translations && Object.keys(translations).length > 0) initializeI18n({
6
+ defaultLocale: defaultLocale || "en",
7
+ translations
8
+ });
9
+ /**
10
+ * Translates a single string key using dot-notation (e.g. `t("page_home.section_hero_title")`).
11
+ * When augmented via CustomTranslations, keys are strictly type-checked and autocompleted.
12
+ */
13
+ function t(key, params) {
14
+ return t$1(key, params);
19
15
  }
20
16
  function getLocale() {
21
- return currentLocale.get() || "en";
17
+ return currentLocale.get() || defaultLocale || "en";
18
+ }
19
+ /**
20
+ * Returns a standard BCP 47 / RFC 5646 language tag suitable for HTML `<html lang="...">`. Maps
21
+ * 2-letter codes like 'en' to 'en-US', 'pt' to 'pt-BR', 'es' to 'es-ES', etc.
22
+ */
23
+ function getHtmlLang() {
24
+ const loc = getLocale();
25
+ return {
26
+ en: "en-US",
27
+ pt: "pt-BR",
28
+ es: "es-ES"
29
+ }[loc] || loc;
30
+ }
31
+ /**
32
+ * Returns an OpenGraph-compliant locale tag (e.g. 'en_US', 'pt_BR', 'es_ES').
33
+ */
34
+ function getOgLocale() {
35
+ const loc = getLocale();
36
+ return {
37
+ en: "en_US",
38
+ pt: "pt_BR",
39
+ es: "es_ES"
40
+ }[loc] || `${loc}_${loc.toUpperCase()}`;
22
41
  }
23
42
  function getRelativeLocaleUrl(arg1, arg2) {
24
- if (arg2 !== void 0) return getRelativeLocaleUrl$1(arg1, arg2);
25
- else {
26
- const locale = getLocale();
27
- return getRelativeLocaleUrl$1(locale, arg1);
43
+ let targetLocale;
44
+ let targetPath;
45
+ if (arg2 !== void 0) {
46
+ targetLocale = arg1;
47
+ targetPath = arg2;
48
+ } else {
49
+ targetLocale = getLocale();
50
+ targetPath = arg1;
28
51
  }
52
+ if (/^(?:[a-z]+:|\/\/|#)/i.test(targetPath) || targetPath.startsWith("mailto:") || targetPath.startsWith("tel:")) return targetPath;
53
+ const [pathWithoutQueryAndHash, queryAndHash] = (() => {
54
+ const match = targetPath.match(/^([^?#]*)([?#].*)?$/);
55
+ return [match?.[1] ?? "", match?.[2] ?? ""];
56
+ })();
57
+ const rawCleanPath = pathWithoutQueryAndHash.replace(/^\/+/, "");
58
+ const segments = rawCleanPath ? rawCleanPath.split("/") : [];
59
+ if (segments.length > 0 && locales.includes(segments[0])) segments.shift();
60
+ const cleanPath = segments.join("/");
61
+ const prefix = prefixDefaultLocale || targetLocale !== defaultLocale ? `/${targetLocale}` : "";
62
+ if (!cleanPath) return `${prefix ? `${prefix}/` : "/"}${queryAndHash}`;
63
+ return `${`${prefix}/${cleanPath}${pathWithoutQueryAndHash.endsWith("/") ? "/" : ""}`}${queryAndHash}`;
64
+ }
65
+ function getRelativeLocaleUrlList(path = "/") {
66
+ return locales.map((locale) => getRelativeLocaleUrl(locale, path));
67
+ }
68
+ /**
69
+ * Generates an array of hreflang alternates for SEO tags (<link rel="alternate" hreflang="...">).
70
+ * Automatically includes all supported locales and 'x-default'.
71
+ */
72
+ function getLanguageAlternates(pathname, siteUrl = "") {
73
+ const normalizedSiteUrl = siteUrl.replace(/\/+$/, "");
74
+ const alternates = locales.map((loc) => ({
75
+ hreflang: loc,
76
+ href: `${normalizedSiteUrl}${getRelativeLocaleUrl(loc, pathname)}`
77
+ }));
78
+ alternates.push({
79
+ hreflang: "x-default",
80
+ href: `${normalizedSiteUrl}${getRelativeLocaleUrl(defaultLocale, pathname)}`
81
+ });
82
+ return alternates;
29
83
  }
30
84
  let kvBinding = null;
31
85
  function setKVBinding(kv) {
@@ -52,12 +106,5 @@ function createTranslationLoader(translations) {
52
106
  return Object.assign({}, ...results);
53
107
  };
54
108
  }
55
- /**
56
- * Returns the plural category (e.g. 'one', 'few', 'many', 'other') for a given number and locale
57
- * using the native browser Intl.PluralRules API.
58
- */
59
- function getPluralCategory(count, locale = "en") {
60
- return new Intl.PluralRules(locale).select(count);
61
- }
62
109
  //#endregion
63
- export { clearCache, createTranslationLoader, currentLocale, getFormatterInstance, getI18nInstance, getLocale, getPluralCategory, getRelativeLocaleUrl, setKVBinding, t, useFormat, useI18n, useI18nAsync };
110
+ export { clearCache, createTranslationLoader, currentLocale, defaultLocale, getFormatterInstance, getHtmlLang, getI18nInstance, getLanguageAlternates, getLocale, getOgLocale, getPluralCategory, getRelativeLocaleUrl, getRelativeLocaleUrlList, i18n, i18n as rimelightI18n, initializeI18n, locales, prefixDefaultLocale, setKVBinding, t, useFormat, useI18n, useI18nAsync };
@@ -0,0 +1,39 @@
1
+ //#region src/plugin.d.ts
2
+ interface RimelightI18nOptions {
3
+ /**
4
+ * List of supported locale codes (e.g. `["en", "pt"]`).
5
+ */
6
+ locales?: string[];
7
+ /**
8
+ * The default locale code (e.g. `"en"`).
9
+ */
10
+ defaultLocale?: string;
11
+ /**
12
+ * Whether to prefix the default locale in URLs.
13
+ *
14
+ * - `true` (default): all locales are prefixed — `/en/about`, `/pt/about`
15
+ * - `false`: the default locale has no prefix — `/about`, `/pt/about`
16
+ */
17
+ prefixDefaultLocale?: boolean;
18
+ validateExtraction?: boolean;
19
+ translations?: Record<string, Record<string, any>>;
20
+ kvBinding?: string;
21
+ translationLoader?: string;
22
+ }
23
+ interface RimelightI18nVitePlugin {
24
+ name: string;
25
+ enforce?: "pre" | "post";
26
+ resolveId?: (id: string) => string | null | undefined;
27
+ load?: (id: string) => string | null | undefined;
28
+ config?: (config: any) => any;
29
+ closeBundle?: () => Promise<void> | void;
30
+ [key: string]: any;
31
+ }
32
+ type RimelightI18nPlugins = RimelightI18nVitePlugin[];
33
+ /**
34
+ * Pure Vite plugin for Rimelight i18n. Provides virtual modules `@rimelight/i18n:runtime` and
35
+ * `virtual:rimelight-i18n-config`.
36
+ */
37
+ declare function i18n(options?: RimelightI18nOptions): RimelightI18nPlugins;
38
+ //#endregion
39
+ export { RimelightI18nOptions, RimelightI18nPlugins, RimelightI18nVitePlugin, i18n, i18n as rimelightI18n };
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ //#region src/plugin.ts
4
+ /**
5
+ * Pure Vite plugin for Rimelight i18n. Provides virtual modules `@rimelight/i18n:runtime` and
6
+ * `virtual:rimelight-i18n-config`.
7
+ */
8
+ function i18n(options) {
9
+ const translations = options?.translations;
10
+ const kvBinding = options?.kvBinding ?? "TRANSLATIONS_KV";
11
+ const locales = options?.locales ?? ["en"];
12
+ const defaultLocale = options?.defaultLocale ?? "en";
13
+ const prefixDefaultLocale = options?.prefixDefaultLocale ?? true;
14
+ const normalisedTranslations = {};
15
+ if (translations) for (const [locale, val] of Object.entries(translations)) normalisedTranslations[locale] = val;
16
+ return [{
17
+ name: "vite-plugin-rimelight-i18n-config",
18
+ resolveId(id) {
19
+ if (id === "virtual:rimelight-i18n-config") return "\0" + id;
20
+ return null;
21
+ },
22
+ load(id) {
23
+ if (id === "\0virtual:rimelight-i18n-config") return `
24
+ export const locales = ${JSON.stringify(locales)};
25
+ export const defaultLocale = ${JSON.stringify(defaultLocale)};
26
+ export const prefixDefaultLocale = ${JSON.stringify(prefixDefaultLocale)};
27
+ export const translations = ${JSON.stringify(normalisedTranslations)};
28
+ `;
29
+ return null;
30
+ },
31
+ async closeBundle() {
32
+ if (!translations) return;
33
+ const seedData = { keys: [] };
34
+ for (const [locale, localeData] of Object.entries(translations)) for (const [component, componentData] of Object.entries(localeData)) seedData.keys.push({
35
+ key: `locale:${locale}:${component}`,
36
+ value: JSON.stringify(componentData)
37
+ });
38
+ const outDir = process.env.WRANGLER_OUT_DIR ?? "dist";
39
+ const seedFile = path.join(outDir, "_translations-seed.json");
40
+ try {
41
+ fs.mkdirSync(path.dirname(seedFile), { recursive: true });
42
+ fs.writeFileSync(seedFile, JSON.stringify(seedData, null, 2));
43
+ if (process.env.WRANGLER_SEED_KV === "true" || process.env.CI === "true") {
44
+ const { execSync } = await import("node:child_process");
45
+ execSync(`npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"`, {
46
+ stdio: "pipe",
47
+ timeout: 3e4
48
+ });
49
+ }
50
+ } catch {}
51
+ }
52
+ }];
53
+ }
54
+ //#endregion
55
+ export { i18n, i18n as rimelightI18n };
@@ -57,10 +57,11 @@ declare function useI18nAsync<Body extends Translations>(componentName: string,
57
57
  * Translates a single string key using dot-notation (e.g. `t("page_home.section_hero_title")`).
58
58
  */
59
59
  declare function t(key: string, params?: Record<string, any>): string;
60
- declare function t(astro: {
61
- currentLocale?: string | undefined;
62
- params?: Record<string, any>;
63
- } | undefined | null, key: string, params?: Record<string, any>): string;
60
+ /**
61
+ * Returns the plural category (e.g. 'one', 'few', 'many', 'other') for a given number and locale
62
+ * using the native browser Intl.PluralRules API.
63
+ */
64
+ declare function getPluralCategory(count: number, locale?: string): Intl.LDMLPluralRule;
64
65
  /**
65
66
  * Clears the translation cache.
66
67
  *
@@ -68,4 +69,4 @@ declare function t(astro: {
68
69
  */
69
70
  declare function clearCache(locale?: string): void;
70
71
  //#endregion
71
- export { ComponentMessages, InitializeI18nOptions, clearCache, currentLocale, getFormatterInstance, getI18nInstance, initializeI18n, t, useFormat, useI18n, useI18nAsync };
72
+ export { ComponentMessages, InitializeI18nOptions, clearCache, currentLocale, getFormatterInstance, getI18nInstance, getPluralCategory, initializeI18n, t, useFormat, useI18n, useI18nAsync };
package/dist/runtime.mjs CHANGED
@@ -143,26 +143,30 @@ async function useI18nAsync(componentName, baseTranslations) {
143
143
  unsubscribe();
144
144
  return store.get();
145
145
  }
146
- function t(arg1, arg2, arg3) {
147
- let key;
148
- let params;
149
- if (typeof arg1 === "string") {
150
- key = arg1;
151
- params = arg2;
152
- } else {
153
- const locale = arg1?.currentLocale || arg1?.params?.locale;
154
- if (locale) currentLocale.set(locale);
155
- key = arg2;
156
- params = arg3;
157
- }
146
+ /**
147
+ * Translates a single string key using dot-notation (e.g. `t("page_home.section_hero_title")`).
148
+ */
149
+ function t(key, params) {
158
150
  const dotIndex = key.indexOf(".");
159
151
  if (dotIndex === -1) return key;
160
152
  const componentName = key.slice(0, dotIndex);
161
153
  const keyName = key.slice(dotIndex + 1);
162
- const value = (getCachedComponent(currentLocale.get() || baseLocaleDefault, componentName) ?? getCachedComponent(baseLocaleDefault, componentName) ?? {})[keyName] ?? key;
154
+ const activeLocale = currentLocale.get() || baseLocaleDefault;
155
+ let value = (getCachedComponent(activeLocale, componentName) ?? getCachedComponent(baseLocaleDefault, componentName) ?? {})[keyName] ?? key;
156
+ if (params && typeof params.count === "number" && typeof value === "object" && value !== null) {
157
+ const pluralCategory = getPluralCategory(params.count, activeLocale);
158
+ value = value[pluralCategory] ?? value.other ?? key;
159
+ }
163
160
  if (typeof value === "function") return value(params);
164
161
  if (params && typeof value === "string") return value.replace(/\{(\w+)\}/g, (_, k) => String(params[k] ?? `{${k}}`));
165
- return value;
162
+ return typeof value === "string" ? value : String(value);
163
+ }
164
+ /**
165
+ * Returns the plural category (e.g. 'one', 'few', 'many', 'other') for a given number and locale
166
+ * using the native browser Intl.PluralRules API.
167
+ */
168
+ function getPluralCategory(count, locale = "en") {
169
+ return new Intl.PluralRules(locale).select(count);
166
170
  }
167
171
  /**
168
172
  * Clears the translation cache.
@@ -176,4 +180,4 @@ function clearCache(locale) {
176
180
  else for (const key in cache) cache[key] = {};
177
181
  }
178
182
  //#endregion
179
- export { clearCache, currentLocale, getFormatterInstance, getI18nInstance, initializeI18n, t, useFormat, useI18n, useI18nAsync };
183
+ export { clearCache, currentLocale, getFormatterInstance, getI18nInstance, getPluralCategory, initializeI18n, t, useFormat, useI18n, useI18nAsync };
package/dist/types.d.mts CHANGED
@@ -12,5 +12,21 @@ interface KVNamespaceBinding {
12
12
  get(key: string, type: "json"): Promise<Record<string, string> | null>;
13
13
  put(key: string, value: string): Promise<void>;
14
14
  }
15
+ /**
16
+ * Derives dot-notated nested translation keys (e.g. "page_home.section_hero_title") from a
17
+ * translation schema type.
18
+ */
19
+ type NestedTranslationKeys<T> = T extends object ? { [K in keyof T & (string | number)]: T[K] extends object ? `${K}.${NestedTranslationKeys<T[K]>}` : `${K}`; }[keyof T & (string | number)] : never;
20
+ /**
21
+ * Augmented interface for app-level strongly typed translation keys. Applications can declare:
22
+ *
23
+ * ```ts
24
+ * declare module "@rimelight/i18n" {
25
+ * interface CustomTranslations extends typeof import("./translations/en.json") {}
26
+ * }
27
+ * ```
28
+ */
29
+ interface CustomTranslations {}
30
+ type TranslationKey = [keyof CustomTranslations] extends [never] ? string : NestedTranslationKeys<CustomTranslations>;
15
31
  //#endregion
16
- export { type ComponentsJSON, FlattenedTranslations, KVNamespaceBinding, LocaleFile };
32
+ export { type ComponentsJSON, CustomTranslations, FlattenedTranslations, KVNamespaceBinding, LocaleFile, NestedTranslationKeys, TranslationKey };
package/dist/utils.d.mts CHANGED
@@ -3,5 +3,10 @@ import { ComponentsJSON, FlattenedTranslations } from "./types.mjs";
3
3
  declare function flatten(obj: ComponentsJSON): FlattenedTranslations;
4
4
  declare function unflatten(flat: FlattenedTranslations): ComponentsJSON;
5
5
  declare function extractKeys(source: ComponentsJSON): string[];
6
+ /**
7
+ * Compares a target translation dictionary against a base/source translation dictionary, returning
8
+ * any missing component keys.
9
+ */
10
+ declare function findMissingKeys(base: Record<string, Record<string, any>>, target: Record<string, Record<string, any>>): string[];
6
11
  //#endregion
7
- export { extractKeys, flatten, unflatten };
12
+ export { extractKeys, findMissingKeys, flatten, unflatten };
package/dist/utils.mjs CHANGED
@@ -24,5 +24,20 @@ function extractKeys(source) {
24
24
  for (const [component, translations] of Object.entries(source)) if (translations && typeof translations === "object") for (const key of Object.keys(translations)) keys.push(`${component}.${key}`);
25
25
  return keys;
26
26
  }
27
+ /**
28
+ * Compares a target translation dictionary against a base/source translation dictionary, returning
29
+ * any missing component keys.
30
+ */
31
+ function findMissingKeys(base, target) {
32
+ const missing = [];
33
+ for (const [component, keys] of Object.entries(base)) {
34
+ if (!target[component] || typeof target[component] !== "object") {
35
+ missing.push(component);
36
+ continue;
37
+ }
38
+ for (const key of Object.keys(keys)) if (target[component][key] === void 0) missing.push(`${component}.${key}`);
39
+ }
40
+ return missing;
41
+ }
27
42
  //#endregion
28
- export { extractKeys, flatten, unflatten };
43
+ export { extractKeys, findMissingKeys, flatten, unflatten };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rimelight/i18n",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "private": false,
5
5
  "description": "Rimelight Entertainment's Internationalization Package",
6
6
  "homepage": "https://rimelight.com/docs",
@@ -24,13 +24,13 @@
24
24
  "types": "./dist/index.d.mts",
25
25
  "import": "./dist/index.mjs"
26
26
  },
27
- "./integration": {
28
- "types": "./dist/integration.d.mts",
29
- "import": "./dist/integration.mjs"
27
+ "./plugin": {
28
+ "types": "./dist/plugin.d.mts",
29
+ "import": "./dist/plugin.mjs"
30
30
  },
31
- "./middleware": {
32
- "types": "./dist/middleware.d.mts",
33
- "import": "./dist/middleware.mjs"
31
+ "./hono": {
32
+ "types": "./dist/hono.d.mts",
33
+ "import": "./dist/hono.mjs"
34
34
  },
35
35
  "./runtime": {
36
36
  "types": "./dist/runtime.d.mts",
@@ -54,17 +54,8 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@rimelight/config": "0.0.4",
57
- "astro": "7.3.1",
58
57
  "typescript": "6.0.3"
59
58
  },
60
- "peerDependencies": {
61
- "astro": ">=7.0.0"
62
- },
63
- "peerDependenciesMeta": {
64
- "astro": {
65
- "optional": true
66
- }
67
- },
68
59
  "engines": {
69
60
  "node": ">=26.7.0"
70
61
  },
@@ -1,11 +0,0 @@
1
- import { AstroIntegration } from "astro";
2
- //#region src/integration.d.ts
3
- interface RimelightI18nOptions {
4
- validateExtraction?: boolean;
5
- translations?: Record<string, Record<string, any>>;
6
- kvBinding?: string;
7
- translationLoader?: string;
8
- }
9
- declare function rimelightI18n(options?: RimelightI18nOptions): AstroIntegration[];
10
- //#endregion
11
- export { RimelightI18nOptions, rimelightI18n };
@@ -1,122 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- //#region src/integration.ts
4
- function rimelightI18n(options) {
5
- const validateExtraction = options?.validateExtraction ?? true;
6
- const translations = options?.translations;
7
- const kvBinding = options?.kvBinding ?? "TRANSLATIONS_KV";
8
- const translationLoader = options?.translationLoader;
9
- const normalisedTranslations = {};
10
- if (translations) for (const [locale, val] of Object.entries(translations)) normalisedTranslations[locale] = val;
11
- const VIRTUAL_MODULE_ID = "@rimelight/i18n:runtime";
12
- const RESOLVED_VIRTUAL_MODULE_ID = "\0" + VIRTUAL_MODULE_ID;
13
- const runtimeSpecifier = "@rimelight/i18n/runtime";
14
- const loaderImport = translationLoader ? `import translationLoader from ${JSON.stringify(translationLoader)};` : "";
15
- const loaderOption = translationLoader ? ", get: translationLoader" : "";
16
- return [{
17
- name: "@rimelight/i18n",
18
- hooks: {
19
- "astro:config:setup": ({ config, addMiddleware, updateConfig, logger }) => {
20
- const locales = config.i18n?.locales ?? ["en"];
21
- const defaultLocale = config.i18n?.defaultLocale ?? "en";
22
- const virtualModuleContent = `\
23
- import { initializeI18n, useFormat, useI18n, useI18nAsync, t,
24
- currentLocale, getI18nInstance, getFormatterInstance, clearCache }
25
- from ${JSON.stringify(runtimeSpecifier)};
26
- ${loaderImport}
27
-
28
- initializeI18n({
29
- defaultLocale: ${JSON.stringify(defaultLocale)},
30
- translations: ${JSON.stringify(normalisedTranslations)}${loaderOption}
31
- });
32
-
33
- export { useFormat, useI18n, useI18nAsync, t, currentLocale,
34
- getI18nInstance, getFormatterInstance, clearCache };
35
- `;
36
- addMiddleware({
37
- entrypoint: "@rimelight/i18n/middleware",
38
- order: "pre"
39
- });
40
- updateConfig({ vite: {
41
- ssr: { noExternal: ["@rimelight/i18n"] },
42
- plugins: [{
43
- name: "vite-plugin-rimelight-i18n-runtime",
44
- resolveId(id) {
45
- if (id === VIRTUAL_MODULE_ID) return RESOLVED_VIRTUAL_MODULE_ID;
46
- return null;
47
- },
48
- load(id) {
49
- if (id === RESOLVED_VIRTUAL_MODULE_ID) return virtualModuleContent;
50
- return null;
51
- }
52
- }, {
53
- name: "vite-plugin-rimelight-i18n-config",
54
- resolveId(id) {
55
- if (id === "virtual:rimelight-i18n-config") return "\0" + id;
56
- return null;
57
- },
58
- load(id) {
59
- if (id === "\0virtual:rimelight-i18n-config") return `
60
- export const locales = ${JSON.stringify(locales)};
61
- export const defaultLocale = ${JSON.stringify(defaultLocale)};
62
- `;
63
- return null;
64
- }
65
- }]
66
- } });
67
- if (validateExtraction) logger.info("i18n extraction validation active");
68
- },
69
- "astro:config:done": ({ injectTypes }) => {
70
- injectTypes({
71
- filename: "rimelight-i18n.d.ts",
72
- content: `\
73
- declare module "@rimelight/i18n:runtime" {
74
- import type { Translations } from '@nanostores/i18n';
75
- export type { InitializeI18nOptions } from '@rimelight/i18n/runtime';
76
- export const currentLocale: import('nanostores').PreinitializedWritableAtom<string> & object;
77
- export declare function initializeI18n(options: import('@rimelight/i18n/runtime').InitializeI18nOptions): void;
78
- export declare function useFormat(): import('@nanostores/i18n').Formatter;
79
- export type ComponentMessages = Record<string, string>;
80
- export declare function useI18n(componentName: string): ComponentMessages;
81
- export declare function useI18n<Body extends Translations>(componentName: string, baseTranslations: Body): ComponentMessages & Body;
82
- export declare function useI18nAsync(componentName: string): Promise<Record<string, string>>;
83
- export declare function useI18nAsync<Body extends Translations>(componentName: string, baseTranslations: Body): Promise<Record<string, string> & Body>;
84
- export declare function t(key: string, params?: Record<string, any>): string;
85
- export declare function t(astro: { currentLocale?: string | undefined; params?: Record<string, any> } | undefined | null, key: string, params?: Record<string, any>): string;
86
- export declare function getLocale(): string;
87
- export declare function getI18nInstance(): ReturnType<typeof import('@nanostores/i18n').createI18n>;
88
- export declare function getFormatterInstance(): ReturnType<typeof import('@nanostores/i18n').formatter>;
89
- export declare function clearCache(locale?: string): void;
90
- }
91
- `
92
- });
93
- },
94
- "astro:build:done": async ({ logger }) => {
95
- if (translations) {
96
- const seedData = { keys: [] };
97
- for (const [locale, localeData] of Object.entries(translations)) for (const [component, componentData] of Object.entries(localeData)) seedData.keys.push({
98
- key: `locale:${locale}:${component}`,
99
- value: JSON.stringify(componentData)
100
- });
101
- const outDir = process.env.WRANGLER_OUT_DIR ?? "dist";
102
- const seedFile = path.join(outDir, "_translations-seed.json");
103
- fs.mkdirSync(path.dirname(seedFile), { recursive: true });
104
- fs.writeFileSync(seedFile, JSON.stringify(seedData, null, 2));
105
- logger.info(`translations seed file written to ${seedFile}`);
106
- if (process.env.WRANGLER_SEED_KV === "true" || process.env.CI === "true") try {
107
- const { execSync } = await import("node:child_process");
108
- execSync(`npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"`, {
109
- stdio: "pipe",
110
- timeout: 3e4
111
- });
112
- logger.info(`KV "${kvBinding}" seeded with translations`);
113
- } catch {
114
- logger.warn(`could not seed KV automatically — run "npx wrangler kv:bulk put --binding "${kvBinding}" "${seedFile}"" to seed translations into KV`);
115
- }
116
- } else logger.info("i18n build complete (no translations to seed)");
117
- }
118
- }
119
- }];
120
- }
121
- //#endregion
122
- export { rimelightI18n };
@@ -1,7 +0,0 @@
1
- //#region src/middleware/routing.d.ts
2
- declare const i18n: (context: any, next: any) => Promise<any>;
3
- //#endregion
4
- //#region src/middleware.d.ts
5
- declare const onRequest: (_context: unknown, next: () => Promise<Response>) => Promise<Response>;
6
- //#endregion
7
- export { i18n, onRequest };
@@ -1,56 +0,0 @@
1
- import { setKVBinding } from "./index.mjs";
2
- import { currentLocale } from "./runtime.mjs";
3
- import { env } from "cloudflare:workers";
4
- import { defaultLocale, locales } from "virtual:rimelight-i18n-config";
5
- //#region src/middleware/routing.ts
6
- const SUPPORTED_LOCALES = new Set(locales);
7
- const DEFAULT_LOCALE = defaultLocale;
8
- function getPreferredLocale(acceptLanguage) {
9
- if (!acceptLanguage) return DEFAULT_LOCALE;
10
- const parsed = acceptLanguage.split(",").map((lang) => {
11
- const parts = lang.split(";");
12
- const code = (parts[0] ?? "").trim().toLowerCase();
13
- const base = code.split("-")[0] ?? "";
14
- let q = 1;
15
- const qualityPart = parts[1];
16
- if (qualityPart) {
17
- const qMatch = qualityPart.match(/q=([0-9.]+)/);
18
- if (qMatch) q = parseFloat(qMatch[1] ?? "1");
19
- }
20
- return {
21
- code,
22
- base,
23
- q
24
- };
25
- }).toSorted((a, b) => b.q - a.q);
26
- for (const item of parsed) {
27
- if (SUPPORTED_LOCALES.has(item.code)) return item.code;
28
- const base = item.base;
29
- if (SUPPORTED_LOCALES.has(base)) return base;
30
- }
31
- return DEFAULT_LOCALE;
32
- }
33
- const i18n = async (context, next) => {
34
- const url = new URL(context.request.url);
35
- const pathname = url.pathname;
36
- const paramLocale = context.params?.locale;
37
- const firstSegment = pathname.split("/").find(Boolean);
38
- const activeLocale = (paramLocale && SUPPORTED_LOCALES.has(paramLocale) ? paramLocale : null) || (firstSegment && SUPPORTED_LOCALES.has(firstSegment) ? firstSegment : null) || context.currentLocale || DEFAULT_LOCALE;
39
- currentLocale.set(activeLocale);
40
- if (context.request.method !== "GET" && context.request.method !== "HEAD") return next();
41
- if (pathname.startsWith("/api") || pathname.startsWith("/_") || pathname.includes(".")) return next();
42
- if (firstSegment && SUPPORTED_LOCALES.has(firstSegment)) return next();
43
- const locale = getPreferredLocale(context.request.headers.get("accept-language"));
44
- currentLocale.set(locale);
45
- const targetPath = `/${locale}${pathname}${url.search}`;
46
- return context.redirect(targetPath, 302);
47
- };
48
- //#endregion
49
- //#region src/middleware.ts
50
- const onRequest = async (_context, next) => {
51
- const kv = Reflect.get(env, "TRANSLATIONS_KV");
52
- if (kv) setKVBinding(kv);
53
- return next();
54
- };
55
- //#endregion
56
- export { i18n, onRequest };