diff --git a/bower.json b/bower.json index 7d709c40867e..5aaf47017080 100644 --- a/bower.json +++ b/bower.json @@ -19,6 +19,9 @@ }, "roboto-fontface": { "main": "css/roboto-fontface.css" + }, + "google-closure-library": { + "main": ["closure/goog/base.js", "closure/goog/deps.js"] } }, "devDependencies": { diff --git a/build/build.js b/build/build.js index afdb8a86fb87..fd162adc0566 100644 --- a/build/build.js +++ b/build/build.js @@ -22,10 +22,10 @@ import gulpHtmlmin from 'gulp-htmlmin'; import gulpUglify from 'gulp-uglify'; import gulpIf from 'gulp-if'; import gulpUseref from 'gulp-useref'; -import gulpRev from 'gulp-rev'; -import gulpRevReplace from 'gulp-rev-replace'; -import uglifySaveLicense from 'uglify-save-license'; +import GulpRevAll from 'gulp-rev-all'; +import mergeStream from 'merge-stream'; import path from 'path'; +import uglifySaveLicense from 'uglify-save-license'; import conf from './conf'; import {multiDest} from './multidest'; @@ -41,19 +41,67 @@ gulp.task('build', ['backend:prod', 'build-frontend']); gulp.task('build:cross', ['backend:prod:cross', 'build-frontend:cross']); /** - * Builds production version of the frontend application for the current architecture. + * Builds production version of the frontend application for the default architecture. + */ +gulp.task( + 'build-frontend', ['localize', 'locales-for-backend'], function() { return doRevision(); }); + +/** + * Builds production version of the frontend application for all supported architectures. + */ +gulp.task('build-frontend:cross', ['localize:cross', 'locales-for-backend:cross'], function() { + return doRevision(); +}); + +/** + * Localizes all pre-created frontend copies for the default arch, so that they are ready to serve. + */ +gulp.task('localize', ['frontend-copies'], function() { + return localize([path.join(conf.paths.distPre, conf.arch.default, 'public')]); +}); + +/** + * Localizes all pre-created frontend copies in all cross-arch directories, so that they are ready + * to serve. + */ +gulp.task('localize:cross', ['frontend-copies:cross'], function() { + return localize(conf.arch.list.map((arch) => path.join(conf.paths.distPre, arch, 'public'))); +}); + +/** + * Copies the locales configuration to the default arch directory. + * This configuration file is then used by the backend to localize dashboard. + */ +gulp.task('locales-for-backend', ['clean-dist'], function() { + return localesForBackend([conf.paths.dist]); +}); + +/** + * Copies the locales configuration to each arch directory. + * This configuration file is then used by the backend to localize dashboard. + */ +gulp.task('locales-for-backend:cross', ['clean-dist'], function() { + return localesForBackend(conf.paths.distCross); +}); + +/** + * Builds production version of the frontend application for the default architecture + * (one copy per locale) and plcaes it under .tmp/dist , preparing it for localization and revision. */ -gulp.task('build-frontend', ['fonts', 'icons', 'assets', 'index:prod', 'clean-dist'], function() { - return buildFrontend(conf.paths.distPublic); +gulp.task('frontend-copies', ['fonts', 'icons', 'assets', 'index:prod', 'clean-dist'], function() { + return createFrontendCopies([path.join(conf.paths.distPre, conf.arch.default, 'public')]); }); /** - * Builds production version of the frontend application for all architecures. + * Builds production versions of the frontend application for all architecures + * (one copy per locale) and places them under .tmp, preparing them for localization and revision. */ gulp.task( - 'build-frontend:cross', - ['fonts:cross', 'icons:cross', 'assets:cross', 'index:prod', 'clean-dist'], - function() { return buildFrontend(conf.paths.distPublicCross); }); + 'frontend-copies:cross', + ['fonts:cross', 'icons:cross', 'assets:cross', 'index:prod', 'clean-dist'], function() { + return createFrontendCopies( + conf.arch.list.map((arch) => path.join(conf.paths.distPre, arch, 'public'))); + }); /** * Copies assets to the dist directory for current architecture. @@ -69,24 +117,22 @@ gulp.task( /** * Copies icons to the dist directory for current architecture. */ -gulp.task('icons', ['clean-dist'], function() { return icons(conf.paths.iconsDistPublic); }); +gulp.task('icons', ['clean-dist'], function() { return icons([conf.paths.distPublic]); }); /** * Copies icons to the dist directory for all architectures. */ -gulp.task( - 'icons:cross', ['clean-dist'], function() { return icons(conf.paths.iconsDistPublicCross); }); +gulp.task('icons:cross', ['clean-dist'], function() { return icons(conf.paths.distPublicCross); }); /** * Copies fonts to the dist directory for current architecture. */ -gulp.task('fonts', ['clean-dist'], function() { return fonts(conf.paths.fontsDistPublic); }); +gulp.task('fonts', ['clean-dist'], function() { return fonts([conf.paths.distPublic]); }); /** * Copies fonts to the dist directory for all architectures. */ -gulp.task( - 'fonts:cross', ['clean-dist'], function() { return fonts(conf.paths.fontsDistPublicCross); }); +gulp.task('fonts:cross', ['clean-dist'], function() { return fonts(conf.paths.distPublicCross); }); /** * Cleans all build artifacts. @@ -98,20 +144,27 @@ gulp.task('clean', ['clean-dist'], function() { /** * Cleans all build artifacts in the dist/ folder. */ -gulp.task('clean-dist', function() { return del([conf.paths.distRoot]); }); +gulp.task('clean-dist', function() { return del([conf.paths.distRoot, conf.paths.distPre]); }); /** - * Builds production version of the frontend application. + * Builds production version of the frontend application and copies it to all + * the specified outputDirs, creating one copy per (outputDir x locale) tuple. * * Following steps are done here: * 1. Vendor CSS and JS files are concatenated and minified. * 2. index.html is minified. - * 3. CSS and JS assets are suffixed with version hash. - * 4. Everything is saved in the dist directory. - * @param {string|!Array} outputDirs + * 3. Everything is saved in the .tmp/dist directory, ready to be localized and revisioned. + * + * @param {!Array} outputDirs * @return {stream} */ -function buildFrontend(outputDirs) { +function createFrontendCopies(outputDirs) { + // create an output for each locale + let localizedOutputDirs = outputDirs.reduce((localizedDirs, outputDir) => { + return localizedDirs.concat( + conf.translations.map((translation) => { return path.join(outputDir, translation.key); })); + }, []); + let searchPath = [ // To resolve local paths. path.relative(conf.paths.base, conf.paths.prodTmp), @@ -123,44 +176,109 @@ function buildFrontend(outputDirs) { .pipe(gulpUseref({searchPath: searchPath})) .pipe(gulpIf('**/vendor.css', gulpMinifyCss())) .pipe(gulpIf('**/vendor.js', gulpUglify({preserveComments: uglifySaveLicense}))) - .pipe(gulpIf(['**/*.js', '**/*.css'], gulpRev())) - .pipe(gulpUseref({searchPath: searchPath})) - .pipe(gulpRevReplace()) .pipe(gulpIf('*.html', gulpHtmlmin({ removeComments: true, collapseWhitespace: true, conservativeCollapse: true, }))) - .pipe(multiDest(outputDirs)); + .pipe(multiDest(localizedOutputDirs)); } /** - * @param {string|!Array} outputDirs + * Creates revisions of all .js anc .css files at once (for production). + * Replaces the occurances of those files in index.html with their new names. + * index.html does not get renamed in the process. + * The processed files are then moved to the dist directory. + * @return {stream} + */ +function doRevision() { + // Do not update references other than in index.html. Do not rev index.html itself. + let revAll = + new GulpRevAll({dontRenameFile: ['index.html'], dontSearchFile: [/^(?!.*index\.html$).*$/]}); + return gulp.src([path.join(conf.paths.distPre, '**'), '!**/assets/**/*']) + .pipe(revAll.revision()) + .pipe(gulp.dest(conf.paths.distRoot)); +} + +/** + * Copies the localized app.js files for each supported language in outputDir//static + * for each of the specified output dirs. + * @param {!Array} outputDirs - list of all arch directories + * @return {stream} + */ +function localize(outputDirs) { + let streams = conf.translations.map((translation) => { + let localizedOutputDirs = + outputDirs.map((outputDir) => { return path.join(outputDir, translation.key, 'static'); }); + return gulp.src(path.join(conf.paths.i18nProd, translation.key, '*.js')) + .pipe(multiDest(localizedOutputDirs)); + }); + + return mergeStream.apply(null, streams); +} + +/** + * Copies the locales configuration file at the base of each arch directory, next to + * all of the localized subdirs. This file is meant to be used by the backend binary + * to compare against and determine the right locale to serve at runtime. + * @param {!Array} outputDirs - list of all arch directories + * @return {stream} + */ +function localesForBackend(outputDirs) { + return gulp.src(path.join(conf.paths.base, 'i18n', '*.json')).pipe(multiDest(outputDirs)); +} + +/** + * Copies the assets files to all dist directories per arch and locale. + * @param {!Array} outputDirs * @return {stream} */ function assets(outputDirs) { + let localizedOutputDirs = createLocalizedOutputs(outputDirs); return gulp.src(path.join(conf.paths.assets, '/**/*'), {base: conf.paths.app}) - .pipe(multiDest(outputDirs)); + .pipe(multiDest(localizedOutputDirs)); } /** - * @param {string|!Array} outputDirs + * Copies the icons files to all dist directories per arch and locale. + * @param {!Array} outputDirs * @return {stream} */ function icons(outputDirs) { + let localizedOutputDirs = createLocalizedOutputs(outputDirs, 'static'); return gulp .src( path.join(conf.paths.materialIcons, '/**/*.+(woff2|woff|eot|ttf)'), {base: conf.paths.materialIcons}) - .pipe(multiDest(outputDirs)); + .pipe(multiDest(localizedOutputDirs)); } /** - * @param {string|!Array} outputDirs + * Copies the font files to all dist directories per arch and locale. + * @param {!Array} outputDirs * @return {stream} */ function fonts(outputDirs) { + let localizedOutputDirs = createLocalizedOutputs(outputDirs, 'fonts'); return gulp .src(path.join(conf.paths.robotoFonts, '/**/*.+(woff2)'), {base: conf.paths.robotoFonts}) - .pipe(multiDest(outputDirs)); + .pipe(multiDest(localizedOutputDirs)); +} + +/** + * Returns one subdirectory path for each supported locale inside all of the specified + * outputDirs. Optionally, a subdirectory structure can be passed to append after each locale path. + * @param {!Array} outputDirs + * @param {undefined|string} opt_subdir - an optional sub directory inside each locale directory. + * @return {!Array} localized output directories + */ +function createLocalizedOutputs(outputDirs, opt_subdir) { + return outputDirs.reduce((localizedDirs, outputDir) => { + return localizedDirs.concat(conf.translations.map((translation) => { + if (opt_subdir) { + return path.join(outputDir, translation.key, opt_subdir); + } + return path.join(outputDir, translation.key); + })); + }, []); } diff --git a/build/conf.js b/build/conf.js index ab38e155e666..a5e5bf433f9c 100644 --- a/build/conf.js +++ b/build/conf.js @@ -17,6 +17,11 @@ */ import path from 'path'; +/** + * Load the i18n and l10n configuration. Used when dashboard is built in production. + */ +let localization = require('../i18n/locale_conf.json'); + /** * Base path for all other paths. */ @@ -154,6 +159,13 @@ export default { !!process.env.TRAVIS && process.env.TRAVIS_PULL_REQUEST === 'false', }, + /** + * Configuration for i18n & l10n. + */ + translations: localization.translations.map((translation) => { + return {path: path.join(basePath, 'i18n', translation.file), key: translation.key}; + }), + /** * Absolute paths to known directories, e.g., to source directory. */ @@ -172,21 +184,17 @@ export default { deploySrc: path.join(basePath, 'src/deploy'), dist: path.join(basePath, 'dist', arch.default), distCross: arch.list.map((arch) => path.join(basePath, 'dist', arch)), + distPre: path.join(basePath, '.tmp/dist'), distPublic: path.join(basePath, 'dist', arch.default, 'public'), distPublicCross: arch.list.map((arch) => path.join(basePath, 'dist', arch, 'public')), distRoot: path.join(basePath, 'dist'), externs: path.join(basePath, 'src/app/externs'), - fontsDistPublic: path.join(basePath, 'dist', arch.default, 'public/fonts'), - fontsDistPublicCross: - arch.list.map((arch) => path.join(basePath, 'dist', arch, 'public/fonts')), frontendSrc: path.join(basePath, 'src/app/frontend'), frontendTest: path.join(basePath, 'src/test/frontend'), goTools: path.join(basePath, '.tools/go'), goWorkspace: path.join(basePath, '.go_workspace'), hyperkube: path.join(basePath, 'build/hyperkube.sh'), - iconsDistPublic: path.join(basePath, 'dist', arch.default, 'public/static'), - iconsDistPublicCross: - arch.list.map((arch) => path.join(basePath, 'dist', arch, 'public/static')), + i18nProd: path.join(basePath, '.tmp/i18n'), integrationTest: path.join(basePath, 'src/test/integration'), karmaConf: path.join(basePath, 'build/karma.conf.js'), materialIcons: path.join(basePath, 'bower_components/material-design-icons/iconfont'), @@ -198,5 +206,6 @@ export default { serve: path.join(basePath, '.tmp/serve'), src: path.join(basePath, 'src'), tmp: path.join(basePath, '.tmp'), + xtbgenerator: path.join(basePath, '.tools/xtbgenerator/bin/XtbGenerator.jar'), }, }; diff --git a/build/i18n.js b/build/i18n.js new file mode 100644 index 000000000000..5cb544e04106 --- /dev/null +++ b/build/i18n.js @@ -0,0 +1,63 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * @fileoverview Gulp tasks for the extraction of translatable messages. + */ +import childProcess from 'child_process'; +import fileExists from 'file-exists'; +import gulp from 'gulp'; +import gulpUtil from 'gulp-util'; +import path from 'path'; +import q from 'q'; + +import conf from './conf'; + +/** + * Extracts the translatable text messages for the given language key from the pre-compiled + * files under conf.paths.serve. + * @param {string} langKey - the locale key + * @return {!Promise} A promise object. + */ +function extractForLanguage(langKey) { + let deferred = q.defer(); + + let translationBundle = path.join(conf.paths.base, `i18n/messages-${langKey}.xtb`); + let codeSource = path.join(conf.paths.serve, '*.js'); + let command = `java -jar ${conf.paths.xtbgenerator} --lang cs` + + ` --xtb_output_file ${translationBundle}` + ` --js ${codeSource}`; + if (fileExists(translationBundle)) { + command = `${command} --translations_file ${translationBundle}`; + } + + childProcess.exec(command, function(err, stdout, stderr) { + if (err) { + gulpUtil.log(stdout); + gulpUtil.log(stderr); + deferred.reject(); + deferred.reject(new Error(err)); + } + return deferred.resolve(); + }); + + return deferred.promise; +} + +/** + * Extracts all translation messages into XTB bundles. + */ +gulp.task('extract-translations', ['scripts'], function() { + let promises = conf.translations.map((translation) => extractForLanguage(translation.key)); + return q.all(promises); +}); diff --git a/build/index.js b/build/index.js index ce8c7856072d..aaa9f7458330 100644 --- a/build/index.js +++ b/build/index.js @@ -27,9 +27,10 @@ import conf from './conf'; * Creates index file in the given directory with dependencies injected from that directory. * * @param {string} indexPath + * @param {boolean} dev - development or production build * @return {!stream.Stream} */ -function createIndexFile(indexPath) { +function createIndexFile(indexPath, dev) { let injectStyles = gulp.src(path.join(indexPath, '**/*.css'), {read: false}); let injectScripts = gulp.src(path.join(indexPath, '**/*.js'), {read: false}); @@ -46,22 +47,27 @@ function createIndexFile(indexPath) { ignorePath: path.relative(conf.paths.frontendSrc, conf.paths.base), }; + if (dev) { + wiredepOptions.devDependencies = true; + } + return gulp.src(path.join(conf.paths.frontendSrc, 'index.html')) .pipe(gulpInject(injectStyles, injectOptions)) .pipe(gulpInject(injectScripts, injectOptions)) .pipe(wiredep.stream(wiredepOptions)) - .pipe(gulp.dest(indexPath)) - .pipe(browserSync.stream()); + .pipe(gulp.dest(indexPath)); } /** * Creates frontend application index file with development dependencies injected. */ -gulp.task('index', ['scripts', 'styles'], function() { return createIndexFile(conf.paths.serve); }); +gulp.task('index', ['scripts', 'styles'], function() { + return createIndexFile(conf.paths.serve, true).pipe(browserSync.stream()); +}); /** * Creates frontend application index file with production dependencies injected. */ gulp.task('index:prod', ['scripts:prod', 'styles:prod'], function() { - return createIndexFile(conf.paths.prodTmp); + return createIndexFile(conf.paths.prodTmp, false); }); diff --git a/build/karma.conf.js b/build/karma.conf.js index 111c9889a988..ae5325721d9e 100644 --- a/build/karma.conf.js +++ b/build/karma.conf.js @@ -40,6 +40,12 @@ function getFileList() { path.join(conf.paths.frontendTest, '**/*.js'), path.join(conf.paths.frontendSrc, '**/*.js'), path.join(conf.paths.frontendSrc, '**/*.html'), + path.join(conf.paths.bowerComponents, 'google-closure-library/closure/goog/base.js'), + { + pattern: path.join(conf.paths.bowerComponents, 'google-closure-library/closure/goog/deps.js'), + included: false, + served: false, + }, ]); } @@ -60,7 +66,7 @@ module.exports = function(config) { // This allows to get elements by selector(angular.element('body')), use find function to // search elements by class(element.find(class)) and the most important it allows to // directly test DOM changes on elements, f.e. changes of element width/height. - frameworks: ['jasmine-jquery', 'jasmine', 'browserify'], + frameworks: ['jasmine-jquery', 'jasmine', 'browserify', 'closure'], browserNoActivityTimeout: 5 * 60 * 1000, // 5 minutes. @@ -78,6 +84,7 @@ module.exports = function(config) { plugins: [ 'karma-chrome-launcher', + 'karma-closure', 'karma-jasmine', 'karma-jasmine-jquery', 'karma-coverage', @@ -148,8 +155,13 @@ module.exports = function(config) { } // Convert all JS code written ES6 with modules to ES5 bundles that browsers can digest. - configuration.preprocessors[path.join(conf.paths.frontendTest, '**/*.js')] = ['browserify']; - configuration.preprocessors[path.join(conf.paths.frontendSrc, '**/*.js')] = ['browserify']; + configuration.preprocessors[path.join(conf.paths.frontendTest, '**/*.js')] = + ['browserify', 'closure', 'closure-iit']; + configuration.preprocessors[path.join(conf.paths.frontendSrc, '**/*.js')] = + ['browserify', 'closure']; + configuration.preprocessors[path.join( + conf.paths.bowerComponents, 'google-closure-library/closure/goog/deps.js')] = + ['closure-deps']; // Convert HTML templates into JS files that serve code through $templateCache. configuration.preprocessors[path.join(conf.paths.frontendSrc, '**/*.html')] = ['ng-html2js']; diff --git a/build/multidest.js b/build/multidest.js index f4ac2dd2c126..f1911badf390 100644 --- a/build/multidest.js +++ b/build/multidest.js @@ -18,9 +18,10 @@ import through from 'through2'; /** * Utility function for specifying multiple gulp.dest destinations. * @param {string|!Array} outputDirs destinations for the gulp dest function calls + * @param {function(?Error=)|undefined} opt_doneFn - Callback. * @return {stream} */ -export function multiDest(outputDirs) { +export function multiDest(outputDirs, opt_doneFn) { if (!Array.isArray(outputDirs)) { outputDirs = [outputDirs]; } @@ -30,5 +31,16 @@ export function multiDest(outputDirs) { outputStream.on('data', (data) => outputs.forEach((dest) => { dest.write(data); })); outputStream.on('end', () => outputs.forEach((dest) => { dest.end(); })); + // build a closure to track all streams + let stillRunning = outputs.length; + if (opt_doneFn) { + outputs.forEach((output) => output.on('finish', () => { + stillRunning--; + if (stillRunning === 0) { + opt_doneFn(); + } + })); + } + return outputStream; } diff --git a/build/postinstall.sh b/build/postinstall.sh index 7ac57af09e9a..4aad198051a9 100755 --- a/build/postinstall.sh +++ b/build/postinstall.sh @@ -18,5 +18,10 @@ ./node_modules/.bin/bower install --allow-root -# Godep is required by the project. Install it in the tools directory. +# Godep is required by the project. Install it in the .tools directory. GOPATH=`pwd`/.tools/go go get github.com/tools/godep +# XtbGeneator is required by the project. Clone it into .tools. +if ! [ -a "./.tools/xtbgenerator/bin/XtbGenerator.jar" ] +then + (cd ./.tools/; git clone https://github.com/kuzmisin/xtbgenerator; cd xtbgenerator; git checkout d6a6c9ed0833f461508351a80bc36854bc5509b2) +fi diff --git a/build/script.js b/build/script.js index c898fbe9b5e1..82c5f992cfe8 100644 --- a/build/script.js +++ b/build/script.js @@ -15,6 +15,7 @@ /** * Gulp tasks for processing and compiling frontend JavaScript files. */ +import async from 'async'; import gulp from 'gulp'; import gulpAngularTemplatecache from 'gulp-angular-templatecache'; import gulpClosureCompiler from 'gulp-closure-compiler'; @@ -53,10 +54,15 @@ gulp.task('scripts', function() { }); /** - * Compiles frontend JavaScript files into production bundle located in {conf.paths.prodTmp} - * directory. + * Creates a google-closure compilation stream in which the .js sources are localized + * for a specific translation / locale. + * @param {undefined|Object} translation - optional translation spec, otherwise compiles the default + * application logic. + * @return {function(function(Object, Object))} - a function with a 'next' callback as parameter. + * When executed, it runs the gulp compilation stream and calls next() when done. Required by + * 'async'. */ -gulp.task('scripts:prod', ['angular-templates'], function() { +function createCompileTask(translation) { let closureCompilerConfig = { fileName: 'app.js', // "foo_flag: null" means that a flag is enabled. @@ -105,17 +111,44 @@ gulp.task('scripts:prod', ['angular-templates'], function() { tieredCompilation: true, }; - return gulp - .src([ - // Application source files. - path.join(conf.paths.frontendSrc, '**/*.js'), - // Partials generated by other tasks, e.g., Angular templates. - path.join(conf.paths.partials, '**/*.js'), - // Include base.js to enable some compiler functions, e.g., @export annotation handling. - path.join(conf.paths.bowerComponents, 'google-closure-library/closure/goog/base.js'), - ]) - .pipe(gulpClosureCompiler(closureCompilerConfig)) - .pipe(gulp.dest(conf.paths.prodTmp)); + if (translation && translation.path) { + closureCompilerConfig.compilerFlags.translations_file = translation.path; + } + + let outputDir = + translation ? path.join(conf.paths.i18nProd, `/${translation.key}`) : conf.paths.prodTmp; + + return ( + (next) => + gulp.src([ + // Application source files. + path.join(conf.paths.frontendSrc, '**/*.js'), + // Partials generated by other tasks, e.g., Angular templates. + path.join(conf.paths.partials, '**/*.js'), + // Include base.js to enable some compiler functions, e.g., @export annotation + // handling and getMsg() translations. + path.join( + conf.paths.bowerComponents, 'google-closure-library/closure/goog/base.js'), + ]) + .pipe(gulpClosureCompiler(closureCompilerConfig)) + .pipe(gulp.dest(outputDir)) + .on('end', next)); +} + +/** + * Compiles frontend JavaScript files into production bundle located in {conf.paths.prodTmp} + * directory. A separated bundle is created for each i18n locale. + */ +gulp.task('scripts:prod', ['angular-templates', 'extract-translations'], function(doneFn) { + // add a compilation step to stream for each translation file + let streams = conf.translations.map((translation) => { return createCompileTask(translation); }); + + // add a default compilation task (no localization) + streams = streams.concat(createCompileTask()); + + // TODO (taimir) : do not run the tasks sequentially once + // gulp-closure-compiler can be run in parallel + async.series(streams, doneFn); }); /** diff --git a/build/serve.js b/build/serve.js index b39ba4119e58..6c2096fb1eee 100644 --- a/build/serve.js +++ b/build/serve.js @@ -132,9 +132,10 @@ gulp.task('serve:prod', ['spawn-backend:prod']); * Spawns new backend application process and finishes the task immediately. Previously spawned * backend process is killed beforehand, if any. The frontend pages are served by BrowserSync. */ -gulp.task('spawn-backend', ['backend', 'kill-backend'], function() { +gulp.task('spawn-backend', ['backend', 'kill-backend', 'locales-for-backend:dev'], function() { runningBackendProcess = child.spawn( - path.join(conf.paths.serve, conf.backend.binaryName), backendDevArgs, {stdio: 'inherit'}); + path.join(conf.paths.serve, conf.backend.binaryName), backendDevArgs, + {stdio: 'inherit', cwd: conf.paths.serve}); runningBackendProcess.on('exit', function() { // Mark that there is no backend process running anymore. @@ -158,6 +159,14 @@ gulp.task('spawn-backend:prod', ['build-frontend', 'backend', 'kill-backend'], f }); }); +/** + * Copies the locales configuration to the serve directory. + * In development, this configuration plays no significant role and serves as a stub. + */ +gulp.task('locales-for-backend:dev', function() { + return gulp.src(path.join(conf.paths.base, 'i18n', '*.json')).pipe(gulp.dest(conf.paths.serve)); +}); + /** * Kills running backend process (if any). */ diff --git a/docs/design/i18n.md b/docs/design/i18n.md new file mode 100644 index 000000000000..a89cc4e7784f --- /dev/null +++ b/docs/design/i18n.md @@ -0,0 +1,38 @@ +# Using goog.getMsg() for translation + +### Localizing dashboard +* The translatable text messages are specified as MSG_ attributes of all the controller classes, using goog.getMsg(). +* Each variable has a @desc annotation which describes the logical meaning of the text to be translated. +* The supported languages are listed in a separate json file, which is imported into `./build/conf.js` during the build process. +* When doing production builds (e.g. `gulp build-frontend` task), all of the messages are extracted (via XtbGenerator) into the mentioned XTB translation bundles (under the directory `./i18n`, one per language) automatically (`extract-translations` task). Already existing translations are of course not overwritten. Newly found messages are added to the translation bundles. NOTE: AFAICS, old obsolete messages are not automatically removed from the existing bundles => the translators will need to clean them manually, it's the behavior of XtbGenerator. That's the only flaw I've seen so far. +* When building the sources we have the following steps in the build chain: + * Google Closure compiler builds one app.js file for each language, in a separate directory in .tmp, using the respective .xtb translation bundle in the process and injecting the messages. + * Through an intermediate step, the "production-ready" code is prepared in .tmp, ready to be localized and revisioned. + * Then the localization is done by replacing the app.js files for each locale. + * Finally, throught the `build-frontend` gulp task, the final "compiled" code is moved into `./dist`, keeping the per-language folder structure and is revisioned in the process. The dist folder looks like this: + ``` + dist/ + +-- amd64/ + | +-- en/ + | | +-- public/ + | | +-- index.html <-- uses the assets from static + | | +-- static/ + | | +-- vendor.js <--- the packed library deps + | | +-- app.js <--- the localized code (*en* in this case) + | | +-- vendor.css <-- packed vendor .css + | | +-- app.css <-- packed application .css + | +-- ja + | | +-- public/ + | | +-- ... same as above... + | | + +-- ... + | +-- ... + | + +-- locale_conf.json + ``` +* the `locale_conf.json` file is for the dashboard backend. It is used by the backend to determine the right localized version of the `index.html` that should be served. +* The human translators can now easily translate messages in the XTB bundles, and those changes will be picked up by the closure compiler (via our build pipeline) during each production build. + +### Serving the localized index.html +* In production, we use our GOLANG backend as a webserver (serves the pages). In development we do not (we serve via browsersync). We do not have internationalization when serving in development mode. +* In production, the GO backend's server component intercepts all requests and checks their "Accept-Language" header, to determine the locale of the user. Based on the locale, the server chooses from which of the `./dist/` directories to serve. To avoid caching issues in browsers, the "index.html" pages are never cached (header `Cahce-Controle: no-cache` in the requests). diff --git a/gulpfile.babel.js b/gulpfile.babel.js index 51de219e7959..1bf6d0ab4550 100644 --- a/gulpfile.babel.js +++ b/gulpfile.babel.js @@ -29,6 +29,6 @@ import './build/script'; import './build/serve'; import './build/style'; import './build/test'; - +import './build/i18n'; // No business logic in this file. diff --git a/i18n/locale_conf.json b/i18n/locale_conf.json new file mode 100644 index 000000000000..a19e1bf228dc --- /dev/null +++ b/i18n/locale_conf.json @@ -0,0 +1,6 @@ +{ + "translations": [ + {"file": "messages-en.xtb", "key": "en"}, + {"file": "messages-ja.xtb", "key": "ja"} + ] +} diff --git a/i18n/messages-en.xtb b/i18n/messages-en.xtb new file mode 100644 index 000000000000..9427ad895235 --- /dev/null +++ b/i18n/messages-en.xtb @@ -0,0 +1,5 @@ + + + + An 'app' label with this value will be added to the Replication Controller and Service that get deployed. + \ No newline at end of file diff --git a/i18n/messages-ja.xtb b/i18n/messages-ja.xtb new file mode 100644 index 000000000000..9427ad895235 --- /dev/null +++ b/i18n/messages-ja.xtb @@ -0,0 +1,5 @@ + + + + An 'app' label with this value will be added to the Replication Controller and Service that get deployed. + \ No newline at end of file diff --git a/package.json b/package.json index 9a97f19bd35c..73773d3188c0 100644 --- a/package.json +++ b/package.json @@ -8,20 +8,22 @@ }, "license": "Apache-2.0", "devDependencies": { + "async": "~2.0.0-rc.5", "babel": "~6.5.2", "babel-core": "~6.9.0", - "babel-loader": "~6.2.0", + "babel-loader": "~6.2.4", "babel-preset-es2015": "~6.9.0", "babelify": "~7.3.0", - "bower": "~1.7.1", - "browser-sync": "~2.12.1", + "bower": "~1.7.9", + "browser-sync": "~2.12.8", "browser-sync-spa": "~1.0.3", - "browserify": "~13.0.0", + "browserify": "~13.0.1", "browserify-istanbul": "~2.0.0", "del": "~2.2.0", - "eslint-plugin-angular": "~1.0.0", + "eslint-plugin-angular": "~1.0.1", + "file-exists": "1.0.0", "google-closure-compiler": "~20160517.1.0", - "gulp": "~3.9.0", + "gulp": "~3.9.1", "gulp-angular-templatecache": "~1.8.0", "gulp-autoprefixer": "~3.1.0", "gulp-browserify": "~0.5.1", @@ -32,27 +34,27 @@ "gulp-eslint": "~2.0.0", "gulp-flatten": "~0.2.0", "gulp-htmlmin": "~2.0.0", - "gulp-if": "~2.0.0", + "gulp-if": "~2.0.1", "gulp-inject": "~4.1.0", - "gulp-minify-css": "~1.2.2", + "gulp-minify-css": "~1.2.4", "gulp-protractor": "~2.3.0", "gulp-rename": "~1.2.2", "gulp-replace": "~0.5.4", - "gulp-rev": "~7.0.0", - "gulp-rev-replace": "~0.4.3", - "gulp-sass": "~2.3.0", - "gulp-sass-lint": "~1.1.0", + "gulp-rev-all": "~0.8.24", + "gulp-sass": "~2.3.1", + "gulp-sass-lint": "~1.1.1", "gulp-size": "~2.1.0", "gulp-sourcemaps": "~1.6.0", - "gulp-uglify": "~1.5.1", + "gulp-uglify": "~1.5.3", "gulp-useref": "~3.1.0", "gulp-util": "~3.0.7", - "gulp-watch": "~4.3.5", - "html-minifier": "~2.1.0", + "gulp-watch": "~4.3.6", + "html-minifier": "~2.1.3", "isparta": "~4.0.0", "karma": "0.13.22", "karma-browserify": "~5.0.5", "karma-chrome-launcher": "~1.0.1", + "karma-closure": "~0.1.1", "karma-coverage": "~1.0.0", "karma-firefox-launcher": "~1.0.0", "karma-jasmine": "~1.0.2", @@ -61,16 +63,18 @@ "karma-sauce-launcher": "~1.0.0", "karma-sourcemap-loader": "~0.3.6", "lodash": "~4.13.0", + "merge-stream": "~1.0.0", "npm-check-updates": "~2.6.2", "proxy-middleware": "~0.15.0", "q": "~1.4.1", "semver": "~5.1.0", "through2": "~2.0.1", "uglify-save-license": "~0.4.1", + "vinyl-paths": "~2.1.0", "watchify": "~3.7.0", "webpack-stream": "~3.2.0", "wiredep": "~4.0.0", - "wrench": "~1.5.8" + "wrench": "~1.5.9" }, "engines": { "node": ">=4.2.2" diff --git a/src/app/backend/dashboard.go b/src/app/backend/dashboard.go index b5bb8cfb1f1b..73ee4b699577 100644 --- a/src/app/backend/dashboard.go +++ b/src/app/backend/dashboard.go @@ -58,12 +58,12 @@ func main() { heapsterRESTClient, err := CreateHeapsterRESTClient(*argHeapsterHost, apiserverClient) if err != nil { - log.Print("Could not create heapster client: %s. Continuing.", err) + log.Printf("Could not create heapster client: %s. Continuing.", err) } // Run a HTTP server that serves static public files from './public' and handles API calls. // TODO(bryk): Disable directory listing. - http.Handle("/", http.FileServer(http.Dir("./public"))) + http.Handle("/", CreateLocaleHandler()) http.Handle("/api/", CreateHttpApiHandler(apiserverClient, heapsterRESTClient, config)) // TODO(maciaszczykm): Move to /appConfig.json as it was discussed in #640. http.Handle("/api/appConfig.json", AppHandler(ConfigHandler)) diff --git a/src/app/backend/handler/localehandler.go b/src/app/backend/handler/localehandler.go new file mode 100644 index 000000000000..d1a19a390297 --- /dev/null +++ b/src/app/backend/handler/localehandler.go @@ -0,0 +1,116 @@ +// Copyright 2015 Google Inc. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handler + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "os" + "strings" + + "github.com/golang/glog" +) + +const defaultDir = "./public/en" + +// Localization is a spec for the localization configuration of dashboard. +type Localization struct { + Translations []Translation `json:"translations"` +} + +// Translation is a single translation definition spec. +type Translation struct { + File string `json:"file"` + Key string `json:"key"` +} + +// LocaleHandler serves different localized versions of the frontend application +// based on the Accept-Language header. +type LocaleHandler struct { + SupportedLocales []string +} + +// CreateLocaleHandler loads the localization configuration and constructs a LocaleHandler. +func CreateLocaleHandler() *LocaleHandler { + locales, err := getSupportedLocales("./locale_conf.json") + if err != nil { + glog.Warningf("Error when loading the localization configuration. Dashboard will not be localized. %s", err) + locales = []string{} + } + return &LocaleHandler{SupportedLocales: locales} +} + +func getSupportedLocales(configFile string) ([]string, error) { + // read config file + localesFile, err := ioutil.ReadFile(configFile) + if err != nil { + return []string{}, err + } + + // unmarshall + localization := Localization{} + err = json.Unmarshal(localesFile, &localization) + if err != nil { + glog.Warningf("%s %s", string(localesFile), err) + } + + // filter locale keys + result := []string{} + for _, translation := range localization.Translations { + result = append(result, translation.Key) + } + return result, nil +} + +// LocaleHandler serves different html versions based on the Accept-Language header. +func (handler *LocaleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() == "/" || r.URL.EscapedPath() == "/index.html" { + // Do not store the html page in the cache. If the user is to click on 'switch language', + // we want a different index.html (for the right locale) to be served when the page refreshes. + w.Header().Add("Cache-Control", "no-store") + } + acceptLanguage := r.Header.Get("Accept-Language") + dirName := handler.determineLocalizedDir(acceptLanguage) + http.FileServer(http.Dir(dirName)).ServeHTTP(w, r) +} + +func (handler *LocaleHandler) determineLocalizedDir(locale string) string { + tokens := strings.Split(locale, "-") + if len(tokens) == 0 { + return defaultDir + } + matchedLocale := "" + for _, l := range handler.SupportedLocales { + if l == tokens[0] { + matchedLocale = l + } + } + localeDir := "./public/" + matchedLocale + if matchedLocale != "" && handler.dirExists(localeDir) { + return localeDir + } + return defaultDir +} + +func (handler *LocaleHandler) dirExists(name string) bool { + if _, err := os.Stat(name); err != nil { + if os.IsNotExist(err) { + glog.Warningf(name) + return false + } + } + return true +} diff --git a/src/app/frontend/deploy/deployfromsettings.html b/src/app/frontend/deploy/deployfromsettings.html index 41eb120ee8c8..c0244d97fa8a 100644 --- a/src/app/frontend/deploy/deployfromsettings.html +++ b/src/app/frontend/deploy/deployfromsettings.html @@ -43,8 +43,7 @@ - An 'app' label with this value will be added to the Replication Controller and Service that get - deployed. + {{ctrl.MSG_DEPLOY_APP_NAME_USER_HELP}} Learn more open_in_new diff --git a/src/app/frontend/deploy/deployfromsettings_controller.js b/src/app/frontend/deploy/deployfromsettings_controller.js index 0f0cf899cdfe..d9c01de386a0 100644 --- a/src/app/frontend/deploy/deployfromsettings_controller.js +++ b/src/app/frontend/deploy/deployfromsettings_controller.js @@ -173,6 +173,14 @@ export default class DeployFromSettingsController { /** @private {!md.$dialog} */ this.mdDialog_ = $mdDialog; + + /** + * @export + * @type string + * @desc User help text for the "App name" on the deploy page. + */ + this.MSG_DEPLOY_APP_NAME_USER_HELP = goog.getMsg( + `An 'app' label with this value will be added to the Replication Controller and Service that get deployed.`); } /** diff --git a/src/test/backend/handler/localehandler_test.go b/src/test/backend/handler/localehandler_test.go new file mode 100644 index 000000000000..dbe57872f805 --- /dev/null +++ b/src/test/backend/handler/localehandler_test.go @@ -0,0 +1,105 @@ +package handler + +import ( + "encoding/json" + "io/ioutil" + "os" + "reflect" + "testing" +) + +func TestGetSupportedLocales(t *testing.T) { + cases := []struct { + localization Localization + expected []string + }{ + { + Localization{ + Translations: []Translation{ + Translation{File: "en/index.html", Key: "en"}, + Translation{File: "ja/index.html", Key: "ja"}, + }, + }, + []string{"en", "ja"}, + }, + { + Localization{}, + []string{}, + }, + } + + for _, c := range cases { + configFile, err := ioutil.TempFile("", "test-locale-config") + if err != nil { + t.Fatalf("%s", err) + } + defer os.Remove(configFile.Name()) + + fileContent, _ := json.Marshal(c.localization) + configFile.Write(fileContent) + actual, _ := getSupportedLocales(configFile.Name()) + if !reflect.DeepEqual(actual, c.expected) { + t.Errorf("getSupportedLocales() returns %#v, expected %#v", actual, c.expected) + } + } +} + +func TestDetermineLocale(t *testing.T) { + cases := []struct { + handler *LocaleHandler + createDir bool + acceptLanguageKey string + expected string + }{ + { + &LocaleHandler{ + SupportedLocales: []string{"en", "ja"}, + }, + false, + "en", + defaultDir, + }, + { + &LocaleHandler{ + SupportedLocales: []string{"en", "ja"}, + }, + false, + "de", + defaultDir, + }, + { + &LocaleHandler{ + SupportedLocales: []string{"en", "ja"}, + }, + false, + "ja", + defaultDir, + }, + { + &LocaleHandler{ + SupportedLocales: []string{"en", "ja"}, + }, + true, + "ja", + "./public/ja", + }, + } + + for _, c := range cases { + if c.createDir { + err := os.Mkdir("./public", 0777) + if err != nil { + t.Fatalf("%s", err) + } + err = os.Mkdir("./public/"+c.acceptLanguageKey, 0777) + if err != nil { + t.Fatalf("%s", err) + } + defer os.RemoveAll("./public") + } + actual := c.handler.determineLocalizedDir(c.acceptLanguageKey) + if !reflect.DeepEqual(actual, c.expected) { + t.Errorf("localeHandler.determineLocalizedDir() returns %#v, expected %#v", actual, c.expected) + } + } +}