UNPKG

react-i18next

Version:

Internationalization for react done right. Using the i18next i18n ecosystem.

216 lines (177 loc) 6.8 kB
import React from 'react'; import { TranslationParserError } from './TranslationParserError.js'; import { tokenize } from './tokenizer.js'; import { decodeHtmlEntities } from './htmlEntityDecoder.js'; /** * Render a React element tree from a declaration node and its children * * @param {Object} declaration - The component declaration (type + props) * @param {Array<React.ReactNode>} children - Array of child nodes (text, numbers, React elements) * @param {Array<Object>} [childDeclarations] - Optional array of child declarations to use for nested rendering * @returns {React.ReactElement} A React element */ const renderDeclarationNode = (declaration, children, childDeclarations) => { const { type, props = {} } = declaration; // If props contain a children declaration AND we have childDeclarations to work with, // we need to recursively render the content with those child declarations if (props.children && Array.isArray(props.children) && childDeclarations) { // The children array contains the parsed content from inside this tag // We need to rebuild the translation string and re-parse it with child declarations // For now, we'll use the children directly as they're already parsed // This happens when renderTranslation is called recursively // Remove children from props since we'll pass them as the third argument // eslint-disable-next-line no-unused-vars const { children: _childrenToRemove, ...propsWithoutChildren } = props; return React.createElement(type, propsWithoutChildren, ...children); } // Standard rendering with children from translation if (children.length === 0) { return React.createElement(type, props); } if (children.length === 1) { return React.createElement(type, props, children[0]); } return React.createElement(type, props, ...children); }; /** * Render translation string with declaration tree to create React elements * * This function parses an ICU format translation string and reconstructs * a React element tree using the provided declaration tree. It replaces * numbered tags (e.g., <0>, <1>) with the corresponding components from * the declaration array and fills them with the translated text. * * @param {string} translation - ICU format string (e.g., "<0>Click here</0>") * @param {Array<Object>} [declarations=[]] - Array of component declarations matching tag numbers * @returns {Array<React.ReactNode>} Array of React nodes (elements and text) * * @example * ```jsx * const result = renderTranslation( * "<0>bonjour</0> monde", * [{ type: 'strong', props: { className: 'bold' } }] * ); * // Returns: [<strong className="bold">bonjour</strong>, " monde"] * ``` * * @example * ```jsx * // With nested children in declaration * const result = renderTranslation( * "<0>Click <1>here</1></0>", * [ * { * type: 'div', * props: { * children: [{ type: 'span', props: {} }] * } * } * ] * ); * ``` */ export const renderTranslation = (translation, declarations = []) => { if (!translation) { return []; } const tokens = tokenize(translation); const result = []; const stack = []; // Track tag numbers that should be treated as literal text (no declaration found) const literalTagNumbers = new Set(); // Helper to get the current declarations array based on context const getCurrentDeclarations = () => { if (stack.length === 0) { return declarations; } const parentFrame = stack[stack.length - 1]; // If the parent declaration has children declarations, use those if ( parentFrame.declaration.props?.children && Array.isArray(parentFrame.declaration.props.children) ) { return parentFrame.declaration.props.children; } // Otherwise, use the parent's declarations array return parentFrame.declarations; }; tokens.forEach((token) => { // eslint-disable-next-line default-case switch (token.type) { case 'Text': { const decoded = decodeHtmlEntities(token.value); const targetArray = stack.length > 0 ? stack[stack.length - 1].children : result; targetArray.push(decoded); } break; case 'TagOpen': { const { tagNumber } = token; const currentDeclarations = getCurrentDeclarations(); const declaration = currentDeclarations[tagNumber]; if (!declaration) { // No declaration found - treat this tag as literal text literalTagNumbers.add(tagNumber); const literalText = `<${tagNumber}>`; const targetArray = stack.length > 0 ? stack[stack.length - 1].children : result; targetArray.push(literalText); break; } stack.push({ tagNumber, children: [], position: token.position, declaration, declarations: currentDeclarations, }); } break; case 'TagClose': { const { tagNumber } = token; // If this tag was treated as literal, output the closing tag as literal text if (literalTagNumbers.has(tagNumber)) { const literalText = `</${tagNumber}>`; const literalTargetArray = stack.length > 0 ? stack[stack.length - 1].children : result; literalTargetArray.push(literalText); literalTagNumbers.delete(tagNumber); break; } if (stack.length === 0) { throw new TranslationParserError( `Unexpected closing tag </${tagNumber}> at position ${token.position}`, token.position, translation, ); } const frame = stack.pop(); if (frame.tagNumber !== tagNumber) { throw new TranslationParserError( `Mismatched tags: expected </${frame.tagNumber}> but got </${tagNumber}> at position ${token.position}`, token.position, translation, ); } // Render the element using the declaration and collected children const element = renderDeclarationNode( frame.declaration, frame.children, frame.declarations, ); const elementTargetArray = stack.length > 0 ? stack[stack.length - 1].children : result; elementTargetArray.push(element); } break; } }); if (stack.length > 0) { const unclosed = stack[stack.length - 1]; throw new TranslationParserError( `Unclosed tag <${unclosed.tagNumber}> at position ${unclosed.position}`, unclosed.position, translation, ); } return result; };