lino-i18n 0.1.1 → 0.2.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # lino-i18n Changelog
2
2
 
3
+ ## 0.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 8e331fd: Add reactive runtime subscriptions and first-class React bindings with provider,
8
+ translation hooks, rich interpolation, locale selection, and Intl formatters.
9
+
3
10
  ## 0.1.1
4
11
 
5
12
  ### Patch Changes
package/README.md CHANGED
@@ -33,6 +33,51 @@ i18n.t('role', { context: 'female' }); // → "She is a developer"
33
33
  i18n.t('telegram.help.solve.alias.detail'); // → "Tool aliases imply `--tool <tool>`"
34
34
  ```
35
35
 
36
+ ## React
37
+
38
+ React is an optional peer dependency. The `lino-i18n/react` adapter uses the
39
+ same `.lino` catalogues as the framework-independent runtime:
40
+
41
+ ```jsx
42
+ import { createI18n } from 'lino-i18n';
43
+ import {
44
+ CurrencyFormat,
45
+ I18nProvider,
46
+ LocaleSelector,
47
+ Trans,
48
+ useTranslation,
49
+ } from 'lino-i18n/react';
50
+
51
+ const i18n = createI18n({ locales: catalogues, defaultLocale: 'en' });
52
+
53
+ function Checkout() {
54
+ const { t } = useTranslation('checkout');
55
+ return (
56
+ <>
57
+ <h1>{t('title')}</h1>
58
+ <Trans
59
+ id="checkout.total"
60
+ values={{ amount: <CurrencyFormat value={19.99} currency="USD" /> }}
61
+ />
62
+ <LocaleSelector labels={{ en: 'English', fr: 'Français' }} />
63
+ </>
64
+ );
65
+ }
66
+
67
+ export default function App() {
68
+ return (
69
+ <I18nProvider i18n={i18n}>
70
+ <Checkout />
71
+ </I18nProvider>
72
+ );
73
+ }
74
+ ```
75
+
76
+ `useI18n`, `useLocale`, and `useTranslation` update when `setLocale`,
77
+ `addLocale`, or an asynchronous catalogue loader changes the instance.
78
+ `NumberFormat`, `DateTimeFormat`, `CurrencyFormat`, and `RelativeTimeFormat`
79
+ format values with the active locale through the platform `Intl` APIs.
80
+
36
81
  A sample `.lino` catalogue looks like this:
37
82
 
38
83
  ```lino
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lino-i18n",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Universal i18n library that stores translations in Links Notation (.lino) instead of JSON.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -21,7 +21,11 @@
21
21
  "import": "./src/index.js"
22
22
  },
23
23
  "./converters": "./src/converters/index.js",
24
- "./loaders": "./src/loaders.js"
24
+ "./loaders": "./src/loaders.js",
25
+ "./react": {
26
+ "types": "./src/react.d.ts",
27
+ "import": "./src/react.js"
28
+ }
25
29
  },
26
30
  "scripts": {
27
31
  "test": "node --test --test-timeout=30000 tests/*.test.js",
@@ -69,11 +73,23 @@
69
73
  "devDependencies": {
70
74
  "@changesets/cli": "^2.31.0",
71
75
  "@eslint/js": "^10.0.1",
76
+ "@types/node": "^26.1.1",
77
+ "@types/react": "^19.2.17",
72
78
  "eslint": "^10.4.0",
73
79
  "eslint-config-prettier": "^10.1.8",
74
80
  "eslint-plugin-prettier": "^5.5.5",
75
81
  "jscpd": "^4.2.2",
76
82
  "prettier": "^3.8.3",
83
+ "react": "^19.1.1",
84
+ "react-test-renderer": "^19.1.1",
77
85
  "test-anywhere": "^0.9.1"
86
+ },
87
+ "peerDependencies": {
88
+ "react": ">=18.0.0"
89
+ },
90
+ "peerDependenciesMeta": {
91
+ "react": {
92
+ "optional": true
93
+ }
78
94
  }
79
95
  }
package/src/i18n.js CHANGED
@@ -46,8 +46,31 @@ export function createI18n(options = {}) {
46
46
  }
47
47
 
48
48
  let currentLocale = defaultLocale;
