From 17cab33878134f49d14ca406c6eb9a0a421e26ae Mon Sep 17 00:00:00 2001 From: Liza Katz Date: Wed, 6 Feb 2019 14:00:44 -0800 Subject: [PATCH] support automatic i18n string translation to pseudo code for debugging (#1512) * support automatic i18n string translation to pseudo code for debugging * support automatic i18n string translation to pseudo code for debugging * added script to fetch all i18n strings from components * added script to fetch all i18n strings from components * Pseudo-translate I18n string placeholder values * Update pseudo-translation toggle in docs * write i18n token data to src-docs * new package scripts * List all I18n tokens on their own docs page * Pull i18n tokens out during build, validate --- package.json | 3 +- scripts/babel/fetch-i18n-strings.js | 126 +++ src-docs/src/actions/action_types.js | 3 + src-docs/src/actions/index.js | 4 + src-docs/src/actions/locale_actions.js | 8 + .../guide_locale_selector.js | 46 ++ .../components/guide_locale_selector/index.js | 1 + .../guide_page/guide_page_chrome.js | 19 +- src-docs/src/i18ntokens.json | 722 ++++++++++++++++++ src-docs/src/routes.js | 6 +- src-docs/src/services/index.js | 2 + .../string/pseudo_locale_translator.ts | 72 ++ src-docs/src/store/configure_store.js | 2 + src-docs/src/store/index.js | 4 + src-docs/src/store/reducers/locale_reducer.js | 20 + src-docs/src/views/app_container.js | 4 + src-docs/src/views/app_view.js | 21 +- src-docs/src/views/package/i18n_tokens.js | 49 ++ src/components/context/context.tsx | 1 + .../i18n/__snapshots__/i18n.test.tsx.snap | 22 + src/components/i18n/i18n.test.tsx | 19 + src/components/i18n/i18n.tsx | 14 +- src/components/i18n/i18n_util.test.tsx | 12 + src/components/i18n/i18n_util.tsx | 7 +- 24 files changed, 1178 insertions(+), 9 deletions(-) create mode 100644 scripts/babel/fetch-i18n-strings.js create mode 100644 src-docs/src/actions/locale_actions.js create mode 100644 src-docs/src/components/guide_locale_selector/guide_locale_selector.js create mode 100644 src-docs/src/components/guide_locale_selector/index.js create mode 100644 src-docs/src/i18ntokens.json create mode 100644 src-docs/src/services/string/pseudo_locale_translator.ts create mode 100644 src-docs/src/store/reducers/locale_reducer.js create mode 100644 src-docs/src/views/package/i18n_tokens.js diff --git a/package.json b/package.json index 9eac97b5827..95e9b4384ca 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "test-docker": "docker pull $npm_package_docker_image && docker run --rm -i -e GIT_COMMITTER_NAME=test -e GIT_COMMITTER_EMAIL=test --user=$(id -u):$(id -g) -e HOME=/tmp -v $(pwd):/app -w /app $npm_package_docker_image bash -c 'npm config set spin false && /opt/yarn*/bin/yarn && npm run test && npm run build'", "sync-docs": "node ./scripts/docs-sync.js", "build-docs": "webpack --config=src-docs/webpack.config.js", - "build": "node ./scripts/compile-clean.js && node ./scripts/compile-eui.js && node ./scripts/compile-scss.js", + "build": "yarn extract-i18n-strings && node ./scripts/compile-clean.js && node ./scripts/compile-eui.js && node ./scripts/compile-scss.js", + "extract-i18n-strings": "node ./scripts/babel/fetch-i18n-strings", "lint": "yarn lint-es && yarn lint-ts && yarn lint-sass && yarn lint-framer", "lint-fix": "yarn lint-es-fix && yarn lint-ts-fix", "lint-es": "eslint --cache --ignore-pattern \"**/*.snap.js\" \"src/**/*.js\" \"src-docs/**/*.js\"", diff --git a/scripts/babel/fetch-i18n-strings.js b/scripts/babel/fetch-i18n-strings.js new file mode 100644 index 00000000000..7977396e5e6 --- /dev/null +++ b/scripts/babel/fetch-i18n-strings.js @@ -0,0 +1,126 @@ +const babel = require('@babel/core'); +const babelOptions = require('../../.babelrc'); +const fs = require('fs'); +const { promisify } = require('util'); +const { basename, join, relative } = require('path'); +const glob = require('glob'); +const asyncGlob = promisify(glob); + +const rootDir = join(__dirname, '..', '..'); +const srcDir = join(rootDir, 'src'); + +const tokenMappings = []; + +function getCodeForExpression(expressionNode) { + return babel.transformFromAst(babel.types.program([ + babel.types.expressionStatement( + babel.types.removeComments(babel.types.cloneDeep(expressionNode)) + ) + ])).code; +} + +function handleJSXPath(path) { + if (path.node.name.name === 'EuiI18n') { + const symbols = []; + + const attributes = path.node.attributes.reduce( + (attributes, node) => { + attributes[node.name.name] = node.value; + return attributes; + }, + {} + ); + + if (attributes.hasOwnProperty('token') && attributes.hasOwnProperty('default')) { + const tokenNode = attributes.token; + const defStringNode = attributes.default; + + let defString; + let highlighting; + if (defStringNode.type === 'StringLiteral') { + defString = defStringNode.value; + highlighting = 'string'; + } else if (defStringNode.type === 'JSXExpressionContainer') { + defString = getCodeForExpression(defStringNode.expression); + highlighting = 'code'; + } + symbols.push({ + token: tokenNode.value, + defString, + highlighting, + loc: path.node.loc + }); + } + + return symbols; + } +} + +function traverseFile(filepath) { + const source = fs.readFileSync(filepath); + const ast = babel.parse( + source, + { + ...babelOptions, + filename: basename(filepath), + ast: true + } + ); + + babel.traverse( + ast, + { + JSXOpeningElement(path) { + if (path.node.name.name === 'EuiI18n') { + const symbols = handleJSXPath(path); + for (let i = 0; i < symbols.length; i++) { + tokenMappings.push( + { ...symbols[i], filepath: relative(rootDir, filepath) } + ); + } + } + } + } + ); +} + +(async () => { + const files = (await asyncGlob( + '**/*.@(js|ts|tsx)', + { cwd: srcDir, realpath: true }, + )).filter(filepath => { + if (filepath.endsWith('index.d.ts')) return false; + if (filepath.endsWith('test.ts')) return false; + if (filepath.endsWith('test.tsx')) return false; + if (filepath.endsWith('test.js')) return false; + + return true; + }); + + // extract tokens from source files + files.forEach(filename => traverseFile(filename)); + + // validate tokens + tokenMappings.reduce( + (mappings, symbol) => { + const { token, defString } = symbol; + + if (mappings.hasOwnProperty(token)) { + if (mappings[token] !== defString) { + console.error(`Token ${token} has two differing defaults:\n${defString}\n${mappings[token]}`); + process.exit(1); + } + } else { + mappings[token] = defString; + } + + return mappings; + }, + {} + ); + + fs.writeFileSync( + join(rootDir, 'src-docs', 'src', 'i18ntokens.json'), + JSON.stringify(tokenMappings, null, 2) + ); +})(); diff --git a/src-docs/src/actions/action_types.js b/src-docs/src/actions/action_types.js index 737b33e0460..412b911becb 100644 --- a/src-docs/src/actions/action_types.js +++ b/src-docs/src/actions/action_types.js @@ -7,4 +7,7 @@ export default keyMirror({ // Theme actions TOGGLE_THEME: null, + + // Locale actions + TOGGLE_LOCALE: null, }); diff --git a/src-docs/src/actions/index.js b/src-docs/src/actions/index.js index d3ac88f393f..f953685a5c2 100644 --- a/src-docs/src/actions/index.js +++ b/src-docs/src/actions/index.js @@ -1,3 +1,7 @@ export { toggleTheme, } from './theme_actions'; + +export { + toggleLocale, +} from './locale_actions'; diff --git a/src-docs/src/actions/locale_actions.js b/src-docs/src/actions/locale_actions.js new file mode 100644 index 00000000000..17e84a49056 --- /dev/null +++ b/src-docs/src/actions/locale_actions.js @@ -0,0 +1,8 @@ +import ActionTypes from './action_types'; + +export const toggleLocale = locale => ({ + type: ActionTypes.TOGGLE_LOCALE, + data: { + locale, + }, +}); diff --git a/src-docs/src/components/guide_locale_selector/guide_locale_selector.js b/src-docs/src/components/guide_locale_selector/guide_locale_selector.js new file mode 100644 index 00000000000..6e8eba87fb9 --- /dev/null +++ b/src-docs/src/components/guide_locale_selector/guide_locale_selector.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { + EuiSwitch, +} from '../../../../src/components'; + +export class GuideLocaleSelector extends Component { + constructor(props) { + super(props); + + this.state = { + isLocalePopoverOpen: false, + }; + } + + onLocaleButtonClick = () => { + this.setState({ + isLocalePopoverOpen: !this.state.isLocalePopoverOpen, + }); + }; + + closeLocalePopover = () => { + this.setState({ + isLocalePopoverOpen: false, + }); + }; + + render() { + const otherLocale = this.props.selectedLocale === 'en' ? 'en-xa' : 'en'; + + return ( + this.props.onToggleLocale(otherLocale)} + compressed={true} + /> + ); + } +} + +GuideLocaleSelector.propTypes = { + onToggleLocale: PropTypes.func.isRequired, + selectedLocale: PropTypes.string.isRequired, +}; diff --git a/src-docs/src/components/guide_locale_selector/index.js b/src-docs/src/components/guide_locale_selector/index.js new file mode 100644 index 00000000000..7f4a4fbcfb4 --- /dev/null +++ b/src-docs/src/components/guide_locale_selector/index.js @@ -0,0 +1 @@ +export { GuideLocaleSelector } from './guide_locale_selector'; diff --git a/src-docs/src/components/guide_page/guide_page_chrome.js b/src-docs/src/components/guide_page/guide_page_chrome.js index c60ec28488e..c5d5ba282ef 100644 --- a/src-docs/src/components/guide_page/guide_page_chrome.js +++ b/src-docs/src/components/guide_page/guide_page_chrome.js @@ -16,6 +16,9 @@ import { EuiText, } from '../../../../src/components'; +import { + GuideLocaleSelector, +} from '../guide_locale_selector'; import { GuideThemeSelector, } from '../guide_theme_selector'; @@ -78,7 +81,7 @@ export class GuidePageChrome extends Component { ); return ( - + {homeLink} @@ -89,6 +92,18 @@ export class GuidePageChrome extends Component { selectedTheme={this.props.selectedTheme} /> + { + location.host === 'localhost:8030' // eslint-disable-line no-restricted-globals + ? ( + + + + ) + : null + } ); } @@ -198,5 +213,7 @@ GuidePageChrome.propTypes = { currentRouteName: PropTypes.string, onToggleTheme: PropTypes.func.isRequired, selectedTheme: PropTypes.string.isRequired, + onToggleLocale: PropTypes.func.isRequired, + selectedLocale: PropTypes.string.isRequired, navigation: PropTypes.array.isRequired, }; diff --git a/src-docs/src/i18ntokens.json b/src-docs/src/i18ntokens.json new file mode 100644 index 00000000000..0c5224c9c3c --- /dev/null +++ b/src-docs/src/i18ntokens.json @@ -0,0 +1,722 @@ +[ + { + "token": "euiBasicTable.tableDescription", + "defString": "Below is a table of {itemCount} items.", + "highlighting": "string", + "loc": { + "start": { + "line": 422, + "column": 10 + }, + "end": { + "line": 426, + "column": 12 + } + }, + "filepath": "src/components/basic_table/basic_table.js" + }, + { + "token": "euiBasicTable.selectAllRows", + "defString": "Select all rows", + "highlighting": "string", + "loc": { + "start": { + "line": 459, + "column": 10 + }, + "end": { + "line": 459, + "column": 81 + } + }, + "filepath": "src/components/basic_table/basic_table.js" + }, + { + "token": "euiBasicTable.selectThisRow", + "defString": "Select this row", + "highlighting": "string", + "loc": { + "start": { + "line": 737, + "column": 8 + }, + "end": { + "line": 737, + "column": 79 + } + }, + "filepath": "src/components/basic_table/basic_table.js" + }, + { + "token": "euiCollapsedItemActions.allActions", + "defString": "All actions", + "highlighting": "string", + "loc": { + "start": { + "line": 92, + "column": 6 + }, + "end": { + "line": 92, + "column": 80 + } + }, + "filepath": "src/components/basic_table/collapsed_item_actions.js" + }, + { + "token": "euiCollapsedItemActions.allActions", + "defString": "All actions", + "highlighting": "string", + "loc": { + "start": { + "line": 108, + "column": 6 + }, + "end": { + "line": 108, + "column": 80 + } + }, + "filepath": "src/components/basic_table/collapsed_item_actions.js" + }, + { + "token": "euiBottomBar.screenReaderAnnouncement", + "defString": "There is a new menu opening with page level controls at the bottom of the document.", + "highlighting": "string", + "loc": { + "start": { + "line": 57, + "column": 12 + }, + "end": { + "line": 60, + "column": 14 + } + }, + "filepath": "src/components/bottom_bar/bottom_bar.js" + }, + { + "token": "euiCodeEditor.startInteracting", + "defString": "Press Enter to start interacting with the code.", + "highlighting": "string", + "loc": { + "start": { + "line": 161, + "column": 16 + }, + "end": { + "line": 164, + "column": 18 + } + }, + "filepath": "src/components/code_editor/code_editor.js" + }, + { + "token": "euiCodeEditor.startEditing", + "defString": "Press Enter to start editing.", + "highlighting": "string", + "loc": { + "start": { + "line": 167, + "column": 16 + }, + "end": { + "line": 170, + "column": 18 + } + }, + "filepath": "src/components/code_editor/code_editor.js" + }, + { + "token": "euiCodeEditor.stopInteracting", + "defString": "When you're done, press Escape to stop interacting with the code.", + "highlighting": "string", + "loc": { + "start": { + "line": 179, + "column": 16 + }, + "end": { + "line": 182, + "column": 18 + } + }, + "filepath": "src/components/code_editor/code_editor.js" + }, + { + "token": "euiCodeEditor.stopEditing", + "defString": "When you're done, press Escape to stop editing.", + "highlighting": "string", + "loc": { + "start": { + "line": 185, + "column": 16 + }, + "end": { + "line": 188, + "column": 18 + } + }, + "filepath": "src/components/code_editor/code_editor.js" + }, + { + "token": "euiColorPicker.transparentColor", + "defString": "transparent", + "highlighting": "string", + "loc": { + "start": { + "line": 35, + "column": 6 + }, + "end": { + "line": 38, + "column": 7 + } + }, + "filepath": "src/components/color_picker/color_picker.js" + }, + { + "token": "euiColorPicker.colorSelectionLabel", + "defString": "Color selection is {colorValue}", + "highlighting": "string", + "loc": { + "start": { + "line": 43, + "column": 12 + }, + "end": { + "line": 47, + "column": 13 + } + }, + "filepath": "src/components/color_picker/color_picker.js" + }, + { + "token": "euiComboBoxPill.removeSelection", + "defString": "Remove {children} from selection in this group", + "highlighting": "string", + "loc": { + "start": { + "line": 51, + "column": 8 + }, + "end": { + "line": 55, + "column": 9 + } + }, + "filepath": "src/components/combo_box/combo_box_input/combo_box_pill.js" + }, + { + "token": "euiComboBoxOptionsList.loadingOptions", + "defString": "Loading options", + "highlighting": "string", + "loc": { + "start": { + "line": 133, + "column": 12 + }, + "end": { + "line": 133, + "column": 94 + } + }, + "filepath": "src/components/combo_box/combo_box_options_list/combo_box_options_list.js" + }, + { + "token": "euiComboBoxOptionsList.alreadyAdded", + "defString": "{label} has already been added", + "highlighting": "string", + "loc": { + "start": { + "line": 144, + "column": 14 + }, + "end": { + "line": 148, + "column": 16 + } + }, + "filepath": "src/components/combo_box/combo_box_options_list/combo_box_options_list.js" + }, + { + "token": "euiComboBoxOptionsList.createCustomOption", + "defString": "Hit {key} to add {searchValue} as a custom option", + "highlighting": "string", + "loc": { + "start": { + "line": 154, + "column": 14 + }, + "end": { + "line": 158, + "column": 16 + } + }, + "filepath": "src/components/combo_box/combo_box_options_list/combo_box_options_list.js" + }, + { + "token": "euiComboBoxOptionsList.noMatchingOptions", + "defString": "{searchValue} doesn't match any options", + "highlighting": "string", + "loc": { + "start": { + "line": 165, + "column": 12 + }, + "end": { + "line": 169, + "column": 14 + } + }, + "filepath": "src/components/combo_box/combo_box_options_list/combo_box_options_list.js" + }, + { + "token": "euiComboBoxOptionsList.noAvailableOptions", + "defString": "There aren't any options available", + "highlighting": "string", + "loc": { + "start": { + "line": 176, + "column": 10 + }, + "end": { + "line": 176, + "column": 115 + } + }, + "filepath": "src/components/combo_box/combo_box_options_list/combo_box_options_list.js" + }, + { + "token": "euiComboBoxOptionsList.allOptionsSelected", + "defString": "You've selected all available options", + "highlighting": "string", + "loc": { + "start": { + "line": 182, + "column": 10 + }, + "end": { + "line": 182, + "column": 118 + } + }, + "filepath": "src/components/combo_box/combo_box_options_list/combo_box_options_list.js" + }, + { + "token": "euiFormControlLayoutClearButton.label", + "defString": "Clear input", + "highlighting": "string", + "loc": { + "start": { + "line": 16, + "column": 4 + }, + "end": { + "line": 16, + "column": 81 + } + }, + "filepath": "src/components/form/form_control_layout/form_control_layout_clear_button.js" + }, + { + "token": "euiForm.addressFormErrors", + "defString": "Please address the errors in your form.", + "highlighting": "string", + "loc": { + "start": { + "line": 35, + "column": 6 + }, + "end": { + "line": 35, + "column": 99 + } + }, + "filepath": "src/components/form/form.js" + }, + { + "token": "euiSuperSelectControl.selectAnOption", + "defString": "Select an option: {selectedValue}, is selected", + "highlighting": "string", + "loc": { + "start": { + "line": 77, + "column": 12 + }, + "end": { + "line": 81, + "column": 14 + } + }, + "filepath": "src/components/form/super_select/super_select_control.js" + }, + { + "token": "euiSuperSelect.screenReaderAnnouncement", + "defString": "You are in a form selector of {optionsCount} items and must select a single option.\n Use the up and down keys to navigate or escape to close.", + "highlighting": "string", + "loc": { + "start": { + "line": 249, + "column": 12 + }, + "end": { + "line": 254, + "column": 14 + } + }, + "filepath": "src/components/form/super_select/super_select.js" + }, + { + "token": "euiHeaderAlert.dismiss", + "defString": "Dismiss", + "highlighting": "string", + "loc": { + "start": { + "line": 29, + "column": 4 + }, + "end": { + "line": 29, + "column": 62 + } + }, + "filepath": "src/components/header/header_alert/header_alert.js" + }, + { + "token": "euiHeaderLinks.openNavigationMenu", + "defString": "Open navigation menu", + "highlighting": "string", + "loc": { + "start": { + "line": 46, + "column": 8 + }, + "end": { + "line": 46, + "column": 90 + } + }, + "filepath": "src/components/header/header_links/header_links.js" + }, + { + "token": "euiHeaderLinks.appNavigation", + "defString": "App navigation", + "highlighting": "string", + "loc": { + "start": { + "line": 60, + "column": 6 + }, + "end": { + "line": 60, + "column": 77 + } + }, + "filepath": "src/components/header/header_links/header_links.js" + }, + { + "token": "euiModal.closeModal", + "defString": "Closes this modal window", + "highlighting": "string", + "loc": { + "start": { + "line": 64, + "column": 10 + }, + "end": { + "line": 64, + "column": 82 + } + }, + "filepath": "src/components/modal/modal.js" + }, + { + "token": "euiPagination.pageOfTotal", + "defString": "Page {page} of {total}", + "highlighting": "string", + "loc": { + "start": { + "line": 28, + "column": 6 + }, + "end": { + "line": 33, + "column": 7 + } + }, + "filepath": "src/components/pagination/pagination.js" + }, + { + "token": "euiPagination.previousPage", + "defString": "Previous page", + "highlighting": "string", + "loc": { + "start": { + "line": 51, + "column": 4 + }, + "end": { + "line": 51, + "column": 72 + } + }, + "filepath": "src/components/pagination/pagination.js" + }, + { + "token": "euiPagination.pageOfTotal", + "defString": "Page {page} of {total}", + "highlighting": "string", + "loc": { + "start": { + "line": 69, + "column": 6 + }, + "end": { + "line": 74, + "column": 7 + } + }, + "filepath": "src/components/pagination/pagination.js" + }, + { + "token": "euiPagination.jumpToLastPage", + "defString": "Jump to the last page, number {pageCount}", + "highlighting": "string", + "loc": { + "start": { + "line": 118, + "column": 6 + }, + "end": { + "line": 123, + "column": 7 + } + }, + "filepath": "src/components/pagination/pagination.js" + }, + { + "token": "euiPagination.nextPage", + "defString": "Next page", + "highlighting": "string", + "loc": { + "start": { + "line": 138, + "column": 4 + }, + "end": { + "line": 138, + "column": 64 + } + }, + "filepath": "src/components/pagination/pagination.js" + }, + { + "token": "euiPopover.screenReaderAnnouncement", + "defString": "You are in a popup. To exit this popup, hit escape.", + "highlighting": "string", + "loc": { + "start": { + "line": 435, + "column": 14 + }, + "end": { + "line": 435, + "column": 130 + } + }, + "filepath": "src/components/popover/popover.js" + }, + { + "token": "euiStepHorizontal.buttonTitle", + "defString": "({\n step,\n title,\n disabled,\n isComplete\n}) => {\n let titleAppendix = '';\n\n if (disabled) {\n titleAppendix = ' is disabled';\n } else if (isComplete) {\n titleAppendix = ' is complete';\n }\n\n return `Step ${step}: ${title}${titleAppendix}`;\n};", + "highlighting": "code", + "loc": { + "start": { + "line": 56, + "column": 4 + }, + "end": { + "line": 69, + "column": 5 + } + }, + "filepath": "src/components/steps/step_horizontal.js" + }, + { + "token": "euiStepHorizontal.step", + "defString": "Step", + "highlighting": "string", + "loc": { + "start": { + "line": 82, + "column": 38 + }, + "end": { + "line": 82, + "column": 94 + } + }, + "filepath": "src/components/steps/step_horizontal.js" + }, + { + "token": "euiStepNumber.isComplete", + "defString": "complete", + "highlighting": "string", + "loc": { + "start": { + "line": 42, + "column": 6 + }, + "end": { + "line": 42, + "column": 67 + } + }, + "filepath": "src/components/steps/step_number.js" + }, + { + "token": "euiStepNumber.hasWarnings", + "defString": "has warnings", + "highlighting": "string", + "loc": { + "start": { + "line": 48, + "column": 6 + }, + "end": { + "line": 48, + "column": 72 + } + }, + "filepath": "src/components/steps/step_number.js" + }, + { + "token": "euiStepNumber.hasErrors", + "defString": "has errors", + "highlighting": "string", + "loc": { + "start": { + "line": 54, + "column": 6 + }, + "end": { + "line": 54, + "column": 68 + } + }, + "filepath": "src/components/steps/step_number.js" + }, + { + "token": "euiStep.incompleteStep", + "defString": "Incomplete Step", + "highlighting": "string", + "loc": { + "start": { + "line": 35, + "column": 23 + }, + "end": { + "line": 35, + "column": 90 + } + }, + "filepath": "src/components/steps/step.js" + }, + { + "token": "euiStep.completeStep", + "defString": "Step", + "highlighting": "string", + "loc": { + "start": { + "line": 37, + "column": 23 + }, + "end": { + "line": 37, + "column": 77 + } + }, + "filepath": "src/components/steps/step.js" + }, + { + "token": "euiTableSortMobile.sorting", + "defString": "Sorting", + "highlighting": "string", + "loc": { + "start": { + "line": 59, + "column": 8 + }, + "end": { + "line": 59, + "column": 71 + } + }, + "filepath": "src/components/table/mobile/table_sort_mobile.js" + }, + { + "token": "euiTablePagination.rowsPerPage", + "defString": "Rows per page", + "highlighting": "string", + "loc": { + "start": { + "line": 53, + "column": 8 + }, + "end": { + "line": 53, + "column": 81 + } + }, + "filepath": "src/components/table/table_pagination/table_pagination.js" + }, + { + "token": "euiToast.dismissToast", + "defString": "Dismiss toast", + "highlighting": "string", + "loc": { + "start": { + "line": 49, + "column": 6 + }, + "end": { + "line": 49, + "column": 69 + } + }, + "filepath": "src/components/toast/toast.js" + }, + { + "token": "euiToast.newNotification", + "defString": "A new notification appears", + "highlighting": "string", + "loc": { + "start": { + "line": 86, + "column": 11 + }, + "end": { + "line": 86, + "column": 91 + } + }, + "filepath": "src/components/toast/toast.js" + }, + { + "token": "euiToast.notification", + "defString": "Notification", + "highlighting": "string", + "loc": { + "start": { + "line": 89, + "column": 6 + }, + "end": { + "line": 89, + "column": 68 + } + }, + "filepath": "src/components/toast/toast.js" + } +] \ No newline at end of file diff --git a/src-docs/src/routes.js b/src-docs/src/routes.js index 762d21fb05a..ec984247185 100644 --- a/src-docs/src/routes.js +++ b/src-docs/src/routes.js @@ -267,6 +267,9 @@ import { XYChartLineExample } import { Changelog } from './views/package/changelog'; +import { I18nTokens } + from './views/package/i18n_tokens'; + import { SuperSelectExample } from './views/super_select/super_select_example'; @@ -441,7 +444,8 @@ const navigation = [{ }, { name: 'Package', items: [ - Changelog + Changelog, + I18nTokens, ] }].map(({ name, items, ...rest }) => ({ name, diff --git a/src-docs/src/services/index.js b/src-docs/src/services/index.js index c7a3a967ba4..92b15f18701 100644 --- a/src-docs/src/services/index.js +++ b/src-docs/src/services/index.js @@ -1,5 +1,7 @@ export { renderToHtml } from './string/render_to_html'; +export { translateUsingPseudoLocale } from './string/pseudo_locale_translator'; + export { registerTheme, applyTheme, diff --git a/src-docs/src/services/string/pseudo_locale_translator.ts b/src-docs/src/services/string/pseudo_locale_translator.ts new file mode 100644 index 00000000000..dbd7d8f0ea8 --- /dev/null +++ b/src-docs/src/services/string/pseudo_locale_translator.ts @@ -0,0 +1,72 @@ +const CHARS_FOR_PSEUDO_LOCALIZATION_REGEX = /[a-z]/gi; + +/** + * Replaces every latin char by pseudo char and repeats every third char twice. + */ +function replacer() { + let count = 0; + + return (match: string) => { + const pseudoChar = pseudoAccentCharMap[match] || match; + return ++count % 3 === 0 ? pseudoChar.repeat(2) : pseudoChar; + }; +} + +export function translateUsingPseudoLocale(message: string) { + return message.replace(CHARS_FOR_PSEUDO_LOCALIZATION_REGEX, replacer()); +} + +const pseudoAccentCharMap: Record = { + a: 'à', + b: 'ƀ', + c: 'ç', + d: 'ð', + e: 'é', + f: 'ƒ', + g: 'ĝ', + h: 'ĥ', + i: 'î', + l: 'ļ', + k: 'ķ', + j: 'ĵ', + m: 'ɱ', + n: 'ñ', + o: 'ô', + p: 'þ', + q: 'ǫ', + r: 'ŕ', + s: 'š', + t: 'ţ', + u: 'û', + v: 'ṽ', + w: 'ŵ', + x: 'ẋ', + y: 'ý', + z: 'ž', + A: 'À', + B: 'Ɓ', + C: 'Ç', + D: 'Ð', + E: 'É', + F: 'Ƒ', + G: 'Ĝ', + H: 'Ĥ', + I: 'Î', + L: 'Ļ', + K: 'Ķ', + J: 'Ĵ', + M: 'Ṁ', + N: 'Ñ', + O: 'Ô', + P: 'Þ', + Q: 'Ǫ', + R: 'Ŕ', + S: 'Š', + T: 'Ţ', + U: 'Û', + V: 'Ṽ', + W: 'Ŵ', + X: 'Ẋ', + Y: 'Ý', + Z: 'Ž', +}; diff --git a/src-docs/src/store/configure_store.js b/src-docs/src/store/configure_store.js index d8cc0fa28fd..6dc78577140 100644 --- a/src-docs/src/store/configure_store.js +++ b/src-docs/src/store/configure_store.js @@ -12,6 +12,7 @@ import { import Routes from '../routes'; +import localeReducer from './reducers/locale_reducer'; import themeReducer from './reducers/theme_reducer'; /** @@ -23,6 +24,7 @@ export default function configureStore(initialState) { return { routing: routerReducer(state.routing, action), theme: themeReducer(state.theme, action), + locale: localeReducer(state.locale, action), routes: Routes, }; } diff --git a/src-docs/src/store/index.js b/src-docs/src/store/index.js index a9df2b1f466..3b387c4b2c4 100644 --- a/src-docs/src/store/index.js +++ b/src-docs/src/store/index.js @@ -5,3 +5,7 @@ export function getTheme(state) { export function getRoutes(state) { return state.routes; } + +export function getLocale(state) { + return state.locale.locale; +} \ No newline at end of file diff --git a/src-docs/src/store/reducers/locale_reducer.js b/src-docs/src/store/reducers/locale_reducer.js new file mode 100644 index 00000000000..6a2bf5a78c5 --- /dev/null +++ b/src-docs/src/store/reducers/locale_reducer.js @@ -0,0 +1,20 @@ +import ActionTypes from '../../actions/action_types'; + +const defaultState = { + locale: 'en', +}; + +export default function localeReducer(state = defaultState, action) { + switch (action.type) { + case ActionTypes.TOGGLE_LOCALE: { + return { + locale: action.data.locale, + }; + } + + default: + break; + } + + return state; +} diff --git a/src-docs/src/views/app_container.js b/src-docs/src/views/app_container.js index 5276db0615c..36db4253313 100644 --- a/src-docs/src/views/app_container.js +++ b/src-docs/src/views/app_container.js @@ -5,10 +5,12 @@ import { AppView } from './app_view'; import { getTheme, getRoutes, + getLocale, } from '../store'; import { toggleTheme, + toggleLocale, } from '../actions'; function mapStateToProps(state, ownProps) { @@ -16,6 +18,7 @@ function mapStateToProps(state, ownProps) { routes: ownProps.routes, currentRoute: ownProps.routes[1], theme: getTheme(state), + locale: getLocale(state), routes: getRoutes(state), }; } @@ -24,6 +27,7 @@ export const AppContainer = connect( mapStateToProps, { toggleTheme, + toggleLocale, }, )(AppView); diff --git a/src-docs/src/views/app_view.js b/src-docs/src/views/app_view.js index f7c245d71f3..5d44884d7a8 100644 --- a/src-docs/src/views/app_view.js +++ b/src-docs/src/views/app_view.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { applyTheme, + translateUsingPseudoLocale } from '../services'; import { @@ -13,6 +14,7 @@ import { EuiErrorBoundary, EuiPage, EuiPageBody, + EuiContext } from '../../../src/components'; import { keyCodes } from '../../../src/services'; @@ -46,11 +48,22 @@ export class AppView extends Component { currentRoute, toggleTheme, theme, + toggleLocale, + locale, routes, } = this.props; const { navigation } = routes; + const mappingFuncs = { + 'en-xa': translateUsingPseudoLocale + }; + + const i18n = { + mappingFunc: mappingFuncs[locale], + formatNumber: (value) => new Intl.NumberFormat(locale).format(value), + }; + return ( @@ -59,12 +72,16 @@ export class AppView extends Component { currentRouteName={currentRoute.name} onToggleTheme={toggleTheme} selectedTheme={theme} + onToggleLocale={toggleLocale} + selectedLocale={locale} navigation={navigation} />
- {children} + + {children} +
@@ -117,6 +134,8 @@ AppView.propTypes = { currentRoute: PropTypes.object.isRequired, theme: PropTypes.string.isRequired, toggleTheme: PropTypes.func.isRequired, + locale: PropTypes.string.isRequired, + toggleLocale: PropTypes.func.isRequired, routes: PropTypes.object.isRequired, }; diff --git a/src-docs/src/views/package/i18n_tokens.js b/src-docs/src/views/package/i18n_tokens.js new file mode 100644 index 00000000000..40f2c7ce201 --- /dev/null +++ b/src-docs/src/views/package/i18n_tokens.js @@ -0,0 +1,49 @@ +import React, { Fragment } from 'react'; +import tokens from '../../i18ntokens'; + +import { EuiCodeBlock, EuiInMemoryTable } from '../../../../src'; +import { GuidePage } from '../../components/guide_page'; + +const columns = [ + { name: 'Token', field: 'token' }, + { + name: 'Default', + render({ defString, highlighting }) { + return ( + {defString} + ); + } + }, + { + name: 'File', + render({ filepath, loc }) { + return ( + + {filepath}:{loc.start.line}:{loc.start.column} + + ); + }, + }, +]; + +const search = { + box: { + incremental: true, + schema: true + } +}; + +export const I18nTokens = { + name: 'I18n Tokens', + component: () => ( + + + + ), +}; diff --git a/src/components/context/context.tsx b/src/components/context/context.tsx index a8bc7e1247b..b2de3bd56af 100644 --- a/src/components/context/context.tsx +++ b/src/components/context/context.tsx @@ -10,6 +10,7 @@ export interface I18nShape { mapping?: { [key: string]: Renderable; }; + mappingFunc?: (value: string) => string; formatNumber?: (x: number) => string; formatDateTime?: (x: Date) => string; } diff --git a/src/components/i18n/__snapshots__/i18n.test.tsx.snap b/src/components/i18n/__snapshots__/i18n.test.tsx.snap index 2892971d76e..baf2a58d676 100644 --- a/src/components/i18n/__snapshots__/i18n.test.tsx.snap +++ b/src/components/i18n/__snapshots__/i18n.test.tsx.snap @@ -135,6 +135,28 @@ exports[`EuiI18n default rendering rendering to dom renders a string with placeh `; +exports[`EuiI18n reading values from context mappingFunc calls the mapping function with the source string 1`] = ` + + +
+ THIS IS THE BASIC STRING. +
+
+
+`; + exports[`EuiI18n reading values from context render prop with multiple tokens renders mapped render prop result to the dom 1`] = ` { expect(component).toMatchSnapshot(); }); }); + + describe('mappingFunc', () => { + it('calls the mapping function with the source string', () => { + const component = mount( + value.toUpperCase(), + }}> + + {(one: ReactChild) =>
{one}
} +
+
+ ); + expect(component).toMatchSnapshot(); + }); + }); }); }); diff --git a/src/components/i18n/i18n.tsx b/src/components/i18n/i18n.tsx index f89a6779d25..b085bddc79a 100644 --- a/src/components/i18n/i18n.tsx +++ b/src/components/i18n/i18n.tsx @@ -12,9 +12,11 @@ function lookupToken( token: string, i18nMapping: I18nShape['mapping'], valueDefault: Renderable, + i18nMappingFunc?: (token: string) => string, values?: I18nTokenShape['values'] ): ReactChild { - const renderable = (i18nMapping && i18nMapping[token]) || valueDefault; + let renderable = (i18nMapping && i18nMapping[token]) || valueDefault; + if (typeof renderable === 'function') { if (values === undefined) { return throwError(); @@ -22,10 +24,13 @@ function lookupToken( return renderable(values); } } else if (values === undefined || typeof renderable !== 'string') { + if (i18nMappingFunc && typeof valueDefault === 'string') { + renderable = i18nMappingFunc(valueDefault); + } return renderable; } - const children = processStringToChildren(renderable, values); + const children = processStringToChildren(renderable, values, i18nMappingFunc); if (typeof children === 'string') { return children; } @@ -61,11 +66,11 @@ function hasTokens(x: EuiI18nProps): x is I18nTokensShape { const EuiI18n = (props: EuiI18nProps) => ( {i18nConfig => { - const { mapping } = i18nConfig; + const { mapping, mappingFunc } = i18nConfig; if (hasTokens(props)) { return props.children( props.tokens.map((token, idx) => - lookupToken(token, mapping, props.defaults[idx]) + lookupToken(token, mapping, props.defaults[idx], mappingFunc) ) ); } @@ -74,6 +79,7 @@ const EuiI18n = (props: EuiI18nProps) => ( props.token, mapping, props.default, + mappingFunc, props.values ); if (props.children) { diff --git a/src/components/i18n/i18n_util.test.tsx b/src/components/i18n/i18n_util.test.tsx index 4cccba8e8c8..a8a21648153 100644 --- a/src/components/i18n/i18n_util.test.tsx +++ b/src/components/i18n/i18n_util.test.tsx @@ -31,5 +31,17 @@ describe('i18n_util', () => { expect(processStringToChildren(message, {})).toEqual(message); }); }); + + describe('i18nMappingFunction', () => { + it('calls the mapping function with the source string', () => { + expect( + processStringToChildren( + 'Hello, {name}', + { greeting: 'Hello', name: 'John' }, + value => value.toUpperCase() + ) + ).toEqual('HELLO, JOHN'); + }); + }); }); }); diff --git a/src/components/i18n/i18n_util.tsx b/src/components/i18n/i18n_util.tsx index 20405efe57c..f51b13afff4 100644 --- a/src/components/i18n/i18n_util.tsx +++ b/src/components/i18n/i18n_util.tsx @@ -22,11 +22,13 @@ function hasPropName(child: Child): child is { propName: string } { * e.g. input:'Hello, {name}' will replace `{name}` with `values[name]` * @param {string} input * @param {RenderableValues} values + * @param {Function} i18nMappingFunc * @returns {string | React.ReactChild[]} */ export function processStringToChildren( input: string, - values: RenderableValues + values: RenderableValues, + i18nMappingFunc?: (token: string) => string ): string | ReactChild[] { const children: ReactChild[] = []; @@ -56,6 +58,9 @@ export function processStringToChildren( // this won't be called, propName children are converted to a ReactChild before calling this } else { // everything else can go straight in + if (i18nMappingFunc !== undefined && typeof value === 'string') { + value = i18nMappingFunc(value); + } children.push(value); } }