react-i18next 16.0.0 → 16.1.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/amd/react-i18next.js +3028 -844
  3. package/dist/amd/react-i18next.min.js +1 -1
  4. package/dist/commonjs/IcuTrans.js +35 -0
  5. package/dist/commonjs/IcuTransUtils/TranslationParserError.js +18 -0
  6. package/dist/commonjs/IcuTransUtils/htmlEntityDecoder.js +218 -0
  7. package/dist/commonjs/IcuTransUtils/index.js +49 -0
  8. package/dist/commonjs/IcuTransUtils/renderTranslation.js +114 -0
  9. package/dist/commonjs/IcuTransUtils/tokenizer.js +58 -0
  10. package/dist/commonjs/IcuTransWithoutContext.js +56 -0
  11. package/dist/commonjs/TransWithoutContext.js +2 -1
  12. package/dist/es/IcuTrans.js +29 -0
  13. package/dist/es/IcuTransUtils/TranslationParserError.js +11 -0
  14. package/dist/es/IcuTransUtils/htmlEntityDecoder.js +211 -0
  15. package/dist/es/IcuTransUtils/index.js +4 -0
  16. package/dist/es/IcuTransUtils/renderTranslation.js +106 -0
  17. package/dist/es/IcuTransUtils/tokenizer.js +51 -0
  18. package/dist/es/IcuTransWithoutContext.js +49 -0
  19. package/dist/es/TransWithoutContext.js +2 -1
  20. package/dist/es/package.json +1 -1
  21. package/dist/umd/react-i18next.js +3031 -847
  22. package/dist/umd/react-i18next.min.js +1 -1
  23. package/icu.macro.js +170 -48
  24. package/package.json +6 -3
  25. package/react-i18next.js +3031 -847
  26. package/react-i18next.min.js +1 -1
  27. package/src/IcuTrans.js +103 -0
  28. package/src/IcuTransUtils/TranslationParserError.js +24 -0
  29. package/src/IcuTransUtils/htmlEntityDecoder.js +264 -0
  30. package/src/IcuTransUtils/index.js +4 -0
  31. package/src/IcuTransUtils/renderTranslation.js +215 -0
  32. package/src/IcuTransUtils/tokenizer.js +78 -0
  33. package/src/IcuTransWithoutContext.js +146 -0
  34. package/src/TransWithoutContext.js +5 -1
