Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions src/IcuTrans.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { useContext } from 'react';
import { IcuTransWithoutContext } from './IcuTransWithoutContext.js';
import { getI18n, I18nContext } from './context.js';

/**
* IcuTrans component for rendering ICU MessageFormat translations with React components
*
* This component provides a context-aware wrapper around IcuTransWithoutContext,
* automatically retrieving the i18n instance from React context when used within
* an I18nextProvider. It uses a declaration tree approach where components are
* defined as type + props blueprints, fetches the translated string, and reconstructs
* the React element tree by replacing numbered tags with actual components.
*
* Key features:
* - Supports React Context (I18nextProvider)
* - Falls back to global i18n instance
* - ICU MessageFormat compatible
* - Type-safe component declarations
* - Automatic HTML entity decoding
*
* @param {Object} props - Component props
* @param {string} props.i18nKey - The i18n key to look up the translation
* @param {string} props.defaultTranslation - The default translation in ICU format with numbered tags (e.g., "<0>Click here</0>")
* @param {Array<{type: string|React.ComponentType, props?: Object}>} props.content - Declaration tree describing React components and their props
* @param {string|string[]} [props.ns] - Optional namespace(s) for the translation
* @param {Object} [props.values] - Optional values for ICU variable interpolation
* @param {Object} [props.i18n] - Optional i18next instance (overrides context)
* @param {Function} [props.t] - Optional translation function (overrides context)
* @returns {React.ReactElement} React fragment containing the rendered translation
*
* @example
* ```jsx
* // Basic usage with context
* <I18nextProvider i18n={i18n}>
* <IcuTrans
* i18nKey="welcome.message"
* defaultTranslation="Welcome <0>friend</0>!"
* content={[
* { type: 'strong', props: { className: 'highlight' } }
* ]}
* />
* </I18nextProvider>
* ```
*
* @example
* ```jsx
* // With custom components and nested structure
* <IcuTrans
* i18nKey="docs.link"
* defaultTranslation="Read the <0>documentation <1></1></0> for more info"
* content={[
* { type: 'a', props: { href: '/docs' } },
* { type: Icon, props: { name: 'external' } }
* ]}
* />
* ```
*
* @example
* ```jsx
* // With nested children in declarations
* <IcuTrans
* i18nKey="list.items"
* defaultTranslation="<0><0>First item</0><1>Second item</1></0>"
* content={[
* {
* type: 'ul',
* props: {
* children: [
* { type: 'li', props: {} },
* { type: 'li', props: {} }
* ]
* }
* }
* ]}
* />
* ```
*/
export function IcuTrans({
i18nKey,
defaultTranslation,
content,
ns,
values = {},
i18n: i18nFromProps,
t: tFromProps,
}) {
const { i18n: i18nFromContext, defaultNS: defaultNSFromContext } = useContext(I18nContext) || {};
const i18n = i18nFromProps || i18nFromContext || getI18n();

const t = tFromProps || i18n?.t.bind(i18n);

return IcuTransWithoutContext({
i18nKey,
defaultTranslation,
content,
ns: ns || t?.ns || defaultNSFromContext || i18n?.options?.defaultNS,
values,
i18n,
t: tFromProps,
});
}

