react-i18next 16.0.1 → 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.
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Common HTML entities map for fast lookup
3
+ */
4
+ const commonEntities = {
5
+ // Basic entities
6
+ ' ': '\u00A0', // Non-breaking space
7
+ '&': '&',
8
+ '&lt;': '<',
9
+ '&gt;': '>',
10
+ '&quot;': '"',
11
+ '&apos;': "'",
12
+
13
+ // Copyright, trademark, and registration
14
+ '&copy;': '©',
15
+ '&reg;': '®',
16
+ '&trade;': '™',
17
+
18
+ // Punctuation
19
+ '&hellip;': '…',
20
+ '&ndash;': '–',
21
+ '&mdash;': '—',
22
+ '&lsquo;': '\u2018',
23
+ '&rsquo;': '\u2019',
24
+ '&sbquo;': '\u201A',
25
+ '&ldquo;': '\u201C',
26
+ '&rdquo;': '\u201D',
27
+ '&bdquo;': '\u201E',
28
+ '&dagger;': '†',
29
+ '&Dagger;': '‡',
30
+ '&bull;': '•',
31
+ '&prime;': '′',
32
+ '&Prime;': '″',
33
+ '&lsaquo;': '‹',
34
+ '&rsaquo;': '›',
35
+ '&sect;': '§',
36
+ '&para;': '¶',
37
+ '&middot;': '·',
38
+
39
+ // Spaces
40
+ '&ensp;': '\u2002',
41
+ '&emsp;': '\u2003',
42
+ '&thinsp;': '\u2009',
43
+
44
+ // Currency
45
+ '&euro;': '€',
46
+ '&pound;': '£',
47
+ '&yen;': '¥',
48
+ '&cent;': '¢',
49
+ '&curren;': '¤',
50
+
51
+ // Math symbols
52
+ '&times;': '×',
53
+ '&divide;': '÷',
54
+ '&minus;': '−',
55
+ '&plusmn;': '±',
56
+ '&ne;': '≠',
57
+ '&le;': '≤',
58
+ '&ge;': '≥',
59
+ '&asymp;': '≈',
60
+ '&equiv;': '≡',
61
+ '&infin;': '∞',
62
+ '&int;': '∫',
63
+ '&sum;': '∑',
64
+ '&prod;': '∏',
65
+ '&radic;': '√',
66
+ '&part;': '∂',
67
+ '&permil;': '‰',
68
+ '&deg;': '°',
69
+ '&micro;': 'µ',
70
+
71
+ // Arrows
72
+ '&larr;': '←',
73
+ '&uarr;': '↑',
74
+ '&rarr;': '→',
75
+ '&darr;': '↓',
76
+ '&harr;': '↔',
77
+ '&crarr;': '↵',
78
+ '&lArr;': '⇐',
79
+ '&uArr;': '⇑',
80
+ '&rArr;': '⇒',
81
+ '&dArr;': '⇓',
82
+ '&hArr;': '⇔',
83
+
84
+ // Greek letters (lowercase)
85
+ '&alpha;': 'α',
86
+ '&beta;': 'β',
87
+ '&gamma;': 'γ',
88
+ '&delta;': 'δ',
89
+ '&epsilon;': 'ε',
90
+ '&zeta;': 'ζ',
91
+ '&eta;': 'η',
92
+ '&theta;': 'θ',
93
+ '&iota;': 'ι',
94
+ '&kappa;': 'κ',
95
+ '&lambda;': 'λ',
96
+ '&mu;': 'μ',
97
+ '&nu;': 'ν',
98
+ '&xi;': 'ξ',
99
+ '&omicron;': 'ο',
100
+ '&pi;': 'π',
101
+ '&rho;': 'ρ',
102
+ '&sigma;': 'σ',
103
+ '&tau;': 'τ',
104
+ '&upsilon;': 'υ',
105
+ '&phi;': 'φ',
106
+ '&chi;': 'χ',
107
+ '&psi;': 'ψ',
108
+ '&omega;': 'ω',
109
+
110
+ // Greek letters (uppercase)
111
+ '&Alpha;': 'Α',
112
+ '&Beta;': 'Β',
113
+ '&Gamma;': 'Γ',
114
+ '&Delta;': 'Δ',
115
+ '&Epsilon;': 'Ε',
116
+ '&Zeta;': 'Ζ',
117
+ '&Eta;': 'Η',
118
+ '&Theta;': 'Θ',
119
+ '&Iota;': 'Ι',
120
+ '&Kappa;': 'Κ',
121
+ '&Lambda;': 'Λ',
122
+ '&Mu;': 'Μ',
123
+ '&Nu;': 'Ν',
124
+ '&Xi;': 'Ξ',
125
+ '&Omicron;': 'Ο',
126
+ '&Pi;': 'Π',
127
+ '&Rho;': 'Ρ',
128
+ '&Sigma;': 'Σ',
129
+ '&Tau;': 'Τ',
130
+ '&Upsilon;': 'Υ',
131
+ '&Phi;': 'Φ',
132
+ '&Chi;': 'Χ',
133
+ '&Psi;': 'Ψ',
134
+ '&Omega;': 'Ω',
135
+
136
+ // Latin extended
137
+ '&Agrave;': 'À',
138
+ '&Aacute;': 'Á',
139
+ '&Acirc;': 'Â',
140
+ '&Atilde;': 'Ã',
141
+ '&Auml;': 'Ä',
142
+ '&Aring;': 'Å',
143
+ '&AElig;': 'Æ',
144
+ '&Ccedil;': 'Ç',
145
+ '&Egrave;': 'È',
146
+ '&Eacute;': 'É',
147
+ '&Ecirc;': 'Ê',
148
+ '&Euml;': 'Ë',
149
+ '&Igrave;': 'Ì',
150
+ '&Iacute;': 'Í',
151
+ '&Icirc;': 'Î',
152
+ '&Iuml;': 'Ï',
153
+ '&ETH;': 'Ð',
154
+ '&Ntilde;': 'Ñ',
155
+ '&Ograve;': 'Ò',
156
+ '&Oacute;': 'Ó',
157
+ '&Ocirc;': 'Ô',
158
+ '&Otilde;': 'Õ',
159
+ '&Ouml;': 'Ö',
160
+ '&Oslash;': 'Ø',
161
+ '&Ugrave;': 'Ù',
162
+ '&Uacute;': 'Ú',
163
+ '&Ucirc;': 'Û',
164
+ '&Uuml;': 'Ü',
165
+ '&Yacute;': 'Ý',
166
+ '&THORN;': 'Þ',
167
+ '&szlig;': 'ß',
168
+ '&agrave;': 'à',
169
+ '&aacute;': 'á',
170
+ '&acirc;': 'â',
171
+ '&atilde;': 'ã',
172
+ '&auml;': 'ä',
173
+ '&aring;': 'å',
174
+ '&aelig;': 'æ',
175
+ '&ccedil;': 'ç',
176
+ '&egrave;': 'è',
177
+ '&eacute;': 'é',
178
+ '&ecirc;': 'ê',
179
+ '&euml;': 'ë',
180
+ '&igrave;': 'ì',
181
+ '&iacute;': 'í',
182
+ '&icirc;': 'î',
183
+ '&iuml;': 'ï',
184
+ '&eth;': 'ð',
185
+ '&ntilde;': 'ñ',
186
+ '&ograve;': 'ò',
187
+ '&oacute;': 'ó',
188
+ '&ocirc;': 'ô',
189
+ '&otilde;': 'õ',
190
+ '&ouml;': 'ö',
191
+ '&oslash;': 'ø',
192
+ '&ugrave;': 'ù',
193
+ '&uacute;': 'ú',
194
+ '&ucirc;': 'û',
195
+ '&uuml;': 'ü',
196
+ '&yacute;': 'ý',
197
+ '&thorn;': 'þ',
198
+ '&yuml;': 'ÿ',
199
+
200
+ // Special characters
201
+ '&iexcl;': '¡',
202
+ '&iquest;': '¿',
203
+ '&fnof;': 'ƒ',
204
+ '&circ;': 'ˆ',
205
+ '&tilde;': '˜',
206
+ '&OElig;': 'Œ',
207
+ '&oelig;': 'œ',
208
+ '&Scaron;': 'Š',
209
+ '&scaron;': 'š',
210
+ '&Yuml;': 'Ÿ',
211
+ '&ordf;': 'ª',
212
+ '&ordm;': 'º',
213
+ '&macr;': '¯',
214
+ '&acute;': '´',
215
+ '&cedil;': '¸',
216
+ '&sup1;': '¹',
217
+ '&sup2;': '²',
218
+ '&sup3;': '³',
219
+ '&frac14;': '¼',
220
+ '&frac12;': '½',
221
+ '&frac34;': '¾',
222
+
223
+ // Card suits
224
+ '&spades;': '♠',
225
+ '&clubs;': '♣',
226
+ '&hearts;': '♥',
227
+ '&diams;': '♦',
228
+
229
+ // Miscellaneous
230
+ '&loz;': '◊',
231
+ '&oline;': '‾',
232
+ '&frasl;': '⁄',
233
+ '&weierp;': '℘',
234
+ '&image;': 'ℑ',
235
+ '&real;': 'ℜ',
236
+ '&alefsym;': 'ℵ',
237
+ };
238
+
239
+ // Create regex pattern for all entities
240
+ const entityPattern = new RegExp(
241
+ Object.keys(commonEntities)
242
+ .map((entity) => entity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
243
+ .join('|'),
244
+ 'g',
245
+ );
246
+
247
+ /**
248
+ * Decode HTML entities in text
249
+ *
250
+ * Uses a hybrid approach:
251
+ * 1. First pass: decode common named entities using a map
252
+ * 2. Second pass: decode numeric entities (decimal and hexadecimal)
253
+ *
254
+ * @param {string} text - Text with HTML entities
255
+ * @returns {string} Decoded text
256
+ */
257
+ export const decodeHtmlEntities = (text) =>
258
+ text
259
+ // First pass: common named entities
260
+ .replace(entityPattern, (match) => commonEntities[match])
261
+ // Second pass: numeric entities (decimal)
262
+ .replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)))
263
+ // Third pass: numeric entities (hexadecimal)
264
+ .replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
@@ -0,0 +1,4 @@
1
+ export * from './TranslationParserError';
2
+ export * from './htmlEntityDecoder';
3
+ export * from './tokenizer';
4
+ export * from './renderTranslation';
@@ -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
+ };