提交 76ea2396 编写于 作者: P Piotr Bryk

Merge pull request #725 from taimir/i18n-get-msg-improved

Improved localization through google getMsg()
......@@ -19,6 +19,9 @@
},
"roboto-fontface": {
"main": "css/roboto-fontface.css"
},
"google-closure-library": {
"main": ["closure/goog/base.js", "closure/goog/deps.js"]
}
},
"devDependencies": {
......
......@@ -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<string>} outputDirs
* 3. Everything is saved in the .tmp/dist directory, ready to be localized and revisioned.
*
* @param {!Array<string>} 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<string>} 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/<locale>/static
* for each of the specified output dirs.
* @param {!Array<string>} 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<string>} 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<string>} 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<string>} outputDirs
* Copies the icons files to all dist directories per arch and locale.
* @param {!Array<string>} 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<string>} outputDirs
* Copies the font files to all dist directories per arch and locale.
* @param {!Array<string>} 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<string>} outputDirs
* @param {undefined|string} opt_subdir - an optional sub directory inside each locale directory.
* @return {!Array<string>} 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);
}));
}, []);
}
......@@ -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'),
},
};
// 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);
});
......@@ -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);
});
......@@ -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'];
......
......@@ -18,9 +18,10 @@ import through from 'through2';
/**
* Utility function for specifying multiple gulp.dest destinations.
* @param {string|!Array<string>} 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;
}
......@@ -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
......@@ -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);
});
/**
......
......@@ -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).
*/
......
# Using goog.getMsg() for translation
### Localizing dashboard
* The translatable text messages are specified as MSG_<something> 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...
| |
+-- <other archs> ...
| +-- <locales per arch> ...
|
+-- 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/<locale>` directories to serve. To avoid caching issues in browsers, the "index.html" pages are never cached (header `Cahce-Controle: no-cache` in the requests).
......@@ -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.
{
"translations": [
{"file": "messages-en.xtb", "key": "en"},
{"file": "messages-ja.xtb", "key": "ja"}
]
}
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE translationbundle>
<translationbundle lang="cs">
<translation id="8895655645940102616" key="MSG_DEPLOY_APP_NAME_USER_HELP" source="/home/mirchev/go_workspace/src/github.com/kubernetes/dashboard/.tmp/serve/app-dev.js" desc="User help text for the &quot;App name&quot; on the deploy page.">An 'app' label with this value will be added to the Replication Controller and Service that get deployed.</translation>
</translationbundle>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE translationbundle>
<translationbundle lang="cs">
<translation id="8895655645940102616" key="MSG_DEPLOY_APP_NAME_USER_HELP" source="/home/mirchev/go_workspace/src/github.com/kubernetes/dashboard/.tmp/serve/app-dev.js" desc="User help text for the &quot;App name&quot; on the deploy page.">An 'app' label with this value will be added to the Replication Controller and Service that get deployed.</translation>
</translationbundle>
\ No newline at end of file
......@@ -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"
......
......@@ -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))
......
// 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
}
......@@ -43,8 +43,7 @@ limitations under the License.
</md-input-container>
<kd-user-help>
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}}
<a href="http://kubernetes.io/docs/user-guide/labels/" target="_blank" tabindex="-1">
Learn more <i class="material-icons">open_in_new</i>
</a>
......
......@@ -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.`);
}
/**
......
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)
}
}
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册