icu.macro.js 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862
  1. const { createMacro } = require('babel-plugin-macros');
  2. // copy to:
  3. // https://astexplorer.net/#/gist/642aebbb9e449e959f4ad8907b4adf3a/4a65742e2a3e926eb55eaa3d657d1472b9ac7970
  4. module.exports = createMacro(ICUMacro);
  5. function ICUMacro({ references, state, babel }) {
  6. const {
  7. Trans = [],
  8. Plural = [],
  9. Select = [],
  10. SelectOrdinal = [],
  11. number = [],
  12. date = [],
  13. select = [],
  14. selectOrdinal = [],
  15. plural = [],
  16. time = [],
  17. } = references;
  18. // assert we have the react-i18next IcuTrans component imported
  19. addNeededImports(state, babel, references);
  20. // transform Plural and SelectOrdinal
  21. [...Plural, ...SelectOrdinal].forEach((referencePath) => {
  22. if (referencePath.parentPath.type === 'JSXOpeningElement') {
  23. pluralAsJSX(
  24. referencePath.parentPath,
  25. {
  26. attributes: referencePath.parentPath.get('attributes'),
  27. children: referencePath.parentPath.parentPath.get('children'),
  28. },
  29. babel,
  30. );
  31. } else {
  32. // throw a helpful error message or something :)
  33. }
  34. });
  35. // transform Select
  36. Select.forEach((referencePath) => {
  37. if (referencePath.parentPath.type === 'JSXOpeningElement') {
  38. selectAsJSX(
  39. referencePath.parentPath,
  40. {
  41. attributes: referencePath.parentPath.get('attributes'),
  42. children: referencePath.parentPath.parentPath.get('children'),
  43. },
  44. babel,
  45. );
  46. } else {
  47. // throw a helpful error message or something :)
  48. }
  49. });
  50. // transform Trans
  51. Trans.forEach((referencePath) => {
  52. if (referencePath.parentPath.type === 'JSXOpeningElement') {
  53. transAsJSX(
  54. referencePath.parentPath,
  55. {
  56. attributes: referencePath.parentPath.get('attributes'),
  57. children: referencePath.parentPath.parentPath.get('children'),
  58. },
  59. babel,
  60. );
  61. } else {
  62. // throw a helpful error message or something :)
  63. }
  64. });
  65. // check for number`` and others outside of <Trans>
  66. Object.entries({
  67. number,
  68. date,
  69. time,
  70. select,
  71. plural,
  72. selectOrdinal,
  73. }).forEach(([name, node]) => {
  74. node.forEach((item) => {
  75. let f = item.parentPath;
  76. while (f) {
  77. if (babel.types.isJSXElement(f.node)) {
  78. const { openingElement } = f.node;
  79. const elementName = openingElement.name;
  80. const isMemberExpression = babel.types.isJSXMemberExpression(elementName);
  81. if (
  82. !isMemberExpression &&
  83. (elementName.name === 'IcuTrans' || elementName.name === 'Trans')
  84. ) {
  85. // this is a valid use of number/date/time/etc.
  86. // Check for both IcuTrans (after transformation) and Trans (during transformation)
  87. return;
  88. }
  89. }
  90. f = f.parentPath;
  91. }
  92. const { loc } = item.node;
  93. if (!loc) {
  94. throw new Error(`"${name}\`\`" can only be used inside <Trans> in "unknown file"`);
  95. }
  96. throw new Error(
  97. `"${name}\`\`" can only be used inside <Trans> in "${loc.filename}" on line ${loc.start.line}`,
  98. );
  99. });
  100. });
  101. }
  102. function pluralAsJSX(parentPath, { attributes }, babel) {
  103. const t = babel.types;
  104. const toObjectProperty = (name, value) =>
  105. t.objectProperty(t.identifier(name), t.identifier(name), false, !value);
  106. // plural or selectordinal
  107. const nodeName = parentPath.node.name.name.toLocaleLowerCase();
  108. // will need to merge count attribute with existing values attribute in some cases
  109. const existingValuesAttribute = findAttribute('values', attributes);
  110. const existingValues = existingValuesAttribute
  111. ? existingValuesAttribute.node.value.expression.properties
  112. : [];
  113. let componentStartIndex = 0;
  114. const extracted = attributes.reduce(
  115. (mem, attr) => {
  116. if (attr.node.name.name === 'i18nKey') {
  117. // copy the i18nKey
  118. mem.attributesToCopy.push(attr.node);
  119. } else if (attr.node.name.name === 'count') {
  120. // take the count for element
  121. let exprName = attr.node.value.expression.name;
  122. if (!exprName) {
  123. exprName = 'count';
  124. }
  125. if (exprName === 'count') {
  126. // if the prop expression name is also "count", copy it instead: <Plural count={count} --> <IcuTrans count={count}
  127. mem.attributesToCopy.push(attr.node);
  128. } else {
  129. mem.values.unshift(toObjectProperty(exprName));
  130. }
  131. mem.defaults = `{${exprName}, ${nodeName}, ${mem.defaults}`;
  132. } else if (attr.node.name.name === 'values') {
  133. // skip the values attribute, as it has already been processed into mem from existingValues
  134. } else if (attr.node.value.type === 'StringLiteral') {
  135. // take any string node as plural option
  136. let pluralForm = attr.node.name.name;
  137. if (pluralForm.indexOf('$') === 0) pluralForm = pluralForm.replace('$', '=');
  138. mem.defaults = `${mem.defaults} ${pluralForm} {${attr.node.value.value}}`;
  139. } else if (attr.node.value.type === 'JSXExpressionContainer') {
  140. // convert any Trans component to plural option extracting any values and components
  141. const children = attr.node.value.expression.children || [];
  142. const thisTrans = processTrans(children, babel, componentStartIndex);
  143. let pluralForm = attr.node.name.name;
  144. if (pluralForm.indexOf('$') === 0) pluralForm = pluralForm.replace('$', '=');
  145. mem.defaults = `${mem.defaults} ${pluralForm} {${thisTrans.defaults}}`;
  146. mem.components = mem.components.concat(thisTrans.components);
  147. componentStartIndex += thisTrans.components.length;
  148. }
  149. return mem;
  150. },
  151. { attributesToCopy: [], values: existingValues, components: [], defaults: '' },
  152. );
  153. // replace the node with the new IcuTrans
  154. parentPath.replaceWith(buildTransElement(extracted, extracted.attributesToCopy, t, true));
  155. }
  156. function selectAsJSX(parentPath, { attributes }, babel) {
  157. const t = babel.types;
  158. const toObjectProperty = (name, value) =>
  159. t.objectProperty(t.identifier(name), t.identifier(name), false, !value);
  160. // will need to merge switch attribute with existing values attribute
  161. const existingValuesAttribute = findAttribute('values', attributes);
  162. const existingValues = existingValuesAttribute
  163. ? existingValuesAttribute.node.value.expression.properties
  164. : [];
  165. let componentStartIndex = 0;
  166. const extracted = attributes.reduce(
  167. (mem, attr) => {
  168. if (attr.node.name.name === 'i18nKey') {
  169. // copy the i18nKey
  170. mem.attributesToCopy.push(attr.node);
  171. } else if (attr.node.name.name === 'switch') {
  172. // take the switch for select element
  173. let exprName = attr.node.value.expression.name;
  174. if (!exprName) {
  175. exprName = 'selectKey';
  176. mem.values.unshift(t.objectProperty(t.identifier(exprName), attr.node.value.expression));
  177. } else {
  178. mem.values.unshift(toObjectProperty(exprName));
  179. }
  180. mem.defaults = `{${exprName}, select, ${mem.defaults}`;
  181. } else if (attr.node.name.name === 'values') {
  182. // skip the values attribute, as it has already been processed into mem as existingValues
  183. } else if (attr.node.value.type === 'StringLiteral') {
  184. // take any string node as select option
  185. mem.defaults = `${mem.defaults} ${attr.node.name.name} {${attr.node.value.value}}`;
  186. } else if (attr.node.value.type === 'JSXExpressionContainer') {
  187. // convert any Trans component to select option extracting any values and components
  188. const children = attr.node.value.expression.children || [];
  189. const thisTrans = processTrans(children, babel, componentStartIndex);
  190. mem.defaults = `${mem.defaults} ${attr.node.name.name} {${thisTrans.defaults}}`;
  191. mem.components = mem.components.concat(thisTrans.components);
  192. componentStartIndex += thisTrans.components.length;
  193. }
  194. return mem;
  195. },
  196. { attributesToCopy: [], values: existingValues, components: [], defaults: '' },
  197. );
  198. // replace the node with the new IcuTrans
  199. parentPath.replaceWith(buildTransElement(extracted, extracted.attributesToCopy, t, true));
  200. }
  201. function transAsJSX(parentPath, { attributes, children }, babel) {
  202. const defaultsAttr = findAttribute('defaults', attributes);
  203. const contentAttr = findAttribute('content', attributes);
  204. const componentsAttr = findAttribute('components', attributes);
  205. // if there is "defaults" attribute and no "content"/"components" attribute, parse defaults and extract from the parsed defaults instead of children
  206. // if a "content" or "components" attribute has been provided, we assume they have already constructed a valid "defaults" and it does not need to be parsed
  207. const parseDefaults = defaultsAttr && !contentAttr && !componentsAttr;
  208. let extracted;
  209. if (parseDefaults) {
  210. const defaultsExpression = defaultsAttr.node.value.value;
  211. const parsed = babel.parse(`<>${defaultsExpression}</>`, {
  212. presets: ['@babel/react'],
  213. filename: babel.state?.filename || 'unknown',
  214. }).program.body[0].expression.children;
  215. extracted = processTrans(parsed, babel);
  216. } else {
  217. extracted = processTrans(children, babel);
  218. }
  219. let clonedAttributes = cloneExistingAttributes(attributes);
  220. if (parseDefaults) {
  221. // remove existing defaults so it can be replaced later with the new parsed defaults
  222. clonedAttributes = clonedAttributes.filter((node) => node.name.name !== 'defaults');
  223. }
  224. // replace the node with the new IcuTrans
  225. const replacePath = children.length ? children[0].parentPath : parentPath;
  226. replacePath.replaceWith(
  227. buildTransElement(extracted, clonedAttributes, babel.types, false, !!children.length),
  228. );
  229. }
  230. function buildTransElement(
  231. extracted,
  232. finalAttributes,
  233. t,
  234. closeDefaults = false,
  235. wasElementWithChildren = false,
  236. ) {
  237. const nodeName = t.jSXIdentifier('IcuTrans');
  238. // plural, select open { but do not close it while reduce
  239. if (closeDefaults) extracted.defaults += '}';
  240. // convert JSX elements to declaration objects: { type: Component, props: {...} }
  241. const contentDeclarations = extracted.components.map((component) =>
  242. jsxElementToDeclaration(component, t),
  243. );
  244. const content = t.arrayExpression(contentDeclarations);
  245. const values = t.objectExpression(extracted.values);
  246. // add generated IcuTrans attributes
  247. if (!attributeExistsAlready('defaultTranslation', finalAttributes)) {
  248. if (extracted.defaults.includes('"')) {
  249. // wrap defaultTranslation that contains double quotes in expression container
  250. finalAttributes.push(
  251. t.jSXAttribute(
  252. t.jSXIdentifier('defaultTranslation'),
  253. t.jSXExpressionContainer(t.stringLiteral(extracted.defaults)),
  254. ),
  255. );
  256. } else {
  257. finalAttributes.push(
  258. t.jSXAttribute(t.jSXIdentifier('defaultTranslation'), t.stringLiteral(extracted.defaults)),
  259. );
  260. }
  261. }
  262. if (!attributeExistsAlready('content', finalAttributes))
  263. finalAttributes.push(
  264. t.jSXAttribute(t.jSXIdentifier('content'), t.jSXExpressionContainer(content)),
  265. );
  266. if (!attributeExistsAlready('values', finalAttributes))
  267. finalAttributes.push(
  268. t.jSXAttribute(t.jSXIdentifier('values'), t.jSXExpressionContainer(values)),
  269. );
  270. // create selfclosing IcuTrans component
  271. const openElement = t.jSXOpeningElement(nodeName, finalAttributes, true);
  272. if (!wasElementWithChildren) return openElement;
  273. return t.jSXElement(openElement, null, [], true);
  274. }
  275. /**
  276. * Convert a JSX element to a declaration object: { type: Component, props: {...} }
  277. */
  278. function jsxElementToDeclaration(jsxElement, t) {
  279. // Handle case where jsxElement is not actually a JSXElement
  280. if (!jsxElement || !jsxElement.openingElement) {
  281. console.error('Invalid JSXElement passed to jsxElementToDeclaration:', jsxElement);
  282. return t.objectExpression([t.objectProperty(t.identifier('type'), t.stringLiteral('div'))]);
  283. }
  284. const elementName = jsxElement.openingElement.name;
  285. // Get the type - either a string for HTML elements or identifier for components
  286. let typeValue;
  287. if (t.isJSXIdentifier(elementName)) {
  288. // For HTML elements like 'div', 'strong', use string literal
  289. if (elementName.name.toLowerCase() === elementName.name) {
  290. typeValue = t.stringLiteral(elementName.name);
  291. } else {
  292. // For React components, use identifier
  293. typeValue = t.identifier(elementName.name);
  294. }
  295. } else if (t.isJSXMemberExpression(elementName)) {
  296. // For member expressions like Icon.Svg
  297. const objectName = t.isJSXIdentifier(elementName.object) ? elementName.object.name : 'unknown';
  298. const propertyName = elementName.property.name;
  299. typeValue = t.memberExpression(t.identifier(objectName), t.identifier(propertyName));
  300. } else {
  301. // Fallback
  302. typeValue = t.stringLiteral('div');
  303. }
  304. const properties = [t.objectProperty(t.identifier('type'), typeValue)];
  305. // Convert JSX attributes to props object
  306. const propsProperties = [];
  307. jsxElement.openingElement.attributes.forEach((attr) => {
  308. if (t.isJSXAttribute(attr)) {
  309. const propName = t.isJSXIdentifier(attr.name) ? attr.name.name : String(attr.name);
  310. let propValue;
  311. if (attr.value === null) {
  312. // Boolean prop like <Component disabled />
  313. propValue = t.booleanLiteral(true);
  314. } else if (t.isStringLiteral(attr.value)) {
  315. propValue = attr.value;
  316. } else if (t.isJSXExpressionContainer(attr.value)) {
  317. propValue = attr.value.expression;
  318. } else {
  319. // fallback
  320. propValue = t.nullLiteral();
  321. }
  322. // Use string literal for keys that aren't valid identifiers (e.g., contain hyphens)
  323. const propKey = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(propName)
  324. ? t.identifier(propName)
  325. : t.stringLiteral(propName);
  326. propsProperties.push(t.objectProperty(propKey, propValue));
  327. } else if (t.isJSXSpreadAttribute(attr)) {
  328. // Handle spread attributes like {...spreadProps}
  329. propsProperties.push(t.spreadElement(attr.argument));
  330. }
  331. });
  332. // Handle nested children if any
  333. if (jsxElement.children && jsxElement.children.length > 0) {
  334. const childDeclarations = jsxElement.children
  335. .filter((child) =>
  336. // Only include JSXElements in the declaration tree
  337. // JSXText content is already captured in the defaults string
  338. t.isJSXElement(child),
  339. )
  340. .map((child) => jsxElementToDeclaration(child, t));
  341. if (childDeclarations.length > 0) {
  342. propsProperties.push(
  343. t.objectProperty(t.identifier('children'), t.arrayExpression(childDeclarations)),
  344. );
  345. }
  346. }
  347. if (propsProperties.length > 0) {
  348. properties.push(t.objectProperty(t.identifier('props'), t.objectExpression(propsProperties)));
  349. }
  350. return t.objectExpression(properties);
  351. }
  352. function cloneExistingAttributes(attributes) {
  353. return attributes.reduce((mem, attr) => {
  354. const node = attr.node ? attr.node : attr;
  355. // Skip 'defaults' attribute as we're replacing it with 'defaultTranslation'
  356. if (node.type === 'JSXAttribute' && node.name && node.name.name === 'defaults') {
  357. return mem;
  358. }
  359. // Skip 'components' attribute as we're replacing it with 'content'
  360. if (node.type === 'JSXAttribute' && node.name && node.name.name === 'components') {
  361. return mem;
  362. }
  363. mem.push(node);
  364. return mem;
  365. }, []);
  366. }
  367. function findAttribute(name, attributes) {
  368. return attributes.find((child) => {
  369. const ele = child.node ? child.node : child;
  370. return ele.name.name === name;
  371. });
  372. }
  373. function attributeExistsAlready(name, attributes) {
  374. return !!findAttribute(name, attributes);
  375. }
  376. function processTrans(children, babel, componentStartIndex = 0) {
  377. const res = {};
  378. res.defaults = mergeChildren(children, babel, componentStartIndex);
  379. res.components = getComponents(children, babel);
  380. res.values = getValues(children, babel);
  381. return res;
  382. }
  383. const leadingNewLineAndWhitespace = /^\n\s+/g;
  384. const trailingNewLineAndWhitespace = /\n\s+$/g;
  385. function trimIndent(text) {
  386. const newText = text
  387. .replace(leadingNewLineAndWhitespace, '')
  388. .replace(trailingNewLineAndWhitespace, '');
  389. return newText;
  390. }
  391. /**
  392. * add comma-delimited expressions like `{ val, number }`
  393. */
  394. function mergeCommaExpressions(ele) {
  395. if (ele.expression && ele.expression.expressions) {
  396. return `{${ele.expression.expressions
  397. .reduce((m, i) => {
  398. m.push(i.name || i.value);
  399. return m;
  400. }, [])
  401. .join(', ')}}`;
  402. }
  403. return '';
  404. }
  405. /**
  406. * this is for supporting complex icu type interpolations
  407. * date`${variable}` and number`{${varName}, ::percent}`
  408. * also, plural`{${count}, one { ... } other { ... }}
  409. */
  410. function mergeTaggedTemplateExpressions(ele, componentFoundIndex, t, babel) {
  411. if (t.isTaggedTemplateExpression(ele.expression)) {
  412. const [, text, index] = getTextAndInterpolatedVariables(
  413. ele.expression.tag.name,
  414. ele.expression,
  415. componentFoundIndex,
  416. babel,
  417. );
  418. return [text, index];
  419. }
  420. return ['', componentFoundIndex];
  421. }
  422. function mergeChildren(children, babel, componentStartIndex = 0) {
  423. const t = babel.types;
  424. let componentFoundIndex = componentStartIndex;
  425. return children.reduce((mem, child) => {
  426. const ele = child.node ? child.node : child;
  427. let result = mem;
  428. // add text, but trim indentation whitespace
  429. if (t.isJSXText(ele) && ele.value) result += trimIndent(ele.value);
  430. // add ?!? forgot
  431. if (ele.expression && ele.expression.value) result += ele.expression.value;
  432. // add `{ val }`
  433. if (ele.expression && ele.expression.name) result += `{${ele.expression.name}}`;
  434. // add `{ val, number }`
  435. result += mergeCommaExpressions(ele);
  436. const [nextText, newIndex] = mergeTaggedTemplateExpressions(ele, componentFoundIndex, t, babel);
  437. result += nextText;
  438. componentFoundIndex = newIndex;
  439. // add <strong>...</strong> with replace to <0>inner string</0>
  440. if (t.isJSXElement(ele)) {
  441. result += `<${componentFoundIndex}>${mergeChildren(
  442. ele.children,
  443. babel,
  444. )}</${componentFoundIndex}>`;
  445. componentFoundIndex += 1;
  446. }
  447. return result;
  448. }, '');
  449. }
  450. const extractTaggedTemplateValues = (ele, babel, toObjectProperty) => {
  451. // date`${variable}` and so on
  452. if (ele.expression && ele.expression.type === 'TaggedTemplateExpression') {
  453. const [variables] = getTextAndInterpolatedVariables(
  454. ele.expression.tag.name,
  455. ele.expression,
  456. 0,
  457. babel,
  458. );
  459. return variables.map((vari) => toObjectProperty(vari));
  460. }
  461. return [];
  462. };
  463. /**
  464. * Extract the names of interpolated value as object properties to pass to IcuTrans
  465. */
  466. function getValues(children, babel) {
  467. const t = babel.types;
  468. const toObjectProperty = (name, value) =>
  469. t.objectProperty(t.identifier(name), t.identifier(name), false, !value);
  470. return children.reduce((mem, child) => {
  471. const ele = child.node ? child.node : child;
  472. let result = mem;
  473. // add `{ var }` to values
  474. if (ele.expression && ele.expression.name) mem.push(toObjectProperty(ele.expression.name));
  475. // add `{ var, number }` to values
  476. if (ele.expression && ele.expression.expressions)
  477. result.push(
  478. toObjectProperty(ele.expression.expressions[0].name || ele.expression.expressions[0].value),
  479. );
  480. // add `{ var: 'bar' }` to values
  481. if (ele.expression && ele.expression.properties)
  482. result = result.concat(ele.expression.properties);
  483. // date`${variable}` and so on
  484. result = result.concat(extractTaggedTemplateValues(ele, babel, toObjectProperty));
  485. // recursive add inner elements stuff to values
  486. if (t.isJSXElement(ele)) {
  487. result = result.concat(getValues(ele.children, babel));
  488. }
  489. return result;
  490. }, []);
  491. }
  492. /**
  493. * Common logic for adding a child element of Trans to the list of components to hydrate the translation
  494. * @param {JSXElement} jsxElement
  495. * @param {JSXElement[]} mem
  496. */
  497. const processJSXElement = (jsxElement, mem, t) => {
  498. const clone = t.clone(jsxElement);
  499. clone.children = clone.children.reduce((clonedMem, clonedChild) => {
  500. const clonedEle = clonedChild.node ? clonedChild.node : clonedChild;
  501. // clean out invalid definitions by replacing `{ catchDate, date, short }` with `{ catchDate }`
  502. if (clonedEle.expression && clonedEle.expression.expressions)
  503. clonedEle.expression.expressions = [clonedEle.expression.expressions[0]];
  504. clonedMem.push(clonedChild);
  505. return clonedMem;
  506. }, []);
  507. mem.push(jsxElement);
  508. };
  509. /**
  510. * Extract the React components to pass to IcuTrans as content
  511. */
  512. function getComponents(children, babel) {
  513. const t = babel.types;
  514. return children.reduce((mem, child) => {
  515. const ele = child.node ? child.node : child;
  516. if (t.isJSXExpressionContainer(ele)) {
  517. // check for date`` and so on
  518. if (t.isTaggedTemplateExpression(ele.expression)) {
  519. ele.expression.quasi.expressions.forEach((expr) => {
  520. // check for sub-expressions. This can happen with plural`` or select`` or selectOrdinal``
  521. // these can have nested components
  522. if (t.isTaggedTemplateExpression(expr) && expr.quasi.expressions.length) {
  523. mem.push(...getComponents(expr.quasi.expressions, babel));
  524. }
  525. if (!t.isJSXElement(expr)) {
  526. // ignore anything that is not a component
  527. return;
  528. }
  529. processJSXElement(expr, mem, t);
  530. });
  531. }
  532. }
  533. if (t.isJSXElement(ele)) {
  534. processJSXElement(ele, mem, t);
  535. }
  536. return mem;
  537. }, []);
  538. }
  539. const icuInterpolators = ['date', 'time', 'number', 'plural', 'select', 'selectOrdinal'];
  540. const importsToAdd = ['IcuTrans'];
  541. /**
  542. * helper split out of addNeededImports to make codeclimate happy
  543. *
  544. * This does the work of amending an existing import from "react-i18next", or
  545. * creating a new one if it doesn't exist
  546. */
  547. function addImports(state, existingImport, allImportsToAdd, t) {
  548. // append imports to existing or add a new react-i18next import for the IcuTrans and icu tagged template literals
  549. if (existingImport) {
  550. allImportsToAdd.forEach((name) => {
  551. if (
  552. existingImport.specifiers.findIndex(
  553. (specifier) => specifier.imported && specifier.imported.name === name,
  554. ) === -1
  555. ) {
  556. existingImport.specifiers.push(t.importSpecifier(t.identifier(name), t.identifier(name)));
  557. }
  558. });
  559. } else {
  560. state.file.path.node.body.unshift(
  561. t.importDeclaration(
  562. allImportsToAdd.map((name) => t.importSpecifier(t.identifier(name), t.identifier(name))),
  563. t.stringLiteral('react-i18next'),
  564. ),
  565. );
  566. }
  567. }
  568. /**
  569. * Add `import { IcuTrans, number, date, <etc.> } from "react-i18next"` as needed
  570. */
  571. function addNeededImports(state, babel, references) {
  572. const t = babel.types;
  573. // check if there is an existing react-i18next import
  574. const existingImport = state.file.path.node.body.find(
  575. (importNode) =>
  576. t.isImportDeclaration(importNode) && importNode.source.value === 'react-i18next',
  577. );
  578. // check for any of the tagged template literals that are used in the source, and add them
  579. const usedRefs = Object.keys(references).filter((importName) => {
  580. if (!icuInterpolators.includes(importName)) {
  581. return false;
  582. }
  583. return references[importName].length;
  584. });
  585. // combine IcuTrans + any tagged template literals
  586. const allImportsToAdd = importsToAdd.concat(usedRefs);
  587. addImports(state, existingImport, allImportsToAdd, t);
  588. }
  589. /**
  590. * iterate over a node detected inside a tagged template literal
  591. *
  592. * This is a helper function for `extractVariableNamesFromQuasiNodes` defined below
  593. *
  594. * this is called using reduce as a way of tricking what would be `.map()`
  595. * into passing in the parameters needed to both modify `componentFoundIndex`,
  596. * `stringOutput`, and `interpolatedVariableNames`
  597. * and to pass in the dependencies babel, and type. Type is the template type.
  598. * For "date``" the type will be `date`. for "number``" the type is `number`, etc.
  599. */
  600. const extractNestedTemplatesAndComponents = (
  601. { componentFoundIndex: lastIndex, babel, stringOutput, type, interpolatedVariableNames },
  602. node,
  603. ) => {
  604. let componentFoundIndex = lastIndex;
  605. if (node.type === 'JSXElement') {
  606. // perform the interpolation of components just as we do in a normal Trans setting
  607. const subText = `<${componentFoundIndex}>${mergeChildren(
  608. node.children,
  609. babel,
  610. )}</${componentFoundIndex}>`;
  611. componentFoundIndex += 1;
  612. stringOutput.push(subText);
  613. } else if (node.type === 'TaggedTemplateExpression') {
  614. // a nested date``/number``/plural`` etc., extract whatever is inside of it
  615. const tagName = babel.types.isIdentifier(node.tag) && node.tag.name ? node.tag.name : 'unknown';
  616. const [variableNames, childText, newIndex] = getTextAndInterpolatedVariables(
  617. tagName,
  618. node,
  619. componentFoundIndex,
  620. babel,
  621. );
  622. interpolatedVariableNames.push(...variableNames);
  623. componentFoundIndex = newIndex;
  624. stringOutput.push(childText);
  625. } else if (node.type === 'Identifier') {
  626. // turn date`${thing}` into `thing, date`
  627. const nodeName = node.name || 'unknown';
  628. stringOutput.push(`${nodeName}, ${type}`);
  629. } else if (node.type === 'TemplateElement') {
  630. // convert all whitespace into a single space for the text in the tagged template literal
  631. const cookedValue = node.value.cooked || '';
  632. stringOutput.push(cookedValue.replace(/\s+/g, ' '));
  633. } else {
  634. // unknown node type, ignore
  635. }
  636. return { componentFoundIndex, babel, stringOutput, type, interpolatedVariableNames };
  637. };
  638. /**
  639. * filter the list of nodes within a tagged template literal to the 4 types we can process,
  640. * and ignore anything else.
  641. *
  642. * this is a helper function for `extractVariableNamesFromQuasiNodes`
  643. */
  644. const filterNodes = (node) => {
  645. if (node.type === 'Identifier') {
  646. // if the node has a name, keep it
  647. return node.name;
  648. }
  649. if (node.type === 'JSXElement' || node.type === 'TaggedTemplateExpression') {
  650. // always keep interpolated elements or other tagged template literals like a nested date`` inside a plural``
  651. return true;
  652. }
  653. if (node.type === 'TemplateElement') {
  654. // return the "cooked" (escaped) text for the text in the template literal (`, ::percent` in number`${varname}, ::percent`)
  655. return node.value.cooked;
  656. }
  657. // unknown node type, ignore
  658. return false;
  659. };
  660. const errorOnInvalidQuasiNodes = (primaryNode) => {
  661. const noInterpolationError = !primaryNode.quasi.expressions.length;
  662. const wrongOrderError = primaryNode.quasi.quasis[0].value.raw.length;
  663. const tagName = primaryNode.tag.name || 'unknown';
  664. const message = `${tagName} argument must be interpolated ${
  665. noInterpolationError ? 'in' : 'at the beginning of'
  666. } "${tagName}\`\`" in "${primaryNode.loc?.filename}" on line ${primaryNode.loc?.start.line}`;
  667. if (noInterpolationError || wrongOrderError) {
  668. throw new Error(message);
  669. }
  670. };
  671. const extractNodeVariableNames = (varNode, babel) => {
  672. const interpolatedVariableNames = [];
  673. if (varNode.type === 'JSXElement') {
  674. // extract inner interpolated variables and add to the list
  675. interpolatedVariableNames.push(
  676. ...getValues(varNode.children, babel).map((value) => {
  677. if (babel.types.isIdentifier(value.value)) {
  678. return value.value.name;
  679. }
  680. return String(value.value);
  681. }),
  682. );
  683. } else if (varNode.type === 'Identifier') {
  684. // the name of the interpolated variable
  685. interpolatedVariableNames.push(varNode.name);
  686. }
  687. return interpolatedVariableNames;
  688. };
  689. const extractVariableNamesFromQuasiNodes = (primaryNode, babel) => {
  690. errorOnInvalidQuasiNodes(primaryNode);
  691. // this will contain all the nodes to convert to the ICU messageformat text
  692. // at first they are unsorted, but will be ordered correctly at the end of the function
  693. const text = [];
  694. // the variable names. These are converted to object references as required for the IcuTrans values
  695. // in getValues() (toObjectProperty helper function)
  696. const interpolatedVariableNames = [];
  697. primaryNode.quasi.expressions.forEach((varNode) => {
  698. if (
  699. !babel.types.isIdentifier(varNode) &&
  700. !babel.types.isTaggedTemplateExpression(varNode) &&
  701. !babel.types.isJSXElement(varNode)
  702. ) {
  703. const tagName = primaryNode.tag.name || 'unknown';
  704. throw new Error(
  705. `Must pass a variable, not an expression to "${tagName}\`\`" in "${primaryNode.loc?.filename}" on line ${primaryNode.loc?.start.line}`,
  706. );
  707. }
  708. text.push(varNode);
  709. interpolatedVariableNames.push(...extractNodeVariableNames(varNode, babel));
  710. });
  711. primaryNode.quasi.quasis.forEach((quasiNode) => {
  712. // these are the text surrounding the variable interpolation
  713. // so in date`${varname}, short` it would be `''` and `, short`.
  714. // (the empty string before `${varname}` and the stuff after it)
  715. text.push(quasiNode);
  716. });
  717. return { text, interpolatedVariableNames };
  718. };
  719. const throwOnInvalidType = (type, primaryNode) => {
  720. if (!icuInterpolators.includes(type)) {
  721. throw new Error(
  722. `Unsupported tagged template literal "${type}", must be one of date, time, number, plural, select, selectOrdinal in "${primaryNode.loc?.filename}" on line ${primaryNode.loc?.start.line}`,
  723. );
  724. }
  725. };
  726. /**
  727. * Retrieve the new text to use, and any interpolated variables
  728. *
  729. * This is used to process tagged template literals like date`${variable}` and number`${num}, ::percent`
  730. *
  731. * for the data example, it will return text of `{variable, date}` with a variable of `variable`
  732. * for the number example, it will return text of `{num, number, ::percent}` with a variable of `num`
  733. * @param {string} type the name of the tagged template (`date`, `number`, `plural`, etc. - any valid complex ICU type)
  734. * @param {TaggedTemplateExpression} primaryNode the template expression node
  735. * @param {int} index starting index number of components to be used for interpolations like <0>
  736. * @param {*} babel
  737. */
  738. function getTextAndInterpolatedVariables(type, primaryNode, index, babel) {
  739. throwOnInvalidType(type, primaryNode);
  740. const componentFoundIndex = index;
  741. const { text, interpolatedVariableNames } = extractVariableNamesFromQuasiNodes(
  742. primaryNode,
  743. babel,
  744. );
  745. const { stringOutput, componentFoundIndex: newIndex } = text
  746. .filter(filterNodes)
  747. // sort by the order they appear in the source code
  748. .sort((a, b) => {
  749. const aStart = a.start != null ? a.start : null;
  750. const bStart = b.start != null ? b.start : null;
  751. if (aStart != null && bStart != null && aStart > bStart) return 1;
  752. return -1;
  753. })
  754. .reduce(extractNestedTemplatesAndComponents, {
  755. babel,
  756. componentFoundIndex,
  757. stringOutput: [],
  758. type,
  759. interpolatedVariableNames,
  760. });
  761. return [
  762. interpolatedVariableNames,
  763. `{${stringOutput.join('')}}`,
  764. // return the new component interpolation index
  765. newIndex,
  766. ];
  767. }