49
+ let revision = 0;
50
+ const listeners = new Set();
49
51
  const fallbacks = normalizeFallbacks(fallback);
50
52
 
53
+ function notify() {
54
+ revision += 1;
55
+ for (const listener of listeners) {
56
+ listener();
57
+ }
58
+ }
59
+
60
+ function subscribe(listener) {
61
+ if (typeof listener !== 'function') {
62
+ throw new TypeError('subscribe expects a function');
63
+ }
64
+ listeners.add(listener);
65
+ return () => {
66
+ listeners.delete(listener);
67
+ };
68
+ }
69
+
70
+ function getRevision() {
71
+ return revision;
72
+ }
73
+
51
74
  function getLocale() {
52
75
  return currentLocale;
53
76
  }
@@ -56,7 +79,10 @@ export function createI18n(options = {}) {
56
79
  if (typeof locale !== 'string' || !locale) {
57
80
  throw new TypeError('setLocale expects a non-empty string locale');
58
81
  }
59
- currentLocale = locale;
82
+ if (locale !== currentLocale) {
83
+ currentLocale = locale;
84
+ notify();
85
+ }
60
86
  }
61
87
 
62
88
  function getFallbacks() {
@@ -135,6 +161,7 @@ export function createI18n(options = {}) {
135
161
  { compatibilityAliases }
136
162
  )
137
163
  );
164
+ notify();
138
165
  }
139
166
 
140
167
  async function loadLocale(locale, text) {
@@ -174,5 +201,7 @@ export function createI18n(options = {}) {
174
201
  loadLocaleFile,
175
202
  loadDirectory,
176
203
  interpolation,
204
+ subscribe,
205
+ getRevision,
177
206
  };
178
207
  }
package/src/index.d.ts CHANGED
@@ -62,6 +62,8 @@ export interface I18nInstance {
62
62
  loadLocaleFile(filePath: string): Promise<string>;
63
63
  loadDirectory(directory: string): Promise<string[]>;
64
64
  interpolation: { prefix?: string; suffix?: string };
65
+ subscribe(listener: () => void): () => void;
66
+ getRevision(): number;
65
67
  }
66
68
 
67
69
  export declare function createI18n(options?: I18nOptions): I18nInstance;
