1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-22 11:22:43 +02:00

add MetaMaskTranslation component (#10405)

* add MetaMaskTranslation component

* add stories

* Update ui/app/components/app/metamask-translation/metamask-translation.js

Co-authored-by: Mark Stacey <markjstacey@gmail.com>

* Update ui/app/components/app/metamask-translation/metamask-translation.js

Co-authored-by: Mark Stacey <markjstacey@gmail.com>

* fix regex

Co-authored-by: Mark Stacey <markjstacey@gmail.com>
This commit is contained in:
Brad Decker 2021-02-12 11:24:50 -06:00 committed by GitHub
parent e48053a6d5
commit 8e2d4e6fdd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 174 additions and 27 deletions

View File

@ -22,9 +22,9 @@
// //////////////////////////////////////////////////////////////////////////////
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const log = require('loglevel');
const glob = require('fast-glob');
const matchAll = require('string.prototype.matchall').getPolyfill();
const localeIndex = require('../app/_locales/index.json');
const {
@ -34,7 +34,6 @@ const {
getLocalePath,
} = require('./lib/locales');
const readdir = promisify(fs.readdir);
const readFile = promisify(fs.readFile);
const writeFile = promisify(fs.writeFile);
@ -168,15 +167,18 @@ async function verifyLocale(code) {
async function verifyEnglishLocale() {
const englishLocale = await getLocale('en');
const uiJSFiles = await findJavascriptFiles(
path.resolve(__dirname, '..', 'ui'),
);
const sharedJSFiles = await findJavascriptFiles(
path.resolve(__dirname, '..', 'shared'),
);
const javascriptFiles = sharedJSFiles.concat(uiJSFiles);
// As time allows we'll switch to only performing the strict search.
// In the meantime we'll use glob to specify which paths can be strict searched
// and gradually phase out the key based search
const globsToStrictSearch = [
'ui/app/components/app/metamask-translation/*.js',
];
const javascriptFiles = await glob(['ui/**/*.js', 'shared/**/*.js'], {
ignore: globsToStrictSearch,
});
const javascriptFilesToStrictSearch = await glob(globsToStrictSearch);
const strictSearchRegex = /\bt\(\s*'(\w+)'\s*\)|\btranslationKey:\s*'(\w+)'/gu;
// match "t(`...`)" because constructing message keys from template strings
// prevents this script from finding the messages, and then inappropriately
// deletes them
@ -190,10 +192,21 @@ async function verifyEnglishLocale() {
for (const match of matchAll.call(fileContents, keyRegex)) {
usedMessages.add(match[1] || match[2]);
}
const templateMatches = fileContents.match(templateStringRegex);
if (templateMatches) {
templateMatches.forEach((match) => templateUsage.push(match));
}
}
for await (const fileContents of getFileContents(
javascriptFilesToStrictSearch,
)) {
for (const match of matchAll.call(fileContents, strictSearchRegex)) {
usedMessages.add(match[1] || match[2] || match[3] || match[4]);
}
const templateMatches = fileContents.match(templateStringRegex);
if (templateMatches) {
// concat doesn't work here for some reason
templateMatches.forEach((match) => templateUsage.push(match));
}
}
@ -237,21 +250,6 @@ async function verifyEnglishLocale() {
return true; // failed === true
}
async function findJavascriptFiles(rootDir) {
const javascriptFiles = [];
const contents = await readdir(rootDir, { withFileTypes: true });
for (const file of contents) {
if (file.isDirectory()) {
javascriptFiles.push(
...(await findJavascriptFiles(path.join(rootDir, file.name))),
);
} else if (file.isFile() && file.name.endsWith('.js')) {
javascriptFiles.push(path.join(rootDir, file.name));
}
}
return javascriptFiles;
}
async function* getFileContents(filenames) {
for (const filename of filenames) {
yield readFile(filename, 'utf8');

View File

@ -77,7 +77,7 @@ const MetaMaskTemplateRenderer = ({ sections }) => {
);
};
const SectionShape = {
export const SectionShape = {
props: PropTypes.object,
element: PropTypes.oneOf(Object.keys(safeComponentList)).isRequired,
key: PropTypes.string,

View File

@ -5,8 +5,10 @@ import TruncatedDefinitionList from '../../ui/truncated-definition-list';
import Popover from '../../ui/popover';
import Typography from '../../ui/typography';
import Box from '../../ui/box';
import MetaMaskTranslation from '../metamask-translation';
export const safeComponentList = {
MetaMaskTranslation,
b: 'b',
p: 'p',
div: 'div',

View File

@ -0,0 +1 @@
export { default } from './metamask-translation';

View File

@ -0,0 +1,80 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useI18nContext } from '../../../hooks/useI18nContext';
import MetaMaskTemplateRenderer, {
SectionShape,
} from '../metamask-template-renderer/metamask-template-renderer';
/**
* MetaMaskTranslation is a simple helper Component for adding full translation
* support to the template system. We do pass the translation function to the
* template getValues function, but passing it React components as variables
* would require React to be in scope, and breaks the object pattern paradigm.
*
* This component gets around that by converting variables that are templates
* themselves into tiny React trees. This component does additional validation
* to make sure that the tree has a single root node, with maximum two leaves.
* Each subnode can have a maximum of one child that must be a string.
*
* This enforces a maximum recursion depth of 2, preventing translation strings
* from being performance hogs. We could further limit this, and also attenuate
* the safeComponentList for what kind of components we allow these special
* trees to contain.
*/
export default function MetaMaskTranslation({ translationKey, variables }) {
const t = useI18nContext();
return t(
translationKey,
variables?.map((variable) => {
if (
typeof variable === 'object' &&
!Array.isArray(variable) &&
variable.element
) {
if (!variable.key) {
throw new Error(
`When using MetaMask Template Language in a MetaMaskTranslation variable, you must provide a key for the section regardless of syntax.
Section with element '${variable.element}' for translationKey: '${translationKey}' has no key property`,
);
}
if (
variable.children &&
Array.isArray(variable.children) &&
variable.children.length > 2
) {
throw new Error(
'MetaMaskTranslation only renders templates with a single section and maximum two children',
);
} else if (
(variable.children?.[0]?.children !== undefined &&
typeof variable.children[0].children !== 'string') ||
(variable.children?.[1]?.children !== undefined &&
typeof variable.children[1].children !== 'string')
) {
throw new Error(
'MetaMaskTranslation does not allow for component trees of non trivial depth',
);
}
return (
<MetaMaskTemplateRenderer
key={`${translationKey}-${variable.key}`}
sections={variable}
/>
);
}
return variable;
}),
);
}
MetaMaskTranslation.propTypes = {
translationKey: PropTypes.string.isRequired,
variables: PropTypes.arrayOf(
PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.shape(SectionShape),
]),
),
};

View File

@ -0,0 +1,66 @@
import React from 'react';
import { select, object } from '@storybook/addon-knobs';
import { groupBy } from 'lodash';
import en from '../../../../../app/_locales/en/messages.json';
import MetaMaskTranslation from './metamask-translation';
export default {
title: 'MetaMaskTranslation',
};
const { keysWithSubstitution, keysWithoutSubstitution } = groupBy(
Object.keys(en),
(key) => {
if (en[key].message.includes('$1')) {
return 'keysWithSubstitution';
}
return 'keysWithoutSubstitution';
},
);
export const withoutSubstitutions = () => (
<MetaMaskTranslation
translationKey={select(
'translationKey',
keysWithoutSubstitution,
keysWithoutSubstitution[0],
)}
/>
);
export const withSubstitutions = () => (
<MetaMaskTranslation
translationKey={select(
'translationKey',
keysWithSubstitution,
keysWithSubstitution[0],
)}
variables={object('variables', [])}
/>
);
export const withTemplate = () => (
<MetaMaskTranslation
translationKey={select(
'translationKey',
keysWithSubstitution,
keysWithSubstitution[0],
)}
variables={[
{
element: 'span',
key: 'link',
children: {
element: 'MetaMaskTranslation',
props: {
translationKey: select(
'innerTranslationKey',
keysWithoutSubstitution,
keysWithoutSubstitution[0],
),
},
},
},
]}
/>
);