IcuTrans.displayName = 'IcuTrans';
24 changes: 24 additions & 0 deletions src/IcuTransUtils/TranslationParserError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Error thrown during translation parsing
*/
export class TranslationParserError extends Error {
/**
* @param {string} message - Error message
* @param {number} [position] - Position in the translation string where the error occurred
* @param {string} [translationString] - The full translation string being parsed
*/
constructor(message, position, translationString) {
super(message);

this.name = 'TranslationParserError';

this.position = position;

this.translationString = translationString;

// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, TranslationParserError);
}
}
}
264 changes: 264 additions & 0 deletions src/IcuTransUtils/htmlEntityDecoder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,264 @@
/**
* Common HTML entities map for fast lookup
*/
const commonEntities = {
// Basic entities
'&nbsp;': '\u00A0', // Non-breaking space
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&apos;': "'",

// Copyright, trademark, and registration
'&copy;': '©',
'&reg;': '®',
'&trade;': '™',

// Punctuation
'&hellip;': '…',
'&ndash;': '–',
'&mdash;': '—',
'&lsquo;': '\u2018',
'&rsquo;': '\u2019',
'&sbquo;': '\u201A',
'&ldquo;': '\u201C',
'&rdquo;': '\u201D',
'&bdquo;': '\u201E',
'&dagger;': '†',
'&Dagger;': '‡',
'&bull;': '•',
'&prime;': '′',
'&Prime;': '″',
'&lsaquo;': '‹',
'&rsaquo;': '›',
'&sect;': '§',
'&para;': '¶',
'&middot;': '·',

// Spaces
'&ensp;': '\u2002',
'&emsp;': '\u2003',
'&thinsp;': '\u2009',

// Currency
'&euro;': '€',
'&pound;': '£',
'&yen;': '¥',
'&cent;': '¢',
'&curren;': '¤',

// Math symbols
'&times;': '×',
'&divide;': '÷',
'&minus;': '−',
'&plusmn;': '±',
'&ne;': '≠',
'&le;': '≤',
'&ge;': '≥',
'&asymp;': '≈',
'&equiv;': '≡',
'&infin;': '∞',
'&int;': '∫',
'&sum;': '∑',
'&prod;': '∏',
'&radic;': '√',
'&part;': '∂',
'&permil;': '‰',
'&deg;': '°',
'&micro;': 'µ',

// Arrows
'&larr;': '←',
'&uarr;': '↑',
'&rarr;': '→',
'&darr;': '↓',
'&harr;': '↔',
'&crarr;': '↵',
'&lArr;': '⇐',
'&uArr;': '⇑',
'&rArr;': '⇒',
'&dArr;': '⇓',
'&hArr;': '⇔',

// Greek letters (lowercase)
'&alpha;': 'α',
'&beta;': 'β',
'&gamma;': 'γ',
'&delta;': 'δ',
'&epsilon;': 'ε',
'&zeta;': 'ζ',
'&eta;': 'η',
'&theta;': 'θ',
'&iota;': 'ι',
'&kappa;': 'κ',
'&lambda;': 'λ',
'&mu;': 'μ',
'&nu;': 'ν',
'&xi;': 'ξ',
'&omicron;': 'ο',
'&pi;': 'π',
'&rho;': 'ρ',
'&sigma;': 'σ',
'&tau;': 'τ',
'&upsilon;': 'υ',
'&phi;': 'φ',
'&chi;': 'χ',
'&psi;': 'ψ',
'&omega;': 'ω',

// Greek letters (uppercase)
'&Alpha;': 'Α',
'&Beta;': 'Β',
'&Gamma;': 'Γ',
'&Delta;': 'Δ',
'&Epsilon;': 'Ε',
'&Zeta;': 'Ζ',
'&Eta;': 'Η',
'&Theta;': 'Θ',
'&Iota;': 'Ι',
'&Kappa;': 'Κ',
'&Lambda;': 'Λ',
'&Mu;': 'Μ',
'&Nu;': 'Ν',
'&Xi;': 'Ξ',
'&Omicron;': 'Ο',
'&Pi;': 'Π',
'&Rho;': 'Ρ',
'&Sigma;': 'Σ',
'&Tau;': 'Τ',
'&Upsilon;': 'Υ',
'&Phi;': 'Φ',
'&Chi;': 'Χ',
'&Psi;': 'Ψ',
'&Omega;': 'Ω',

// Latin extended
'&Agrave;': 'À',
'&Aacute;': 'Á',
'&Acirc;': 'Â',
'&Atilde;': 'Ã',
'&Auml;': 'Ä',
'&Aring;': 'Å',
'&AElig;': 'Æ',
'&Ccedil;': 'Ç',
'&Egrave;': 'È',
'&Eacute;': 'É',
'&Ecirc;': 'Ê',
'&Euml;': 'Ë',
'&Igrave;': 'Ì',
'&Iacute;': 'Í',
'&Icirc;': 'Î',
'&Iuml;': 'Ï',
'&ETH;': 'Ð',
'&Ntilde;': 'Ñ',
'&Ograve;': 'Ò',
'&Oacute;': 'Ó',
'&Ocirc;': 'Ô',
'&Otilde;': 'Õ',
'&Ouml;': 'Ö',
'&Oslash;': 'Ø',
'&Ugrave;': 'Ù',
'&Uacute;': 'Ú',
'&Ucirc;': 'Û',
'&Uuml;': 'Ü',
'&Yacute;': 'Ý',
'&THORN;': 'Þ',
'&szlig;': 'ß',
'&agrave;': 'à',
'&aacute;': 'á',
'&acirc;': 'â',
'&atilde;': 'ã',
'&auml;': 'ä',
'&aring;': 'å',
'&aelig;': 'æ',
'&ccedil;': 'ç',
'&egrave;': 'è',
'&eacute;': 'é',
'&ecirc;': 'ê',
'&euml;': 'ë',
'&igrave;': 'ì',
'&iacute;': 'í',
'&icirc;': 'î',
'&iuml;': 'ï',
'&eth;': 'ð',
'&ntilde;': 'ñ',
'&ograve;': 'ò',
'&oacute;': 'ó',
'&ocirc;': 'ô',
'&otilde;': 'õ',
'&ouml;': 'ö',
'&oslash;': 'ø',
'&ugrave;': 'ù',
'&uacute;': 'ú',
'&ucirc;': 'û',
'&uuml;': 'ü',
'&yacute;': 'ý',
'&thorn;': 'þ',
'&yuml;': 'ÿ',

// Special characters
'&iexcl;': '¡',
'&iquest;': '¿',
'&fnof;': 'ƒ',
'&circ;': 'ˆ',
'&tilde;': '˜',
'&OElig;': 'Œ',
'&oelig;': 'œ',
'&Scaron;': 'Š',
'&scaron;': 'š',
'&Yuml;': 'Ÿ',
'&ordf;': 'ª',
'&ordm;': 'º',
'&macr;': '¯',
'&acute;': '´',
'&cedil;': '¸',
'&sup1;': '¹',
'&sup2;': '²',
'&sup3;': '³',
'&frac14;': '¼',
'&frac12;': '½',
'&frac34;': '¾',

// Card suits
'&spades;': '♠',
'&clubs;': '♣',
'&hearts;': '♥',
'&diams;': '♦',

// Miscellaneous
'&loz;': '◊',
'&oline;': '‾',
'&frasl;': '⁄',
'&weierp;': '℘',
'&image;': 'ℑ',
'&real;': 'ℜ',
'&alefsym;': 'ℵ',
};

// Create regex pattern for all entities
const entityPattern = new RegExp(
Object.keys(commonEntities)
.map((entity) => entity.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('|'),
'g',
);

/**
* Decode HTML entities in text
*
* Uses a hybrid approach:
* 1. First pass: decode common named entities using a map
* 2. Second pass: decode numeric entities (decimal and hexadecimal)
*
* @param {string} text - Text with HTML entities
* @returns {string} Decoded text
*/
export const decodeHtmlEntities = (text) =>
text
// First pass: common named entities
.replace(entityPattern, (match) => commonEntities[match])
// Second pass: numeric entities (decimal)
.replace(/&#(\d+);/g, (_, num) => String.fromCharCode(parseInt(num, 10)))
// Third pass: numeric entities (hexadecimal)
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
4 changes: 4 additions & 0 deletions src/IcuTransUtils/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './TranslationParserError';
export * from './htmlEntityDecoder';
export * from './tokenizer';
export * from './renderTranslation';
Loading
Loading