@@ -0,0 +1,215 @@
1
+ import React from 'react';
2
+
3
+ import { TranslationParserError } from './TranslationParserError';
4
+ import { tokenize } from './tokenizer';
5
+ import { decodeHtmlEntities } from './htmlEntityDecoder';
6
+
7
+ /**
8
+ * Render a React element tree from a declaration node and its children
9
+ *
10
+ * @param {Object} declaration - The component declaration (type + props)
11
+ * @param {Array<React.ReactNode>} children - Array of child nodes (text, numbers, React elements)
12
+ * @param {Array<Object>} [childDeclarations] - Optional array of child declarations to use for nested rendering
13
+ * @returns {React.ReactElement} A React element
14
+ */
15
+ const renderDeclarationNode = (declaration, children, childDeclarations) => {
16
+ const { type, props = {} } = declaration;
17
+
18
+ // If props contain a children declaration AND we have childDeclarations to work with,
19
+ // we need to recursively render the content with those child declarations
20
+ if (props.children && Array.isArray(props.children) && childDeclarations) {
21
+ // The children array contains the parsed content from inside this tag
22
+ // We need to rebuild the translation string and re-parse it with child declarations
23
+ // For now, we'll use the children directly as they're already parsed
24
+ // This happens when renderTranslation is called recursively
25
+
26
+ // Remove children from props since we'll pass them as the third argument
27
+ // eslint-disable-next-line no-unused-vars
28
+ const { children: _childrenToRemove, ...propsWithoutChildren } = props;
29
+
30
+ return React.createElement(type, propsWithoutChildren, ...children);
31
+ }
32
+
33
+ // Standard rendering with children from translation
34
+ if (children.length === 0) {
35
+ return React.createElement(type, props);
36
+ }
37
+ if (children.length === 1) {
38
+ return React.createElement(type, props, children[0]);
39
+ }
40
+ return React.createElement(type, props, ...children);
41
+ };
42
+
43
+ /**
44
+ * Render translation string with declaration tree to create React elements
45
+ *
46
+ * This function parses an ICU format translation string and reconstructs
47
+ * a React element tree using the provided declaration tree. It replaces
48
+ * numbered tags (e.g., <0>, <1>) with the corresponding components from
49
+ * the declaration array and fills them with the translated text.
50
+ *
51
+ * @param {string} translation - ICU format string (e.g., "<0>Click here</0>")
52
+ * @param {Array<Object>} [declarations=[]] - Array of component declarations matching tag numbers
53
+ * @returns {Array<React.ReactNode>} Array of React nodes (elements and text)
54
+ *
55
+ * @example
56
+ * ```jsx
57
+ * const result = renderTranslation(
58
+ * "<0>bonjour</0> monde",
59
+ * [{ type: 'strong', props: { className: 'bold' } }]
60
+ * );
61
+ * // Returns: [<strong className="bold">bonjour</strong>, " monde"]
62
+ * ```
63
+ *
64
+ * @example
65
+ * ```jsx
66
+ * // With nested children in declaration
67
+ * const result = renderTranslation(
68
+ * "<0>Click <1>here</1></0>",
69
+ * [
70
+ * {
71
+ * type: 'div',
72
+ * props: {
73
+ * children: [{ type: 'span', props: {} }]
74
+ * }
75
+ * }
76
+ * ]
77
+ * );
78
+ * ```
79
+ */
80
+ export const renderTranslation = (translation, declarations = []) => {
81
+ if (!translation) {
82
+ return [];
83
+ }
84
+
85
+ const tokens = tokenize(translation);
86
+ const result = [];
87
+ const stack = [];
88
+
89
+ // Track tag numbers that should be treated as literal text (no declaration found)
90
+ const literalTagNumbers = new Set();
91
+
92
+ // Helper to get the current declarations array based on context
93
+ const getCurrentDeclarations = () => {
94
+ if (stack.length === 0) {
95
+ return declarations;
96
+ }
97
+
98
+ const parentFrame = stack[stack.length - 1];
99
+
100
+ // If the parent declaration has children declarations, use those
101
+ if (
102
+ parentFrame.declaration.props?.children &&
103
+ Array.isArray(parentFrame.declaration.props.children)
104
+ ) {
105
+ return parentFrame.declaration.props.children;
106
+ }
107
+
108
+ // Otherwise, use the parent's declarations array
109
+ return parentFrame.declarations;
110
+ };
111
+
112
+ tokens.forEach((token) => {
113
+ // eslint-disable-next-line default-case
114
+ switch (token.type) {
115
+ case 'Text':
116
+ {
117
+ const decoded = decodeHtmlEntities(token.value);
118
+ const targetArray = stack.length > 0 ? stack[stack.length - 1].children : result;
119
+
120
+ targetArray.push(decoded);
121
+ }
122
+
123
+ break;
124
+
125
+ case 'TagOpen':
126
+ {
127
+ const { tagNumber } = token;
128
+ const currentDeclarations = getCurrentDeclarations();
129
+ const declaration = currentDeclarations[tagNumber];
130
+
131
+ if (!declaration) {
132
+ // No declaration found - treat this tag as literal text
133
+ literalTagNumbers.add(tagNumber);
134
+
135
+ const literalText = `<${tagNumber}>`;
136
+ const targetArray = stack.length > 0 ? stack[stack.length - 1].children : result;
137
+
138
+ targetArray.push(literalText);
139
+
140
+ break;
141
+ }
142
+
143
+ stack.push({
144
+ tagNumber,
145
+ children: [],
146
+ position: token.position,
147
+ declaration,
148
+ declarations: currentDeclarations,
149
+ });
150
+ }
151
+
152
+ break;
153
+
154
+ case 'TagClose':
155
+ {
156
+ const { tagNumber } = token;
157
+
158
+ // If this tag was treated as literal, output the closing tag as literal text
159
+ if (literalTagNumbers.has(tagNumber)) {
160
+ const literalText = `</${tagNumber}>`;
161
+ const literalTargetArray = stack.length > 0 ? stack[stack.length - 1].children : result;
162
+
163
+ literalTargetArray.push(literalText);
164
+
165
+ literalTagNumbers.delete(tagNumber);
166
+
167
+ break;
168
+ }
169
+
170
+ if (stack.length === 0) {
171
+ throw new TranslationParserError(
172
+ `Unexpected closing tag </${tagNumber}> at position ${token.position}`,
173
+ token.position,
174
+ translation,
175
+ );
176
+ }
177
+
178
+ const frame = stack.pop();
179
+
180
+ if (frame.tagNumber !== tagNumber) {
181
+ throw new TranslationParserError(
182
+ `Mismatched tags: expected </${frame.tagNumber}> but got </${tagNumber}> at position ${token.position}`,
183
+ token.position,
184
+ translation,
185
+ );
186
+ }
187
+
188
+ // Render the element using the declaration and collected children
189
+ const element = renderDeclarationNode(
190
+ frame.declaration,
191
+ frame.children,
192
+ frame.declarations,
193
+ );
194
+
195
+ const elementTargetArray = stack.length > 0 ? stack[stack.length - 1].children : result;
196
+
197
+ elementTargetArray.push(element);
198
+ }
199
+
200
+ break;
201
+ }
202
+ });
203
+
204
+ if (stack.length > 0) {
205
+ const unclosed = stack[stack.length - 1];
206
+
207
+ throw new TranslationParserError(
208
+ `Unclosed tag <${unclosed.tagNumber}> at position ${unclosed.position}`,
209
+ unclosed.position,
210
+ translation,
211
+ );
212
+ }
213
+
214
+ return result;
215
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Tokenize a translation string with numbered tags
3
+ * Note: Variables are already interpolated by the i18n system before we receive the string
4
+ *
5
+ * @param {string} translation - Translation string with numbered tags
6
+ * @returns {Array<Token>} Array of tokens
7
+ */
8
+ export const tokenize = (translation) => {
9
+ const tokens = [];
10
+
11
+ let position = 0;
12
+
13
+ let currentText = '';
14
+
15
+ const flushText = () => {
16
+ if (currentText) {
17
+ tokens.push({
18
+ type: 'Text',
19
+ value: currentText,
20
+ position: position - currentText.length,
21
+ });
22
+
23
+ currentText = '';
24
+ }
25
+ };
26
+
27
+ while (position < translation.length) {
28
+ const char = translation[position];
29
+
30
+ // Check for opening tag: <0>, <1>, etc.
31
+ if (char === '<') {
32
+ const tagMatch = translation.slice(position).match(/^<(\d+)>/);
33
+
34
+ if (tagMatch) {
35
+ flushText();
36
+
37
+ tokens.push({
38
+ type: 'TagOpen',
39
+ value: tagMatch[0],
40
+ position,
41
+ tagNumber: parseInt(tagMatch[1], 10),
42
+ });
43
+
44
+ position += tagMatch[0].length;
45
+ } else {
46
+ // Check for closing tag: </0>, </1>, etc.
47
+ const closeTagMatch = translation.slice(position).match(/^<\/(\d+)>/);
48
+
49
+ if (closeTagMatch) {
50
+ flushText();
51
+
52
+ tokens.push({
53
+ type: 'TagClose',
54
+ value: closeTagMatch[0],
55
+ position,
56
+ tagNumber: parseInt(closeTagMatch[1], 10),
57
+ });
58
+
59
+ position += closeTagMatch[0].length;
60
+ } else {
61
+ // Regular text (including any { } characters that aren't our tags)
62
+ currentText += char;
63
+
64
+ position += 1;
65
+ }
66
+ }
67
+ } else {
68
+ // Regular text (including any { } characters that aren't our tags)
69
+ currentText += char;
70
+
71
+ position += 1;
72
+ }
73
+ }
74
+
75
+ flushText();
76
+
77
+ return tokens;
78
+ };
@@ -0,0 +1,146 @@
1
+ import React from 'react';
2
+ import { warn, warnOnce, isString } from './utils.js';
3
+ import { getI18n } from './i18nInstance.js';
4
+ import { renderTranslation } from './IcuTransUtils';
5
+
6
+ /**
7
+ * IcuTrans component for rendering ICU MessageFormat translations (without React Context)
8
+ *
9
+ * This is the core implementation without React hooks or context dependencies,
10
+ * making it suitable for use in any environment. It uses a declaration tree
11
+ * approach where components are defined as type + props blueprints, fetches
12
+ * the translated string via i18next, and reconstructs the React element tree
13
+ * by replacing numbered tags (<0>, <1>) with actual components.
14
+ *
15
+ * Key features:
16
+ * - No React hooks or context (can be used anywhere)
17
+ * - ICU MessageFormat compatible
18
+ * - Supports nested component declarations
19
+ * - Automatic HTML entity decoding
20
+ * - Graceful error handling with fallbacks
21
+ * - Merges default interpolation variables
22
+ *
23
+ * Note: Users should typically use the IcuTrans export which provides automatic
24
+ * context support. This component is exposed for advanced use cases where direct
25
+ * i18n instance control is needed, or for use outside of React Context.
26
+ *
27
+ * @param {Object} props - Component props
28
+ * @param {string} props.i18nKey - The i18n key to look up the translation
29
+ * @param {string} props.defaultTranslation - The default translation in ICU format with numbered tags (e.g., "<0>Click here</0>")
30
+ * @param {Array<{type: string|React.ComponentType, props?: Object}>} props.content - Declaration tree describing React components and their props
31
+ * @param {string|string[]} [props.ns] - Optional namespace(s) for the translation. Falls back to t.ns, then i18n.options.defaultNS, then 'translation'
32
+ * @param {Object} [props.values={}] - Optional values for ICU variable interpolation (merged with i18n.options.interpolation.defaultVariables if present)
33
+ * @param {Object} [props.i18n] - i18next instance. If not provided, uses global instance from getI18n()
34
+ * @param {Function} [props.t] - Custom translation function. If not provided, uses i18n.t.bind(i18n)
35
+ * @returns {React.ReactElement} React fragment containing the rendered translation
36
+ *
37
+ * @example
38
+ * ```jsx
39
+ * // Direct usage with i18n instance
40
+ * <IcuTransWithoutContext
41
+ * i18nKey="welcome.message"
42
+ * defaultTranslation="Welcome <0>back</0>!"
43
+ * content={[
44
+ * { type: 'strong', props: { className: 'highlight' } }
45
+ * ]}
46
+ * i18n={i18nInstance}
47
+ * />
48
+ * ```
49
+ *
50
+ * @example
51
+ * ```jsx
52
+ * // With nested declarations for list rendering
53
+ * <IcuTransWithoutContext
54
+ * i18nKey="features.list"
55
+ * defaultTranslation="Features: <0><0>Fast</0><1>Reliable</1><2>Secure</2></0>"
56
+ * content={[
57
+ * {
58
+ * type: 'ul',
59
+ * props: {
60
+ * children: [
61
+ * { type: 'li', props: {} },
62
+ * { type: 'li', props: {} },
63
+ * { type: 'li', props: {} }
64
+ * ]
65
+ * }
66
+ * }
67
+ * ]}
68
+ * i18n={i18nInstance}
69
+ * />
70
+ * ```
71
+ *
72
+ * @example
73
+ * ```jsx
74
+ * // With values for ICU variable interpolation
75
+ * <IcuTransWithoutContext
76
+ * i18nKey="greeting"
77
+ * defaultTranslation="Hello <0>{name}</0>!"
78
+ * content={[{ type: 'strong', props: {} }]}
79
+ * values={{ name: 'Alice' }}
80
+ * i18n={i18nInstance}
81
+ * />
82
+ * ```
83
+ */
84
+ export function IcuTransWithoutContext({
85
+ i18nKey,
86
+ defaultTranslation,
87
+ content,
88
+ ns,
89
+ values = {},
90
+ i18n: i18nFromProps,
91
+ t: tFromProps,
92
+ }) {
93
+ const i18n = i18nFromProps || getI18n();
94
+
95
+ if (!i18n) {
96
+ warnOnce(
97
+ i18n,
98
+ 'NO_I18NEXT_INSTANCE',
99
+ `IcuTrans: You need to pass in an i18next instance using i18nextReactModule`,
100
+ { i18nKey },
101
+ );
102
+ return React.createElement(React.Fragment, {}, defaultTranslation);
103
+ }
104
+
105
+ const t = tFromProps || i18n.t?.bind(i18n) || ((k) => k);
106
+
107
+ // prepare having a namespace
108
+ let namespaces = ns || t.ns || i18n.options?.defaultNS;
109
+ namespaces = isString(namespaces) ? [namespaces] : namespaces || ['translation'];
110
+
111
+ // Merge default interpolation variables if they exist
112
+ let mergedValues = values;
113
+ if (i18n.options?.interpolation?.defaultVariables) {
114
+ mergedValues =
115
+ values && Object.keys(values).length > 0
116
+ ? { ...values, ...i18n.options.interpolation.defaultVariables }
117
+ : { ...i18n.options.interpolation.defaultVariables };
118
+ }
119
+
120
+ // Get the translation, falling back to defaultTranslation
121
+ const translation = t(i18nKey, {
122
+ defaultValue: defaultTranslation,
123
+ ...mergedValues,
124
+ ns: namespaces,
125
+ });
126
+
127
+ // Render the translation with the declaration tree
128
+ try {
129
+ const rendered = renderTranslation(translation, content);
130
+
131
+ // Return as a React fragment to avoid extra wrapper
132
+ return React.createElement(React.Fragment, {}, ...rendered);
133
+ } catch (error) {
134
+ // If rendering fails, warn and fall back to the translation string
135
+ warn(
136
+ i18n,
137
+ 'ICU_TRANS_RENDER_ERROR',
138
+ `IcuTrans component error for key "${i18nKey}": ${error.message}`,
139
+ { i18nKey, error },
140
+ );
141
+
142
+ return React.createElement(React.Fragment, {}, translation);
143
+ }
144
+ }
145
+
146
+ IcuTransWithoutContext.displayName = 'IcuTransWithoutContext';
@@ -1,4 +1,5 @@
1
1
  import { Fragment, isValidElement, cloneElement, createElement, Children } from 'react';
2
+ import { keyFromSelector } from 'i18next';
2
3
  import HTML from 'html-parse-stringify';
3
4
  import { isObject, isString, warn, warnOnce } from './utils.js';
4
5
  import { getDefaults } from './defaults.js';
@@ -424,7 +425,10 @@ export function Trans({
424
425
 
425
426
  const nodeAsString = nodesToString(children, reactI18nextOptions, i18n, i18nKey);
426
427
  const defaultValue =
427
- defaults || nodeAsString || reactI18nextOptions.transEmptyNodeValue || i18nKey;
428
+ defaults ||
429
+ nodeAsString ||
430
+ reactI18nextOptions.transEmptyNodeValue ||
431
+ (typeof i18nKey === 'function' ? keyFromSelector(i18nKey) : i18nKey);
428
432
  const { hashTransKey } = reactI18nextOptions;
429
433
  const key =
430
434
  i18nKey ||