diff --git a/config/kibana.yml b/config/kibana.yml index 6ae69153be2a1..be96fdc55187b 100644 --- a/config/kibana.yml +++ b/config/kibana.yml @@ -98,3 +98,7 @@ # Set the interval in milliseconds to sample system and process performance # metrics. Minimum is 100ms. Defaults to 5000. #ops.interval: 5000 + +# The default locale. This locale can be used in certain circumstances to substitute any missing +# translations. +#i18n.defaultLocale: "en" diff --git a/package.json b/package.json index 700574785ee4c..000b1530a1eb2 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "@spalger/test-subj-selector": "0.2.1", "@spalger/ui-ace": "0.2.3", "JSONStream": "1.1.1", + "accept-language-parser": "1.2.0", "angular": "1.4.7", "angular-bootstrap-colorpicker": "3.0.19", "angular-elastic": "2.5.0", diff --git a/src/core_plugins/kibana/index.js b/src/core_plugins/kibana/index.js index 5b6dc2de58ae9..ffb04b380feec 100644 --- a/src/core_plugins/kibana/index.js +++ b/src/core_plugins/kibana/index.js @@ -1,5 +1,8 @@ +import { resolve } from 'path'; + import Promise from 'bluebird'; import { mkdirp as mkdirpNode } from 'mkdirp'; + import manageUuid from './server/lib/manage_uuid'; import ingest from './server/routes/api/ingest'; import search from './server/routes/api/search'; @@ -13,6 +16,7 @@ module.exports = function (kibana) { const kbnBaseUrl = '/app/kibana'; return new kibana.Plugin({ id: 'kibana', + config: function (Joi) { return Joi.object({ enabled: Joi.boolean().default(true), @@ -100,12 +104,17 @@ module.exports = function (kibana) { linkToLastSubUrl: false }, ], + injectDefaultVars(server, options) { return { kbnIndex: options.index, kbnBaseUrl }; }, + + translations: [ + resolve(__dirname, './translations/en.json') + ] }, preInit: async function (server) { @@ -128,9 +137,7 @@ module.exports = function (kibana) { search(server); settings(server); scripts(server); - server.expose('systemApi', systemApi); } }); - }; diff --git a/src/core_plugins/kibana/translations/en.json b/src/core_plugins/kibana/translations/en.json new file mode 100644 index 0000000000000..ddb7a272e1129 --- /dev/null +++ b/src/core_plugins/kibana/translations/en.json @@ -0,0 +1,4 @@ +{ + "UI-WELCOME_MESSAGE": "Loading Kibana", + "UI-WELCOME_ERROR": "Kibana did not load properly. Check the server output for more information." +} diff --git a/src/server/config/schema.js b/src/server/config/schema.js index c0bae0769f54f..d3f640495edd0 100644 --- a/src/server/config/schema.js +++ b/src/server/config/schema.js @@ -178,4 +178,8 @@ module.exports = () => Joi.object({ enabled: Joi.boolean().default(true) }).default(), + i18n: Joi.object({ + defaultLocale: Joi.string().default('en'), + }).default(), + }).default(); diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/de.json b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/de.json new file mode 100644 index 0000000000000..ab9171f3a86cf --- /dev/null +++ b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/de.json @@ -0,0 +1,4 @@ +{ + "test_plugin_1-NO_SSL": "Dont run the DE dev server using HTTPS", + "test_plugin_1-DEV": "Run the DE server with development mode defaults" +} diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/en.json b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/en.json new file mode 100644 index 0000000000000..53dddcb859f70 --- /dev/null +++ b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/en.json @@ -0,0 +1,6 @@ +{ + "test_plugin_1-NO_SSL": "Dont run the dev server using HTTPS", + "test_plugin_1-DEV": "Run the server with development mode defaults", + "test_plugin_1-NO_RUN_SERVER": "Dont run the dev server", + "test_plugin_1-HOME": "Run along home now!" +} diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/es-ES.json b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/es-ES.json new file mode 100644 index 0000000000000..4a7ce753d9354 --- /dev/null +++ b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_1/es-ES.json @@ -0,0 +1,3 @@ +{ + "test_plugin_1-NO_SSL": "Dont run the es-ES dev server using HTTPS! I am regsitered afterwards!" +} diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/de.json b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/de.json new file mode 100644 index 0000000000000..d87b0a4f3a88c --- /dev/null +++ b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/de.json @@ -0,0 +1,3 @@ +{ + "test_plugin_1-NO_SSL": "Dont run the DE dev server using HTTPS! I am regsitered afterwards!" +} diff --git a/src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/en.json b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/en.json new file mode 100644 index 0000000000000..3e791c7d6e776 --- /dev/null +++ b/src/ui/i18n/__tests__/fixtures/translations/test_plugin_2/en.json @@ -0,0 +1,6 @@ +{ + "test_plugin_2-XXXXXX": "This is XXXXXX string", + "test_plugin_2-YYYY_PPPP": "This is YYYY_PPPP string", + "test_plugin_2-FFFFFFFFFFFF": "This is FFFFFFFFFFFF string", + "test_plugin_2-ZZZ": "This is ZZZ string" +} diff --git a/src/ui/i18n/__tests__/i18n.js b/src/ui/i18n/__tests__/i18n.js new file mode 100644 index 0000000000000..480b454cb5318 --- /dev/null +++ b/src/ui/i18n/__tests__/i18n.js @@ -0,0 +1,226 @@ +import expect from 'expect.js'; +import _ from 'lodash'; +import { join } from 'path'; + +import { I18n } from '../'; + +const FIXTURES = join(__dirname, 'fixtures'); + +describe('ui/i18n module', function () { + + describe('one plugin', function () { + + const i18nObj = new I18n(); + + before('registerTranslations - one plugin', function () { + const pluginName = 'test_plugin_1'; + const pluginTranslationPath = join(FIXTURES, 'translations', pluginName); + const translationFiles = [ + join(pluginTranslationPath, 'de.json'), + join(pluginTranslationPath, 'en.json') + ]; + const filesLen = translationFiles.length; + for (let indx = 0; indx < filesLen; indx++) { + i18nObj.registerTranslations(translationFiles[indx]); + } + }); + + describe('getTranslations', function () { + + it('should return the translations for en locale as registered' , function () { + const languageTag = ['en']; + const expectedTranslationJson = { + 'test_plugin_1-NO_SSL': 'Dont run the dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the server with development mode defaults', + 'test_plugin_1-NO_RUN_SERVER': 'Dont run the dev server', + 'test_plugin_1-HOME': 'Run along home now!' + }; + return checkTranslations(expectedTranslationJson, languageTag, i18nObj); + }); + + it('should return the translations for de locale as registered' , function () { + const languageTag = ['de']; + const expectedTranslationJson = { + 'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the DE server with development mode defaults' + }; + return checkTranslations(expectedTranslationJson, languageTag, i18nObj); + }); + + it('should pick the highest priority language for which translations exist' , function () { + const languageTags = ['es-ES', 'de', 'en']; + const expectedTranslations = { + 'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the DE server with development mode defaults', + }; + return checkTranslations(expectedTranslations, languageTags, i18nObj); + }); + + it('should return translations for highest priority locale where best case match is chosen from registered locales' , function () { + const languageTags = ['es', 'de']; + const expectedTranslations = { + 'test_plugin_1-NO_SSL': 'Dont run the es-ES dev server using HTTPS! I am regsitered afterwards!' + }; + i18nObj.registerTranslations(join(FIXTURES, 'translations', 'test_plugin_1','es-ES.json')); + return checkTranslations(expectedTranslations, languageTags, i18nObj); + }); + + it('should return an empty object for locales with no translations' , function () { + const languageTags = ['ja-JA', 'fr']; + return checkTranslations({}, languageTags, i18nObj); + }); + + }); + + describe('getTranslationsForDefaultLocale', function () { + + it('should return translations for default locale which is set to the en locale' , function () { + const i18nObj1 = new I18n('en'); + const expectedTranslations = { + 'test_plugin_1-NO_SSL': 'Dont run the dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the server with development mode defaults', + 'test_plugin_1-NO_RUN_SERVER': 'Dont run the dev server', + 'test_plugin_1-HOME': 'Run along home now!' + }; + i18nObj1.registerTranslations(join(FIXTURES, 'translations', 'test_plugin_1','en.json')); + return checkTranslationsForDefaultLocale(expectedTranslations, i18nObj1); + }); + + it('should return translations for default locale which is set to the de locale' , function () { + const i18nObj1 = new I18n('de'); + const expectedTranslations = { + 'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the DE server with development mode defaults', + }; + i18nObj1.registerTranslations(join(FIXTURES, 'translations', 'test_plugin_1','de.json')); + return checkTranslationsForDefaultLocale(expectedTranslations, i18nObj1); + }); + + }); + + describe('getAllTranslations', function () { + + it('should return all translations' , function () { + const expectedTranslations = { + de: { + 'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the DE server with development mode defaults' + }, + en: { + 'test_plugin_1-NO_SSL': 'Dont run the dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the server with development mode defaults', + 'test_plugin_1-NO_RUN_SERVER': 'Dont run the dev server', + 'test_plugin_1-HOME': 'Run along home now!' + }, + 'es-ES': { + 'test_plugin_1-NO_SSL': 'Dont run the es-ES dev server using HTTPS! I am regsitered afterwards!' + } + }; + return checkAllTranslations(expectedTranslations, i18nObj); + }); + + }); + + }); + + describe('multiple plugins', function () { + + const i18nObj = new I18n(); + + beforeEach('registerTranslations - multiple plugin', function () { + const pluginTranslationPath = join(FIXTURES, 'translations'); + const translationFiles = [ + join(pluginTranslationPath, 'test_plugin_1', 'de.json'), + join(pluginTranslationPath, 'test_plugin_1', 'en.json'), + join(pluginTranslationPath, 'test_plugin_2', 'en.json') + ]; + const filesLen = translationFiles.length; + for (let indx = 0; indx < filesLen; indx++) { + i18nObj.registerTranslations(translationFiles[indx]); + } + }); + + describe('getTranslations', function () { + + it('should return the translations for en locale as registered' , function () { + const languageTag = ['en']; + const expectedTranslationJson = { + 'test_plugin_1-NO_SSL': 'Dont run the dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the server with development mode defaults', + 'test_plugin_1-NO_RUN_SERVER': 'Dont run the dev server', + 'test_plugin_1-HOME': 'Run along home now!', + 'test_plugin_2-XXXXXX': 'This is XXXXXX string', + 'test_plugin_2-YYYY_PPPP': 'This is YYYY_PPPP string', + 'test_plugin_2-FFFFFFFFFFFF': 'This is FFFFFFFFFFFF string', + 'test_plugin_2-ZZZ': 'This is ZZZ string' + }; + return checkTranslations(expectedTranslationJson, languageTag, i18nObj); + }); + + it('should return the translations for de locale as registered' , function () { + const languageTag = ['de']; + const expectedTranslationJson = { + 'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS', + 'test_plugin_1-DEV': 'Run the DE server with development mode defaults' + }; + return checkTranslations(expectedTranslationJson, languageTag, i18nObj); + }); + + it('should return the most recently registered translation for a key that has multiple translations' , function () { + i18nObj.registerTranslations(join(FIXTURES, 'translations', 'test_plugin_2', 'de.json')); + const languageTag = ['de']; + const expectedTranslationJson = { + 'test_plugin_1-NO_SSL': 'Dont run the DE dev server using HTTPS! I am regsitered afterwards!', + 'test_plugin_1-DEV': 'Run the DE server with development mode defaults' + }; + return checkTranslations(expectedTranslationJson, languageTag, i18nObj); + }); + + }); + + }); + + describe('registerTranslations', function () { + + const i18nObj = new I18n(); + + it('should throw error when registering relative path', function () { + return expect(i18nObj.registerTranslations).withArgs('./some/path').to.throwError(); + }); + + it('should throw error when registering empty filename', function () { + return expect(i18nObj.registerTranslations).withArgs('').to.throwError(); + }); + + it('should throw error when registering filename with no extension', function () { + return expect(i18nObj.registerTranslations).withArgs('file1').to.throwError(); + }); + + it('should throw error when registering filename with non JSON extension', function () { + return expect(i18nObj.registerTranslations).withArgs('file1.txt').to.throwError(); + }); + + }); + +}); + +function checkTranslations(expectedTranslations, languageTags, i18nObj) { + return i18nObj.getTranslations(...languageTags) + .then(function (actualTranslations) { + expect(_.isEqual(actualTranslations, expectedTranslations)).to.be(true); + }); +} + +function checkAllTranslations(expectedTranslations, i18nObj) { + return i18nObj.getAllTranslations() + .then(function (actualTranslations) { + expect(_.isEqual(actualTranslations, expectedTranslations)).to.be(true); + }); +} + +function checkTranslationsForDefaultLocale(expectedTranslations, i18nObj) { + return i18nObj.getTranslationsForDefaultLocale() + .then(function (actualTranslations) { + expect(_.isEqual(actualTranslations, expectedTranslations)).to.be(true); + }); +} diff --git a/src/ui/i18n/i18n.js b/src/ui/i18n/i18n.js new file mode 100644 index 0000000000000..b3e5d804dcba4 --- /dev/null +++ b/src/ui/i18n/i18n.js @@ -0,0 +1,136 @@ +import path from 'path'; +import Promise from 'bluebird'; +import { readFile } from 'fs'; +import _ from 'lodash'; + +const asyncReadFile = Promise.promisify(readFile); + +const TRANSLATION_FILE_EXTENSION = '.json'; + +function getLocaleFromFileName(fullFileName) { + if (_.isEmpty(fullFileName)) throw new Error('Filename empty'); + + const fileExt = path.extname(fullFileName); + if (fileExt.length <= 0 || fileExt !== TRANSLATION_FILE_EXTENSION) { + throw new Error('Translations must be in a JSON file. File being registered is ' + fullFileName); + } + + return path.basename(fullFileName, TRANSLATION_FILE_EXTENSION); +} + +function getBestLocaleMatch(languageTag, registeredLocales) { + if (_.contains(registeredLocales, languageTag)) { + return languageTag; + } + + // Find the first registered locale that begins with one of the language codes from the provided language tag. + // For example, if there is an 'en' language code, it would match an 'en-US' registered locale. + const languageCode = _.first(languageTag.split('-')) || []; + return _.find(registeredLocales, (locale) => _.startsWith(locale, languageCode)); +} + +export class I18n { + + _registeredTranslations = {}; + + constructor(defaultLocale = 'en') { + this._defaultLocale = defaultLocale; + } + + /** + * Return all translations for registered locales + * @return {Promise} translations - A Promise object where keys are + * the locale and values are Objects + * of translation keys and translations + */ + getAllTranslations() { + const localeTranslations = {}; + + const locales = this._getRegisteredTranslationLocales(); + const translations = _.map(locales, (locale) => { + return this._getTranslationsForLocale(locale) + .then(function (translations) { + localeTranslations[locale] = translations; + }); + }); + + return Promise.all(translations) + .then(translations => _.assign({}, localeTranslations)); + } + + /** + * Return translations for a suitable locale from a user side locale list + * @param {...string} languageTags - BCP 47 language tags. The tags are listed in priority order as set in the Accept-Language header. + * @returns {Promise} translations - promise for an object where + * keys are translation keys and + * values are translations + * This object will contain all registered translations for the highest priority locale which is registered with the i18n module. + * This object can be empty if no locale in the language tags can be matched against the registered locales. + */ + getTranslations(...languageTags) { + const locale = this._getTranslationLocale(languageTags); + return this._getTranslationsForLocale(locale); + } + + /** + * Return all translations registered for the default locale. + * @returns {Promise} translations - promise for an object where + * keys are translation keys and + * values are translations + */ + getTranslationsForDefaultLocale() { + return this._getTranslationsForLocale(this._defaultLocale); + } + + /** + * The translation file is registered with i18n plugin. The plugin contains a list of registered translation file paths per language. + * @param {String} absolutePluginTranslationFilePath - Absolute path to the translation file to register. + */ + registerTranslations(absolutePluginTranslationFilePath) { + if (!path.isAbsolute(absolutePluginTranslationFilePath)) { + throw new TypeError( + 'Paths to translation files must be absolute. ' + + `Got relative path: "${absolutePluginTranslationFilePath}"` + ); + } + + const locale = getLocaleFromFileName(absolutePluginTranslationFilePath); + + this._registeredTranslations[locale] = + _.uniq(_.get(this._registeredTranslations, locale, []).concat(absolutePluginTranslationFilePath)); + } + + _getRegisteredTranslationLocales() { + return Object.keys(this._registeredTranslations); + } + + _getTranslationLocale(languageTags) { + let locale = ''; + const registeredLocales = this._getRegisteredTranslationLocales(); + _.forEach(languageTags, (tag) => { + locale = locale || getBestLocaleMatch(tag, registeredLocales); + }); + return locale; + } + + _getTranslationsForLocale(locale) { + if (!this._registeredTranslations.hasOwnProperty(locale)) { + return Promise.resolve({}); + } + + const translationFiles = this._registeredTranslations[locale]; + const translations = _.map(translationFiles, (filename) => { + return asyncReadFile(filename, 'utf8') + .then(fileContents => JSON.parse(fileContents)) + .catch(SyntaxError, function (e) { + throw new Error('Invalid json in ' + filename); + }) + .catch(function (e) { + throw new Error('Cannot read file ' + filename); + }); + }); + + return Promise.all(translations) + .then(translations => _.assign({}, ...translations)); + } +} diff --git a/src/ui/i18n/index.js b/src/ui/i18n/index.js new file mode 100644 index 0000000000000..4738e20b4facf --- /dev/null +++ b/src/ui/i18n/index.js @@ -0,0 +1 @@ +export { I18n } from './i18n'; diff --git a/src/ui/index.js b/src/ui/index.js index c50fdb9425e79..568ba449e9a39 100644 --- a/src/ui/index.js +++ b/src/ui/index.js @@ -1,21 +1,23 @@ -import { format as formatUrl } from 'url'; -import { readFileSync as readFile } from 'fs'; -import { defaults } from 'lodash'; +import { defaults, _ } from 'lodash'; import { props } from 'bluebird'; import Boom from 'boom'; import { reduce as reduceAsync } from 'bluebird'; import { resolve } from 'path'; -import fromRoot from '../utils/from_root'; + import UiExports from './ui_exports'; import UiBundle from './ui_bundle'; import UiBundleCollection from './ui_bundle_collection'; import UiBundlerEnv from './ui_bundler_env'; +import { UiI18n } from './ui_i18n'; export default async (kbnServer, server, config) => { const uiExports = kbnServer.uiExports = new UiExports({ urlBasePath: config.get('server.basePath') }); + const uiI18n = kbnServer.uiI18n = new UiI18n(config.get('i18n.defaultLocale')); + uiI18n.addUiExportConsumer(uiExports); + const bundlerEnv = new UiBundlerEnv(config.get('optimize.bundleDir')); bundlerEnv.addContext('env', config.get('env.name')); bundlerEnv.addContext('urlBasePath', config.get('server.basePath')); @@ -88,14 +90,18 @@ export default async (kbnServer, server, config) => { async function renderApp({ app, reply, includeUserProvidedConfig = true }) { try { + const request = reply.request; + const translations = await uiI18n.getTranslationsForRequest(request); + return reply.view(app.templateName, { app, kibanaPayload: await getKibanaPayload({ app, - request: reply.request, + request, includeUserProvidedConfig }), bundlePath: `${config.get('server.basePath')}/bundles`, + i18n: key => _.get(translations, key, ''), }); } catch (err) { reply(err); diff --git a/src/ui/translations/en.json b/src/ui/translations/en.json new file mode 100644 index 0000000000000..ac491cf6f3465 --- /dev/null +++ b/src/ui/translations/en.json @@ -0,0 +1,4 @@ +{ + "UI-WELCOME_MESSAGE": "Loading", + "UI-WELCOME_ERROR": "" +} diff --git a/src/ui/ui_exports.js b/src/ui/ui_exports.js index 6479141f14682..7ec3bc965a01f 100644 --- a/src/ui/ui_exports.js +++ b/src/ui/ui_exports.js @@ -41,6 +41,16 @@ class UiExports { this.consumers.push(consumer); } + addConsumerForType(typeToConsume, consumer) { + this.consumers.push({ + exportConsumer(uiExportType) { + if (uiExportType === typeToConsume) { + return consumer; + } + } + }); + } + exportConsumer(type) { for (const consumer of this.consumers) { if (!consumer.exportConsumer) continue; diff --git a/src/ui/ui_i18n.js b/src/ui/ui_i18n.js new file mode 100644 index 0000000000000..ebb062bd25bcb --- /dev/null +++ b/src/ui/ui_i18n.js @@ -0,0 +1,66 @@ +import { resolve } from 'path'; + +import { defaults, compact } from 'lodash'; +import langParser from 'accept-language-parser'; + +import { I18n } from './i18n'; + +function acceptLanguageHeaderToBCP47Tags(header) { + return langParser.parse(header).map(lang => ( + compact([lang.code, lang.region, lang.script]).join('-') + )); +} + +export class UiI18n { + constructor(defaultLocale = 'en') { + this._i18n = new I18n(defaultLocale); + this._i18n.registerTranslations(resolve(__dirname, './translations/en.json')); + } + + /** + * Fetch the language translations as defined by the request. + * + * @param {Hapi.Request} request + * @returns {Promise} translations promise for an object where + * keys are translation keys and + * values are translations + */ + async getTranslationsForRequest(request) { + const header = request.headers['accept-language']; + const tags = acceptLanguageHeaderToBCP47Tags(header); + const requestedTranslations = await this._i18n.getTranslations(...tags); + const defaultTranslations = await this._i18n.getTranslationsForDefaultLocale(); + return defaults({}, requestedTranslations, defaultTranslations); + } + + /** + * uiExport consumers help the uiExports module know what to + * do with the uiExports defined by each plugin. + * + * This consumer will allow plugins to define export with the + * "language" type like so: + * + * new kibana.Plugin({ + * uiExports: { + * languages: [ + * resolve(__dirname, './translations/es.json'), + * ], + * }, + * }); + * + */ + addUiExportConsumer(uiExports) { + uiExports.addConsumerForType('translations', (plugin, translations) => { + translations.forEach(path => { + this._i18n.registerTranslations(path); + }); + }); + } + + /** + Refer to I18n.getAllTranslations() + */ + getAllTranslations() { + return this._i18n.getAllTranslations(); + } +} diff --git a/src/ui/views/ui_app.jade b/src/ui/views/ui_app.jade index fae9982f1c22f..f247dc0e199d4 100644 --- a/src/ui/views/ui_app.jade +++ b/src/ui/views/ui_app.jade @@ -93,7 +93,7 @@ block content .kibanaLoader__logo .kibanaWelcomeLogo .kibanaLoader__content - | Loading Kibana + | #{i18n('UI-WELCOME_MESSAGE')} script. window.onload = function () { @@ -125,7 +125,7 @@ block content err.style['text-align'] = 'center'; err.style['background'] = '#F44336'; err.style['padding'] = '25px'; - err.innerText = 'Kibana did not load properly. Check the server output for more information.'; + err.innerText = '#{i18n('UI-WELCOME_ERROR')}'; document.body.innerHTML = err.outerHTML; } diff --git a/tasks/build/index.js b/tasks/build/index.js index 52610e06dd1c9..cb5cbe08b95ca 100644 --- a/tasks/build/index.js +++ b/tasks/build/index.js @@ -12,6 +12,7 @@ module.exports = function (grunt) { '_build:babelOptions', '_build:plugins', '_build:data', + '_build:verifyTranslations', '_build:packageJson', '_build:readme', '_build:babelCache', diff --git a/tasks/build/verify_translations.js b/tasks/build/verify_translations.js new file mode 100644 index 0000000000000..356118fe9133c --- /dev/null +++ b/tasks/build/verify_translations.js @@ -0,0 +1,62 @@ +import Promise from 'bluebird'; +import _ from 'lodash'; + +import fromRoot from '../../src/utils/from_root'; +import KbnServer from '../../src/server/kbn_server'; +import * as i18nVerify from '../utils/i18n_verify_keys'; + +module.exports = function (grunt) { + grunt.registerTask('_build:verifyTranslations', function () { + const done = this.async(); + const parsePaths = [fromRoot('/src/ui/views/*.jade')]; + + const serverConfig = { + env: 'production', + logging: { + silent: true, + quiet: true, + verbose: false + }, + optimize: { + useBundleCache: false, + enabled: false + }, + server: { + autoListen: false + }, + plugins: { + initialize: true, + scanDirs: [fromRoot('src/core_plugins')] + }, + uiSettings: { + enabled: false + } + }; + + const kbnServer = new KbnServer(serverConfig); + kbnServer.ready() + .then(() => verifyTranslations(kbnServer.uiI18n, parsePaths)) + .then(() => kbnServer.close()) + .then(done) + .catch((err) => { + kbnServer.close() + .then(() => done(err)); + }); + }); +}; + +function verifyTranslations(uiI18nObj, parsePaths) +{ + return uiI18nObj.getAllTranslations() + .then(function (translations) { + return i18nVerify.getTranslationKeys(parsePaths) + .then(function (translationKeys) { + const keysNotTranslatedPerLocale = i18nVerify.getNonTranslatedKeys(translationKeys, translations); + if (!_.isEmpty(keysNotTranslatedPerLocale)) { + const str = JSON.stringify(keysNotTranslatedPerLocale); + const errMsg = 'The following translation keys per locale are not translated: ' + str; + throw new Error(errMsg); + } + }); + }); +} diff --git a/tasks/jenkins.js b/tasks/jenkins.js index 800467dc4b9a5..13820800a6742 100644 --- a/tasks/jenkins.js +++ b/tasks/jenkins.js @@ -30,6 +30,7 @@ module.exports = function (grunt) { 'test:server', 'test:browser-ci', 'test:api', + '_build:verifyTranslations', ]); grunt.registerTask('jenkins:selenium', [ diff --git a/tasks/utils/i18n_verify_keys.js b/tasks/utils/i18n_verify_keys.js new file mode 100644 index 0000000000000..9e431ab578c66 --- /dev/null +++ b/tasks/utils/i18n_verify_keys.js @@ -0,0 +1,81 @@ +import fs from 'fs'; +import glob from 'glob'; +import path from 'path'; +import Promise from 'bluebird'; +import _ from 'lodash'; + +const readFile = Promise.promisify(fs.readFile); +const globProm = Promise.promisify(glob); + +/** + * Return all the translation keys found for the file pattern + * @param {Array} filesPatterns - List of file patterns to be checkd for translation keys + * @param {Array} translations - List of translations keys + * @return {Promise} - A Promise object which will return a String Array of the translation keys + * not translated then the Object will contain all non translated translation keys with value of file the key is from + */ +export function getTranslationKeys(filesPatterns) { + return getFilesToVerify(filesPatterns) + .then(function (filesToVerify) { + return getKeys(filesToVerify); + }); +} + +/** + * Return translation keys that are not translated + * @param {Array} translationKeys - List of translation keys to be checked if translated + * @param {Object} localeTranslations - Object of locales and their translations + * @return {Object} - A object which will be empty if all translation keys are translated. If translation keys are + * not translated then the Object will contain all non translated translation keys per localem + */ +export function getNonTranslatedKeys(translationKeys, localeTranslations) { + const keysNotTranslatedPerLocale = {}; + _.forEach(localeTranslations, (translations, locale) => { + const keysNotTranslated = _.difference(translationKeys, Object.keys(translations)); + if (!_.isEmpty(keysNotTranslated)) { + keysNotTranslatedPerLocale[locale] = keysNotTranslated; + } + }); + return keysNotTranslatedPerLocale; +} + +function getFilesToVerify(verifyFilesPatterns) { + const filesToVerify = []; + + return Promise.map(verifyFilesPatterns, (verifyFilesPattern) => { + const baseSearchDir = path.dirname(verifyFilesPattern); + const pattern = path.basename(verifyFilesPattern); + return globProm(pattern, { cwd: baseSearchDir, matchBase: true }) + .then(function (files) { + for (const file of files) { + filesToVerify.push(path.join(baseSearchDir, file)); + } + }); + }) + .then(function () { + return filesToVerify; + }); +} + +function getKeys(filesToVerify) { + const translationKeys = []; + const translationPattern = 'i18n\\(\'(.*)\'\\)'; + const translationRegEx = new RegExp(translationPattern, 'g'); + + const filePromises = _.map(filesToVerify, (file) => { + return readFile(file, 'utf8') + .then(function (fileContents) { + let regexMatch; + while ((regexMatch = translationRegEx.exec(fileContents)) !== null) { + if (regexMatch.length >= 2) { + const translationKey = regexMatch[1]; + translationKeys.push(translationKey); + } + } + }); + }); + return Promise.all(filePromises) + .then(function () { + return _.uniq(translationKeys); + }); +}