import { Fragment, isValidElement, cloneElement, createElement, Children } from 'react';
import { keyFromSelector } from 'i18next';
import HTML from 'html-parse-stringify';
import { isObject, isString, warn, warnOnce } from './utils.js';
import { getDefaults } from './defaults.js';
import { getI18n } from './i18nInstance.js';
import { unescape } from './unescape.js';
const hasChildren = (node, checkLength) => {
if (!node) return false;
const base = node.props?.children ?? node.children;
if (checkLength) return base.length > 0;
return !!base;
};
const getChildren = (node) => {
if (!node) return [];
const children = node.props?.children ?? node.children;
return node.props?.i18nIsDynamicList ? getAsArray(children) : children;
};
const hasValidReactChildren = (children) =>
Array.isArray(children) && children.every(isValidElement);
const getAsArray = (data) => (Array.isArray(data) ? data : [data]);
const mergeProps = (source, target) => {
const newTarget = { ...target };
// translation props (source.props) should override component props (target.props)
newTarget.props = { ...target.props, ...source.props };
return newTarget;
};
const getValuesFromChildren = (children) => {
const values = {};
if (!children) return values;
const getData = (childs) => {
const childrenArray = getAsArray(childs);
childrenArray.forEach((child) => {
if (isString(child)) return;
if (hasChildren(child)) getData(getChildren(child));
else if (isObject(child) && !isValidElement(child)) Object.assign(values, child);
});
};
getData(children);
return values;
};
export const nodesToString = (children, i18nOptions, i18n, i18nKey) => {
if (!children) return '';
let stringNode = '';
// do not use `React.Children.toArray`, will fail at object children
const childrenArray = getAsArray(children);
const keepArray = i18nOptions?.transSupportBasicHtmlNodes
? (i18nOptions.transKeepBasicHtmlNodesFor ?? [])
: [];
// e.g. lorem ipsum {{ messageCount, format }} dolor bold amet
childrenArray.forEach((child, childIndex) => {
if (isString(child)) {
// actual e.g. lorem
// expected e.g. lorem
stringNode += `${child}`;
return;
}
if (isValidElement(child)) {
const { props, type } = child;
const childPropsCount = Object.keys(props).length;
const shouldKeepChild = keepArray.indexOf(type) > -1;
const childChildren = props.children;
if (!childChildren && shouldKeepChild && !childPropsCount) {
// actual e.g. lorem ipsum
// expected e.g. lorem ipsum
stringNode += `<${type}/>`;
return;
}
if ((!childChildren && (!shouldKeepChild || childPropsCount)) || props.i18nIsDynamicList) {
// actual e.g. lorem
ipsum
// expected e.g. lorem <0>0> ipsum
// or
// we got a dynamic list like
// e.g.
{['a', 'b'].map(item => (
{item}
))}
// expected e.g. "<0>0>", not e.g. "<0><0>a0><1>b1>0>"
stringNode += `<${childIndex}>${childIndex}>`;
return;
}
if (shouldKeepChild && childPropsCount === 1 && isString(childChildren)) {
// actual e.g. dolor bold amet
// expected e.g. dolor bold amet
stringNode += `<${type}>${childChildren}${type}>`;
return;
}
// regular case mapping the inner children
const content = nodesToString(childChildren, i18nOptions, i18n, i18nKey);
stringNode += `<${childIndex}>${content}${childIndex}>`;
return;
}
if (child === null) {
warn(i18n, 'TRANS_NULL_VALUE', `Passed in a null value as child`, { i18nKey });
return;
}
if (isObject(child)) {
// e.g. lorem {{ value, format }} ipsum
const { format, ...clone } = child;
const keys = Object.keys(clone);
if (keys.length === 1) {
const value = format ? `${keys[0]}, ${format}` : keys[0];
stringNode += `{{${value}}}`;
return;
}
warn(
i18n,
'TRANS_INVALID_OBJ',
`Invalid child - Object should only have keys {{ value, format }} (format is optional).`,
{ i18nKey, child },
);
return;
}
warn(
i18n,
'TRANS_INVALID_VAR',
`Passed in a variable like {number} - pass variables for interpolation as full objects like {{number}}.`,
{ i18nKey, child },
);
});
return stringNode;
};
/**
* Escape literal < characters that are not part of valid tags
* Valid tags are: numbered tags like <0>, 0> or named tags from keepArray/knownComponents
* @param {string} str - The string to escape
* @param {Array} keepArray - Array of HTML tag names to keep
* @param {Object} knownComponentsMap - Map of known component names
* @returns {string} String with literal < characters escaped
*/
const escapeLiteralLessThan = (str, keepArray = [], knownComponentsMap = {}) => {
if (!str) return str;
// Build a list of valid tag names (numbered indices and known component names)
const knownNames = Object.keys(knownComponentsMap);
const allValidNames = [...keepArray, ...knownNames];
// Pattern to match:
// 1. Opening tags: or where name is in allValidNames
// 2. Closing tags: or where name is in allValidNames
// 3. Self-closing tags: or where name is in keepArray
// Everything else starting with < should be escaped
let result = '';
let i = 0;
while (i < str.length) {
if (str[i] === '<') {
// Check if this is a valid tag
let isValidTag = false;
// Check for closing tag: or
const closingMatch = str.slice(i).match(/^<\/(\d+|[a-zA-Z][a-zA-Z0-9_-]*)>/);
if (closingMatch) {
const tagName = closingMatch[1];
// Valid if it's a number or in our valid names list
if (/^\d+$/.test(tagName) || allValidNames.includes(tagName)) {
isValidTag = true;
result += closingMatch[0];
i += closingMatch[0].length;
}
}
// Check for opening tag: or or or
// Also handle tags with attributes: <0 href="..."> or
if (!isValidTag) {
// Match:
// Attributes pattern: name="value" or name='value' or name (boolean)
const openingMatch = str
.slice(i)
.match(
/^<(\d+|[a-zA-Z][a-zA-Z0-9_-]*)(\s+[\w-]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]+))?)*\s*(\/)?>/,
);
if (openingMatch) {
const tagName = openingMatch[1];
// Valid if it's a number or in our valid names list
if (/^\d+$/.test(tagName) || allValidNames.includes(tagName)) {
isValidTag = true;
result += openingMatch[0];
i += openingMatch[0].length;
}
}
}
// If not a valid tag, escape the <
if (!isValidTag) {
result += '<';
i += 1;
}
} else {
result += str[i];
i += 1;
}
}
return result;
};
const renderNodes = (
children,
knownComponentsMap,
targetString,
i18n,
i18nOptions,
combinedTOpts,
shouldUnescape,
) => {
if (targetString === '') return [];
// check if contains tags we need to replace from html string to react nodes
const keepArray = i18nOptions.transKeepBasicHtmlNodesFor || [];
const emptyChildrenButNeedsHandling =
targetString && new RegExp(keepArray.map((keep) => `<${keep}`).join('|')).test(targetString);
// no need to replace tags in the targetstring
if (!children && !knownComponentsMap && !emptyChildrenButNeedsHandling && !shouldUnescape)
return [targetString];
// v2 -> interpolates upfront no need for "some <0>{{var}}0>"" -> will be just "some {{var}}" in translation file
const data = knownComponentsMap ?? {};
const getData = (childs) => {
const childrenArray = getAsArray(childs);
childrenArray.forEach((child) => {
if (isString(child)) return;
if (hasChildren(child)) getData(getChildren(child));
else if (isObject(child) && !isValidElement(child)) Object.assign(data, child);
});
};
getData(children);
// Escape literal < characters that are not part of valid tags before parsing
const escapedString = escapeLiteralLessThan(targetString, keepArray, data);
// parse ast from string with additional wrapper tag
// -> avoids issues in parser removing prepending text nodes
const ast = HTML.parse(`<0>${escapedString}0>`);
const opts = { ...data, ...combinedTOpts };
const renderInner = (child, node, rootReactNode) => {
const childs = getChildren(child);
const mappedChildren = mapAST(childs, node.children, rootReactNode);
// `mappedChildren` will always be empty if using the `i18nIsDynamicList` prop,
// but the children might not necessarily be react components
return (hasValidReactChildren(childs) && mappedChildren.length === 0) ||
child.props?.i18nIsDynamicList
? childs
: mappedChildren;
};
const pushTranslatedJSX = (child, inner, mem, i, isVoid) => {
if (child.dummy) {
child.children = inner; // needed on preact!
mem.push(cloneElement(child, { key: i }, isVoid ? undefined : inner));
} else {
mem.push(
...Children.map([child], (c) => {
// Build an override props object while deliberately NOT reading c.ref or c.props.ref
// use a DOM-safe marker name and never forward it to DOM nodes
const INTERNAL_DYNAMIC_MARKER = 'data-i18n-is-dynamic-list';
const override = { key: i, [INTERNAL_DYNAMIC_MARKER]: undefined };
if (c && c.props) {
Object.keys(c.props).forEach((k) => {
// skip special/internal props and the dynamic-list marker so it never reaches DOM
if (
k === 'ref' ||
k === 'children' ||
k === 'i18nIsDynamicList' ||
k === INTERNAL_DYNAMIC_MARKER
)
return;
override[k] = c.props[k];
});
}
// Use cloneElement for all element types so React preserves/forwards refs internally
// and we don't access element.ref nor c.props.ref ourselves.
return cloneElement(c, override, isVoid ? null : inner);
}),
);
}
};
// reactNode (the jsx root element or child)
// astNode (the translation string as html ast)
// rootReactNode (the most outer jsx children array or trans components prop)
const mapAST = (reactNode, astNode, rootReactNode) => {
const reactNodes = getAsArray(reactNode);
const astNodes = getAsArray(astNode);
return astNodes.reduce((mem, node, i) => {
const translationContent =
node.children?.[0]?.content &&
i18n.services.interpolator.interpolate(node.children[0].content, opts, i18n.language);
if (node.type === 'tag') {
// regular array (components or children)
let tmp = reactNodes[parseInt(node.name, 10)];
if (!tmp && knownComponentsMap) tmp = knownComponentsMap[node.name];
// trans components is an object
if (rootReactNode.length === 1 && !tmp) tmp = rootReactNode[0][node.name];
// neither
if (!tmp) tmp = {};
// should fix #1893
const props = { ...node.attrs };
if (shouldUnescape) {
Object.keys(props).forEach((p) => {
const val = props[p];
if (isString(val)) {
props[p] = unescape(val);
}
});
}
const child = Object.keys(props).length !== 0 ? mergeProps({ props }, tmp) : tmp;
const isElement = isValidElement(child);
const isValidTranslationWithChildren =
isElement && hasChildren(node, true) && !node.voidElement;
const isEmptyTransWithHTML =
emptyChildrenButNeedsHandling && isObject(child) && child.dummy && !isElement;
const isKnownComponent =
isObject(knownComponentsMap) && Object.hasOwnProperty.call(knownComponentsMap, node.name);
if (isString(child)) {
const value = i18n.services.interpolator.interpolate(child, opts, i18n.language);
mem.push(value);
} else if (
hasChildren(child) || // the jsx element has children -> loop
isValidTranslationWithChildren // valid jsx element with no children but the translation has -> loop
) {
const inner = renderInner(child, node, rootReactNode);
pushTranslatedJSX(child, inner, mem, i);
} else if (isEmptyTransWithHTML) {
// we have a empty Trans node (the dummy element) with a targetstring that contains html tags needing
// conversion to react nodes
// so we just need to map the inner stuff
const inner = mapAST(
reactNodes /* wrong but we need something */,
node.children,
rootReactNode,
);
pushTranslatedJSX(child, inner, mem, i);
} else if (Number.isNaN(parseFloat(node.name))) {
if (isKnownComponent) {
const inner = renderInner(child, node, rootReactNode);
pushTranslatedJSX(child, inner, mem, i, node.voidElement);
} else if (i18nOptions.transSupportBasicHtmlNodes && keepArray.indexOf(node.name) > -1) {
if (node.voidElement) {
mem.push(createElement(node.name, { key: `${node.name}-${i}` }));
} else {
const inner = mapAST(
reactNodes /* wrong but we need something */,
node.children,
rootReactNode,
);
mem.push(createElement(node.name, { key: `${node.name}-${i}` }, inner));
}
} else if (node.voidElement) {
mem.push(`<${node.name} />`);
} else {
const inner = mapAST(
reactNodes /* wrong but we need something */,
node.children,
rootReactNode,
);
mem.push(`<${node.name}>${inner}${node.name}>`);
}
} else if (isObject(child) && !isElement) {
const content = node.children[0] ? translationContent : null;
// v1
// as interpolation was done already we just have a regular content node
// in the translation AST while having an object in reactNodes
// -> push the content no need to interpolate again
if (content) mem.push(content);
} else {
// If component does not have children, but translation - has
// with this in component could be components={[]} and in translation - 'some text <0>some highlighted message0>'
pushTranslatedJSX(
child,
translationContent,
mem,
i,
node.children.length !== 1 || !translationContent,
);
}
} else if (node.type === 'text') {
const wrapTextNodes = i18nOptions.transWrapTextNodes;
const unescapeFn =
typeof i18nOptions.unescape === 'function'
? i18nOptions.unescape
: getDefaults().unescape;
const content = shouldUnescape
? unescapeFn(i18n.services.interpolator.interpolate(node.content, opts, i18n.language))
: i18n.services.interpolator.interpolate(node.content, opts, i18n.language);
if (wrapTextNodes) {
mem.push(createElement(wrapTextNodes, { key: `${node.name}-${i}` }, content));
} else {
mem.push(content);
}
}
return mem;
}, []);
};
// call mapAST with having react nodes nested into additional node like
// we did for the string ast from translation
// return the children of that extra node to get expected result
const result = mapAST(
[{ dummy: true, children: children || [] }],
ast,
getAsArray(children || []),
);
return getChildren(result[0]);
};
const fixComponentProps = (component, index, translation) => {
const componentKey = component.key || index;
const comp = cloneElement(component, { key: componentKey });
if (
!comp.props ||
!comp.props.children ||
(translation.indexOf(`${index}/>`) < 0 && translation.indexOf(`${index} />`) < 0)
) {
return comp;
}
function Componentized() {
// <>{comp}>
return createElement(Fragment, null, comp);
}
//
return createElement(Componentized, { key: componentKey });
};
const generateArrayComponents = (components, translation) =>
components.map((c, index) => fixComponentProps(c, index, translation));
const generateObjectComponents = (components, translation) => {
const componentMap = {};
Object.keys(components).forEach((c) => {
Object.assign(componentMap, {
[c]: fixComponentProps(components[c], c, translation),
});
});
return componentMap;
};
const generateComponents = (components, translation, i18n, i18nKey) => {
if (!components) return null;
// components could be either an array or an object
if (Array.isArray(components)) {
return generateArrayComponents(components, translation);
}
if (isObject(components)) {
return generateObjectComponents(components, translation);
}
// if components is not an array or an object, warn the user
// and return null
warnOnce(
i18n,
'TRANS_INVALID_COMPONENTS',
` "components" prop expects an object or array`,
{ i18nKey },
);
return null;
};
// A component map is an object like: { Button: