feat(ivy): i18n - implement compile-time inlining (#32881)
This commit implements a tool that will inline translations and generate a translated copy of a set of application files from a set of translation files. PR Close #32881
This commit is contained in:
committed by
Alex Rickabaugh
parent
d5b87d32b0
commit
2cdb3a079d
@@ -175,7 +175,11 @@
|
||||
version "9.0.0-next.9"
|
||||
|
||||
"@angular/localize@file:../../dist/packages-dist/localize":
|
||||
version "9.0.0-next.9"
|
||||
version "0.0.0"
|
||||
dependencies:
|
||||
"@babel/core" "^7.5.5"
|
||||
glob "7.1.2"
|
||||
yargs "13.1.0"
|
||||
|
||||
"@angular/platform-browser-dynamic@file:../../dist/packages-dist/platform-browser-dynamic":
|
||||
version "9.0.0-next.9"
|
||||
@@ -226,6 +230,26 @@
|
||||
semver "^5.4.1"
|
||||
source-map "^0.5.0"
|
||||
|
||||
"@babel/core@^7.5.5":
|
||||
version "7.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.6.2.tgz#069a776e8d5e9eefff76236bc8845566bd31dd91"
|
||||
integrity sha512-l8zto/fuoZIbncm+01p8zPSDZu/VuuJhAfA7d/AbzM09WR7iVhavvfNDYCNpo1VvLk6E6xgAoP9P+/EMJHuRkQ==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.5.5"
|
||||
"@babel/generator" "^7.6.2"
|
||||
"@babel/helpers" "^7.6.2"
|
||||
"@babel/parser" "^7.6.2"
|
||||
"@babel/template" "^7.6.0"
|
||||
"@babel/traverse" "^7.6.2"
|
||||
"@babel/types" "^7.6.0"
|
||||
convert-source-map "^1.1.0"
|
||||
debug "^4.1.0"
|
||||
json5 "^2.1.0"
|
||||
lodash "^4.17.13"
|
||||
resolve "^1.3.2"
|
||||
semver "^5.4.1"
|
||||
source-map "^0.5.0"
|
||||
|
||||
"@babel/generator@^7.0.0", "@babel/generator@^7.2.2":
|
||||
version "7.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.2.2.tgz#18c816c70962640eab42fe8cae5f3947a5c65ccc"
|
||||
@@ -248,6 +272,16 @@
|
||||
source-map "^0.5.0"
|
||||
trim-right "^1.0.1"
|
||||
|
||||
"@babel/generator@^7.6.2":
|
||||
version "7.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.6.2.tgz#dac8a3c2df118334c2a29ff3446da1636a8f8c03"
|
||||
integrity sha512-j8iHaIW4gGPnViaIHI7e9t/Hl8qLjERI6DcV9kEpAIDJsAOrcnXqRS7t+QbhL76pwbtqP+QCQLL0z1CyVmtjjQ==
|
||||
dependencies:
|
||||
"@babel/types" "^7.6.0"
|
||||
jsesc "^2.5.1"
|
||||
lodash "^4.17.13"
|
||||
source-map "^0.5.0"
|
||||
|
||||
"@babel/helper-annotate-as-pure@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.0.0.tgz#323d39dd0b50e10c7c06ca7d7638e6864d8c5c32"
|
||||
@@ -419,6 +453,15 @@
|
||||
"@babel/traverse" "^7.5.5"
|
||||
"@babel/types" "^7.5.5"
|
||||
|
||||
"@babel/helpers@^7.6.2":
|
||||
version "7.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.6.2.tgz#681ffe489ea4dcc55f23ce469e58e59c1c045153"
|
||||
integrity sha512-3/bAUL8zZxYs1cdX2ilEE0WobqbCmKWr/889lf2SS0PpDcpEIY8pb1CCyz0pEcX3pEb+MCbks1jIokz2xLtGTA==
|
||||
dependencies:
|
||||
"@babel/template" "^7.6.0"
|
||||
"@babel/traverse" "^7.6.2"
|
||||
"@babel/types" "^7.6.0"
|
||||
|
||||
"@babel/highlight@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.0.0.tgz#f710c38c8d458e6dd9a201afb637fcb781ce99e4"
|
||||
@@ -438,6 +481,11 @@
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.5.tgz#02f077ac8817d3df4a832ef59de67565e71cca4b"
|
||||
integrity sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g==
|
||||
|
||||
"@babel/parser@^7.6.0", "@babel/parser@^7.6.2":
|
||||
version "7.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.6.2.tgz#205e9c95e16ba3b8b96090677a67c9d6075b70a1"
|
||||
integrity sha512-mdFqWrSPCmikBoaBYMuBulzTIKuXVPtEISFbRRVNwMWpCms/hmE2kRq0bblUHaNRKrjRlmVbx1sDHmjmRgD2Xg==
|
||||
|
||||
"@babel/plugin-proposal-async-generator-functions@^7.2.0":
|
||||
version "7.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.2.0.tgz#b289b306669dce4ad20b0252889a15768c9d417e"
|
||||
@@ -843,6 +891,15 @@
|
||||
"@babel/parser" "^7.4.4"
|
||||
"@babel/types" "^7.4.4"
|
||||
|
||||
"@babel/template@^7.6.0":
|
||||
version "7.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.6.0.tgz#7f0159c7f5012230dad64cca42ec9bdb5c9536e6"
|
||||
integrity sha512-5AEH2EXD8euCk446b7edmgFdub/qfH1SN6Nii3+fyXP807QRx9Q73A2N5hNwRRslC2H9sNzaFhsPubkS4L8oNQ==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.0.0"
|
||||
"@babel/parser" "^7.6.0"
|
||||
"@babel/types" "^7.6.0"
|
||||
|
||||
"@babel/traverse@^7.0.0":
|
||||
version "7.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.2.3.tgz#7ff50cefa9c7c0bd2d81231fdac122f3957748d8"
|
||||
@@ -873,6 +930,21 @@
|
||||
globals "^11.1.0"
|
||||
lodash "^4.17.13"
|
||||
|
||||
"@babel/traverse@^7.6.2":
|
||||
version "7.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.6.2.tgz#b0e2bfd401d339ce0e6c05690206d1e11502ce2c"
|
||||
integrity sha512-8fRE76xNwNttVEF2TwxJDGBLWthUkHWSldmfuBzVRmEDWOtu4XdINTgN7TDWzuLg4bbeIMLvfMFD9we5YcWkRQ==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.5.5"
|
||||
"@babel/generator" "^7.6.2"
|
||||
"@babel/helper-function-name" "^7.1.0"
|
||||
"@babel/helper-split-export-declaration" "^7.4.4"
|
||||
"@babel/parser" "^7.6.2"
|
||||
"@babel/types" "^7.6.0"
|
||||
debug "^4.1.0"
|
||||
globals "^11.1.0"
|
||||
lodash "^4.17.13"
|
||||
|
||||
"@babel/types@^7.0.0", "@babel/types@^7.2.2":
|
||||
version "7.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.2.2.tgz#44e10fc24e33af524488b716cdaee5360ea8ed1e"
|
||||
@@ -891,6 +963,15 @@
|
||||
lodash "^4.17.13"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@babel/types@^7.6.0":
|
||||
version "7.6.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.6.1.tgz#53abf3308add3ac2a2884d539151c57c4b3ac648"
|
||||
integrity sha512-X7gdiuaCmA0uRjCmRtYJNAVCc/q+5xSgsfKJHqMN4iNLILX39677fJE1O40arPMh0TTtS9ItH67yre6c7k6t0g==
|
||||
dependencies:
|
||||
esutils "^2.0.2"
|
||||
lodash "^4.17.13"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@ngtools/[email protected]":
|
||||
version "8.3.0-next.1"
|
||||
resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-8.3.0-next.1.tgz#06052331611bf1ca440d58fbe29593d561b54514"
|
||||
@@ -3615,6 +3696,18 @@ [email protected]:
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
[email protected]:
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.2.tgz#c19c9df9a028702d678612384a6552404c636d15"
|
||||
integrity sha512-MJTUg1kjuLeQCJ+ccE4Vpa6kKVXkPYJ2mOCQyUuKLcLQsdrMCpBPUi8qVE6+YuaJkozeA9NusTAw3hLr8Xe5EQ==
|
||||
dependencies:
|
||||
fs.realpath "^1.0.0"
|
||||
inflight "^1.0.4"
|
||||
inherits "2"
|
||||
minimatch "^3.0.4"
|
||||
once "^1.3.0"
|
||||
path-is-absolute "^1.0.0"
|
||||
|
||||
[email protected], glob@^7.1.4:
|
||||
version "7.1.4"
|
||||
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255"
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@angular-devkit/core": "^8.0.0-beta.15",
|
||||
"@angular-devkit/schematics": "^8.0.0-beta.15",
|
||||
"@angular/bazel": "file:./tools/npm/@angular_bazel",
|
||||
"@babel/core": "^7.5.5",
|
||||
"@bazel/jasmine": "0.38.1",
|
||||
"@bazel/karma": "0.38.1",
|
||||
"@bazel/protractor": "0.38.1",
|
||||
|
||||
@@ -76,6 +76,7 @@ export * from './ml_parser/html_tags';
|
||||
export * from './ml_parser/interpolation_config';
|
||||
export * from './ml_parser/tags';
|
||||
export {LexerRange} from './ml_parser/lexer';
|
||||
export * from './ml_parser/xml_parser';
|
||||
export {NgModuleCompiler} from './ng_module_compiler';
|
||||
export {ArrayType, AssertNotNull, DYNAMIC_TYPE, BinaryOperator, BinaryOperatorExpr, BuiltinMethod, BuiltinType, BuiltinTypeName, BuiltinVar, CastExpr, ClassField, ClassMethod, ClassStmt, CommaExpr, CommentStmt, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, Expression, ExpressionStatement, ExpressionType, ExpressionVisitor, ExternalExpr, ExternalReference, literalMap, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, InvokeMethodExpr, JSDocCommentStmt, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, MapType, NotExpr, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, StatementVisitor, ThrowStmt, TryCatchStmt, Type, TypeVisitor, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr, StmtModifier, Statement, STRING_TYPE, TypeofExpr, collectExternalReferences} from './output/output_ast';
|
||||
export {EmitterVisitorContext} from './output/abstract_emitter';
|
||||
|
||||
@@ -12,9 +12,8 @@ ts_library(
|
||||
),
|
||||
module_name = "@angular/localize",
|
||||
deps = [
|
||||
"//packages/compiler",
|
||||
"//packages/localize/src/localize",
|
||||
"@npm//@types/node",
|
||||
"//packages/localize/src/utils",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -27,6 +26,7 @@ ng_package(
|
||||
entry_point = ":index.ts",
|
||||
packages = [
|
||||
"//packages/localize/schematics:npm_package",
|
||||
"//packages/localize/src/tools:npm_package",
|
||||
],
|
||||
tags = [
|
||||
"release-with-framework",
|
||||
|
||||
@@ -10,4 +10,4 @@
|
||||
// The public API exports are specified in the `./localize` module, which is checked by the
|
||||
// public_api_guard rules
|
||||
|
||||
export * from './localize';
|
||||
export * from './localize';
|
||||
@@ -8,4 +8,7 @@
|
||||
|
||||
// This file contains the public API of the `@angular/localize` entry-point
|
||||
|
||||
export {clearTranslations, loadTranslations} from './src/translate';
|
||||
export {clearTranslations, loadTranslations} from './src/translate';
|
||||
|
||||
// Exports that are not part of the public API
|
||||
export * from './private';
|
||||
@@ -10,6 +10,9 @@
|
||||
"fesm5": "./fesm5/localize.js",
|
||||
"fesm2015": "./fesm2015/localize.js",
|
||||
"typings": "./index.d.ts",
|
||||
"bin": {
|
||||
"localize-translate": "./src/tools/src/translate/main.js"
|
||||
},
|
||||
"author": "angular",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
@@ -27,5 +30,10 @@
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.5.5",
|
||||
"glob": "7.1.2",
|
||||
"yargs": "13.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
// This file exports all the `utils` as private exports so that other parts of `@angular/localize`
|
||||
// can make use of them.
|
||||
export {MessageId as ɵMessageId, MissingTranslationError as ɵMissingTranslationError, ParsedMessage as ɵParsedMessage, ParsedTranslation as ɵParsedTranslation, ParsedTranslations as ɵParsedTranslations, SourceMessage as ɵSourceMessage, TargetMessage as ɵTargetMessage, computeMsgId as ɵcomputeMsgId, findEndOfBlock as ɵfindEndOfBlock, isMissingTranslationError as ɵisMissingTranslationError, makeParsedTranslation as ɵmakeParsedTranslation, makeTemplateObject as ɵmakeTemplateObject, parseMessage as ɵparseMessage, parseMetadata as ɵparseMetadata, parseTranslation as ɵparseTranslation, splitBlock as ɵsplitBlock, translate as ɵtranslate} from './src/utils';
|
||||
@@ -0,0 +1,38 @@
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
load("//tools:defaults.bzl", "npm_package", "ts_library")
|
||||
load("@npm_bazel_typescript//:index.bzl", "ts_config")
|
||||
|
||||
ts_config(
|
||||
name = "tsconfig",
|
||||
src = "tsconfig-build.json",
|
||||
deps = ["//packages:tsconfig-build.json"],
|
||||
)
|
||||
|
||||
ts_library(
|
||||
name = "tools",
|
||||
srcs = glob(
|
||||
[
|
||||
"**/*.ts",
|
||||
],
|
||||
),
|
||||
tsconfig = ":tsconfig",
|
||||
deps = [
|
||||
"//packages/compiler",
|
||||
"//packages/localize",
|
||||
"@npm//@babel/core",
|
||||
"@npm//@babel/types",
|
||||
"@npm//@types/glob",
|
||||
"@npm//@types/node",
|
||||
"@npm//@types/yargs",
|
||||
],
|
||||
)
|
||||
|
||||
npm_package(
|
||||
name = "npm_package",
|
||||
srcs = [
|
||||
],
|
||||
deps = [
|
||||
":tools",
|
||||
],
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
/**
|
||||
* This class is used to collect and then report warnings and errors that occur during the execution
|
||||
* of the tools.
|
||||
*/
|
||||
export class Diagnostics {
|
||||
readonly messages: {type: 'warning' | 'error', message: string}[] = [];
|
||||
get hasErrors() { return this.messages.some(m => m.type === 'error'); }
|
||||
warn(message: string) { this.messages.push({type: 'warning', message}); }
|
||||
error(message: string) { this.messages.push({type: 'error', message}); }
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export class FileUtils {
|
||||
static readFile(absolutePath: string): string { return fs.readFileSync(absolutePath, 'utf8'); }
|
||||
|
||||
static readFileBuffer(absolutePath: string): Buffer { return fs.readFileSync(absolutePath); }
|
||||
|
||||
static writeFile(absolutePath: string, contents: string|Buffer) {
|
||||
FileUtils.ensureDir(path.dirname(absolutePath));
|
||||
fs.writeFileSync(absolutePath, contents);
|
||||
}
|
||||
|
||||
static ensureDir(absolutePath: string): void {
|
||||
const parents: string[] = [];
|
||||
while (!FileUtils.isRoot(absolutePath) && !fs.existsSync(absolutePath)) {
|
||||
parents.push(absolutePath);
|
||||
absolutePath = path.dirname(absolutePath);
|
||||
}
|
||||
while (parents.length) {
|
||||
fs.mkdirSync(parents.pop() !);
|
||||
}
|
||||
}
|
||||
|
||||
static remove(p: string): void {
|
||||
const stat = fs.statSync(p);
|
||||
if (stat.isFile()) {
|
||||
fs.unlinkSync(p);
|
||||
} else if (stat.isDirectory()) {
|
||||
fs.readdirSync(p).forEach(child => {
|
||||
const absChild = path.resolve(p, child);
|
||||
FileUtils.remove(absChild);
|
||||
});
|
||||
fs.rmdirSync(p);
|
||||
}
|
||||
}
|
||||
|
||||
static isRoot(absolutePath: string): boolean {
|
||||
return path.dirname(absolutePath) === absolutePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {Diagnostics} from '../../diagnostics';
|
||||
import {FileUtils} from '../../file_utils';
|
||||
import {OutputPathFn} from '../output_path';
|
||||
import {TranslationBundle, TranslationHandler} from '../translator';
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Translate an asset file by simply copying it to the appropriate translation output paths.
|
||||
*/
|
||||
export class AssetTranslationHandler implements TranslationHandler {
|
||||
canTranslate(_relativeFilePath: string, _contents: Buffer): boolean { return true; }
|
||||
translate(
|
||||
diagnostics: Diagnostics, _sourceRoot: string, relativeFilePath: string, contents: Buffer,
|
||||
outputPathFn: OutputPathFn, translations: TranslationBundle[]): void {
|
||||
for (const translation of translations) {
|
||||
try {
|
||||
FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents);
|
||||
} catch (e) {
|
||||
diagnostics.error(e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import * as glob from 'glob';
|
||||
import {resolve} from 'path';
|
||||
import * as yargs from 'yargs';
|
||||
|
||||
import {AssetTranslationHandler} from './asset_files/asset_translation_handler';
|
||||
import {getOutputPathFn, OutputPathFn} from './output_path';
|
||||
import {SourceFileTranslationHandler} from './source_files/source_file_translation_handler';
|
||||
import {MissingTranslationStrategy} from './source_files/source_file_utils';
|
||||
import {TranslationLoader} from './translation_files/translation_file_loader';
|
||||
import {SimpleJsonTranslationParser} from './translation_files/translation_parsers/simple_json/simple_json_translation_parser';
|
||||
import {Xliff1TranslationParser} from './translation_files/translation_parsers/xliff1/xliff1_translation_parser';
|
||||
import {Xliff2TranslationParser} from './translation_files/translation_parsers/xliff2/xliff2_translation_parser';
|
||||
import {Translator} from './translator';
|
||||
import {Diagnostics} from '../diagnostics';
|
||||
|
||||
if (require.main === module) {
|
||||
const args = process.argv.slice(2);
|
||||
const options =
|
||||
yargs
|
||||
.option('r', {
|
||||
alias: 'root',
|
||||
required: true,
|
||||
describe:
|
||||
'The root path of the files to translate, either absolute or relative to the current working directory. E.g. `dist/en`.',
|
||||
})
|
||||
.option('s', {
|
||||
alias: 'source',
|
||||
required: true,
|
||||
describe:
|
||||
'A glob pattern indicating what files to translate, relative to the `root` path. E.g. `bundles/**/*`.',
|
||||
})
|
||||
|
||||
.option('t', {
|
||||
alias: 'translations',
|
||||
required: true,
|
||||
describe:
|
||||
'A glob pattern indicating what translation files to load, either absolute or relative to the current working directory. E.g. `my_proj/src/locale/messages.*.xlf.',
|
||||
})
|
||||
.option('o', {
|
||||
alias: 'outputPath',
|
||||
required: true,
|
||||
describe:
|
||||
'A output path pattern to where the translated files will be written. The marker `{{LOCALE}}` will be replaced with the target locale. E.g. `dist/{{LOCALE}}`.'
|
||||
})
|
||||
.option('m', {
|
||||
alias: 'missingTranslation',
|
||||
describe: 'How to handle missing translations.',
|
||||
choices: ['error', 'warning', 'ignore'],
|
||||
default: 'warning',
|
||||
})
|
||||
.help()
|
||||
.parse(args);
|
||||
|
||||
const sourceRootPath = options['r'];
|
||||
const sourceFilePaths =
|
||||
glob.sync(options['s'], {absolute: true, cwd: sourceRootPath, nodir: true});
|
||||
const translationFilePaths = glob.sync(options['t'], {absolute: true, nodir: true});
|
||||
const outputPathFn = getOutputPathFn(options['o']);
|
||||
const diagnostics = new Diagnostics();
|
||||
const missingTranslation: MissingTranslationStrategy = options['m'];
|
||||
|
||||
translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn, diagnostics,
|
||||
missingTranslation});
|
||||
|
||||
diagnostics.messages.forEach(m => console.warn(`${m.type}: ${m.message}`));
|
||||
process.exit(diagnostics.hasErrors ? 1 : 0);
|
||||
}
|
||||
|
||||
export interface TranslateFilesOptions {
|
||||
sourceRootPath: string;
|
||||
sourceFilePaths: string[];
|
||||
translationFilePaths: string[];
|
||||
outputPathFn: OutputPathFn;
|
||||
diagnostics: Diagnostics;
|
||||
missingTranslation: MissingTranslationStrategy;
|
||||
}
|
||||
|
||||
export function translateFiles({sourceRootPath, sourceFilePaths, translationFilePaths, outputPathFn,
|
||||
diagnostics, missingTranslation}: TranslateFilesOptions) {
|
||||
const translationLoader = new TranslationLoader([
|
||||
new Xliff2TranslationParser(),
|
||||
new Xliff1TranslationParser(),
|
||||
new SimpleJsonTranslationParser(),
|
||||
]);
|
||||
|
||||
const resourceProcessor = new Translator(
|
||||
[
|
||||
new SourceFileTranslationHandler({missingTranslation}),
|
||||
new AssetTranslationHandler(),
|
||||
],
|
||||
diagnostics);
|
||||
|
||||
const translations = translationLoader.loadBundles(translationFilePaths);
|
||||
sourceRootPath = resolve(sourceRootPath);
|
||||
resourceProcessor.translateFiles(sourceFilePaths, sourceRootPath, outputPathFn, translations);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
|
||||
export interface MessageRenderer<T> {
|
||||
message: T;
|
||||
startRender(): void;
|
||||
endRender(): void;
|
||||
text(text: string): void;
|
||||
placeholder(name: string, body: string|undefined): void;
|
||||
startPlaceholder(name: string): void;
|
||||
closePlaceholder(name: string): void;
|
||||
startContainer(): void;
|
||||
closeContainer(): void;
|
||||
startIcu(): void;
|
||||
endIcu(): void;
|
||||
}
|
||||
|
||||
export function stripInterpolationMarkers(interpolation: string): string {
|
||||
return interpolation.replace(/^\{\{/, '').replace(/}}$/, '');
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {ɵParsedTranslation, ɵmakeParsedTranslation} from '@angular/localize';
|
||||
import {MessageRenderer} from './message_renderer';
|
||||
|
||||
/**
|
||||
* A message renderer that outputs `ɵParsedTranslation` objects.
|
||||
*/
|
||||
export class TargetMessageRenderer implements MessageRenderer<ɵParsedTranslation> {
|
||||
private current: MessageInfo = {messageParts: [], placeholderNames: [], text: ''};
|
||||
private icuDepth = 0;
|
||||
|
||||
get message(): ɵParsedTranslation {
|
||||
const {messageParts, placeholderNames} = this.current;
|
||||
return ɵmakeParsedTranslation(messageParts, placeholderNames);
|
||||
}
|
||||
startRender(): void {}
|
||||
endRender(): void { this.storeMessagePart(); }
|
||||
text(text: string): void { this.current.text += text; }
|
||||
placeholder(name: string, body: string|undefined): void { this.renderPlaceholder(name); }
|
||||
startPlaceholder(name: string): void { this.renderPlaceholder(name); }
|
||||
closePlaceholder(name: string): void { this.renderPlaceholder(name); }
|
||||
startContainer(): void {}
|
||||
closeContainer(): void {}
|
||||
startIcu(): void {
|
||||
this.icuDepth++;
|
||||
this.text('{');
|
||||
}
|
||||
endIcu(): void {
|
||||
this.icuDepth--;
|
||||
this.text('}');
|
||||
}
|
||||
private renderPlaceholder(name: string) {
|
||||
if (this.icuDepth > 0) {
|
||||
this.text(`{${name}}`);
|
||||
} else {
|
||||
this.storeMessagePart();
|
||||
this.current.placeholderNames.push(name);
|
||||
}
|
||||
}
|
||||
private storeMessagePart() {
|
||||
this.current.messageParts.push(this.current.text);
|
||||
this.current.text = '';
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageInfo {
|
||||
messageParts: string[];
|
||||
placeholderNames: string[];
|
||||
text: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {join} from 'path';
|
||||
|
||||
export interface OutputPathFn { (locale: string, relativePath: string): string; }
|
||||
|
||||
/**
|
||||
* Create a function that will compute the absolute path to where a translated file should be
|
||||
* written.
|
||||
*
|
||||
* The special `{{LOCALE}}` marker will be replaced with the locale code of the current translation.
|
||||
* @param outputFolder An absolute path to the folder containing this set of translations.
|
||||
*/
|
||||
export function getOutputPathFn(outputFolder: string): OutputPathFn {
|
||||
const [pre, post] = outputFolder.split('{{LOCALE}}');
|
||||
return post === undefined ? (_locale, relativePath) => join(pre, relativePath) :
|
||||
(locale, relativePath) => join(pre + locale + post, relativePath);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {ɵParsedTranslation} from '@angular/localize';
|
||||
import {NodePath, PluginObj} from '@babel/core';
|
||||
import {TaggedTemplateExpression} from '@babel/types';
|
||||
import {Diagnostics} from '../../diagnostics';
|
||||
import {TranslatePluginOptions, buildLocalizeReplacement, isBabelParseError, isGlobalIdentifier, isNamedIdentifier, translate, unwrapMessagePartsFromTemplateLiteral} from './source_file_utils';
|
||||
|
||||
export function makeEs2015TranslatePlugin(
|
||||
diagnostics: Diagnostics, translations: Record<string, ɵParsedTranslation>,
|
||||
{missingTranslation = 'error', localizeName = '$localize'}: TranslatePluginOptions = {}):
|
||||
PluginObj {
|
||||
return {
|
||||
visitor: {
|
||||
TaggedTemplateExpression(path: NodePath<TaggedTemplateExpression>) {
|
||||
try {
|
||||
const tag = path.get('tag');
|
||||
if (isNamedIdentifier(tag, localizeName) && isGlobalIdentifier(tag)) {
|
||||
const messageParts = unwrapMessagePartsFromTemplateLiteral(path.node.quasi.quasis);
|
||||
const translated = translate(
|
||||
diagnostics, translations, messageParts, path.node.quasi.expressions,
|
||||
missingTranslation);
|
||||
path.replaceWith(buildLocalizeReplacement(translated[0], translated[1]));
|
||||
}
|
||||
} catch (e) {
|
||||
if (isBabelParseError(e)) {
|
||||
// If we get a BabelParseError here then something went wrong with Babel itself
|
||||
// since there must be something wrong with the structure of the AST generated
|
||||
// by Babel parsing a TaggedTemplateExpression.
|
||||
throw path.hub.file.buildCodeFrameError(e.node, e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {ɵParsedTranslation} from '@angular/localize';
|
||||
import {NodePath, PluginObj} from '@babel/core';
|
||||
import {CallExpression} from '@babel/types';
|
||||
import {Diagnostics} from '../../diagnostics';
|
||||
import {TranslatePluginOptions, buildLocalizeReplacement, isBabelParseError, isGlobalIdentifier, isNamedIdentifier, translate, unwrapMessagePartsFromLocalizeCall, unwrapSubstitutionsFromLocalizeCall} from './source_file_utils';
|
||||
|
||||
export function makeEs5TranslatePlugin(
|
||||
diagnostics: Diagnostics, translations: Record<string, ɵParsedTranslation>,
|
||||
{missingTranslation = 'error', localizeName = '$localize'}: TranslatePluginOptions = {}):
|
||||
PluginObj {
|
||||
return {
|
||||
visitor: {
|
||||
CallExpression(callPath: NodePath<CallExpression>) {
|
||||
try {
|
||||
const calleePath = callPath.get('callee');
|
||||
if (isNamedIdentifier(calleePath, localizeName) && isGlobalIdentifier(calleePath)) {
|
||||
const messageParts = unwrapMessagePartsFromLocalizeCall(callPath.node);
|
||||
const expressions = unwrapSubstitutionsFromLocalizeCall(callPath.node);
|
||||
const translated =
|
||||
translate(diagnostics, translations, messageParts, expressions, missingTranslation);
|
||||
callPath.replaceWith(buildLocalizeReplacement(translated[0], translated[1]));
|
||||
}
|
||||
} catch (e) {
|
||||
if (isBabelParseError(e)) {
|
||||
diagnostics.error(callPath.hub.file.buildCodeFrameError(e.node, e.message).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {parseSync, transformFromAstSync} from '@babel/core';
|
||||
import {extname, join} from 'path';
|
||||
|
||||
import {Diagnostics} from '../../diagnostics';
|
||||
import {FileUtils} from '../../file_utils';
|
||||
import {OutputPathFn} from '../output_path';
|
||||
import {TranslationBundle, TranslationHandler} from '../translator';
|
||||
|
||||
import {makeEs2015TranslatePlugin} from './es2015_translate_plugin';
|
||||
import {makeEs5TranslatePlugin} from './es5_translate_plugin';
|
||||
import {TranslatePluginOptions} from './source_file_utils';
|
||||
|
||||
/**
|
||||
* Translate a file by inlining all messages tagged by `$localize` with the appropriate translated
|
||||
* message.
|
||||
*/
|
||||
export class SourceFileTranslationHandler implements TranslationHandler {
|
||||
constructor(private translationOptions: TranslatePluginOptions = {}) {}
|
||||
|
||||
canTranslate(relativeFilePath: string, contents: Buffer): boolean {
|
||||
return extname(relativeFilePath) === '.js';
|
||||
}
|
||||
|
||||
translate(
|
||||
diagnostics: Diagnostics, sourceRoot: string, relativeFilePath: string, contents: Buffer,
|
||||
outputPathFn: OutputPathFn, translations: TranslationBundle[]): void {
|
||||
const sourceCode = contents.toString('utf8');
|
||||
// A short-circuit check to avoid parsing the file into an AST if it does not contain any
|
||||
// `$localize` identifiers.
|
||||
if (!sourceCode.includes('$localize')) {
|
||||
for (const translation of translations) {
|
||||
FileUtils.writeFile(outputPathFn(translation.locale, relativeFilePath), contents);
|
||||
}
|
||||
} else {
|
||||
const ast = parseSync(sourceCode, {sourceRoot, filename: relativeFilePath});
|
||||
if (!ast) {
|
||||
diagnostics.error(`Unable to parse source file: ${join(sourceRoot, relativeFilePath)}`);
|
||||
return;
|
||||
}
|
||||
for (const translationBundle of translations) {
|
||||
const translated = transformFromAstSync(ast, sourceCode, {
|
||||
compact: true,
|
||||
generatorOpts: {minified: true},
|
||||
plugins: [
|
||||
makeEs2015TranslatePlugin(
|
||||
diagnostics, translationBundle.translations, this.translationOptions),
|
||||
makeEs5TranslatePlugin(
|
||||
diagnostics, translationBundle.translations, this.translationOptions),
|
||||
],
|
||||
filename: relativeFilePath,
|
||||
});
|
||||
if (translated && translated.code) {
|
||||
FileUtils.writeFile(
|
||||
outputPathFn(translationBundle.locale, relativeFilePath), translated.code);
|
||||
} else {
|
||||
diagnostics.error(
|
||||
`Unable to translate source file: ${join(sourceRoot, relativeFilePath)}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {ɵParsedTranslation, ɵisMissingTranslationError, ɵmakeTemplateObject, ɵtranslate} from '@angular/localize';
|
||||
import {NodePath} from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import {Diagnostics} from '../../diagnostics';
|
||||
|
||||
/**
|
||||
* Is the given `expression` an identifier with the correct name
|
||||
* @param expression The expression to check.
|
||||
*/
|
||||
export function isNamedIdentifier(
|
||||
expression: NodePath<t.Expression>, name: string): expression is NodePath<t.Identifier> {
|
||||
return expression.isIdentifier() && expression.node.name === name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given `identifier` declared globally.
|
||||
* @param identifier The identifier to check.
|
||||
*/
|
||||
export function isGlobalIdentifier(identifier: NodePath<t.Identifier>) {
|
||||
return !identifier.scope || !identifier.scope.hasBinding(identifier.node.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a translated expression to replace the call to `$localize`.
|
||||
* @param messageParts The static parts of the message.
|
||||
* @param substitutions The expressions to substitute into the message.
|
||||
*/
|
||||
export function buildLocalizeReplacement(
|
||||
messageParts: TemplateStringsArray, substitutions: readonly t.Expression[]): t.Expression {
|
||||
let mappedString: t.Expression = t.stringLiteral(messageParts[0]);
|
||||
for (let i = 1; i < messageParts.length; i++) {
|
||||
mappedString =
|
||||
t.binaryExpression('+', mappedString, wrapInParensIfNecessary(substitutions[i - 1]));
|
||||
mappedString = t.binaryExpression('+', mappedString, t.stringLiteral(messageParts[i]));
|
||||
}
|
||||
return mappedString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the message parts from the given `call` (to `$localize`).
|
||||
*
|
||||
* The message parts will either by the first argument to the `call` or it will be wrapped in call
|
||||
* to a helper function like `__makeTemplateObject`.
|
||||
*
|
||||
* @param call The AST node of the call to process.
|
||||
*/
|
||||
export function unwrapMessagePartsFromLocalizeCall(call: t.CallExpression): TemplateStringsArray {
|
||||
let cooked = call.arguments[0];
|
||||
if (!t.isExpression(cooked)) {
|
||||
throw new BabelParseError(call, 'Unexpected argument to `$localize`: ' + cooked);
|
||||
}
|
||||
|
||||
// If there is no call to `__makeTemplateObject(...)`, then `raw` must be the same as `cooked`.
|
||||
let raw = cooked;
|
||||
|
||||
// Check for cached call of the form `x || x = __makeTemplateObject(...)`
|
||||
if (t.isLogicalExpression(cooked) && cooked.operator === '||' && t.isIdentifier(cooked.left) &&
|
||||
t.isExpression(cooked.right)) {
|
||||
if (t.isAssignmentExpression(cooked.right)) {
|
||||
cooked = cooked.right.right;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for `__makeTemplateObject(cooked, raw)` call
|
||||
if (t.isCallExpression(cooked)) {
|
||||
raw = cooked.arguments[1] as t.Expression;
|
||||
if (!t.isExpression(raw)) {
|
||||
throw new BabelParseError(
|
||||
raw,
|
||||
'Unexpected `raw` argument to the "makeTemplateObject()" function (expected an expression).');
|
||||
}
|
||||
cooked = cooked.arguments[0];
|
||||
if (!t.isExpression(cooked)) {
|
||||
throw new BabelParseError(
|
||||
cooked,
|
||||
'Unexpected `cooked` argument to the "makeTemplateObject()" function (expected an expression).');
|
||||
}
|
||||
}
|
||||
|
||||
const cookedStrings = unwrapStringLiteralArray(cooked);
|
||||
const rawStrings = unwrapStringLiteralArray(raw);
|
||||
return ɵmakeTemplateObject(cookedStrings, rawStrings);
|
||||
}
|
||||
|
||||
|
||||
export function unwrapSubstitutionsFromLocalizeCall(call: t.CallExpression): t.Expression[] {
|
||||
const expressions = call.arguments.splice(1);
|
||||
if (!isArrayOfExpressions(expressions)) {
|
||||
const badExpression = expressions.find(expression => !t.isExpression(expression)) !;
|
||||
throw new BabelParseError(
|
||||
badExpression,
|
||||
'Invalid substitutions for `$localize` (expected all substitution arguments to be expressions).');
|
||||
}
|
||||
return expressions;
|
||||
}
|
||||
|
||||
export function unwrapMessagePartsFromTemplateLiteral(elements: t.TemplateElement[]):
|
||||
TemplateStringsArray {
|
||||
const cooked = elements.map(q => {
|
||||
if (q.value.cooked === undefined) {
|
||||
throw new BabelParseError(
|
||||
q, `Unexpected undefined message part in "${elements.map(q => q.value.cooked)}"`);
|
||||
}
|
||||
return q.value.cooked;
|
||||
});
|
||||
const raw = elements.map(q => q.value.raw);
|
||||
return ɵmakeTemplateObject(cooked, raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the given `expression` in parentheses if it is a binary expression.
|
||||
*
|
||||
* This ensures that this expression is evaluated correctly if it is embedded in another expression.
|
||||
*
|
||||
* @param expression The expression to potentially wrap.
|
||||
*/
|
||||
export function wrapInParensIfNecessary(expression: t.Expression): t.Expression {
|
||||
if (t.isBinaryExpression(expression)) {
|
||||
return t.parenthesizedExpression(expression);
|
||||
} else {
|
||||
return expression;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the string values from an `array` of string literals.
|
||||
* @param array The array to unwrap.
|
||||
*/
|
||||
export function unwrapStringLiteralArray(array: t.Expression): string[] {
|
||||
if (!isStringLiteralArray(array)) {
|
||||
throw new BabelParseError(
|
||||
array, 'Unexpected messageParts for `$localize` (expected an array of strings).');
|
||||
}
|
||||
return array.elements.map((str: t.StringLiteral) => str.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the given `node` an array of literal strings?
|
||||
*
|
||||
* @param node The node to test.
|
||||
*/
|
||||
export function isStringLiteralArray(node: t.Node): node is t.Expression&
|
||||
{elements: t.StringLiteral[]} {
|
||||
return t.isArrayExpression(node) && node.elements.every(element => t.isStringLiteral(element));
|
||||
}
|
||||
|
||||
/**
|
||||
* Are all the given `nodes` expressions?
|
||||
* @param nodes The nodes to test.
|
||||
*/
|
||||
export function isArrayOfExpressions(nodes: t.Node[]): nodes is t.Expression[] {
|
||||
return nodes.every(element => t.isExpression(element));
|
||||
}
|
||||
|
||||
/** Options that affect how the `makeEsXXXTranslatePlugin()` functions work. */
|
||||
export interface TranslatePluginOptions {
|
||||
missingTranslation?: MissingTranslationStrategy;
|
||||
localizeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How to handle missing translations.
|
||||
*/
|
||||
export type MissingTranslationStrategy = 'error' | 'warning' | 'ignore';
|
||||
|
||||
/**
|
||||
* Translate the text of the given message, using the given translations.
|
||||
*
|
||||
* Logs as warning if the translation is not available
|
||||
*/
|
||||
export function translate(
|
||||
diagnostics: Diagnostics, translations: Record<string, ɵParsedTranslation>,
|
||||
messageParts: TemplateStringsArray, substitutions: readonly any[],
|
||||
missingTranslation: MissingTranslationStrategy): [TemplateStringsArray, readonly any[]] {
|
||||
try {
|
||||
return ɵtranslate(translations, messageParts, substitutions);
|
||||
} catch (e) {
|
||||
if (ɵisMissingTranslationError(e)) {
|
||||
if (missingTranslation === 'error') {
|
||||
diagnostics.error(e.message);
|
||||
} else if (missingTranslation === 'warning') {
|
||||
diagnostics.warn(e.message);
|
||||
}
|
||||
} else {
|
||||
diagnostics.error(e.message);
|
||||
}
|
||||
return [messageParts, substitutions];
|
||||
}
|
||||
}
|
||||
|
||||
export class BabelParseError extends Error {
|
||||
private readonly type = 'BabelParseError';
|
||||
constructor(public node: t.BaseNode, message: string) { super(message); }
|
||||
}
|
||||
|
||||
export function isBabelParseError(e: any): e is BabelParseError {
|
||||
return e.type === 'BabelParseError';
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {FileUtils} from '../../file_utils';
|
||||
import {TranslationBundle} from '../translator';
|
||||
import {TranslationParser} from './translation_parsers/translation_parser';
|
||||
|
||||
/**
|
||||
* Use this class to load a collection of translation files from disk.
|
||||
*/
|
||||
export class TranslationLoader {
|
||||
constructor(private translationParsers: TranslationParser[]) {}
|
||||
|
||||
/**
|
||||
* Load and parse the translation files into a collection of `TranslationBundles`.
|
||||
*
|
||||
* @param translationFilePaths A collection of absolute paths to the translation files.
|
||||
*/
|
||||
loadBundles(translationFilePaths: string[]): TranslationBundle[] {
|
||||
return translationFilePaths.map(filePath => {
|
||||
const fileContents = FileUtils.readFile(filePath);
|
||||
for (const translationParser of this.translationParsers) {
|
||||
if (translationParser.canParse(filePath, fileContents)) {
|
||||
return translationParser.parse(filePath, fileContents);
|
||||
}
|
||||
}
|
||||
throw new Error(`Unable to parse translation file: ${filePath}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {Attribute, Comment, Element, Expansion, ExpansionCase, Text, Visitor} from '@angular/compiler';
|
||||
|
||||
/**
|
||||
* A simple base class for the `Visitor` interface, which is a noop for every method.
|
||||
*
|
||||
* Sub-classes only need to override the methods that they care about.
|
||||
*/
|
||||
export class BaseVisitor implements Visitor {
|
||||
visitElement(_element: Element, _context: any): any {}
|
||||
visitAttribute(_attribute: Attribute, _context: any): any {}
|
||||
visitText(_text: Text, _context: any): any {}
|
||||
visitComment(_comment: Comment, _context: any): any {}
|
||||
visitExpansion(_expansion: Expansion, _context: any): any {}
|
||||
visitExpansionCase(_expansionCase: ExpansionCase, _context: any): any {}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {ParseErrorLevel, ParseSourceSpan} from '@angular/compiler';
|
||||
|
||||
/**
|
||||
* Instances of this class are thrown when there is an error in the source code that is being
|
||||
* translated.
|
||||
*/
|
||||
export class I18nError extends Error {
|
||||
constructor(
|
||||
public span: ParseSourceSpan, public msg: string,
|
||||
public level: ParseErrorLevel = ParseErrorLevel.ERROR) {
|
||||
super(msg);
|
||||
}
|
||||
|
||||
contextualMessage(): string {
|
||||
const ctx = this.span.start.getContext(100, 3);
|
||||
return ctx ? `${this.msg} ("${ctx.before}[${ParseErrorLevel[this.level]} ->]${ctx.after}")` :
|
||||
this.msg;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
const details = this.span.details ? `, ${this.span.details}` : '';
|
||||
return `${this.contextualMessage()}: ${this.span.start}${details}`;
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {ɵMessageId, ɵParsedTranslation, ɵparseTranslation} from '@angular/localize';
|
||||
import {extname} from 'path';
|
||||
import {TranslationBundle} from '../../../translator';
|
||||
import {TranslationParser} from '../translation_parser';
|
||||
|
||||
/**
|
||||
* A translation parser that can parse JSON that has the form:
|
||||
*
|
||||
* ```
|
||||
* {
|
||||
* "locale": "...",
|
||||
* "translations": {
|
||||
* "message-id": "Target message string",
|
||||
* ...
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class SimpleJsonTranslationParser implements TranslationParser {
|
||||
canParse(filePath: string, _contents: string): boolean { return (extname(filePath) === '.json'); }
|
||||
|
||||
parse(_filePath: string, contents: string): TranslationBundle {
|
||||
const {locale, translations} = JSON.parse(contents);
|
||||
const parsedTranslations: Record<ɵMessageId, ɵParsedTranslation> = {};
|
||||
for (const messageId in translations) {
|
||||
const targetMessage = translations[messageId];
|
||||
parsedTranslations[messageId] = ɵparseTranslation(targetMessage);
|
||||
}
|
||||
return {locale, translations: parsedTranslations};
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {TranslationBundle} from '../../translator';
|
||||
|
||||
/**
|
||||
* Implement this interface to provide a class that can parse the contents of a translation file.
|
||||
*/
|
||||
export interface TranslationParser {
|
||||
/**
|
||||
* Returns true if this parser can parse the given file.
|
||||
*
|
||||
* @param filePath The absolute path to the translation file.
|
||||
* @param contents The contents of the translation file.
|
||||
*/
|
||||
canParse(filePath: string, contents: string): boolean;
|
||||
|
||||
/**
|
||||
* Parses the given file, extracting the target locale and translations.
|
||||
*
|
||||
* @param filePath The absolute path to the translation file.
|
||||
* @param contents The contents of the translation file.
|
||||
*/
|
||||
parse(filePath: string, contents: string): TranslationBundle;
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {Element, LexerRange, Node, XmlParser} from '@angular/compiler';
|
||||
import {I18nError} from './i18n_error';
|
||||
|
||||
export function getAttrOrThrow(element: Element, attrName: string): string {
|
||||
const attrValue = getAttribute(element, attrName);
|
||||
if (attrValue === undefined) {
|
||||
throw new I18nError(element.sourceSpan, `Missing required "${attrName}" attribute:`);
|
||||
}
|
||||
return attrValue;
|
||||
}
|
||||
|
||||
export function getAttribute(element: Element, attrName: string): string|undefined {
|
||||
const attr = element.attrs.find(a => a.name === attrName);
|
||||
return attr !== undefined ? attr.value : undefined;
|
||||
}
|
||||
|
||||
export function parseInnerRange(element: Element): Node[] {
|
||||
const xmlParser = new XmlParser();
|
||||
const xml = xmlParser.parse(
|
||||
element.sourceSpan.start.file.content, element.sourceSpan.start.file.url,
|
||||
{tokenizeExpansionForms: true, range: getInnerRange(element)});
|
||||
return xml.rootNodes;
|
||||
}
|
||||
|
||||
function getInnerRange(element: Element): LexerRange {
|
||||
const start = element.startSourceSpan !.end;
|
||||
const end = element.endSourceSpan !.start;
|
||||
return {
|
||||
startPos: start.offset,
|
||||
startLine: start.line,
|
||||
startCol: start.col,
|
||||
endPos: end.offset,
|
||||
};
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {Element, Expansion, ExpansionCase, Node, Text, visitAll} from '@angular/compiler';
|
||||
import {MessageRenderer} from '../../../message_renderers/message_renderer';
|
||||
import {BaseVisitor} from '../base_visitor';
|
||||
import {I18nError} from '../i18n_error';
|
||||
import {getAttrOrThrow} from '../translation_utils';
|
||||
|
||||
const INLINE_ELEMENTS = ['g', 'bx', 'ex', 'bpt', 'ept', 'ph', 'it', 'mrk'];
|
||||
|
||||
export class Xliff1MessageSerializer<T> extends BaseVisitor {
|
||||
constructor(private renderer: MessageRenderer<T>) { super(); }
|
||||
|
||||
serialize(nodes: Node[]): T {
|
||||
this.renderer.startRender();
|
||||
visitAll(this, nodes);
|
||||
this.renderer.endRender();
|
||||
return this.renderer.message;
|
||||
}
|
||||
|
||||
visitElement(element: Element): void {
|
||||
if (element.name === 'x') {
|
||||
this.visitPlaceholder(getAttrOrThrow(element, 'id'), '');
|
||||
} else if (INLINE_ELEMENTS.indexOf(element.name) !== -1) {
|
||||
visitAll(this, element.children);
|
||||
} else {
|
||||
throw new I18nError(element.sourceSpan, `Invalid element found in message.`);
|
||||
}
|
||||
}
|
||||
|
||||
visitText(text: Text): void { this.renderer.text(text.value); }
|
||||
|
||||
visitExpansion(expansion: Expansion): void {
|
||||
this.renderer.startIcu();
|
||||
this.renderer.text(`${expansion.switchValue}, ${expansion.type},`);
|
||||
visitAll(this, expansion.cases);
|
||||
this.renderer.endIcu();
|
||||
}
|
||||
|
||||
visitExpansionCase(expansionCase: ExpansionCase): void {
|
||||
this.renderer.text(` ${expansionCase.value} {`);
|
||||
this.renderer.startContainer();
|
||||
visitAll(this, expansionCase.expression);
|
||||
this.renderer.closeContainer();
|
||||
this.renderer.text(`}`);
|
||||
}
|
||||
|
||||
visitPlaceholder(name: string, body: string|undefined): void {
|
||||
this.renderer.placeholder(name, body);
|
||||
}
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {Element, Node, XmlParser, visitAll} from '@angular/compiler';
|
||||
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize';
|
||||
import {extname} from 'path';
|
||||
import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer';
|
||||
import {TranslationBundle} from '../../../translator';
|
||||
import {BaseVisitor} from '../base_visitor';
|
||||
import {I18nError} from '../i18n_error';
|
||||
import {TranslationParser} from '../translation_parser';
|
||||
import {getAttrOrThrow, parseInnerRange} from '../translation_utils';
|
||||
import {Xliff1MessageSerializer} from './xliff1_message_serializer';
|
||||
|
||||
const XLIFF_1_2_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:1.2"/;
|
||||
|
||||
/**
|
||||
* A translation parser that can load XLIFF 1.2 files.
|
||||
*
|
||||
* http://docs.oasis-open.org/xliff/v1.2/os/xliff-core.html
|
||||
* http://docs.oasis-open.org/xliff/v1.2/xliff-profile-html/xliff-profile-html-1.2.html
|
||||
*
|
||||
*/
|
||||
export class Xliff1TranslationParser implements TranslationParser {
|
||||
canParse(filePath: string, contents: string): boolean {
|
||||
return (extname(filePath) === '.xlf') && XLIFF_1_2_NS_REGEX.test(contents);
|
||||
}
|
||||
|
||||
parse(filePath: string, contents: string): TranslationBundle {
|
||||
const xmlParser = new XmlParser();
|
||||
const xml = xmlParser.parse(contents, filePath);
|
||||
const bundle = XliffFileElementVisitor.extractBundle(xml.rootNodes);
|
||||
if (bundle === undefined) {
|
||||
throw new Error(`Unable to parse "${filePath}" as XLIFF 1.2 format.`);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
|
||||
class XliffFileElementVisitor extends BaseVisitor {
|
||||
private bundle: TranslationBundle|undefined;
|
||||
|
||||
static extractBundle(xliff: Node[]): TranslationBundle|undefined {
|
||||
const visitor = new this();
|
||||
visitAll(visitor, xliff);
|
||||
return visitor.bundle;
|
||||
}
|
||||
|
||||
visitElement(element: Element): any {
|
||||
if (element.name === 'file') {
|
||||
this.bundle = {
|
||||
locale: getAttrOrThrow(element, 'target-language'),
|
||||
translations: XliffTranslationVisitor.extractTranslations(element)
|
||||
};
|
||||
} else {
|
||||
return visitAll(this, element.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class XliffTranslationVisitor extends BaseVisitor {
|
||||
private translations: Record<ɵMessageId, ɵParsedTranslation> = {};
|
||||
|
||||
static extractTranslations(file: Element): Record<string, ɵParsedTranslation> {
|
||||
const visitor = new this();
|
||||
visitAll(visitor, file.children);
|
||||
return visitor.translations;
|
||||
}
|
||||
|
||||
visitElement(element: Element): any {
|
||||
if (element.name === 'trans-unit') {
|
||||
const id = getAttrOrThrow(element, 'id');
|
||||
if (this.translations[id] !== undefined) {
|
||||
throw new I18nError(element.sourceSpan, `Duplicated translations for message "${id}"`);
|
||||
}
|
||||
|
||||
const targetMessage = element.children.find(isTargetElement);
|
||||
if (targetMessage === undefined) {
|
||||
throw new I18nError(element.sourceSpan, 'Missing required <target> element');
|
||||
}
|
||||
this.translations[id] = serializeTargetMessage(targetMessage);
|
||||
} else {
|
||||
return visitAll(this, element.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function serializeTargetMessage(source: Element): ɵParsedTranslation {
|
||||
const serializer = new Xliff1MessageSerializer(new TargetMessageRenderer());
|
||||
return serializer.serialize(parseInnerRange(source));
|
||||
}
|
||||
|
||||
function isTargetElement(node: Node): node is Element {
|
||||
return node instanceof Element && node.name === 'target';
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {Element, Expansion, ExpansionCase, Node, Text, visitAll} from '@angular/compiler';
|
||||
import {MessageRenderer} from '../../../message_renderers/message_renderer';
|
||||
import {BaseVisitor} from '../base_visitor';
|
||||
import {I18nError} from '../i18n_error';
|
||||
import {getAttrOrThrow, getAttribute} from '../translation_utils';
|
||||
|
||||
const INLINE_ELEMENTS = ['cp', 'sc', 'ec', 'mrk', 'sm', 'em'];
|
||||
|
||||
export class Xliff2MessageSerializer<T> extends BaseVisitor {
|
||||
constructor(private renderer: MessageRenderer<T>) { super(); }
|
||||
|
||||
serialize(nodes: Node[]): T {
|
||||
this.renderer.startRender();
|
||||
visitAll(this, nodes);
|
||||
this.renderer.endRender();
|
||||
return this.renderer.message;
|
||||
}
|
||||
|
||||
visitElement(element: Element): void {
|
||||
if (element.name === 'ph') {
|
||||
this.visitPlaceholder(getAttrOrThrow(element, 'equiv'), getAttribute(element, 'disp'));
|
||||
} else if (element.name === 'pc') {
|
||||
this.visitPlaceholderContainer(
|
||||
getAttrOrThrow(element, 'equivStart'), element.children,
|
||||
getAttrOrThrow(element, 'equivEnd'));
|
||||
} else if (INLINE_ELEMENTS.indexOf(element.name) !== -1) {
|
||||
visitAll(this, element.children);
|
||||
} else {
|
||||
throw new I18nError(element.sourceSpan, `Invalid element found in message.`);
|
||||
}
|
||||
}
|
||||
|
||||
visitText(text: Text): void { this.renderer.text(text.value); }
|
||||
|
||||
visitExpansion(expansion: Expansion): void {
|
||||
this.renderer.startIcu();
|
||||
this.renderer.text(`${expansion.switchValue}, ${expansion.type},`);
|
||||
visitAll(this, expansion.cases);
|
||||
this.renderer.endIcu();
|
||||
}
|
||||
|
||||
visitExpansionCase(expansionCase: ExpansionCase): void {
|
||||
this.renderer.text(` ${expansionCase.value} {`);
|
||||
this.renderer.startContainer();
|
||||
visitAll(this, expansionCase.expression);
|
||||
this.renderer.closeContainer();
|
||||
this.renderer.text(`}`);
|
||||
}
|
||||
|
||||
visitContainedNodes(nodes: Node[]): void {
|
||||
const length = nodes.length;
|
||||
let index = 0;
|
||||
while (index < length) {
|
||||
if (!isPlaceholderContainer(nodes[index])) {
|
||||
const startOfContainedNodes = index;
|
||||
while (index < length - 1) {
|
||||
index++;
|
||||
if (isPlaceholderContainer(nodes[index])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (index - startOfContainedNodes > 1) {
|
||||
// Only create a container if there are two or more contained Nodes in a row
|
||||
this.renderer.startContainer();
|
||||
visitAll(this, nodes.slice(startOfContainedNodes, index - 1));
|
||||
this.renderer.closeContainer();
|
||||
}
|
||||
}
|
||||
if (index < length) {
|
||||
nodes[index].visit(this, undefined);
|
||||
}
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
visitPlaceholder(name: string, body: string|undefined): void {
|
||||
this.renderer.placeholder(name, body);
|
||||
}
|
||||
|
||||
visitPlaceholderContainer(startName: string, children: Node[], closeName: string): void {
|
||||
this.renderer.startPlaceholder(startName);
|
||||
this.visitContainedNodes(children);
|
||||
this.renderer.closePlaceholder(closeName);
|
||||
}
|
||||
}
|
||||
|
||||
function isPlaceholderContainer(node: Node): boolean {
|
||||
return node instanceof Element && node.name === 'pc';
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright Google Inc. All Rights Reserved.
|
||||
*
|
||||
* Use of this source code is governed by an MIT-style license that can be
|
||||
* found in the LICENSE file at https://angular.io/license
|
||||
*/
|
||||
import {Element, Node, XmlParser, visitAll} from '@angular/compiler';
|
||||
import {ɵMessageId, ɵParsedTranslation} from '@angular/localize';
|
||||
import {extname} from 'path';
|
||||
import {TargetMessageRenderer} from '../../../message_renderers/target_message_renderer';
|
||||
import {TranslationBundle} from '../../../translator';
|
||||
import {BaseVisitor} from '../base_visitor';
|
||||
import {I18nError} from '../i18n_error';
|
||||
import {TranslationParser} from '../translation_parser';
|
||||
import {getAttrOrThrow, parseInnerRange} from '../translation_utils';
|
||||
import {Xliff2MessageSerializer} from './xliff2_message_serializer';
|
||||
|
||||
const XLIFF_2_0_NS_REGEX = /xmlns="urn:oasis:names:tc:xliff:document:2.0"/;
|
||||
|
||||
/**
|
||||
* A translation parser that can load translations from XLIFF 2 files.
|
||||
*
|
||||
* http://docs.oasis-open.org/xliff/xliff-core/v2.0/os/xliff-core-v2.0-os.html
|
||||
*
|
||||
*/
|
||||
export class Xliff2TranslationParser implements TranslationParser {
|
||||
canParse(filePath: string, contents: string): boolean {
|
||||
return (extname(filePath) === '.xlf') && XLIFF_2_0_NS_REGEX.test(contents);
|
||||
}
|
||||
|
||||
parse(filePath: string, contents: string): TranslationBundle {
|
||||
const xmlParser = new XmlParser();
|
||||
const xml = xmlParser.parse(contents, filePath);
|
||||
const bundle = Xliff2TranslationBundleVisitor.extractBundle(xml.rootNodes);
|
||||
if (bundle === undefined) {
|
||||
throw new Error(`Unable to parse "${filePath}" as XLIFF 2.0 format.`);
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
}
|
||||
|
||||
class Xliff2TranslationBundleVisitor extends BaseVisitor {
|
||||
private locale: string|undefined;
|
||||
private bundle: TranslationBundle|undefined;
|
||||
|
||||
static extractBundle(xliff: Node[]): TranslationBundle|undefined {
|
||||
const visitor = new this();
|
||||
visitAll(visitor, xliff);
|
||||
return visitor.bundle;
|
||||
}
|
||||
|
||||
visitElement(element: Element): any {
|
||||
if (element.name === 'xliff') {
|
||||
this.locale = getAttrOrThrow(element, 'trgLang');
|
||||
return visitAll(this, element.children);
|
||||
} else if (element.name === 'file') {
|
||||
this.bundle = {
|
||||
locale: this.locale !,
|
||||
translations: Xliff2TranslationVisitor.extractTranslations(element)
|
||||
};
|
||||
} else {
|
||||
return visitAll(this, element.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Xliff2TranslationVisitor extends BaseVisitor {
|
||||
private translations: Record<ɵMessageId, ɵParsedTranslation> = {};
|
||||
|
||||
static extractTranslations(file: Element): Record<string, ɵParsedTranslation> {
|
||||
const visitor = new this();
|
||||
visitAll(visitor, file.children);
|
||||
return visitor.translations;
|
||||
}
|
||||
|
||||
visitElement(element: Element, context: any): any {
|
||||
if (element.name === 'unit') {
|
||||
const externalId = getAttrOrThrow(element, 'id');
|
||||
if (this.translations[externalId] !== undefined) {
|
||||
throw new I18nError(
|
||||
element.sourceSpan, `Duplicated translations for message "${externalId}"`);
|
||||
}
|
||||
visitAll(this, element.children, {unit: externalId});
|
||||
} else if (element.name === 'segment') {
|
||||
assertTranslationUnit(element, context);
|
||||
const targetMessage = element.children.find(isTargetElement);
|
||||
if (targetMessage === undefined) {
|
||||
throw new I18nError(element.sourceSpan, 'Missing required <target> element');
|
||||
}
|
||||
this.translations[context.unit] = serializeTargetMessage(targetMessage);
|
||||
} else {
|
||||
return visitAll(this, element.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertTranslationUnit(segment: Element, context: any) {
|
||||
if (context === undefined || context.unit === undefined) {
|
||||
throw new I18nError(
|
||||
segment.sourceSpan, 'Invalid <segment> element: should be a child of a <unit> element.');
|
||||
}
|
||||
}
|
||||
|
||||
function serializeTargetMessage(source: Element): ɵParsedTranslation {
|
||||
const serializer = new Xliff2MessageSerializer(new TargetMessageRenderer());
|
||||
return serializer.serialize(parseInnerRange(source));
|
||||
}
|
||||
|
||||
function isTargetElement(node: Node): node is Element {
|
||||
return node instanceof Element && node.name === 'target';
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user