package/src/react.d.ts ADDED
@@ -0,0 +1,44 @@
1
+ import type { ReactNode, SelectHTMLAttributes } from 'react';
2
+ import type { I18nInstance, TOptions, TParams } from './index.js';
3
+
4
+ export declare function I18nProvider(props: {
5
+ i18n: I18nInstance;
6
+ children?: ReactNode;
7
+ }): ReactNode;
8
+ export declare function useI18n(): I18nInstance;
9
+ export declare function useLocale(): string;
10
+ export declare function useTranslation(keyPrefix?: string): {
11
+ t(key: string, params?: TParams, options?: TOptions): string;
12
+ i18n: I18nInstance;
13
+ locale: string;
14
+ };
15
+ export declare function Trans(props: {
16
+ id: string;
17
+ values?: Record<string, unknown>;
18
+ options?: TOptions;
19
+ fallback?: string;
20
+ }): ReactNode;
21
+ export declare function LocaleSelector(
22
+ props: SelectHTMLAttributes<HTMLSelectElement> & {
23
+ locales?: string[];
24
+ labels?: Record<string, ReactNode>;
25
+ }
26
+ ): ReactNode;
27
+ export declare function NumberFormat(props: {
28
+ value: number | bigint;
29
+ options?: Intl.NumberFormatOptions;
30
+ }): ReactNode;
31
+ export declare function DateTimeFormat(props: {
32
+ value: Date | number;
33
+ options?: Intl.DateTimeFormatOptions;
34
+ }): ReactNode;
35
+ export declare function RelativeTimeFormat(props: {
36
+ value: number;
37
+ unit: Intl.RelativeTimeFormatUnit;
38
+ options?: Intl.RelativeTimeFormatOptions;
39
+ }): ReactNode;
40
+ export declare function CurrencyFormat(props: {
41
+ value: number | bigint;
42
+ currency: string;
43
+ options?: Intl.NumberFormatOptions;
44
+ }): ReactNode;
package/src/react.js ADDED
@@ -0,0 +1,120 @@
1
+ import React, {
2
+ createContext,
3
+ createElement,
4
+ useCallback,
5
+ useContext,
6
+ useSyncExternalStore,
7
+ } from 'react';
8
+
9
+ const I18nContext = createContext(null);
10
+
11
+ function useI18nContext() {
12
+ const i18n = useContext(I18nContext);
13
+ if (!i18n) {
14
+ throw new Error(
15
+ 'lino-i18n React hooks must be used inside an I18nProvider'
16
+ );
17
+ }
18
+ useSyncExternalStore(i18n.subscribe, i18n.getRevision, i18n.getRevision);
19
+ return i18n;
20
+ }
21
+
22
+ export function I18nProvider({ i18n, children }) {
23
+ if (!i18n?.t || !i18n?.subscribe) {
24
+ throw new TypeError('I18nProvider requires a createI18n instance');
25
+ }
26
+ return createElement(I18nContext.Provider, { value: i18n }, children);
27
+ }
28
+
29
+ export function useI18n() {
30
+ return useI18nContext();
31
+ }
32
+
33
+ export function useLocale() {
34
+ return useI18nContext().getLocale();
35
+ }
36
+
37
+ export function useTranslation(keyPrefix = '') {
38
+ const i18n = useI18nContext();
39
+ const t = useCallback(
40
+ (key, params, options) =>
41
+ i18n.t(keyPrefix ? `${keyPrefix}.${key}` : key, params, options),
42
+ [i18n, keyPrefix]
43
+ );
44
+ return { t, i18n, locale: i18n.getLocale() };
45
+ }
46
+
47
+ export function Trans({ id, values = {}, options, fallback }) {
48
+ const i18n = useI18nContext();
49
+ const entries = Object.entries(values);
50
+ const markers = Object.fromEntries(
51
+ entries.map(([key, value], index) => [
52
+ key,
53
+ React.isValidElement(value) ? `\uE000${index}\uE001` : value,
54
+ ])
55
+ );
56
+ const translated = i18n.t(
57
+ id,
58
+ { ...markers, defaultValue: fallback },
59
+ options
60
+ );
61
+ if (!entries.some(([, value]) => React.isValidElement(value))) {
62
+ return translated;
63
+ }
64
+ const pattern = /(\uE000\d+\uE001)/g;
65
+ return translated.split(pattern).map((part, index) => {
66
+ const match = /^\uE000(\d+)\uE001$/.exec(part);
67
+ return match
68
+ ? React.cloneElement(entries[Number(match[1])][1], { key: index })
69
+ : part;
70
+ });
71
+ }
72
+
73
+ export function LocaleSelector({ locales, labels = {}, ...props }) {
74
+ const i18n = useI18nContext();
75
+ const available = locales || i18n.listLocales();
76
+ return createElement(
77
+ 'select',
78
+ {
79
+ 'aria-label': 'Language',
80
+ ...props,
81
+ value: i18n.getLocale(),
82
+ onChange: (event) => {
83
+ i18n.setLocale(event.target.value);
84
+ props.onChange?.(event);
85
+ },
86
+ },
87
+ available.map((locale) =>
88
+ createElement(
89
+ 'option',
90
+ { key: locale, value: locale },
91
+ labels[locale] || locale
92
+ )
93
+ )
94
+ );
95
+ }
96
+
97
+ function Format({ value, formatter, options }) {
98
+ const locale = useLocale();
99
+ return new Intl[formatter](locale, options).format(value);
100
+ }
101
+
102
+ export function NumberFormat(props) {
103
+ return createElement(Format, { ...props, formatter: 'NumberFormat' });
104
+ }
105
+
106
+ export function DateTimeFormat(props) {
107
+ return createElement(Format, { ...props, formatter: 'DateTimeFormat' });
108
+ }
109
+
110
+ export function RelativeTimeFormat({ value, unit, options }) {
111
+ const locale = useLocale();
112
+ return new Intl.RelativeTimeFormat(locale, options).format(value, unit);
113
+ }
114
+
115
+ export function CurrencyFormat({ value, currency, options }) {
116
+ return createElement(NumberFormat, {
117
+ value,
118
+ options: { style: 'currency', currency, ...options },
119
+ });
120
+ }