`)
33 | - Include line and column numbers in warning and error messages
34 | - Emit an error if a loader is registered, but the plugin is not
35 |
36 | Bugfixes:
37 | - Correctly register empty elements with translate attribute (``)
38 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Micha Reiser
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.MD:
--------------------------------------------------------------------------------
1 | # webpack-angular-translate
2 |
3 | [](https://nodei.co/npm/webpack-angular-translate/)
4 |
5 | [](https://travis-ci.org/MichaReiser/webpack-angular-translate)
6 | [](https://coveralls.io/github/MichaReiser/webpack-angular-translate?branch=master)
7 | [](https://gemnasium.com/DatenMetgzerX/webpack-angular-translate)
8 | [](http://badge.fury.io/js/webpack-angular-translate)
9 |
10 | This plugin extracts the translation id's and default texts from angular-translate and writes them into a separate json file.
11 | The json file can be used by a backend component to initialize all the used translations. The benefit of this approach is,
12 | that the frontend developer can define all translations directly in the frontend code and doesn't need to modify any backend-code.
13 |
14 | ## Getting started
15 |
16 | Install the plugin using npm:
17 |
18 | ```bash
19 | npm install webpack-angular-translate
20 | ```
21 |
22 | Configure the loader and the plugin in the webpack configuration.
23 |
24 | ```js
25 | var WebPackAngularTranslate = require("webpack-angular-translate");
26 | {
27 | ...
28 | module: {
29 | rules: [
30 | {
31 | test: /\.html$/,
32 | use: [
33 | {
34 | loader: "html-loader",
35 | options: {
36 | removeEmptyAttributes: false,
37 | attrs: []
38 | }
39 | },
40 | {
41 | loader: WebPackAngularTranslate.htmlLoader()
42 | }
43 | ]
44 | },
45 | {
46 | test: /\.js/,
47 | loader: WebPackAngularTranslate.jsLoader(),
48 | options: {
49 | parserOptions: {
50 | sourceType: "module"
51 | }
52 | }
53 | }
54 | ]
55 | },
56 |
57 | plugins: [new WebPackAngularTranslate.Plugin()]
58 | }
59 | ```
60 |
61 | The htmlLoader should be used as pre loader as it expects html as input (and not html embedded into js, what is the result of the _html-loader_).
62 | The javascriptLoader can be used like any other loader (pre / post or normal loader). The loader requires that the input is valid javascript. It's possible to only use the javascript or the html loader. It's advised to only apply the loader to relevant files, as it requires an additional parsing step, which has an effect on the build performance.
63 |
64 | The plugin accepts the following options in the constructor:
65 |
66 | - fileName: The name of the file that contains all the translations, default `translations.json`
67 |
68 | The loaders accepts the name of a loader that should be applied in advance. E.g. the js loader can be applied to the result of the typescript loader:
69 |
70 | ```js
71 | {
72 | test: /\.ts$/,
73 | loader: WebPackAngularTranslate.jsLoader('ts-loader')
74 | }
75 | ```
76 |
77 | ### Custom HTML Translation extractors
78 |
79 | The htmlLoader supports registering custom HTML text extractors. The API of an extractor is:
80 |
81 | ```
82 | export interface HtmlTranslationExtractor {
83 | (element: AngularElement, context: HtmlTranslationExtractionContext): void;
84 | }
85 |
86 | export interface AngularElement {
87 | tagName: string;
88 | attributes: Attribute[];
89 | texts: Text[];
90 | startPosition: number;
91 | }
92 |
93 | export interface HtmlTranslationExtractionContext {
94 | emitError(message: string, position: number): void;
95 | emitSuppressableError(message: string, position: number): void;
96 | registerTranslation(translation: {
97 | translationId: string;
98 | defaultText?: string;
99 | position: number;
100 | }): void;
101 |
102 | asHtml(): void;
103 | }
104 | ```
105 |
106 | The extractor receives an angular element with all its attributes and its direct text siblings as well as a context that can be used to either register a new translation or emit a warning/error.
107 | The [translate-directive-translation-extractor.ts](src/html/translate-directive-translation-extractor.ts) contains an implementation of an extractor.
108 | Custom extractors can be specified with the html loader:
109 |
110 | ```js
111 | {
112 | loader: WebPackAngularTranslate.htmlLoader({
113 | translationExtractors: [(element, context) => { ... }]
114 | })
115 | }
116 | ```
117 |
118 | #### AngularI18nTranslationsExtractor
119 |
120 | WebpackAngularTranslates provides the `angularI18nTranslationsExtractor` to support extractions of translations in applications using [angular](https://angular.io/).
121 | It extracts translations from the `i18n` and `i18n-[attr]` directives, used by [Angular for Internationalization](https://angular.io/guide/i18n).
122 |
123 | ```js
124 | {
125 | use: [{
126 | loader: WebPackAngularTranslate.htmlLoader(),
127 | options: {
128 | translationExtractors: [WebPackAngularTranslate.angularI18nTranslationsExtractor]
129 | }
130 | }]
131 | }
132 | ```
133 |
134 | Examples:
135 |
136 | `
A title
` results in a translation with `{id: "A title", defaultTranslation: "A title"}`
137 |
138 | `
This is a very long text for the loan intro!
` results in a translation with `{id: "loan-intro-description-text", defaultTranslation: "This is a very long text for the loan intro!"}`
139 |
140 | `` results in a translation with `{id: "MyImage", defaultTranslation: "My image title"}`
141 |
142 | Note: The extraction will only work for labels with an explicitly provided `@@id` and default translation.
143 |
144 | ## Supported Expressions
145 |
146 | ### Directive
147 |
148 | The directive is supported for static translations. Dynamic translations are not supported.
149 |
150 | ```html
151 |
158 | Translation-ID
159 |
160 |
161 |
162 | ```
163 |
164 | ### Filter
165 |
166 | Filters in Angular-Expression statements are supported when the value, to which the filter is applied to, is a literal and no other filter is applied before the `translate` filter.
167 | The following examples are supported:
168 |
169 | ```html
170 |
171 |
{{ 'My long translation' | translate | limitTo:20 }}
172 |
173 | {{ "4" | translate }} {{ "x" | translate }}
174 | ```
175 |
176 | Filters in `ng-bind` and other attributes are currently not supported. In the most scenarios `ng-bind` can be replaced with the `translate` directive or a filter can be applied directly.
177 |
178 | ### Service
179 |
180 | The `$translate` service is supported for literals only. No dynamic translations are supported. It's required
181 | that the `$translate` service is always called `$translate`.
182 |
183 | The following examples are supported:
184 |
185 | ```js
186 | $translate('Login');
187 |
188 | this.$translate('Login');
189 | _this.$translate('Login'); // also other names then _this
190 |
191 | $translate.instant('Login');
192 | this.$translate.instant('Login');
193 |
194 | $translate('Login', ..., ..., 'Anmelden');
195 | ```
196 |
197 | If the `$translate` service is used with an expression (e.g. variable), then compilation will fail and an error is emitted
198 | to the console. The error is emitted to remind the developer that he is responsible to register the dynamic translation.
199 | If the dynamic translations have been registered, then the error can be suppressed using a `/* suppress-dynamic-translation-error: true */`
200 | comment in the block of the `$translate` call. This will suppress all errors for the current block.
201 |
202 | ## Register dynamic translations
203 |
204 | If a dynamic translation is used, then the translation needs to be registered. To do so, the `i18n.registerTranslations({ translationId: defaultText })` function can be used. This might be helpful if the translation id is dynamically composed from a value of a domain object but the set of all possible combinations is limited. The registered translations are merged into the outputted json file. The function calls will be replaced by `(0);`. If UglifyJS is used for minification, then those calls will be removed entirely.
205 |
206 | An alternative is `i18n.registerTranslation(translationId, defaultText?)`. This function is intended to be used when the translation id's are known in the javascript code but not known in the html file. Following an example where the title is dynamically set, depending if it is a new item or an existing one:
207 |
208 | ```html
209 |
{{editCtrl.title}}
210 | ```
211 |
212 | The controller defines the id of the translation to use:
213 |
214 | ```js
215 | function EditController(user) {
216 | // compiles to this.title = user.isNew() ? "NEW_USER" : "EDIT_USER";
217 | this.title = user.isNew()
218 | ? i18n.registerTranslation("NEW_USER", "New user")
219 | : i18n.registerTranslation("EDIT_USER", "Edit user");
220 | }
221 | ```
222 |
223 | The call to `i18n.registerTranslation` registers the translation id with the default text (optional). The result of the function is the id of the translation to use. This makes it possible to register translations inplace. Calls to `i18n.registerTranslation` compile to the passed in translation id (the function is not evaluated at runtime).
224 |
225 | The `suppress-dynamic-translation-error` attribute can be defined on any element and will suppress any errors from the plugin for the attributed element and all it's child elements. This attribute is removed for non debugging builds.
226 |
227 | ## API
228 |
229 | **`i18n.registerTranslation(translationId: string, defaultText?: string): string`**
230 |
231 | Registers a translation with the given translation id and default text. If the default text is absent, then the translation id is used as default text.
232 |
233 | Returns the passed in translation id. Can be used to pass to the translate service or can be bound to a translate directive.
234 |
235 | **`i18n.registerTranslations({ translationId: defaultText } ): string[]`**
236 |
237 | Registers a set of translations. Accepts a single object where the keys are used as translation ids and the value are used as default text.
238 |
239 | Returns an array containing the passed in translation ids. The array can be passed to the translate service.
240 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | ["@babel/preset-env", { targets: { node: "8" } }],
4 | "@babel/preset-typescript"
5 | ],
6 | plugins: ["@babel/plugin-proposal-class-properties"]
7 | };
8 |
--------------------------------------------------------------------------------
/example/app.js:
--------------------------------------------------------------------------------
1 | var angular = require("angular");
2 | require("angular-ui-router");
3 | require("angular-translate");
4 | require("angular-translate-loader-url");
5 |
6 | function HomeController($translate) {
7 | "use strict";
8 |
9 | var self = this;
10 |
11 | $translate("Home").then(function (title) {
12 | self.title = title;
13 | });
14 | }
15 |
16 | function UsersController () {
17 | "use strict";
18 |
19 | this.user = {
20 | name: "Test User",
21 | email: "test@company.ch"
22 | };
23 | }
24 |
25 |
26 | var module = angular.module('example', ['ui.router', 'pascalprecht.translate']);
27 |
28 | module.config(function ($stateProvider, $urlRouterProvider, $translateProvider) {
29 | "use strict";
30 |
31 | $stateProvider.state("home", {
32 | url: '/home',
33 | template: require("./home.html"),
34 | controllerAs: 'home',
35 | controller: HomeController
36 | });
37 |
38 | $stateProvider.state("user", {
39 | url: '/user',
40 | template: require("./user.html"),
41 | controllerAs: 'userCtrl',
42 | controller: UsersController
43 | });
44 |
45 | $urlRouterProvider.otherwise('/home');
46 |
47 | $translateProvider.useUrlLoader('dist/translations.json');
48 | $translateProvider.preferredLanguage('en');
49 |
50 |
51 | });
52 |
53 | module.run(function ($rootScope) {
54 | "use strict";
55 | $rootScope.$on("$stateChangeError", console.log.bind(console));
56 | });
--------------------------------------------------------------------------------
/example/home.html:
--------------------------------------------------------------------------------
1 |
2 |
2 |
3 | User name: {{ this.name}}
4 | E-Mail: {{ this.email }}
5 |
6 |
7 | Edit User
--------------------------------------------------------------------------------
/example/webpack.config.js:
--------------------------------------------------------------------------------
1 | const WebPackAngularTranslate = require("../");
2 | const path = require("path");
3 |
4 | module.exports = {
5 | entry: "./app.js",
6 | output: {
7 | path: path.join(__dirname, "dist"),
8 | filename: "[name].js"
9 | },
10 | mode: "development",
11 | module: {
12 | rules: [
13 | {
14 | test: /\.html$/,
15 | use: [
16 | {
17 | loader: "html-loader",
18 | options: {
19 | removeEmptyAttributes: false,
20 | attrs: []
21 | }
22 | },
23 | {
24 | loader: WebPackAngularTranslate.htmlLoader()
25 | }
26 | ]
27 | },
28 | {
29 | test: /\.js/,
30 | loader: WebPackAngularTranslate.jsLoader(),
31 | options: {
32 | parserOptions: {
33 | sourceType: "module"
34 | }
35 | }
36 | }
37 | ]
38 | },
39 |
40 | plugins: [new WebPackAngularTranslate.Plugin()]
41 | };
42 |
--------------------------------------------------------------------------------
/i18n.d.ts:
--------------------------------------------------------------------------------
1 | declare module i18n {
2 | /**
3 | * Registers a translation that will be exported by the webpack-angular-translate
4 | * plugin. If no default text is specified, then the translation id will be used as default-text
5 | * @param translationId the translation id, needs to be a literal (no expressions allowed)
6 | * @param defaultText the default text to use. If not specified, then the translation id is used.
7 | * @returns the translation id
8 | */
9 | function registerTranslation(translationId: string, defaultText?: string) : string;
10 |
11 | /**
12 | * Registers multiple translations where the key is the translation id and the value is the
13 | * default text. Only literal values are supported.
14 | * @params translations the object hash containing the translations to register
15 | * @returns the array with the translation ids
16 | */
17 | function registerTranslations(translations: { [translationId: string] : string}) : string[];
18 | }
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | // For a detailed explanation regarding each configuration property, visit:
2 | // https://jestjs.io/docs/en/configuration.html
3 |
4 | module.exports = {
5 | // All imported modules in your tests should be mocked automatically
6 | // automock: false,
7 |
8 | // Stop running tests after `n` failures
9 | // bail: 0,
10 |
11 | // Respect "browser" field in package.json when resolving modules
12 | // browser: false,
13 |
14 | // The directory where Jest should store its cached dependency information
15 | // cacheDirectory: "/private/var/folders/pp/d0k78q8950s62486c1rt6zjw0000gn/T/jest_dx",
16 |
17 | // Automatically clear mock calls and instances between every test
18 | clearMocks: true,
19 |
20 | // Indicates whether the coverage information should be collected while executing the test
21 | // collectCoverage: false,
22 |
23 | // An array of glob patterns indicating a set of files for which coverage information should be collected
24 | // collectCoverageFrom: null,
25 |
26 | // The directory where Jest should output its coverage files
27 | coverageDirectory: "coverage",
28 |
29 | // An array of regexp pattern strings used to skip coverage collection
30 | // coveragePathIgnorePatterns: [
31 | // "/node_modules/"
32 | // ],
33 |
34 | // A list of reporter names that Jest uses when writing coverage reports
35 | // coverageReporters: [
36 | // "json",
37 | // "text",
38 | // "lcov",
39 | // "clover"
40 | // ],
41 |
42 | // An object that configures minimum threshold enforcement for coverage results
43 | // coverageThreshold: null,
44 |
45 | // A path to a custom dependency extractor
46 | // dependencyExtractor: null,
47 |
48 | // Make calling deprecated APIs throw helpful error messages
49 | // errorOnDeprecated: false,
50 |
51 | // Force coverage collection from ignored files using an array of glob patterns
52 | // forceCoverageMatch: [],
53 |
54 | // A path to a module which exports an async function that is triggered once before all test suites
55 | // globalSetup: null,
56 |
57 | // A path to a module which exports an async function that is triggered once after all test suites
58 | // globalTeardown: null,
59 |
60 | // A set of global variables that need to be available in all test environments
61 | // globals: {},
62 |
63 | // An array of directory names to be searched recursively up from the requiring module's location
64 | // moduleDirectories: [
65 | // "node_modules"
66 | // ],
67 |
68 | // An array of file extensions your modules use
69 | // moduleFileExtensions: [
70 | // "js",
71 | // "json",
72 | // "jsx",
73 | // "ts",
74 | // "tsx",
75 | // "node"
76 | // ],
77 |
78 | // A map from regular expressions to module names that allow to stub out resources with a single module
79 | // moduleNameMapper: {},
80 |
81 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
82 | // modulePathIgnorePatterns: [],
83 |
84 | // Activates notifications for test results
85 | // notify: false,
86 |
87 | // An enum that specifies notification mode. Requires { notify: true }
88 | // notifyMode: "failure-change",
89 |
90 | // A preset that is used as a base for Jest's configuration
91 | // preset: null,
92 |
93 | // Run tests from one or more projects
94 | // projects: null,
95 |
96 | // Use this configuration option to add custom reporters to Jest
97 | // reporters: undefined,
98 |
99 | // Automatically reset mock state between every test
100 | // resetMocks: false,
101 |
102 | // Reset the module registry before running each individual test
103 | // resetModules: false,
104 |
105 | // A path to a custom resolver
106 | // resolver: null,
107 |
108 | // Automatically restore mock state between every test
109 | // restoreMocks: false,
110 |
111 | // The root directory that Jest should scan for tests and modules within
112 | // rootDir: null,
113 |
114 | // A list of paths to directories that Jest should use to search for files in
115 | // roots: [
116 | // ""
117 | // ],
118 |
119 | // Allows you to use a custom runner instead of Jest's default test runner
120 | // runner: "jest-runner",
121 |
122 | // The paths to modules that run some code to configure or set up the testing environment before each test
123 | // setupFiles: [],
124 |
125 | // A list of paths to modules that run some code to configure or set up the testing framework before each test
126 | // setupFilesAfterEnv: [],
127 |
128 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing
129 | // snapshotSerializers: [],
130 |
131 | // The test environment that will be used for testing
132 | testEnvironment: "node"
133 |
134 | // Options that will be passed to the testEnvironment
135 | // testEnvironmentOptions: {},
136 |
137 | // Adds a location field to test results
138 | // testLocationInResults: false,
139 |
140 | // The glob patterns Jest uses to detect test files
141 | // testMatch: [
142 | // "**/__tests__/**/*.[jt]s?(x)",
143 | // "**/?(*.)+(spec|test).[tj]s?(x)"
144 | // ],
145 |
146 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
147 | // testPathIgnorePatterns: [
148 | // "/node_modules/"
149 | // ],
150 |
151 | // The regexp pattern or array of patterns that Jest uses to detect test files
152 | // testRegex: [],
153 |
154 | // This option allows the use of a custom results processor
155 | // testResultsProcessor: null,
156 |
157 | // This option allows use of a custom test runner
158 | // testRunner: "jasmine2",
159 |
160 | // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
161 | // testURL: "http://localhost",
162 |
163 | // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
164 | // timers: "real",
165 |
166 | // A map from regular expressions to paths to transformers
167 | // transform: null,
168 |
169 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
170 | // transformIgnorePatterns: ["/node_modules/"]
171 |
172 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
173 | // unmockedModulePathPatterns: undefined,
174 |
175 | // Indicates whether each individual test should be reported during the run
176 | // verbose: null,
177 |
178 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
179 | // watchPathIgnorePatterns: [],
180 |
181 | // Whether to use watchman for file crawling
182 | // watchman: true,
183 | };
184 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "webpack-angular-translate",
3 | "version": "3.3.0",
4 | "description": "Webpack plugin that extracts the translation-ids with the default texts.",
5 | "repository": "https://github.com/MichaReiser/webpack-angular-translate",
6 | "main": "dist/index.js",
7 | "typings": "./i18n.d.ts",
8 | "engines": {
9 | "node": ">=10.14.2"
10 | },
11 | "scripts": {
12 | "pretest": "tsc",
13 | "test": "jest",
14 | "start": "tsc --watch",
15 | "prepublish": "tsc"
16 | },
17 | "author": "Micha Reiser ",
18 | "license": "MIT",
19 | "dependencies": {
20 | "acorn": "^8.0.4",
21 | "ast-types": "^0.14.2",
22 | "cheerio": "^1.0.0-rc.2",
23 | "escodegen": "^2.0.0",
24 | "htmlparser2": "^5.0.0",
25 | "loader-utils": "^1.2.3",
26 | "source-map": "^0.7.3",
27 | "webpack": "^4.29.6",
28 | "webpack-sources": "^1.3.0"
29 | },
30 | "devDependencies": {
31 | "@babel/core": "^7.4.0",
32 | "@babel/plugin-proposal-class-properties": "^7.4.0",
33 | "@babel/preset-env": "^7.4.1",
34 | "@babel/preset-typescript": "^7.3.3",
35 | "@types/acorn": "^4.0.5",
36 | "@types/cheerio": "^0.22.11",
37 | "@types/domhandler": "^2.4.1",
38 | "@types/escodegen": "^0.0.6",
39 | "@types/estree": "^0.0.45",
40 | "@types/htmlparser2": "^3.7.31",
41 | "@types/jest": "^26.0.15",
42 | "@types/loader-utils": "^1.1.3",
43 | "@types/node": "^14.14.2",
44 | "@types/webpack": "^4.4.25",
45 | "@types/webpack-sources": "^0.1.5",
46 | "babel-jest": "^26.6.0",
47 | "deep-extend": "^0.6.0",
48 | "html-loader": "^0.5.5",
49 | "jest": "^26.6.0",
50 | "memfs": "^3.2.0",
51 | "prettier": "^2.1.2",
52 | "typescript": "^4.0.3",
53 | "unionfs": "^4.2.0"
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/angular-translate-plugin.ts:
--------------------------------------------------------------------------------
1 | import { RawSource } from "webpack-sources";
2 | import { Plugin, Compiler, compilation } from "webpack";
3 |
4 | import Translation from "./translation";
5 | import TranslationsRegistry, {
6 | TranslationRegistrationError,
7 | EmptyTranslationIdError
8 | } from "./translations-registry";
9 |
10 | interface TranslateOptions {
11 | /**
12 | * The name of the output file
13 | */
14 | fileName?: string;
15 | }
16 |
17 | /**
18 | * Stateful plugin that keeps the found translations stored in this.translations.
19 | * @property translations object hash where the translation id is used to retrieve the Translation object.
20 | * @constructor
21 | */
22 | export class AngularTranslatePlugin implements Plugin {
23 | private options: TranslateOptions;
24 | private compilation: compilation.Compilation;
25 | private translationsRegistry = new TranslationsRegistry();
26 |
27 | constructor(options: TranslateOptions) {
28 | this.options = { fileName: "translations.json", ...options };
29 | }
30 |
31 | /**
32 | * Entry function from webpack that registers the plugin in the required-build-phases
33 | * @param compiler
34 | */
35 | apply(compiler: Compiler) {
36 | compiler.hooks.compilation.tap("webpack-angular-translate", compilation => {
37 | this.compilation = compilation;
38 | /**
39 | * Register the plugin to the normal-module-loader and expose the registerTranslation function in the loaderContext.
40 | * This way the loader can communicate with the plugin and pass the translations to the plugin.
41 | */
42 | compilation.hooks.normalModuleLoader.tap(
43 | "webpack-angular-translate",
44 | loaderContext => {
45 | loaderContext.registerTranslation = this.registerTranslation.bind(
46 | this
47 | );
48 | loaderContext.pruneTranslations = this.translationsRegistry.pruneTranslations.bind(
49 | this.translationsRegistry
50 | );
51 | }
52 | );
53 | });
54 |
55 | compiler.hooks.emit.tap(
56 | "webpack-angular-translate",
57 | this.emitResult.bind(this)
58 | );
59 | }
60 |
61 | /**
62 | * Registers a new translation that should be included in the output
63 | * @param translation {Translation} the translation to register
64 | */
65 | registerTranslation(translation: Translation) {
66 | try {
67 | this.translationsRegistry.registerTranslation(translation);
68 | } catch (e) {
69 | if (e instanceof EmptyTranslationIdError) {
70 | this.compilation.warnings.push(e);
71 | } else if (e instanceof TranslationRegistrationError) {
72 | this.compilation.errors.push(e);
73 | } else {
74 | throw e;
75 | }
76 | }
77 | }
78 |
79 | emitResult(compilation: compilation.Compilation) {
80 | // Only create the file if it is not empty.
81 | // Fixes an issue with karma-webpack where the build fails when the asset is emitted.
82 | if (!this.translationsRegistry.empty) {
83 | const translations = this.translationsRegistry.toJSON();
84 | var content = JSON.stringify(translations, null, "\t");
85 | compilation.assets[this.options.fileName] = new RawSource(content);
86 | }
87 | }
88 | }
89 |
90 | export default AngularTranslatePlugin;
91 |
--------------------------------------------------------------------------------
/src/html/angular-i18n-translations-extractor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | AngularElement,
3 | HtmlTranslationExtractionContext,
4 | } from "./html-translation-extractor";
5 | import { Attribute } from "./element-context";
6 |
7 | const I18N_ATTRIBUTE_REGEX = /^i18n-.*$/;
8 | const I18N_ATTRIBUTE_NAME = "i18n";
9 | const ID_INDICATOR = "@@";
10 |
11 | /**
12 | * Angular uses i18n and i18n-[attr] attributes for internationalization.
13 | * The angularI18nTranslationsExtractor looks for these attributes on elements
14 | * and extracts the found ids and default translations from the elements.
15 | *
16 | * @example
17 | *
some translation
18 | * results in a translation with id: 'translationId' and default translation: 'some translation'
19 | *
20 | * @example
21 | *
22 | * results in a translation with id: 'titleId' and default translation: 'some title'
23 | *
24 | * @param element the element to check for translations
25 | * @param context the current context
26 | */
27 | export default function angularI18nTranslationsExtractor(
28 | element: AngularElement,
29 | context: HtmlTranslationExtractionContext
30 | ): void {
31 | const i18nElementTranslation = element.attributes.find(
32 | (attribute) => attribute.name === I18N_ATTRIBUTE_NAME
33 | );
34 |
35 | if (i18nElementTranslation) {
36 | handleTranslationsOfElements(element, context, i18nElementTranslation);
37 | }
38 |
39 | const i18nAttributeTranslations = element.attributes.filter((attribute) =>
40 | I18N_ATTRIBUTE_REGEX.test(attribute.name)
41 | );
42 |
43 | handleTranslationsOfAttributes(element, context, i18nAttributeTranslations);
44 | }
45 |
46 | function handleTranslationsOfElements(
47 | element: AngularElement,
48 | context: HtmlTranslationExtractionContext,
49 | attribute: Attribute
50 | ): void {
51 | const translationIdExtraction = extractTranslationId(attribute, context);
52 |
53 | if (translationIdExtraction.valid === false) {
54 | context.emitError(translationIdExtraction.error, attribute.startPosition);
55 | } else if (element.texts.length === 0) {
56 | context.emitError(
57 | `The element ${context.asHtml()} with attribute ${
58 | attribute.name
59 | } is empty and is therefore missing the default translation.`,
60 | attribute.startPosition
61 | );
62 | } else if (element.texts.length === 1) {
63 | context.registerTranslation({
64 | translationId: translationIdExtraction.translationId,
65 | defaultText: element.texts[0].text,
66 | position: element.startPosition,
67 | });
68 | } else if (element.texts.length > 1) {
69 | context.emitError(
70 | `The element ${context.asHtml()} has multiple child elements and, therefore, the default translation cannot be extracted.`,
71 | attribute.startPosition
72 | );
73 | }
74 | }
75 |
76 | angularI18nTranslationsExtractor.mayContainTranslations = function (
77 | content: string
78 | ): boolean {
79 | return content.indexOf(I18N_ATTRIBUTE_NAME) !== -1;
80 | };
81 |
82 | function handleTranslationsOfAttributes(
83 | element: AngularElement,
84 | context: HtmlTranslationExtractionContext,
85 | i18nAttributes: Attribute[]
86 | ): void {
87 | for (const i18nAttribute of i18nAttributes) {
88 | handleAttribute(element, context, i18nAttribute);
89 | }
90 | }
91 |
92 | function handleAttribute(
93 | element: AngularElement,
94 | context: HtmlTranslationExtractionContext,
95 | i18nAttribute: Attribute
96 | ): void {
97 | const translationIdExtraction = extractTranslationId(i18nAttribute, context);
98 | if (translationIdExtraction.valid === false) {
99 | context.emitError(
100 | translationIdExtraction.error,
101 | i18nAttribute.startPosition
102 | );
103 | return;
104 | }
105 |
106 | const attributeName = i18nAttribute.name.substr(
107 | `${I18N_ATTRIBUTE_NAME}-`.length
108 | );
109 | const attribute = element.attributes.find(
110 | (attribute) => attribute.name === attributeName
111 | );
112 |
113 | if (!attribute) {
114 | context.emitError(
115 | `The element ${context.asHtml()} with ${
116 | i18nAttribute.name
117 | } is missing a corresponding ${attributeName} attribute.`,
118 | element.startPosition
119 | );
120 | return;
121 | }
122 |
123 | const defaultText = attribute.value;
124 |
125 | if (!defaultText) {
126 | context.emitError(
127 | `The element ${context.asHtml()} with ${
128 | i18nAttribute.name
129 | } is missing a value for the corresponding ${attributeName} attribute.`,
130 | element.startPosition
131 | );
132 | return;
133 | }
134 |
135 | context.registerTranslation({
136 | translationId: translationIdExtraction.translationId,
137 | defaultText: defaultText,
138 | position: i18nAttribute.startPosition,
139 | });
140 | }
141 |
142 | function extractTranslationId(
143 | attribute: Attribute,
144 | context: HtmlTranslationExtractionContext
145 | ): { valid: true; translationId: string } | { valid: false; error: string } {
146 | const index = attribute.value.indexOf(ID_INDICATOR);
147 | if (index < 0) {
148 | return {
149 | valid: false,
150 | error: `The attribute ${
151 | attribute.name
152 | } on element ${context.asHtml()} attribute is missing the custom id indicator '${ID_INDICATOR}'.`,
153 | };
154 | } else if (index + ID_INDICATOR.length === attribute.value.length) {
155 | return {
156 | valid: false,
157 | error: `The attribute ${
158 | attribute.name
159 | } on element ${context.asHtml()} defines an empty ID.`,
160 | };
161 | } else {
162 | return {
163 | valid: true,
164 | translationId: attribute.value.substr(index + ID_INDICATOR.length),
165 | };
166 | }
167 | }
168 |
--------------------------------------------------------------------------------
/src/html/element-context.ts:
--------------------------------------------------------------------------------
1 | import * as path from "path";
2 | import { AngularExpressionMatch } from "./ng-filters";
3 | import TranslateLoaderContext from "../translate-loader-context";
4 |
5 | export interface Attribute {
6 | name: string;
7 | value: string;
8 | expressions: AngularExpressionMatch[];
9 | startPosition: number;
10 | }
11 |
12 | export interface Text {
13 | startPosition: number;
14 | text: string;
15 | raw: string;
16 | expressions: AngularExpressionMatch[];
17 | }
18 |
19 | export abstract class HtmlParseContext {
20 | /**
21 | * The text contents of the element
22 | */
23 | readonly texts: Text[] = [];
24 |
25 | public abstract get suppressDynamicTranslationErrors(): boolean;
26 | public abstract set suppressDynamicTranslationErrors(value: boolean);
27 |
28 | enter(
29 | elementName: string,
30 | attributes: Attribute[],
31 | startPosition: number
32 | ): HtmlParseContext {
33 | return new ElementContext(this, elementName, attributes, startPosition);
34 | }
35 |
36 | abstract leave(): HtmlParseContext;
37 |
38 | addText(text: Text): void {
39 | this.texts.push(text);
40 | }
41 |
42 | abstract emitError(message: string, position: number): void;
43 |
44 | emitSuppressableError(message: string, position: number): void {
45 | if (this.suppressDynamicTranslationErrors) {
46 | return;
47 | }
48 |
49 | this.emitError(message, position);
50 | }
51 |
52 | abstract asHtml(): string;
53 |
54 | abstract loc(position: number): { line: number; column: number };
55 | }
56 |
57 | export class DocumentContext extends HtmlParseContext {
58 | public suppressDynamicTranslationErrors = false;
59 |
60 | constructor(
61 | private readonly loader: TranslateLoaderContext,
62 | private readonly html: string
63 | ) {
64 | super();
65 | }
66 |
67 | leave(): never {
68 | throw new Error(`Cannot leave the root context.`);
69 | }
70 |
71 | emitError(message: string, position: number) {
72 | const loc = this.loc(position);
73 | const relativePath = path.relative(
74 | this.loader.context,
75 | this.loader.resourcePath
76 | );
77 | message = `Failed to extract the angular-translate translations from '${relativePath}':${
78 | loc.line
79 | }:${loc.column}: ${message}`;
80 |
81 | this.loader.emitError(new Error(message));
82 | }
83 |
84 | asHtml(): string {
85 | return this.texts.reduce((memo, text) => memo + text.raw, "");
86 | }
87 |
88 | loc(position: number): { line: number; column: number } {
89 | let line = 1;
90 | let column = 0;
91 | for (let i = 0; i < position; ++i) {
92 | if (this.html[i] === "\n") {
93 | ++line;
94 | column = 0;
95 | } else {
96 | ++column;
97 | }
98 | }
99 |
100 | return { line, column };
101 | }
102 | }
103 |
104 | /**
105 | * Context for an html element.
106 | *
107 | * The context stores the state about the current (html) element and is used by the parser.
108 | * The parser calls `enter` for each new element. This will create a child context of the current context.
109 | * The child context inherits some attributes, like if translation-errors should be suppressed.
110 | */
111 | export default class ElementContext extends HtmlParseContext {
112 | /**
113 | * The html attributes of the current element
114 | */
115 | readonly attributes: Attribute[];
116 |
117 | /**
118 | * The position in the html file where the element has started.
119 | */
120 | readonly elementStartPosition: number;
121 | readonly tagName: string;
122 |
123 | private _suppressDynamicTranslationErrorMessage = false;
124 |
125 | constructor(
126 | public readonly parent: HtmlParseContext,
127 | tagName: string,
128 | attributes: Attribute[],
129 | startPosition: number
130 | ) {
131 | super();
132 | this.attributes = attributes || [];
133 | this.tagName = tagName;
134 | this.elementStartPosition = startPosition;
135 | }
136 |
137 | get suppressDynamicTranslationErrors(): boolean {
138 | return (
139 | this._suppressDynamicTranslationErrorMessage ||
140 | (this.parent && this.parent.suppressDynamicTranslationErrors)
141 | );
142 | }
143 |
144 | set suppressDynamicTranslationErrors(suppress: boolean) {
145 | this._suppressDynamicTranslationErrorMessage = suppress;
146 | }
147 |
148 | emitError(message: string, position: number): void {
149 | return this.parent.emitError(message, position);
150 | }
151 |
152 | asHtml(): string {
153 | let result = `<${this.tagName}`;
154 |
155 | result = this.attributes.reduce(
156 | (memo, { name, value }) => memo + " " + name + "='" + value + "'",
157 | result
158 | );
159 | const text =
160 | this.texts.length === 0
161 | ? "..."
162 | : this.texts.reduce((memo, text) => memo + text.raw, "");
163 | return `${result}>${text}${this.tagName}>`;
164 | }
165 |
166 | leave(): HtmlParseContext {
167 | return this.parent;
168 | }
169 |
170 | loc(position: number) {
171 | return this.parent.loc(position);
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/html/html-loader.ts:
--------------------------------------------------------------------------------
1 | import * as path from "path";
2 | import * as cheerio from "cheerio";
3 | import * as loaderUtils from "loader-utils";
4 |
5 | import TranslateLoaderContext from "../translate-loader-context";
6 | import translateDirectiveTranslationExtractor from "./translate-directive-translation-extractor";
7 | import StatefulHtmlParser, {
8 | SUPPRESS_ATTRIBUTE_NAME,
9 | } from "./translate-html-parser";
10 | import { HtmlTranslationExtractor } from "./html-translation-extractor";
11 |
12 | /**
13 | * Loader that must be used together with the plugin. The loader parses the html content and extracts all
14 | * translate elements, elements with a translate attribute or translate filter.
15 | *
16 | * The loader communicates with the plugin by the registerTranslation functions provided by the plugin (loader.registerTranslation).
17 | * The plugin is responsible for merging the translations and emitting them.
18 | *
19 | * The plugin is required because the loader doesn't know when all files have been processed. The plugin removes
20 | * all suppress-dynamic-translation-error attributes for non dev builds.
21 | *
22 | * The following cases are supported:
23 | * @example
24 | * TRANSLATE-ID
25 | * TRANSLATE-ID
26 | *
27 | * TRANSLATE-ID
28 | * TRANSLATE-ID
29 | *
30 | * Content
31 | * Content
32 | *
33 | * Content
34 | *
35 | *
36 | *
{{ 'My long translation' | translate | limitTo:20 }}
37 | *
38 | * {{ "4" | translate }} {{ "x" | translate }}
39 | *
40 | * @param source the content of the file (expected to be html or xml)
41 | * @param sourceMaps the source maps
42 | */
43 | function loader(source: string, sourceMaps: any): void | string {
44 | "use strict";
45 |
46 | const loader: TranslateLoaderContext = this;
47 | if (!loader.registerTranslation) {
48 | return this.callback(
49 | new Error(
50 | "The WebpackAngularTranslate plugin is missing. Add the plugin to your webpack configurations 'plugins' section."
51 | ),
52 | source,
53 | sourceMaps
54 | );
55 | }
56 |
57 | if (this.cacheable) {
58 | this.cacheable();
59 | }
60 |
61 | loader.pruneTranslations(path.relative(loader.context, loader.resourcePath));
62 |
63 | const options = loaderUtils.getOptions(loader) || {};
64 | const translationExtractors: HtmlTranslationExtractor[] = [
65 | ...(options.translationExtractors || []),
66 | translateDirectiveTranslationExtractor,
67 | ];
68 |
69 | // Don't parse the HTML if none of the extractors detect any possible translations.
70 | if (
71 | translationExtractors.every(
72 | (extractor) =>
73 | extractor.mayContainTranslations != null &&
74 | !extractor.mayContainTranslations(source)
75 | )
76 | ) {
77 | return this.callback(null, source, sourceMaps);
78 | }
79 |
80 | new StatefulHtmlParser(loader, translationExtractors).parse(source);
81 |
82 | let result = source;
83 | if (!this.debug) {
84 | result = removeSuppressTranslationErrorAttributes(source);
85 | }
86 |
87 | this.callback(null, result, sourceMaps);
88 | }
89 |
90 | function removeSuppressTranslationErrorAttributes(source: string): string {
91 | const $ = cheerio.load(source);
92 | const elementsWithSuppressAttribute = $(`[${SUPPRESS_ATTRIBUTE_NAME}]`);
93 | if (elementsWithSuppressAttribute.length === 0) {
94 | return source;
95 | }
96 |
97 | elementsWithSuppressAttribute.removeAttr(SUPPRESS_ATTRIBUTE_NAME);
98 | return $.html();
99 | }
100 |
101 | export = loader;
102 |
--------------------------------------------------------------------------------
/src/html/html-translation-extractor.ts:
--------------------------------------------------------------------------------
1 | import { Attribute, Text } from "./element-context";
2 |
3 | export interface AngularElement {
4 | tagName: string;
5 | attributes: Attribute[];
6 | texts: Text[];
7 | startPosition: number;
8 | }
9 |
10 | export interface TranslationOccurrence {
11 | translationId: string;
12 | defaultText?: string;
13 | position: number;
14 | }
15 |
16 | export interface HtmlTranslationExtractionContext {
17 | emitError(message: string, position: number): void;
18 | emitSuppressableError(message: string, position: number): void;
19 | registerTranslation(translation: TranslationOccurrence): void;
20 | asHtml(): void;
21 | }
22 |
23 | export interface HtmlTranslationExtractor {
24 | (element: AngularElement, context: HtmlTranslationExtractionContext): void;
25 |
26 | mayContainTranslations?(content: string): boolean;
27 | }
28 |
--------------------------------------------------------------------------------
/src/html/ng-filters.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Matches a filter expression containing translate
3 | * Group 1: Value passed to the filter
4 | * Group 2 (optional): Filters applied before the translate filter
5 | */
6 | const angularExpression = /\{\{\s*("[^"]*"|'[^']*'|[^|]+)(?:\s*\|\s*(?!translate)([^|\s]+))*\s*(?:\|\s*translate)\s*(?:\s*\|\s*[^|\s]+)*\s*}}/igm;
7 |
8 | /**
9 | * Match for an angular expression
10 | */
11 | export interface AngularExpressionMatch {
12 | /**
13 | * The full string of characters matched, e.g. `'test' | translate | uppercase`
14 | */
15 | match: string;
16 |
17 | /**
18 | * The value passed to the filter-chain
19 | */
20 | value: string;
21 |
22 | /**
23 | * Filters applied before the translate filter
24 | */
25 | previousFilters: string;
26 | }
27 |
28 | function parseMatch(match: RegExpExecArray): AngularExpressionMatch {
29 | var previousFilters = match[2] ? match[2].trim() : undefined;
30 | return {
31 | match: match[0],
32 | value: match[1].trim(),
33 | previousFilters: previousFilters
34 | };
35 | }
36 |
37 | /**
38 | * Matches the angular expressions from a a text. Returns a match for each expression in the
39 | * passed in text. Can be used to match the angular expressions inside an attribute or in the body text of an element.
40 | * @param text the text to search for angular expressions
41 | * @returns {AngularExpressionMatch[]} an array with the found matches
42 | */
43 | export function matchAngularExpressions(text: string): AngularExpressionMatch[] {
44 | const matches: AngularExpressionMatch[] = [];
45 | let match: RegExpExecArray;
46 |
47 | do {
48 | match = angularExpression.exec(text);
49 |
50 | if (match) {
51 | matches.push(parseMatch(match));
52 | }
53 | } while (match);
54 |
55 | return matches;
56 | }
57 |
--------------------------------------------------------------------------------
/src/html/translate-directive-translation-extractor.ts:
--------------------------------------------------------------------------------
1 | import {
2 | HtmlTranslationExtractionContext,
3 | AngularElement,
4 | } from "./html-translation-extractor";
5 | import { AngularExpressionMatch } from "./ng-filters";
6 | import { Attribute } from "./element-context";
7 | import { SUPPRESS_ATTRIBUTE_NAME } from "./translate-html-parser";
8 |
9 | const translateAttributeRegex = /^translate-attr-.*$/;
10 |
11 | export default function translateDirectiveTranslationExtractor(
12 | element: AngularElement,
13 | context: HtmlTranslationExtractionContext
14 | ) {
15 | let translateDirective: boolean;
16 | let translationId: string;
17 | const translateAttribute = element.attributes.find(
18 | (attribute) => attribute.name === "translate"
19 | );
20 |
21 | if (element.tagName === "translate") {
22 | translateDirective = true;
23 | }
24 |
25 | if (translateAttribute) {
26 | translateDirective = true;
27 | translationId = translateAttribute.value;
28 | }
29 |
30 | for (const attribute of element.attributes) {
31 | handleAttributesWithTranslateExpressions(attribute, context);
32 | }
33 |
34 | if (translateDirective) {
35 | const hasTranslateAttributes = handleTranslateAttributes(element, context);
36 |
37 | const defaultTextAttribute = element.attributes.find(
38 | (attr) => attr.name === "translate-default"
39 | );
40 |
41 | if (!translationId) {
42 | if (element.texts.length === 1) {
43 | translationId = element.texts[0].text;
44 | } else if (element.texts.length > 1) {
45 | context.emitError(
46 | "The element does not specify a translation id but has multiple child text elements. Specify the translation id on the element to define the translation id.",
47 | element.startPosition
48 | );
49 | return;
50 | }
51 | }
52 |
53 | //
54 | if (translationId) {
55 | context.registerTranslation({
56 | translationId,
57 | defaultText: defaultTextAttribute
58 | ? defaultTextAttribute.value
59 | : undefined,
60 | position: element.startPosition,
61 | });
62 | } else if (!hasTranslateAttributes) {
63 | // no translate-attr-* and the element has not specified a translation id, someone is using the directive incorrectly
64 | context.emitSuppressableError(
65 | "the element uses the translate directive but does not specify a translation id nor has any translated attributes (translate-attr-*). Specify a translation id or remove the translate-directive.",
66 | element.startPosition
67 | );
68 | }
69 | } else {
70 | handleTexts(element, context);
71 | }
72 | }
73 |
74 | translateDirectiveTranslationExtractor.mayContainTranslations = function (
75 | content: string
76 | ): boolean {
77 | return content.indexOf("translate") !== -1;
78 | };
79 |
80 | type KeyedAttributes = { [name: string]: Attribute };
81 |
82 | function handleTranslateAttributes(
83 | element: AngularElement,
84 | context: HtmlTranslationExtractionContext
85 | ) {
86 | const keyedAttributes = element.attributes.reduce((obj, attribute) => {
87 | obj[attribute.name] = attribute;
88 | return obj;
89 | }, {} as KeyedAttributes);
90 |
91 | // translate-attr-ATTR
92 | const translateAttributes = element.attributes.filter((attr) =>
93 | translateAttributeRegex.test(attr.name)
94 | );
95 |
96 | for (const attribute of translateAttributes) {
97 | const attributeName = attribute.name.substr("translate-attr-".length);
98 | const defaultTextAttribute =
99 | keyedAttributes[`translate-default-attr-${attributeName}`];
100 |
101 | context.registerTranslation({
102 | translationId: attribute.value,
103 | defaultText: defaultTextAttribute
104 | ? defaultTextAttribute.value
105 | : undefined,
106 | position: attribute.startPosition,
107 | });
108 | }
109 |
110 | return translateAttributes.length > 0;
111 | }
112 |
113 | //
114 | function handleAttributesWithTranslateExpressions(
115 | attribute: Attribute,
116 | context: HtmlTranslationExtractionContext
117 | ) {
118 | for (const expression of attribute.expressions) {
119 | handleAngularExpression(expression, context, attribute.startPosition);
120 | }
121 | }
122 |
123 | function handleAngularExpression(
124 | expression: AngularExpressionMatch,
125 | context: HtmlTranslationExtractionContext,
126 | position: number
127 | ) {
128 | let translationId = expression.value;
129 | // e.g { translate | var} instead of a string constant
130 | if (!(/^".*"$/.test(translationId) || /^'.*'$/.test(translationId))) {
131 | context.emitSuppressableError(
132 | `A dynamic filter expression is used in the text or an attribute of the element '${context.asHtml()}'. Add the '${SUPPRESS_ATTRIBUTE_NAME}' attribute to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).`,
133 | position
134 | );
135 | } else if (expression.previousFilters) {
136 | context.emitSuppressableError(
137 | `Another filter is used before the translate filter in the element ${context.asHtml()}. Add the '${SUPPRESS_ATTRIBUTE_NAME}' to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).`,
138 | position
139 | );
140 | } else {
141 | // trim the quotes
142 | translationId = translationId.substring(1, translationId.length - 1);
143 | context.registerTranslation({
144 | translationId,
145 | position,
146 | });
147 | }
148 | }
149 |
150 | function handleTexts(
151 | element: AngularElement,
152 | context: HtmlTranslationExtractionContext
153 | ) {
154 | for (const text of element.texts) {
155 | for (const expression of text.expressions) {
156 | handleAngularExpression(expression, context, text.startPosition);
157 | }
158 | }
159 | }
160 |
--------------------------------------------------------------------------------
/src/html/translate-html-parser.ts:
--------------------------------------------------------------------------------
1 | import * as path from "path";
2 | import type {DomHandler} from "domhandler";
3 | import {Parser} from 'htmlparser2';
4 |
5 | import Translation from "../translation";
6 | import ElementContext, {
7 | DocumentContext,
8 | HtmlParseContext
9 | } from "./element-context";
10 | import {
11 | HtmlTranslationExtractor,
12 | HtmlTranslationExtractionContext,
13 | TranslationOccurrence
14 | } from "./html-translation-extractor";
15 | import TranslateLoaderContext from "../translate-loader-context";
16 | import { matchAngularExpressions } from "./ng-filters";
17 | import { AngularElement } from "./html-translation-extractor";
18 |
19 | export const SUPPRESS_ATTRIBUTE_NAME = "suppress-dynamic-translation-error";
20 | const angularExpressionRegex = /^{{.*}}$/;
21 |
22 | function isAngularExpression(value: string): boolean {
23 | return angularExpressionRegex.test(value);
24 | }
25 |
26 | /**
27 | * Visitor that implements the logic for extracting the translations.
28 | * Elements with a translate directive where the content should be translated are registered in the closetag event.
29 | * Translated attributes are registered in the opentag event
30 | * Attributes translated with the translate filter are handled in the opentag event
31 | * Expressions used in the body of an element are translated in the text event.
32 | */
33 | export default class TranslateHtmlParser implements Partial {
34 | context: HtmlParseContext;
35 | parser: Parser;
36 |
37 | constructor(
38 | private loader: TranslateLoaderContext,
39 | private translationExtractors: HtmlTranslationExtractor[]
40 | ) {
41 | this.ontext = this.ontext.bind(this);
42 | }
43 |
44 | parse(html: string): void {
45 | this.context = new DocumentContext(this.loader, html);
46 | this.parser = new Parser(this, { decodeEntities: true });
47 | this.parser.parseComplete(html);
48 |
49 | this.context = this.parser = null;
50 | }
51 |
52 | onopentag(name: string, attributes: { [type: string]: string }): void {
53 | const parsedAttributes = Object.keys(attributes).map(attributeName => ({
54 | name: attributeName,
55 | value: attributes[attributeName],
56 | expressions: matchAngularExpressions(attributes[attributeName]),
57 | startPosition: getStartIndex(this.parser)
58 | }));
59 |
60 | this.context = this.context.enter(
61 | name,
62 | parsedAttributes,
63 | getStartIndex(this.parser)
64 | );
65 |
66 | this.context.suppressDynamicTranslationErrors =
67 | typeof attributes[SUPPRESS_ATTRIBUTE_NAME] !== "undefined";
68 | }
69 |
70 | ontext(raw: string): void {
71 | const text = raw.trim();
72 | this.context.addText({
73 | startPosition: getStartIndex(this.parser),
74 | raw,
75 | text,
76 | expressions: matchAngularExpressions(text)
77 | });
78 | }
79 |
80 | onclosetag(): void {
81 | if (!(this.context instanceof ElementContext)) {
82 | throw new Error("onopentag did not create an element context");
83 | }
84 |
85 | const element: AngularElement = {
86 | tagName: this.context.tagName,
87 | attributes: this.context.attributes,
88 | texts: this.context.texts,
89 | startPosition: this.context.elementStartPosition
90 | };
91 |
92 | const extractorContext = this.createExtractorContext();
93 |
94 | for (const extractor of this.translationExtractors) {
95 | extractor(element, extractorContext);
96 | }
97 |
98 | this.context = this.context.leave();
99 | }
100 |
101 | onerror(error: Error): void {
102 | this.context.emitError(
103 | `Failed to parse the html, error is ${error.message}`,
104 | getStartIndex(this.parser)
105 | );
106 | }
107 |
108 | private createExtractorContext(): HtmlTranslationExtractionContext {
109 | return {
110 | registerTranslation: this.registerTranslation.bind(this),
111 | emitError: this.context.emitError.bind(this.context),
112 | emitSuppressableError: this.context.emitSuppressableError.bind(
113 | this.context
114 | ),
115 | asHtml: this.context.asHtml.bind(this.context)
116 | };
117 | }
118 |
119 | private registerTranslation(translation: TranslationOccurrence) {
120 | if (
121 | isAngularExpression(translation.translationId) ||
122 | isAngularExpression(translation.defaultText)
123 | ) {
124 | this.context.emitSuppressableError(
125 | `The element '${this.context.asHtml()}' uses an angular expression as translation id ('${
126 | translation.translationId
127 | }') or as default text ('${
128 | translation.defaultText
129 | }'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the '${SUPPRESS_ATTRIBUTE_NAME}' attribute to this element or any of its parents.`,
130 | translation.position
131 | );
132 | return;
133 | }
134 |
135 | this.loader.registerTranslation(
136 | new Translation(translation.translationId, translation.defaultText, {
137 | resource: path.relative(this.loader.context, this.loader.resourcePath),
138 | loc: this.context.loc(translation.position)
139 | })
140 | );
141 | }
142 | }
143 |
144 | function getStartIndex(parser: Parser): number {
145 | return (parser as any).startIndex;
146 | }
147 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export { default as Plugin } from "./angular-translate-plugin";
2 | export {
3 | default as angularI18nTranslationsExtractor
4 | } from "./html/angular-i18n-translations-extractor";
5 |
6 | export function htmlLoader(before: string, options: any): string {
7 | const loader = require.resolve("./html/html-loader");
8 | options = options ? "?" + JSON.stringify(options) : "";
9 | if (before) {
10 | return loader + "!" + before + options;
11 | }
12 | return loader + options;
13 | }
14 |
15 | export function jsLoader(before: string, options: any): string {
16 | const loader = require.resolve("./js/js-loader");
17 | options = options ? "?" + JSON.stringify(options) : "";
18 |
19 | if (before) {
20 | return loader + "!" + before + options;
21 | }
22 |
23 | return loader + options;
24 | }
25 |
--------------------------------------------------------------------------------
/src/js/js-loader.ts:
--------------------------------------------------------------------------------
1 | import * as path from "path";
2 | import * as escodegen from "escodegen";
3 | import * as acorn from "acorn";
4 | import { CodeWithSourceMap, SourceMapConsumer } from "source-map";
5 | import TranslateLoaderContext from "../translate-loader-context";
6 | import createTranslateVisitor from "./translate-visitor";
7 | import * as loaderUtils from "loader-utils";
8 |
9 | /**
10 | * The optional options passed to the plugin
11 | */
12 | interface LoaderOptions {
13 | /**
14 | * Optional acorn options that are passed to the parser
15 | */
16 | parserOptions?: acorn.Options;
17 | }
18 |
19 | /**
20 | * Webpack loader that extracts translations from calls to the angular-translate $translate service.
21 | * Additionally it provides the `i18n.registerTranslation(translationId, defaultText)` and `i18n.registerTranslations({})`
22 | * functions that can be used to register new translations directly in code.
23 | *
24 | * The loader uses acorn to parse the input file and creates the output javascript using escodegen.
25 | * @param source
26 | * @param inputSourceMaps
27 | */
28 | async function jsLoader(source: string, inputSourceMaps: any) {
29 | const loader: TranslateLoaderContext = this;
30 | const callback = this.async();
31 |
32 | if (!loader.registerTranslation) {
33 | return callback(
34 | new Error(
35 | "The WebpackAngularTranslate plugin is missing. Add the plugin to your webpack configurations 'plugins' section."
36 | ),
37 | source,
38 | inputSourceMaps
39 | );
40 | }
41 |
42 | if (loader.cacheable) {
43 | loader.cacheable();
44 | }
45 |
46 | if (isExcludedResource(loader.resourcePath)) {
47 | return callback(null, source, inputSourceMaps);
48 | }
49 |
50 | const { code, sourceMaps } = await extractTranslations(
51 | loader,
52 | source,
53 | inputSourceMaps
54 | );
55 |
56 | callback(null, code, sourceMaps);
57 | }
58 |
59 | async function extractTranslations(
60 | loader: TranslateLoaderContext,
61 | source: string,
62 | sourceMaps: any
63 | ) {
64 | const options: LoaderOptions = loaderUtils.getOptions(loader) || {};
65 | const parserOptions = options.parserOptions || { ecmaVersion: "latest" };
66 |
67 | loader.pruneTranslations(path.relative(loader.context, loader.resourcePath));
68 |
69 | const visitor = createTranslateVisitor(loader, parserOptions);
70 | const sourceAst = acorn.parse(source, visitor.options);
71 | const transformedAst = visitor.visit(sourceAst);
72 |
73 | let code = source;
74 |
75 | if (visitor.changedAst) {
76 | const generateSourceMaps = !!(loader.sourceMap || sourceMaps);
77 | const result = escodegen.generate(transformedAst, {
78 | comment: true,
79 | sourceMap: generateSourceMaps ? loader.resourcePath : undefined,
80 | sourceMapWithCode: generateSourceMaps,
81 | sourceContent: generateSourceMaps ? source : undefined,
82 | });
83 |
84 | if (generateSourceMaps) {
85 | const codeWithSourceMap = (result as any);
86 | code = codeWithSourceMap.code;
87 | if (sourceMaps) {
88 | // Create a new source maps that is a mapping from original Source -> result from previous loader -> result from this loader
89 | const originalSourceMap = await new SourceMapConsumer(sourceMaps);
90 | codeWithSourceMap.map.applySourceMap(
91 | originalSourceMap,
92 | loader.resourcePath
93 | );
94 | }
95 |
96 | sourceMaps = (codeWithSourceMap.map).toJSON();
97 | }
98 | }
99 |
100 | return { code, sourceMaps };
101 | }
102 |
103 | function isExcludedResource(resource: string): boolean {
104 | return /angular-translate[\/\\]dist[\/\\]angular-translate\.js$/.test(
105 | resource
106 | );
107 | }
108 |
109 | export = jsLoader;
110 |
--------------------------------------------------------------------------------
/src/js/translate-visitor.ts:
--------------------------------------------------------------------------------
1 | import * as path from "path";
2 | import type {NodePath} from "ast-types/lib/node-path";
3 | import {namedTypes as n, builders as b, PathVisitor} from "ast-types";
4 | import { Context } from "ast-types/lib/path-visitor";
5 | import type { namedTypes } from "ast-types";
6 | import Translation from "../translation";
7 | import TranslateLoaderContext from "../translate-loader-context";
8 |
9 | const TRANSLATE_SERVICE_NAME = "$translate";
10 |
11 | export default function createTranslateVisitor(
12 | loader: TranslateLoaderContext,
13 | parserOptions: acorn.Options = { ecmaVersion: "latest" }
14 | ) {
15 | let context: Context = null;
16 | const comments: acorn.Comment[] = [];
17 | const options: acorn.Options = {
18 | ...parserOptions,
19 | locations: true,
20 | onComment: comments,
21 | // onToken: tokens,
22 | ranges: true
23 | };
24 |
25 | /**
26 | * Handles a $translate(translateId, interpolateParams, interpolationId, defaultText) call.
27 | * @param path the path to the call expression
28 | */
29 | function visitTranslate(path: NodePath): void {
30 | const call = path.node;
31 | const args = call.arguments;
32 |
33 | if (args.length < 1) {
34 | throwSuppressableError(
35 | `A call to ${TRANSLATE_SERVICE_NAME} requires at least one argument that is the translation id`,
36 | path
37 | );
38 | }
39 |
40 | const translationIds = getTranslationIdFromTranslateCall(path);
41 | const defaultText = getDefaultTextFromTranslateCall(path);
42 |
43 | for (const translationId of translationIds) {
44 | const translation = createTranslation(translationId, defaultText, call);
45 | loader.registerTranslation(translation);
46 | }
47 | }
48 |
49 | function getTranslationIdFromTranslateCall(
50 | path: NodePath
51 | ): any[] {
52 | const args = path.node.arguments;
53 |
54 | if (n.Literal.check(args[0])) {
55 | return [args[0].value];
56 | }
57 |
58 | if (n.ArrayExpression.check(args[0])) {
59 | const arrayExpression = args[0];
60 | return arrayExpression.elements.map(element => {
61 | if (n.Literal.check(element)) {
62 | return element.value;
63 | }
64 | throwSuppressableError(
65 | "The array with the translation ids should only contain literals",
66 | path
67 | );
68 | });
69 | }
70 |
71 | throwSuppressableError(
72 | "The translation id should either be a string literal or an array containing string literals",
73 | path
74 | );
75 | }
76 |
77 | function getDefaultTextFromTranslateCall(
78 | path: NodePath
79 | ): any {
80 | const args = path.node.arguments;
81 |
82 | if (args.length > 3) {
83 | if (n.Literal.check(args[3])) {
84 | return args[3].value;
85 | }
86 |
87 | throwSuppressableError(
88 | "The default text should be a string literal",
89 | path
90 | );
91 | }
92 |
93 | return undefined;
94 | }
95 |
96 | /**
97 | * Handles a call to i18n.registerTranslation(translationId, defaultText?).
98 | * Evaluates the expression and registers a translation. The call expression itself is replaced with the id of the
99 | * translation id.
100 | * @param path of the call expression.
101 | */
102 | function visitRegisterTranslation(path: NodePath): void {
103 | const call = path.node,
104 | args = call.arguments;
105 |
106 | if (args.length === 0 || !n.Literal.check(args[0])) {
107 | throwError(
108 | "Illegal argument for call to 'i18n.registerTranslation'. The call requires at least the 'translationId' argument that needs to be a literal",
109 | call
110 | );
111 | }
112 |
113 | const translationId = args[0].value;
114 | let defaultText: any;
115 |
116 | if (args.length > 1) {
117 | if (n.Literal.check(args[1])) {
118 | defaultText = args[1].value;
119 | } else {
120 | throwError(
121 | "Illegal argument for call to i18n.registerTranslation: the default text has to be a literal",
122 | call
123 | );
124 | }
125 | }
126 |
127 | const translation = createTranslation(translationId, defaultText, call);
128 |
129 | loader.registerTranslation(translation);
130 | path.replace(b.literal(translation.id));
131 | context.reportChanged();
132 | }
133 |
134 | /**
135 | * Handles a call to i18n.registerTranslations({ translationId: defaultText }).
136 | * @param path the path to the call expression
137 | */
138 | function visitRegisterTranslations(path: NodePath): void {
139 | const call = path.node,
140 | args = call.arguments,
141 | translationsArgument = args.length === 0 ? null : args[0];
142 |
143 | if (
144 | translationsArgument === null ||
145 | !n.ObjectExpression.check(translationsArgument)
146 | ) {
147 | throwError(
148 | "Illegal argument for call to i18n.registerTranslations: requires a single argument that is an object where the key is the translationId and the value is the default text",
149 | call
150 | );
151 | }
152 |
153 | const translations: Translation[] = (translationsArgument).properties.map(property => {
154 | let translationId: any;
155 | let defaultText: any;
156 |
157 | if (
158 | property.type === "SpreadElement" ||
159 | property.type === "SpreadProperty" ||
160 | property.type === "ObjectMethod"
161 | ) {
162 | throwError(
163 | "Illegal argument for call to i18n.registerTranslations: The passed object contains a spread property, spread element, or method. This is not supported.",
164 | property
165 | );
166 | return;
167 | }
168 |
169 | if (n.Identifier.check(property.key)) {
170 | translationId = property.key.name;
171 | } else if (n.Literal.check(property.key)) {
172 | translationId = property.key.value;
173 | } else {
174 | throwError(
175 | "Illegal argument for call to i18n.registerTranslations: The key needs to be a literal or an identifier.",
176 | call
177 | );
178 | }
179 |
180 | if (n.Literal.check(property.value)) {
181 | defaultText = property.value.value;
182 | } else {
183 | throwError(
184 | `Illegal argument for call to i18n.registerTranslations: The value for the key '${translationId}' needs to be a literal`,
185 | call
186 | );
187 | }
188 |
189 | return createTranslation(translationId, defaultText, call);
190 | });
191 |
192 | for (const translation of translations) {
193 | loader.registerTranslation(translation);
194 | }
195 | const ids = b.arrayExpression(
196 | translations.map(translation => b.literal(translation.id))
197 | );
198 | path.replace(ids);
199 | context.reportChanged();
200 | }
201 |
202 | function createTranslation(
203 | translationId: any,
204 | defaultText: any,
205 | node: namedTypes.Node
206 | ): Translation {
207 | const idAsString = valueToString(translationId, "");
208 | const defaultTextAsString = valueToString(defaultText, undefined);
209 | return new Translation(idAsString, defaultTextAsString, {
210 | resource: path.relative(loader.context, loader.resourcePath),
211 | loc: node.loc!.start
212 | });
213 | }
214 |
215 | /**
216 | * Gets the function name from a call expression
217 | * @param call the call expression
218 | * @returns {string} the name of the function
219 | */
220 | function getFunctionName(call: namedTypes.CallExpression): string | undefined {
221 | var callee = call.callee;
222 | if (n.Identifier.check(callee)) {
223 | return callee.name;
224 | } else if (n.MemberExpression.check(callee)) {
225 | const property = callee.property;
226 | if (n.Identifier.check(property)) {
227 | return property.name;
228 | }
229 | return "[expression]";
230 | } else if (n.FunctionExpression.check(callee)) {
231 | return "(function () { ... })";
232 | }
233 | }
234 |
235 | /**
236 | * Gets the name of the callee of a function.
237 | * Returns the name of the object before the dot (.) in a function call,
238 | * e.g this for this.$translate or i18n for i18n.registerTranslation
239 | * @param call the call expression
240 | * @returns {string} the name of the callee or null if the name could not be determined
241 | */
242 | function getCalleeName(call: namedTypes.CallExpression): string | null {
243 | // this.method() or object.method()
244 | if (call.callee.type === "MemberExpression") {
245 | const member = call.callee;
246 | if (member.object.type === "Identifier") {
247 | return member.object.name;
248 | } else if (member.object.type === "ThisExpression") {
249 | return "this";
250 | } else if (member.object.type == "MemberExpression") {
251 | const parent = member.object;
252 | if (parent.property.type === "Identifier") {
253 | return parent.property.name;
254 | }
255 | }
256 | }
257 |
258 | return null;
259 | }
260 |
261 | /**
262 | * Emits an error to webpack and throws an error to abort the processing of the node.
263 | *
264 | * @param message the message to emit
265 | * @param node the node for which a message is emitted
266 | */
267 | function throwError(message: string, node: namedTypes.Node): never {
268 | const relativePath = path.relative(loader.context, loader.resourcePath);
269 | const start = node.loc!.start,
270 | completeMessage = `${message} (${relativePath}:${start.line}:${
271 | start.column
272 | })`;
273 | loader.emitError(new Error(completeMessage));
274 | throw context.abort();
275 | }
276 |
277 | /**
278 | * Emits an error to webpack if no comment with suppress-dynamic-translation-error: true is found
279 | * in the scope of the passed in path
280 | *
281 | * @param message the message to emit
282 | * @param path the path of the node to which the error belongs
283 | */
284 | function throwSuppressableError(message: string, path: NodePath): void {
285 | const call = path.node,
286 | calleeName = getCalleeName(call),
287 | functionName = getFunctionName(call),
288 | completeFunctionName =
289 | (calleeName ? calleeName + "." : "") + functionName,
290 | completeMessage = `Illegal argument for call to ${completeFunctionName}: ${message}. If you have registered the translation manually, you can use a /* suppress-dynamic-translation-error: true */ comment in the block of the function call to suppress this error.`;
291 |
292 | if (!isCommentedWithSuppressErrors(path)) {
293 | throwError(completeMessage, call);
294 | }
295 | context.abort();
296 | }
297 |
298 | /**
299 | * Tests if a {@code suppress-dynamic-translation-error: true } comment exists in the scope of the passed in path.
300 | * @param path the path to check
301 | * @returns {boolean} {@code true} if the current block contains such a comment, otherwise false
302 | */
303 | function isCommentedWithSuppressErrors(path: NodePath): boolean {
304 | return isCommentedWithSuppressError(path, comments);
305 | }
306 |
307 | function valueToString(
308 | value: any,
309 | fallback: TDefault
310 | ): string | TDefault {
311 | if (value === null || typeof value === "undefined") {
312 | return fallback;
313 | }
314 |
315 | return "" + value;
316 | }
317 |
318 | const visitor = PathVisitor.fromMethodsObject({
319 | visitCallExpression(path: NodePath): boolean {
320 | context = this;
321 | const call = path.node,
322 | functionName = getFunctionName(call),
323 | calleeName = getCalleeName(call);
324 |
325 | try {
326 | if (functionName === TRANSLATE_SERVICE_NAME) {
327 | visitTranslate(path);
328 | } else if (
329 | functionName === "registerTranslation" &&
330 | calleeName === "i18n"
331 | ) {
332 | visitRegisterTranslation(path);
333 | } else if (
334 | functionName === "registerTranslations" &&
335 | calleeName === "i18n"
336 | ) {
337 | visitRegisterTranslations(path);
338 | } else if (
339 | functionName === "instant" &&
340 | calleeName === TRANSLATE_SERVICE_NAME
341 | ) {
342 | visitTranslate(path);
343 | } else {
344 | context.traverse(path);
345 | }
346 | } catch (e) {
347 | if (e instanceof context.AbortRequest) {
348 | (e as any).cancel();
349 | } else {
350 | throw e;
351 | }
352 | }
353 |
354 | return false;
355 | }
356 | });
357 |
358 | return {
359 | get changedAst() {
360 | return visitor.wasChangeReported();
361 | },
362 |
363 | comments,
364 | options,
365 |
366 | visit(ast: any) {
367 | return visitor.visit(ast);
368 | }
369 | };
370 | }
371 |
372 | export function isCommentedWithSuppressError(
373 | path: NodePath,
374 | comments: acorn.Comment[]
375 | ): boolean {
376 | let blockStartPath = path;
377 |
378 | while (
379 | blockStartPath.parentPath &&
380 | !(
381 | n.BlockStatement.check(blockStartPath.node) ||
382 | n.Program.check(blockStartPath.node)
383 | )
384 | ) {
385 | blockStartPath = blockStartPath.parentPath;
386 | }
387 |
388 | const blockStart = blockStartPath.node;
389 | const suppressCommentExpression = /suppress-dynamic-translation-error:\s*true/;
390 |
391 | for (let comment of comments) {
392 | if (comment.loc!.end.line > path.node.loc.start.line) {
393 | return false;
394 | }
395 |
396 | if (
397 | comment.loc!.start.line >= blockStart.loc!.start.line &&
398 | suppressCommentExpression.test(comment.value)
399 | ) {
400 | return true;
401 | }
402 | }
403 |
404 | return false;
405 | }
406 |
--------------------------------------------------------------------------------
/src/translate-loader-context.ts:
--------------------------------------------------------------------------------
1 | import Translation from "./translation";
2 | import { loader } from "webpack/";
3 |
4 | export interface TranslateLoaderContext extends loader.LoaderContext {
5 | /**
6 | * Registers a new translation
7 | * @param translation the translation to register
8 | */
9 | registerTranslation?: (translation: Translation) => void;
10 |
11 | /**
12 | * Removes all translations for the passed in resource.
13 | * A resource is only removed if it was the last with the specific translation. If the translation
14 | * is used from multiple resources, then it is not removed.
15 | */
16 | pruneTranslations?: (resource: string) => void;
17 | }
18 |
19 | export default TranslateLoaderContext;
20 |
--------------------------------------------------------------------------------
/src/translation.ts:
--------------------------------------------------------------------------------
1 | interface Usage {
2 | resource: string;
3 | loc: {
4 | line: number;
5 | column: number;
6 | };
7 | }
8 |
9 | /**
10 | * Wrapper for a translation that has an id and optionally a default text.
11 | * The container also knows where the translation has been used for error messages / debugging.
12 | */
13 | export class Translation {
14 | usages: Usage[];
15 |
16 | /**
17 | * @param id {string} the id of the translation
18 | * @param defaultText {string} the default text if defined
19 | * @param usage the usages where the translation with the given id and text is used
20 | */
21 | constructor(
22 | public id: string,
23 | public defaultText: string,
24 | usage: Usage | Usage[]
25 | ) {
26 | if (usage instanceof Array) {
27 | this.usages = usage;
28 | } else {
29 | this.usages = usage ? [usage] : [];
30 | }
31 | }
32 |
33 | /**
34 | * Returns the translation text that should be used.
35 | * @returns {string} The default text if defined or the id
36 | */
37 | get text(): string {
38 | const result = this.defaultText || this.id;
39 |
40 | return result + ""; // convert to string
41 | }
42 |
43 | /**
44 | * Merges the translation with the passed in other translation
45 | * @param other {Translation} another translation that should be merged with this translation
46 | * @returns {Translation} a new translation that is the merge of the current and passed in translation
47 | */
48 | merge(other: Translation): Translation {
49 | const usages = this.usages;
50 |
51 | for (const usage of other.usages) {
52 | if (usages.indexOf(usage) === -1) {
53 | usages.push(usage);
54 | }
55 | }
56 |
57 | return new Translation(
58 | this.id,
59 | this.defaultText || other.defaultText,
60 | usages
61 | );
62 | }
63 |
64 | toString(): string {
65 | let usages = this.usages.map(usage => {
66 | const line = usage.loc ? usage.loc.line : null;
67 | const column = usage.loc ? usage.loc.column : null;
68 | return `${usage.resource}:${line}:${column}`;
69 | });
70 |
71 | return JSON.stringify(
72 | {
73 | id: typeof this.id === "undefined" ? null : this.id,
74 | defaultText:
75 | typeof this.defaultText === "undefined" ? null : this.defaultText,
76 | usages: usages
77 | },
78 | null,
79 | " "
80 | );
81 | }
82 | }
83 |
84 | export default Translation;
85 |
--------------------------------------------------------------------------------
/src/translations-registry.ts:
--------------------------------------------------------------------------------
1 | import Translation from "./translation";
2 |
3 | function setProto(of: any, proto: any) {
4 | if (typeof (Object as any).setPrototypeOf === "undefined") {
5 | of.__proto__ = proto;
6 | } else {
7 | (Object as any).setPrototypeOf(of, proto);
8 | }
9 | }
10 |
11 | export class TranslationRegistrationError extends Error {
12 | constructor(public message: string) {
13 | super(message);
14 | setProto(this, TranslationRegistrationError.prototype);
15 | }
16 | }
17 |
18 | export class EmptyTranslationIdError extends TranslationRegistrationError {
19 | constructor(translation: Translation) {
20 | super(
21 | `Invalid angular-translate translation found: The id of the translation is empty. Consider removing the translate attribute (html) or defining the translation id (js).\nTranslation:\n'${translation}'`
22 | );
23 | setProto(this, EmptyTranslationIdError.prototype);
24 | }
25 | }
26 |
27 | export class TranslationMergeError extends TranslationRegistrationError {
28 | constructor(
29 | public existing: Translation,
30 | public newTranslation: Translation
31 | ) {
32 | super(
33 | `Webpack-Angular-Translate: Two translations with the same id but different default text found.\n\tExisting: ${existing}\n\tNew: ${newTranslation}\n\tPlease define the same default text twice or specify the default text only once.`
34 | );
35 | }
36 | }
37 |
38 | export default class TranslationsRegistry {
39 | private translations: { [translationId: string]: Translation } = {};
40 | // Array with resource -> translation keys;
41 | private translationsByResource: { [resource: string]: string[] } = {};
42 |
43 | registerTranslation(translation: Translation): Translation {
44 | this.validateTranslation(translation);
45 |
46 | for (let usage of translation.usages) {
47 | var translations = (this.translationsByResource[usage.resource] =
48 | this.translationsByResource[usage.resource] || []);
49 | if (translations.indexOf(translation.id) === -1) {
50 | translations.push(translation.id);
51 | }
52 | }
53 |
54 | const existingEntry = this.translations[translation.id];
55 | return (this.translations[translation.id] = existingEntry
56 | ? translation.merge(existingEntry)
57 | : translation);
58 | }
59 |
60 | /**
61 | * Validates the passed in translation. The returned boolean indicates if the translation should be
62 | * registered or not.
63 | * @param translation the translation to validate
64 | */
65 | private validateTranslation(translation: Translation): void {
66 | if (!translation.id || translation.id.trim().length === 0) {
67 | throw new EmptyTranslationIdError(translation);
68 | }
69 |
70 | const existingEntry = this.getTranslation(translation.id);
71 | // If both entries define a default text that doesn't match, emit an error
72 | if (
73 | existingEntry &&
74 | existingEntry.defaultText !== translation.defaultText &&
75 | existingEntry.defaultText &&
76 | translation.defaultText
77 | ) {
78 | throw new TranslationMergeError(existingEntry, translation);
79 | }
80 | }
81 |
82 | pruneTranslations(resource: string): void {
83 | const translationIds = this.translationsByResource[resource] || [];
84 | for (let translationId of translationIds) {
85 | let translation = this.translations[translationId];
86 | if (!translation) {
87 | continue;
88 | }
89 |
90 | for (let usage of translation.usages) {
91 | if (usage.resource === resource) {
92 | translation.usages.splice(translation.usages.indexOf(usage), 1);
93 |
94 | if (translation.usages.length === 0) {
95 | delete this.translations[translation.id];
96 | }
97 | break;
98 | }
99 | }
100 | }
101 |
102 | delete this.translationsByResource[resource];
103 | }
104 |
105 | getTranslation(translationId: string): Translation {
106 | return this.translations[translationId];
107 | }
108 |
109 | get empty(): boolean {
110 | return Object.keys(this.translations).length === 0;
111 | }
112 |
113 | toJSON(): any {
114 | const translationIds = Object.keys(this.translations);
115 | const result: { [translationId: string]: string } = {};
116 |
117 | translationIds.forEach(translationId => {
118 | const translation = this.translations[translationId];
119 | result[translationId] = translation.text;
120 | });
121 |
122 | return result;
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/test/cases/array.js:
--------------------------------------------------------------------------------
1 | var Test = (function () {
2 | function Test($translate, $http) {
3 | this.$translate = $translate;
4 | this.$translate(["FIRST_PAGE", "Next" ]);
5 | }
6 | return Test;
7 | })();
--------------------------------------------------------------------------------
/test/cases/attributes.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Content
10 |
11 |
15 | Content
16 |
17 |
20 |
21 |
--------------------------------------------------------------------------------
/test/cases/defaultText.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Login
9 | Logout
10 |
11 |
--------------------------------------------------------------------------------
/test/cases/defaultText.js:
--------------------------------------------------------------------------------
1 | var Test = (function () {
2 | function Test($translate, $http) {
3 | this.$translate = $translate;
4 | this.$translate("Next", null, null, "Weiter");
5 |
6 | this.$translate(["FIRST_PAGE", "LAST_PAGE" ], null, null, "Missing");
7 | }
8 | return Test;
9 | })();
--------------------------------------------------------------------------------
/test/cases/differentDefaultTexts.js:
--------------------------------------------------------------------------------
1 | var Test = (function () {
2 | function Test($translate, $http) {
3 | this.$translate = $translate;
4 |
5 | this.$translate("Next", null, null, "Weiter");
6 | this.$translate("Next", null, null, "Missing");
7 | }
8 | return Test;
9 | })();
--------------------------------------------------------------------------------
/test/cases/dynamic-filter-custom-element.html:
--------------------------------------------------------------------------------
1 |
with attribute i18n is empty and is therefore missing the default translation.],
7 | ],
8 | ]
9 | `;
10 |
11 | exports[`StatefulHtmlParserSpecs emits an error if the attribute value contains an empty id 1`] = `
12 | Array [
13 | Array [
14 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The attribute i18n on element
Simple case
defines an empty ID.],
15 | ],
16 | ]
17 | `;
18 |
19 | exports[`StatefulHtmlParserSpecs emits an error if the attribute value does not contain the id indicator '@@'. 1`] = `
20 | Array [
21 | Array [
22 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The attribute i18n on element
Simple case
attribute is missing the custom id indicator '@@'.],
23 | ],
24 | ]
25 | `;
26 |
27 | exports[`StatefulHtmlParserSpecs emits an error if the content of the element is an expression 1`] = `
28 | Array [
29 | Array [
30 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element '
{{someValue}}
' uses an angular expression as translation id ('Simple Case id') or as default text ('{{someValue}}'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.],
31 | ],
32 | ]
33 | `;
34 |
35 | exports[`StatefulHtmlParserSpecs emits an error if the element contains multiple child nodes 1`] = `
36 | Array [
37 | Array [
38 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element
Created by at 13:34
has multiple child elements and, therefore, the default translation cannot be extracted.],
39 | ],
40 | ]
41 | `;
42 |
43 | exports[`StatefulHtmlParserSpecs emits an error if the value of the translate id is an expression 1`] = `
44 | Array [
45 | Array [
46 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element '
Not an expression
' uses an angular expression as translation id ('{{id}}') or as default text ('Not an expression'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.],
47 | ],
48 | ]
49 | `;
50 |
51 | exports[`StatefulHtmlParserSpecs emits an error if no corresponding attribute [attr] exist for the i18n-[attr] attribute 1`] = `
52 | Array [
53 | Array [
54 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element
...
with i18n-title is missing a corresponding title attribute.],
55 | ],
56 | ]
57 | `;
58 |
59 | exports[`StatefulHtmlParserSpecs emits an error if the attribute value contains an empty id 1`] = `
60 | Array [
61 | Array [
62 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The attribute i18n-title on element
...
defines an empty ID.],
63 | ],
64 | ]
65 | `;
66 |
67 | exports[`StatefulHtmlParserSpecs emits an error if the attribute value does not contain the id indicator '@@'. 1`] = `
68 | Array [
69 | Array [
70 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The attribute i18n-title on element
...
attribute is missing the custom id indicator '@@'.],
71 | ],
72 | ]
73 | `;
74 |
75 | exports[`StatefulHtmlParserSpecs emits an error if the corresponding attribute [attr] is empty 1`] = `
76 | Array [
77 | Array [
78 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element
...
with i18n-title is missing a value for the corresponding title attribute.],
79 | ],
80 | ]
81 | `;
82 |
83 | exports[`StatefulHtmlParserSpecs emits an error if the default translation is an expression 1`] = `
84 | Array [
85 | Array [
86 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element '
...
' uses an angular expression as translation id ('myTitle') or as default text ('{{myTitle}}'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.],
87 | ],
88 | ]
89 | `;
90 |
91 | exports[`StatefulHtmlParserSpecs emits an error if the i18n-[attr] uses an expression as id 1`] = `
92 | Array [
93 | Array [
94 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element '
...
' uses an angular expression as translation id ('{{id}}') or as default text ('My title'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.],
95 | ],
96 | ]
97 | `;
98 |
--------------------------------------------------------------------------------
/test/html/__snapshots__/translate-html-parser.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`StatefulHtmlParserSpecs emits an error if the translate filter is not the first in the filter chain 1`] = `
4 | Array [
5 | Array [
6 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: Another filter is used before the translate filter in the element .... Add the 'suppress-dynamic-translation-error' to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).],
7 | ],
8 | ]
9 | `;
10 |
11 | exports[`StatefulHtmlParserSpecs emits an error if the translate filter is used for a dynamic value 1`] = `
12 | Array [
13 | Array [
14 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: A dynamic filter expression is used in the text or an attribute of the element '...'. Add the 'suppress-dynamic-translation-error' attribute to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).],
15 | ],
16 | ]
17 | `;
18 |
19 | exports[`StatefulHtmlParserSpecs emits an error if translate-attr uses an expression 1`] = `
20 | Array [
21 | Array [
22 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element '
...
' uses an angular expression as translation id ('{{controller.title}}') or as default text ('undefined'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.],
23 | ],
24 | ]
25 | `;
26 |
27 | exports[`StatefulHtmlParserSpecs emits an error if a translation does not have a valid id 1`] = `
28 | Array [
29 | Array [
30 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: the element uses the translate directive but does not specify a translation id nor has any translated attributes (translate-attr-*). Specify a translation id or remove the translate-directive.],
31 | ],
32 | ]
33 | `;
34 |
35 | exports[`StatefulHtmlParserSpecs emits an error if the value of the translate id is an expression 1`] = `
36 | Array [
37 | Array [
38 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element '
...
' uses an angular expression as translation id ('{{controller.title}}') or as default text ('undefined'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.],
39 | ],
40 | ]
41 | `;
42 |
43 | exports[`StatefulHtmlParserSpecs emits an error if the content of the element is an expression 1`] = `
44 | Array [
45 | Array [
46 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element '{{controller.title}}' uses an angular expression as translation id ('{{controller.title}}') or as default text ('undefined'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.],
47 | ],
48 | ]
49 | `;
50 |
51 | exports[`StatefulHtmlParserSpecs emits an error if the default text is an expression 1`] = `
52 | Array [
53 | Array [
54 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element 'Simple' uses an angular expression as translation id ('Simple') or as default text ('{{controller.title}}'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.],
55 | ],
56 | ]
57 | `;
58 |
59 | exports[`StatefulHtmlParserSpecs {{ any | translate }} emits an error if the translate filter is being used for a dynamic value 1`] = `
60 | Array [
61 | Array [
62 | [Error: Failed to extract the angular-translate translations from 'test.html':1:6: A dynamic filter expression is used in the text or an attribute of the element '{{ controller.title | translate }}'. Add the 'suppress-dynamic-translation-error' attribute to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).],
63 | ],
64 | ]
65 | `;
66 |
67 | exports[`StatefulHtmlParserSpecs {{ any | translate }} emits an error if the translate filter is not the first in the filter chain 1`] = `
68 | Array [
69 | Array [
70 | [Error: Failed to extract the angular-translate translations from 'test.html':1:6: Another filter is used before the translate filter in the element {{ 'title' | uppercase | translate }}. Add the 'suppress-dynamic-translation-error' to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).],
71 | ],
72 | ]
73 | `;
74 |
--------------------------------------------------------------------------------
/test/html/angular-i18n-translations-extractor.spec.js:
--------------------------------------------------------------------------------
1 | import angularI18nTranslationsExtractor from "../../src/html/angular-i18n-translations-extractor";
2 | import StatefulHtmlParser from "../../src/html/translate-html-parser";
3 | import Translation from "../../src/translation";
4 |
5 | require("../translate-jest-matchers");
6 |
7 | describe("StatefulHtmlParserSpecs", function() {
8 | "use strict";
9 |
10 | let loaderContext;
11 |
12 | beforeEach(function() {
13 | loaderContext = {
14 | registerTranslation: jest.fn(),
15 | emitError: jest.fn(),
16 | emitWarning: jest.fn(),
17 | resourcePath: "path/test.html",
18 | context: "path"
19 | };
20 | });
21 |
22 | describe("", function() {
23 | it("uses the value of the i18n attribute without the '@@' as translation id and the content as default translation", function() {
24 | parse("
Simple case
");
25 |
26 | expect(loaderContext.registerTranslation).toHaveBeenLastCalledWith(
27 | new Translation("Simple Case id", "Simple case", {
28 | resource: "test.html",
29 | loc: { line: 1, column: 0 }
30 | })
31 | );
32 | });
33 |
34 | it("emits an error if the attribute value does not contain the id indicator '@@'.", function() {
35 | parse("
Simple case
");
36 |
37 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
38 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
39 | });
40 |
41 | it("emits an error if the attribute value contains an empty id", function() {
42 | parse("
Simple case
");
43 |
44 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
45 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
46 | });
47 |
48 | it("emits an error if no default translation is provided", function() {
49 | parse("");
50 |
51 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
52 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
53 | });
54 |
55 | it("emits an error if the element contains multiple child nodes", function() {
56 | parse(
57 | "
Created by Thomas Muster at 13:34
"
58 | );
59 |
60 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
61 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
62 | });
63 |
64 | it("emits an error if the content of the element is an expression", function() {
65 | parse("
{{someValue}}
");
66 |
67 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
68 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
69 | });
70 |
71 | it("emits an error if the value of the translate id is an expression", function() {
72 | parse("
Not an expression
");
73 |
74 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
75 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
76 | });
77 |
78 | it("suppresses the error if the translation element is an expression and the element is attributed with suppress-dynamic-translation-error", function() {
79 | parse(
80 | "
i18n='@@Simple Case id'>{{someValue}}
"
81 | );
82 |
83 | expect(loaderContext.emitError).not.toHaveBeenCalled();
84 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
85 | });
86 |
87 | it("suppresses the error if the default text is an expression and the element is attributed with suppress-dynamic-translation-error", function() {
88 | parse(
89 | "
Not an expression
"
90 | );
91 |
92 | expect(loaderContext.emitError).not.toHaveBeenCalled();
93 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
94 | });
95 | });
96 |
97 | describe("", function() {
98 | it("uses the value of an i18n-[attr] without '@@' as translation id and the value of the [attr] as default text", function() {
99 | parse(
100 | ""
101 | );
102 |
103 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
104 | new Translation("test attribute id", "test attribute", {
105 | resource: "test.html",
106 | loc: { line: 1, column: 0 }
107 | })
108 | );
109 | });
110 |
111 | it("emits an error if the attribute value does not contain the id indicator '@@'.", function() {
112 | parse(
113 | ""
114 | );
115 |
116 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
117 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
118 | });
119 |
120 | it("emits an error if the attribute value contains an empty id", function() {
121 | parse("");
122 |
123 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
124 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
125 | });
126 |
127 | it("emits an error if no corresponding attribute [attr] exist for the i18n-[attr] attribute", function() {
128 | parse("");
129 |
130 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
131 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
132 | });
133 |
134 | it("emits an error if the corresponding attribute [attr] is empty", function() {
135 | parse("");
136 |
137 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
138 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
139 | });
140 |
141 | it("emits an error if the default translation is an expression", function() {
142 | parse("");
143 |
144 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
145 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
146 | });
147 |
148 | it("emits an error if the i18n-[attr] uses an expression as id", function() {
149 | parse("");
150 |
151 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
152 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
153 | });
154 |
155 | it("suppresses the error if the i18n-[attr] id is used and the element is attributed with suppress-dynamic-translation-error", function() {
156 | parse(
157 | ""
158 | );
159 |
160 | expect(loaderContext.emitError).not.toHaveBeenCalled();
161 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
162 | });
163 |
164 | it("suppresses the error if the default text is an expression and the element is attributed with suppress-dynamic-translation-error", function() {
165 | parse(
166 | ""
167 | );
168 |
169 | expect(loaderContext.emitError).not.toHaveBeenCalled();
170 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
171 | });
172 | });
173 |
174 | function parse(source) {
175 | let statefulParser = new StatefulHtmlParser(loaderContext, [
176 | angularI18nTranslationsExtractor
177 | ]);
178 | statefulParser.parse(source);
179 | return statefulParser;
180 | }
181 | });
182 |
--------------------------------------------------------------------------------
/test/html/element-context.spec.js:
--------------------------------------------------------------------------------
1 | // TODO for some reasons the tests start to fail if importedf rom src
2 | import { DocumentContext } from "../../src/html/element-context";
3 |
4 | describe("ElementContext", function() {
5 | "use strict";
6 |
7 | describe("enter", function() {
8 | let documentContext;
9 |
10 | beforeEach(function() {
11 | documentContext = new DocumentContext(
12 | "test.html",
13 | ``
14 | );
15 | });
16 |
17 | it("sets the parent element correctly", function() {
18 | var child = documentContext.enter("body", [
19 | { name: "class", value: "test" }
20 | ]);
21 |
22 | expect(child.parent).toBe(documentContext);
23 | });
24 |
25 | it("sets the element name and attributes correctly", function() {
26 | var child = documentContext.enter("body", [
27 | { name: "class", value: "test" }
28 | ]);
29 |
30 | expect(child.tagName).toBe("body");
31 | expect(child.attributes).toEqual([{ name: "class", value: "test" }]);
32 | });
33 | });
34 |
35 | describe("leave", function() {
36 | it("returns the previous / parent element", function() {
37 | const documentContext = new DocumentContext(
38 | "test.html",
39 | ``
40 | );
41 | var child = documentContext.enter("body", [
42 | { name: "class", value: "test" }
43 | ]);
44 |
45 | expect(child.leave()).toBe(documentContext);
46 | });
47 | });
48 |
49 | describe("suppressDynamicTranslationErrors", function() {
50 | let documentContext;
51 |
52 | beforeEach(function() {
53 | documentContext = new DocumentContext(
54 | "test.html",
55 | ``
56 | );
57 | });
58 |
59 | it("is false by default", function() {
60 | expect(documentContext.suppressDynamicTranslationErrors).toBe(false);
61 | });
62 |
63 | it("is true if activated on the current element", function() {
64 | documentContext.suppressDynamicTranslationErrors = true;
65 | expect(documentContext.suppressDynamicTranslationErrors).toBe(true);
66 | });
67 |
68 | it("is true if activated on a parent element", function() {
69 | documentContext.suppressDynamicTranslationErrors = true;
70 | var child = documentContext.enter("body");
71 |
72 | expect(child.suppressDynamicTranslationErrors).toBe(true);
73 | });
74 |
75 | it("is false if activated on a child element", function() {
76 | var child = documentContext.enter("body");
77 | child.suppressDynamicTranslationErrors = true;
78 |
79 | expect(documentContext.suppressDynamicTranslationErrors).toBe(false);
80 | });
81 | });
82 |
83 | describe("asHtml", function() {
84 | it("shows the html for the element", function() {
85 | var body = new DocumentContext("test.html", "").enter(
86 | "body"
87 | );
88 |
89 | expect(body.asHtml()).toBe("...");
90 | });
91 |
92 | it("displays the text content of the element", function() {
93 | var body = new DocumentContext(
94 | "test.html",
95 | "Hello World\n"
96 | ).enter("body");
97 |
98 | body.addText({
99 | raw: "Hello World\n",
100 | text: "Hello World"
101 | });
102 |
103 | expect(body.asHtml()).toBe("Hello World\n");
104 | });
105 |
106 | it("adds the attributes to the element", function() {
107 | var body = new DocumentContext(
108 | "test.html",
109 | ``
110 | ).enter("body", [
111 | {
112 | name: "class",
113 | expressions: [],
114 | value: "test"
115 | },
116 | {
117 | name: "id",
118 | value: "main",
119 | expressions: []
120 | }
121 | ]);
122 |
123 | expect(body.asHtml()).toBe("...");
124 | });
125 | });
126 | });
127 |
--------------------------------------------------------------------------------
/test/html/ng-filters.spec.js:
--------------------------------------------------------------------------------
1 | import { matchAngularExpressions } from "../../src/html/ng-filters";
2 |
3 | describe("the filter expression matches angular-filters anywhere in the code using {{}}", function() {
4 | "use strict";
5 |
6 | it("matches a simple translate filter", function() {
7 | let result = matchAngularExpressions("{{ 'Hy' | translate }}");
8 |
9 | expect(result).toEqual([
10 | {
11 | value: "'Hy'",
12 | match: "{{ 'Hy' | translate }}",
13 | previousFilters: undefined
14 | }
15 | ]);
16 | });
17 |
18 | it("matches an expression with another filter before translate", function() {
19 | let result = matchAngularExpressions("{{ 'Hy' | currency | translate }}");
20 |
21 | expect(result).toEqual([
22 | {
23 | value: "'Hy'",
24 | match: "{{ 'Hy' | currency | translate }}",
25 | previousFilters: "currency"
26 | }
27 | ]);
28 | });
29 |
30 | it("matches an expression with a filter following translate", function() {
31 | let result = matchAngularExpressions("{{ 'Hy' | translate | limitTo:6 }}");
32 |
33 | expect(result).toEqual([
34 | {
35 | value: "'Hy'",
36 | match: "{{ 'Hy' | translate | limitTo:6 }}",
37 | previousFilters: undefined
38 | }
39 | ]);
40 | });
41 |
42 | it("matches an expression with a filter before and after translate", function() {
43 | let result = matchAngularExpressions(
44 | "{{ 'Hy' | currency | translate | limitTo:6 }}"
45 | );
46 |
47 | expect(result).toEqual([
48 | {
49 | value: "'Hy'",
50 | match: "{{ 'Hy' | currency | translate | limitTo:6 }}",
51 | previousFilters: "currency"
52 | }
53 | ]);
54 | });
55 |
56 | it("matches multiple expressions", function() {
57 | let result = matchAngularExpressions(
58 | "{{ 'Hy' | translate }}"
59 | );
60 |
61 | expect(result).toEqual([
62 | {
63 | value: "'Hy'",
64 | match: "{{ 'Hy' | translate }}",
65 | previousFilters: undefined
66 | },
67 | {
68 | value: "'Login'",
69 | match: "{{ 'Login' | translate}}",
70 | previousFilters: undefined
71 | }
72 | ]);
73 | });
74 |
75 | it("matches an attribute with translate followed by another filter without spaces", function() {
76 | let result = matchAngularExpressions('{{"name"|translate|limitTo:5}}');
77 |
78 | expect(result).toEqual([
79 | {
80 | value: '"name"',
81 | match: '{{"name"|translate|limitTo:5}}',
82 | previousFilters: undefined
83 | }
84 | ]);
85 | });
86 |
87 | it("matches an attribute with translate where translate follows another filter without spaces", function() {
88 | let result = matchAngularExpressions('{{"name"|currency|translate}}');
89 |
90 | expect(result).toEqual([
91 | {
92 | value: '"name"',
93 | match: '{{"name"|currency|translate}}',
94 | previousFilters: "currency"
95 | }
96 | ]);
97 | });
98 |
99 | it("matches property expressions", function() {
100 | let result = matchAngularExpressions("{{user.sex | translate}}");
101 |
102 | expect(result).toEqual([
103 | {
104 | value: "user.sex",
105 | match: "{{user.sex | translate}}",
106 | previousFilters: undefined
107 | }
108 | ]);
109 | });
110 |
111 | it("matches the expression inside a string", function() {
112 | let result = matchAngularExpressions(
113 | 'Static Text: {{ "CHF" | translate }}'
114 | );
115 |
116 | expect(result).toEqual([
117 | {
118 | value: '"CHF"',
119 | match: '{{ "CHF" | translate }}',
120 | previousFilters: undefined
121 | }
122 | ]);
123 | });
124 |
125 | it("doesn't match an expression without translate filter", function() {
126 | let result = matchAngularExpressions("{{ 1 + 2 }}");
127 |
128 | expect(result).toEqual([]);
129 | });
130 |
131 | it("doesn't match single curly braces ", function() {
132 | let result = matchAngularExpressions("{{ '{{1 + 2}}' }}");
133 |
134 | expect(result).toEqual([]);
135 | });
136 |
137 | it("doesn't match '| translate", function() {
138 | let result = matchAngularExpressions("{{| translate}}");
139 |
140 | expect(result).toEqual([]);
141 | });
142 |
143 | it("doesn't match a string literal that looks like an expression but misses the {}", function() {
144 | let result = matchAngularExpressions('"value" | translate');
145 |
146 | expect(result).toEqual([]);
147 | });
148 |
149 | /**
150 | * Not supported in the current version
151 | */
152 | it("doesn't match the pipe in the string value", function() {
153 | let result = matchAngularExpressions('{{"value|test" | translate}}');
154 |
155 | expect(result).toEqual([
156 | {
157 | value: '"value|test"',
158 | match: '{{"value|test" | translate}}',
159 | previousFilters: undefined
160 | }
161 | ]);
162 | });
163 | });
164 |
--------------------------------------------------------------------------------
/test/html/translate-html-parser.spec.js:
--------------------------------------------------------------------------------
1 | import translateDirectiveTranslationExtractor from "../../src/html/translate-directive-translation-extractor";
2 | import StatefulHtmlParser from "../../src/html/translate-html-parser";
3 | import Translation from "../../src/translation";
4 |
5 | require("../translate-jest-matchers");
6 |
7 | describe("StatefulHtmlParserSpecs", function() {
8 | "use strict";
9 |
10 | let loaderContext;
11 |
12 | beforeEach(function() {
13 | loaderContext = {
14 | registerTranslation: jest.fn(),
15 | emitError: jest.fn(),
16 | emitWarning: jest.fn(),
17 | resourcePath: "path/test.html",
18 | context: "path"
19 | };
20 | });
21 |
22 | describe("", function() {
23 | it("uses the element text as translation id", function() {
24 | parse("Simple");
25 |
26 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
27 | new Translation("Simple", undefined, {
28 | resource: "test.html",
29 | loc: { line: 1, column: 0 }
30 | })
31 | );
32 | });
33 |
34 | it("uses the translate attribute value as id", function() {
35 | parse("Simple");
36 |
37 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
38 | new Translation("simple-id", undefined, {
39 | resource: "test.html",
40 | loc: { line: 1, column: 0 }
41 | })
42 | );
43 | });
44 |
45 | it("uses the value of the translate-default as defaultText", function() {
46 | parse("Simple");
47 |
48 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
49 | new Translation("Simple", "Other default", {
50 | resource: "test.html",
51 | loc: { line: 1, column: 0 }
52 | })
53 | );
54 | });
55 |
56 | it("only translates the attribute if the translation id is undefined", function() {
57 | parse("");
58 |
59 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
60 | new Translation("Simple", undefined, {
61 | resource: "test.html",
62 | loc: { line: 1, column: 0 }
63 | })
64 | );
65 |
66 | expect(loaderContext.registerTranslation).toHaveBeenCalledTimes(1);
67 | });
68 |
69 | it("only translates the attribute if the translation id is empty", function() {
70 | parse("");
71 |
72 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
73 | new Translation("Simple", undefined, {
74 | resource: "test.html",
75 | loc: { line: 1, column: 0 }
76 | })
77 | );
78 |
79 | expect(loaderContext.registerTranslation).toHaveBeenCalledTimes(1);
80 | });
81 |
82 | it("translates the attribute and the content of the element if translate-attr is set and the element has non empty content", function() {
83 | parse(
84 | "Element-Text"
85 | );
86 |
87 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
88 | new Translation("Attribute", undefined, {
89 | resource: "test.html",
90 | loc: { line: 1, column: 0 }
91 | })
92 | );
93 |
94 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
95 | new Translation("Element-Text", undefined, {
96 | resource: "test.html",
97 | loc: { line: 1, column: 0 }
98 | })
99 | );
100 | });
101 |
102 | it("emits an error if the content of the element is an expression", function() {
103 | parse("{{controller.title}}");
104 |
105 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
106 | });
107 |
108 | it("emits an error if the default text is an expression", function() {
109 | parse(
110 | "Simple"
111 | );
112 |
113 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
114 | });
115 |
116 | it("suppresses the error if the translation element is an expression and the element is attributed with suppress-dynamic-translation-error", function() {
117 | parse(
118 | "{{controller.title}}"
119 | );
120 |
121 | expect(loaderContext.emitError).not.toHaveBeenCalled();
122 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
123 | });
124 |
125 | it("suppresses the error if the default text is an expression and the element is attributed with suppress-dynamic-translation-error", function() {
126 | parse(
127 | "simple"
128 | );
129 |
130 | expect(loaderContext.emitError).not.toHaveBeenCalled();
131 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
132 | });
133 | });
134 |
135 | describe("", function() {
136 | it("uses the value of the translate-attribute as translation id", function() {
137 | parse("");
138 |
139 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
140 | new Translation("Simple", undefined, {
141 | resource: "test.html",
142 | loc: { line: 1, column: 0 }
143 | })
144 | );
145 | });
146 |
147 | it("uses the content as translation id if the translate-attribute has no value assigned", function() {
148 | parse("
Simple
");
149 |
150 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
151 | new Translation("Simple", undefined, {
152 | resource: "test.html",
153 | loc: { line: 1, column: 0 }
154 | })
155 | );
156 | });
157 |
158 | it("uses the value of the default-text attribute as default text.", function() {
159 | parse("
Simple
");
160 |
161 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
162 | new Translation("Simple", "Other default", {
163 | resource: "test.html",
164 | loc: { line: 1, column: 0 }
165 | })
166 | );
167 | });
168 |
169 | it("only translates the attribute if the element content is empty", function() {
170 | parse("");
171 |
172 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
173 | new Translation("Simple", undefined, {
174 | resource: "test.html",
175 | loc: { line: 1, column: 0 }
176 | })
177 | );
178 |
179 | expect(loaderContext.registerTranslation).toHaveBeenCalledTimes(1);
180 | });
181 |
182 | it("only translates the attribute if the translation id is empty", function() {
183 | parse("");
184 |
185 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
186 | new Translation("Simple", undefined, {
187 | resource: "test.html",
188 | loc: { line: 1, column: 0 }
189 | })
190 | );
191 |
192 | expect(loaderContext.registerTranslation).toHaveBeenCalledTimes(1);
193 | });
194 |
195 | it("translates the attribute and the content of the element if translate-attr is set and the element has non empty content", function() {
196 | parse(
197 | "
Element-Text
"
198 | );
199 |
200 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
201 | new Translation("Attribute", undefined, {
202 | resource: "test.html",
203 | loc: { line: 1, column: 0 }
204 | })
205 | );
206 |
207 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
208 | new Translation("Element-Text", undefined, {
209 | resource: "test.html",
210 | loc: { line: 1, column: 0 }
211 | })
212 | );
213 | });
214 |
215 | it("translates the attribute and the content of the element if translate-attr is set and the translate attribute has an assigned value", function() {
216 | parse(
217 | ""
218 | );
219 |
220 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
221 | new Translation("Attribute", undefined, {
222 | resource: "test.html",
223 | loc: { line: 1, column: 0 }
224 | })
225 | );
226 |
227 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
228 | new Translation("Element-Text", undefined, {
229 | resource: "test.html",
230 | loc: { line: 1, column: 0 }
231 | })
232 | );
233 | });
234 |
235 | it("emits an error if the value of the translate id is an expression", function() {
236 | parse("");
237 |
238 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
239 | });
240 |
241 | it("suppresses the error of a dynamic value in the translation id attribute if the element is attributed with suppress-dynamic-translation-error", function() {
242 | parse(
243 | ""
244 | );
245 | expect(loaderContext.emitError).not.toHaveBeenCalled();
246 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
247 | });
248 |
249 | it("emits an error if a translation does not have a valid id", function() {
250 | parse("\n ");
251 |
252 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
253 | // ensure the translation is not registered a second time because of a test if scope.text is falsy (what is the case above).
254 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
255 | });
256 |
257 | it("doesn't emit an error if a translation does not have a valid id but an attribute has been translated", function() {
258 | parse("\n ");
259 |
260 | expect(loaderContext.emitError).not.toHaveBeenCalled();
261 | });
262 | });
263 |
264 | describe("", function() {
265 | it("uses the value of the translate-attr-title attribute as translation-id", function() {
266 | parse("");
267 |
268 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
269 | new Translation("test", undefined, {
270 | resource: "test.html",
271 | loc: { line: 1, column: 0 }
272 | })
273 | );
274 | });
275 |
276 | it("uses the default text from the translate-default-attr-*", function() {
277 | parse(
278 | ""
279 | );
280 |
281 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
282 | new Translation("test", "Default Text", {
283 | resource: "test.html",
284 | loc: { line: 1, column: 0 }
285 | })
286 | );
287 | });
288 |
289 | it("doesn't register a translation for the content of an element attributed with translate-attr", function() {
290 | parse(
291 | "
Test
"
292 | );
293 |
294 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
295 | new Translation("test", undefined, {
296 | resource: "test.html",
297 | loc: { line: 1, column: 0 }
298 | })
299 | );
300 |
301 | expect(loaderContext.registerTranslation).toHaveBeenCalledTimes(1);
302 | });
303 |
304 | it("emits an error if translate-attr uses an expression", function() {
305 | parse(
306 | ""
307 | );
308 |
309 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
310 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
311 | });
312 |
313 | it("suppresses the error if translate-attr is used and the element is attributed with suppress-dynamic-translation-error", function() {
314 | parse(
315 | ""
316 | );
317 |
318 | expect(loaderContext.emitError).not.toHaveBeenCalled();
319 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
320 | });
321 | });
322 |
323 | describe("{{ any | translate }}", function() {
324 | it("extracts the translation id of a translate filter with a literal value", function() {
325 | parse("{{ 'test' | translate }}");
326 |
327 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
328 | new Translation("test", undefined, {
329 | resource: "test.html",
330 | loc: { line: 1, column: 6 }
331 | })
332 | );
333 | });
334 |
335 | it("extracts the translation id if the translate filter is the first in chain and a literal value is used", function() {
336 | parse("{{ 'test' | translate | uppercase }}");
337 |
338 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
339 | new Translation("test", undefined, {
340 | resource: "test.html",
341 | loc: { line: 1, column: 6 }
342 | })
343 | );
344 | });
345 |
346 | it("extracts the translation id if the translate filter is used inside a text body", function() {
347 | parse(
348 | "{{ ctrl.total | number:0 }} {{ 'USD' | translate }} ($)"
349 | );
350 |
351 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
352 | new Translation("USD", undefined, {
353 | resource: "test.html",
354 | loc: { line: 1, column: 6 }
355 | })
356 | );
357 | });
358 |
359 | it("emits an error if the translate filter is being used for a dynamic value", function() {
360 | parse("{{ controller.title | translate }}");
361 |
362 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
363 | });
364 |
365 | it("emits an error if the translate filter is not the first in the filter chain", function() {
366 | parse("{{ 'title' | uppercase | translate }}");
367 |
368 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
369 | });
370 |
371 | it("suppresses the error for a filter with a dynamic value if suppress-dynamic-translate-error is used on the parent element", function() {
372 | parse(
373 | "{{ controller.title | translate }}"
374 | );
375 |
376 | expect(loaderContext.emitError).not.toHaveBeenCalled();
377 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
378 | });
379 |
380 | it("suppresses the error for a filter chain where translate is not the first filter if suppress-dynamic-translate-error is used on the parent element", function() {
381 | parse(
382 | "{{ 'title' | uppercase | translate }}"
383 | );
384 |
385 | expect(loaderContext.emitError).not.toHaveBeenCalled();
386 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
387 | });
388 | });
389 |
390 | describe("", function() {
391 | it("extracts the translation from an attribute with translate filter", function() {
392 | parse("");
393 |
394 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
395 | new Translation("Waterfall", undefined, {
396 | resource: "test.html",
397 | loc: { line: 1, column: 0 }
398 | })
399 | );
400 | });
401 |
402 | it("extracts multiple translations from an attribute with translate filters", function() {
403 | parse(
404 | ""
405 | );
406 |
407 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
408 | new Translation("Waterfall", undefined, {
409 | resource: "test.html",
410 | loc: { line: 1, column: 0 }
411 | })
412 | );
413 |
414 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith(
415 | new Translation("Other", undefined, {
416 | resource: "test.html",
417 | loc: { line: 1, column: 0 }
418 | })
419 | );
420 | });
421 |
422 | it("emits an error if the translate filter is not the first in the filter chain", function() {
423 | parse("");
424 |
425 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
426 | });
427 |
428 | it("emits an error if the translate filter is used for a dynamic value", function() {
429 | parse("");
430 |
431 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
432 | });
433 | });
434 |
435 | describe("invalid cases", function() {
436 | it("does not register an empty element without the translate attribute", function() {
437 | parse("");
438 |
439 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
440 | });
441 |
442 | it("does not register a translation for an element without the translate attribute", function() {
443 | parse("
Test
");
444 |
445 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
446 | });
447 |
448 | it("does not register a translation for a translate-attribute if the translate directive is missing on the element", function() {
449 | parse("
Test
");
450 |
451 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
452 | });
453 | });
454 |
455 | function parse(source) {
456 | let statefulParser = new StatefulHtmlParser(loaderContext, [
457 | translateDirectiveTranslationExtractor
458 | ]);
459 | statefulParser.parse(source);
460 | return statefulParser;
461 | }
462 | });
463 |
--------------------------------------------------------------------------------
/test/js/__snapshots__/translate-visitor.spec.js.snap:
--------------------------------------------------------------------------------
1 | // Jest Snapshot v1, https://goo.gl/fbAQLP
2 |
3 | exports[`TranslateVisitor $translate emits an error if any translation id in the passed in array is not a literal 1`] = `
4 | Array [
5 | Array [
6 | [Error: Illegal argument for call to $translate: The array with the translation ids should only contain literals. If you have registered the translation manually, you can use a /* suppress-dynamic-translation-error: true */ comment in the block of the function call to suppress this error. (test.js:1:0)],
7 | ],
8 | ]
9 | `;
10 |
11 | exports[`TranslateVisitor $translate emits an error if the default text is not a literal 1`] = `
12 | Array [
13 | Array [
14 | [Error: Illegal argument for call to $translate: The default text should be a string literal. If you have registered the translation manually, you can use a /* suppress-dynamic-translation-error: true */ comment in the block of the function call to suppress this error. (test.js:1:1)],
15 | ],
16 | ]
17 | `;
18 |
19 | exports[`TranslateVisitor $translate emits an error if the function is called without any arguments 1`] = `
20 | Array [
21 | Array [
22 | [Error: Illegal argument for call to $translate: A call to $translate requires at least one argument that is the translation id. If you have registered the translation manually, you can use a /* suppress-dynamic-translation-error: true */ comment in the block of the function call to suppress this error. (test.js:1:1)],
23 | ],
24 | ]
25 | `;
26 |
27 | exports[`TranslateVisitor $translate emits an error if the translation id is not an array expression and neither a literal 1`] = `
28 | Array [
29 | Array [
30 | [Error: Illegal argument for call to $translate: The translation id should either be a string literal or an array containing string literals. If you have registered the translation manually, you can use a /* suppress-dynamic-translation-error: true */ comment in the block of the function call to suppress this error. (test.js:1:1)],
31 | ],
32 | ]
33 | `;
34 |
35 | exports[`TranslateVisitor i18n.registerTranslation emits an error if called without arguments 1`] = `
36 | Array [
37 | Array [
38 | [Error: Illegal argument for call to 'i18n.registerTranslation'. The call requires at least the 'translationId' argument that needs to be a literal (test.js:1:1)],
39 | ],
40 | ]
41 | `;
42 |
43 | exports[`TranslateVisitor i18n.registerTranslation emits an error if the default text is not a literal 1`] = `
44 | Array [
45 | Array [
46 | [Error: Illegal argument for call to i18n.registerTranslation: the default text has to be a literal (test.js:1:1)],
47 | ],
48 | ]
49 | `;
50 |
51 | exports[`TranslateVisitor i18n.registerTranslation emits an error if the translation is not a literal 1`] = `
52 | Array [
53 | Array [
54 | [Error: Illegal argument for call to 'i18n.registerTranslation'. The call requires at least the 'translationId' argument that needs to be a literal (test.js:1:1)],
55 | ],
56 | ]
57 | `;
58 |
--------------------------------------------------------------------------------
/test/js/translate-visitor.spec.js:
--------------------------------------------------------------------------------
1 | import "../translate-jest-matchers";
2 | import { builders as b, namedTypes as n, NodePath } from "ast-types";
3 | import createTranslateVisitor, {
4 | isCommentedWithSuppressError,
5 | } from "../../src/js/translate-visitor";
6 |
7 | describe("TranslateVisitor", function () {
8 | let loaderContext;
9 | let visitor;
10 |
11 | beforeEach(() => {
12 | loaderContext = {
13 | registerTranslation: jest.fn().mockName("registerTranslation"),
14 | pruneTranslations: jest.fn(),
15 | emitError: jest.fn(),
16 | context: "path",
17 | resourcePath: "path/test.js",
18 | resource: "test.js",
19 | };
20 |
21 | visitor = createTranslateVisitor(loaderContext);
22 | });
23 |
24 | describe("$translate", () => {
25 | let $translate;
26 |
27 | beforeEach(() => {
28 | $translate = b.identifier("$translate");
29 | });
30 |
31 | it("extracts the translation with it's id from a $translate call with a single argument", () => {
32 | let translateCall = b.callExpression($translate, [b.literal("test")]);
33 | translateCall.loc = { start: { line: 1, column: 1 } };
34 |
35 | visitor.visit(translateCall);
36 |
37 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
38 | id: "test",
39 | defaultText: undefined,
40 | usages: [
41 | {
42 | resource: "test.js",
43 | loc: { line: 1, column: 1 },
44 | },
45 | ],
46 | });
47 | });
48 |
49 | it("extracts the translation with it's id and default text from a $translate call with a four argument", () => {
50 | let translateCall = b.callExpression($translate, [
51 | b.literal("test"),
52 | b.identifier("undefined"),
53 | b.identifier("undefined"),
54 | b.literal("Test"),
55 | ]);
56 | translateCall.loc = { start: { line: 1, column: 1 } };
57 |
58 | visitor.visit(translateCall);
59 |
60 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
61 | id: "test",
62 | defaultText: "Test",
63 | usages: [
64 | {
65 | resource: "test.js",
66 | loc: { line: 1, column: 1 },
67 | },
68 | ],
69 | });
70 | });
71 |
72 | it("extracts all translation with their ids for a $translate call with an array of translation ids", function () {
73 | let translateCall = b.callExpression($translate, [
74 | b.arrayExpression([b.literal("test"), b.literal("test2")]),
75 | ]);
76 | translateCall.loc = { start: { line: 1, column: 1 } };
77 |
78 | visitor.visit(translateCall);
79 |
80 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
81 | id: "test",
82 | defaultText: undefined,
83 | usages: [
84 | {
85 | resource: "test.js",
86 | loc: { line: 1, column: 1 },
87 | },
88 | ],
89 | });
90 |
91 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
92 | id: "test2",
93 | defaultText: undefined,
94 | usages: [
95 | {
96 | resource: "test.js",
97 | loc: { line: 1, column: 1 },
98 | },
99 | ],
100 | });
101 | });
102 |
103 | it("extracts the translation when $translate is a member of this", function () {
104 | let translateCall = b.callExpression(
105 | b.memberExpression(b.thisExpression(), $translate),
106 | [b.literal("test")]
107 | );
108 |
109 | translateCall.loc = { start: { line: 1, column: 1 } };
110 |
111 | visitor.visit(translateCall);
112 |
113 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
114 | id: "test",
115 | defaultText: undefined,
116 | usages: [
117 | {
118 | resource: "test.js",
119 | loc: { line: 1, column: 1 },
120 | },
121 | ],
122 | });
123 | });
124 |
125 | it("extracts the translation when $translate is a member", function () {
126 | let translateCall = b.callExpression(
127 | b.memberExpression(b.identifier("_this"), $translate),
128 | [b.literal("test")]
129 | );
130 |
131 | translateCall.loc = { start: { line: 1, column: 1 } };
132 |
133 | visitor.visit(translateCall);
134 |
135 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
136 | id: "test",
137 | defaultText: undefined,
138 | usages: [
139 | {
140 | resource: "test.js",
141 | loc: { line: 1, column: 1 },
142 | },
143 | ],
144 | });
145 | });
146 |
147 | it("emits an error if the function is called without any arguments", function () {
148 | let translateCall = b.callExpression($translate, []);
149 | translateCall.loc = { start: { line: 1, column: 1 } };
150 |
151 | visitor.visit(translateCall);
152 |
153 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
154 | });
155 |
156 | it("emits an error if the translation id is not an array expression and neither a literal", function () {
157 | let translateCall = b.callExpression($translate, [b.identifier("test")]);
158 | translateCall.loc = { start: { line: 1, column: 1 } };
159 |
160 | visitor.visit(translateCall);
161 |
162 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
163 | });
164 |
165 | it("emits an error if any translation id in the passed in array is not a literal", function () {
166 | let translateCall = b.callExpression($translate, [
167 | b.arrayExpression([b.literal("test"), b.identifier("notValid")]),
168 | ]);
169 | translateCall.loc = { start: { line: 1, column: 0 } };
170 |
171 | visitor.visit(translateCall);
172 |
173 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
174 | });
175 |
176 | it("emits an error if the default text is not a literal", function () {
177 | let translateCall = b.callExpression($translate, [
178 | b.literal("test"),
179 | b.literal(null),
180 | b.literal(null),
181 | b.identifier("test"),
182 | ]);
183 | translateCall.loc = { start: { line: 1, column: 1 } };
184 |
185 | visitor.visit(translateCall);
186 |
187 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
188 | });
189 |
190 | it("suppress the call needs at least one argument error if block contains 'suppress-dynamic-translation-error: true' comment", function () {
191 | let lineComment = b.line("suppress-dynamic-translation-error: true");
192 | lineComment.loc = {
193 | start: { line: 1, column: 1 },
194 | end: { line: 1, column: 38 },
195 | };
196 | visitor.comments.push(lineComment);
197 |
198 | let translateCall = b.callExpression($translate, []);
199 | translateCall.loc = { start: { line: 2, column: 1 } };
200 |
201 | let root = b.program([b.expressionStatement(translateCall)]);
202 | root.loc = {
203 | start: { line: 1, column: 1 },
204 | end: { line: 2, column: 12 },
205 | };
206 |
207 | visitor.visit(root);
208 |
209 | expect(loaderContext.emitError).not.toHaveBeenCalled();
210 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
211 | });
212 |
213 | it("suppress the id needs to be a literal error if block contains 'suppress-dynamic-translation-error: true' comment", function () {
214 | let lineComment = b.line("suppress-dynamic-translation-error: true");
215 | lineComment.loc = {
216 | start: { line: 1, column: 1 },
217 | end: { line: 1, column: 38 },
218 | };
219 | visitor.comments.push(lineComment);
220 |
221 | let translateCall = b.callExpression($translate, [b.identifier("test")]);
222 | translateCall.loc = { start: { line: 2, column: 1 } };
223 |
224 | let root = b.program([b.expressionStatement(translateCall)]);
225 | root.loc = {
226 | start: { line: 1, column: 1 },
227 | end: { line: 2, column: 12 },
228 | };
229 |
230 | visitor.visit(root);
231 |
232 | expect(loaderContext.emitError).not.toHaveBeenCalled();
233 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
234 | });
235 |
236 | it("suppress the default value needs to be a literal error if block contains 'suppress-dynamic-translation-error: true' comment", function () {
237 | let lineComment = b.line("suppress-dynamic-translation-error: true");
238 | lineComment.loc = {
239 | start: { line: 1, column: 1 },
240 | end: { line: 1, column: 38 },
241 | };
242 | visitor.comments.push(lineComment);
243 |
244 | let translateCall = b.callExpression($translate, [
245 | b.literal("test"),
246 | b.literal(null),
247 | b.literal(null),
248 | b.identifier("defaultText"),
249 | ]);
250 | translateCall.loc = { start: { line: 2, column: 1 } };
251 |
252 | let root = b.program([b.expressionStatement(translateCall)]);
253 | root.loc = {
254 | start: { line: 1, column: 1 },
255 | end: { line: 2, column: 12 },
256 | };
257 |
258 | visitor.visit(root);
259 |
260 | expect(loaderContext.emitError).not.toHaveBeenCalled();
261 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
262 | });
263 | });
264 |
265 | describe("i18n.registerTranslation", function () {
266 | "use strict";
267 |
268 | let i18n;
269 | let registerTranslation;
270 |
271 | beforeEach(function () {
272 | i18n = b.identifier("i18n");
273 | registerTranslation = b.memberExpression(
274 | i18n,
275 | b.identifier("registerTranslation")
276 | );
277 | });
278 |
279 | it("extracts the translation with it's id from a i18n.registerTranslation call with a single argument", function () {
280 | let registerTranslationCall = b.callExpression(registerTranslation, [
281 | b.literal("test"),
282 | ]);
283 | registerTranslationCall.loc = { start: { line: 1, column: 1 } };
284 |
285 | let ast = visitor.visit(registerTranslationCall);
286 |
287 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
288 | id: "test",
289 | defaultText: undefined,
290 | usages: [
291 | {
292 | resource: "test.js",
293 | loc: { line: 1, column: 1 },
294 | },
295 | ],
296 | });
297 |
298 | expect(visitor.changedAst).toBe(true);
299 | expect(n.Literal.check(ast)).toBe(true);
300 | expect(ast.value).toBe("test");
301 | });
302 |
303 | it("extracts the translation with it's id and default text from a i18n.registerTranslation call with a two arguments", function () {
304 | let registerTranslationCall = b.callExpression(registerTranslation, [
305 | b.literal("test"),
306 | b.literal("default Text"),
307 | ]);
308 | registerTranslationCall.loc = { start: { line: 1, column: 1 } };
309 |
310 | let ast = visitor.visit(registerTranslationCall);
311 |
312 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
313 | id: "test",
314 | defaultText: "default Text",
315 | usages: [
316 | {
317 | resource: "test.js",
318 | loc: { line: 1, column: 1 },
319 | },
320 | ],
321 | });
322 |
323 | expect(visitor.changedAst).toBe(true);
324 | expect(n.Literal.check(ast)).toBe(true);
325 | expect(ast.value).toBe("test");
326 | });
327 |
328 | it("emits an error if called without arguments", function () {
329 | let registerTranslationCall = b.callExpression(registerTranslation, []);
330 | registerTranslationCall.loc = { start: { line: 1, column: 1 } };
331 |
332 | visitor.visit(registerTranslationCall);
333 |
334 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
335 | });
336 |
337 | it("emits an error if the translation is not a literal", function () {
338 | let registerTranslationCall = b.callExpression(registerTranslation, [
339 | b.identifier("test"),
340 | ]);
341 | registerTranslationCall.loc = { start: { line: 1, column: 1 } };
342 |
343 | visitor.visit(registerTranslationCall);
344 |
345 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
346 | });
347 |
348 | it("emits an error if the default text is not a literal", function () {
349 | let registerTranslationCall = b.callExpression(registerTranslation, [
350 | b.literal("test"),
351 | b.identifier("defaultText"),
352 | ]);
353 | registerTranslationCall.loc = { start: { line: 1, column: 1 } };
354 |
355 | visitor.visit(registerTranslationCall);
356 |
357 | expect(loaderContext).toHaveEmittedErrorMatchingSnapshot();
358 | });
359 | });
360 |
361 | describe("i18n.registerTranslations", function () {
362 | "use strict";
363 |
364 | let i18n;
365 | let registerTranslations;
366 |
367 | beforeEach(function () {
368 | i18n = b.identifier("i18n");
369 | registerTranslations = b.memberExpression(
370 | i18n,
371 | b.identifier("registerTranslations")
372 | );
373 | });
374 |
375 | it("can process empty registerTranslations calls", function () {
376 | let registerTranslationCall = b.callExpression(registerTranslations, [
377 | b.objectExpression([]),
378 | ]);
379 |
380 | let ast = visitor.visit(registerTranslationCall);
381 |
382 | expect(loaderContext.registerTranslation).not.toHaveBeenCalled();
383 | expect(visitor.changedAst).toBe(true);
384 | expect(n.ArrayExpression.check(ast)).toBe(true);
385 | });
386 |
387 | it("extracts the translation with it's id and default text from a i18n.registerTranslations call", function () {
388 | let registerTranslationsCall = b.callExpression(registerTranslations, [
389 | b.objectExpression([
390 | b.property("init", b.identifier("test"), b.literal("Test")),
391 | b.property("init", b.identifier("x"), b.literal("X")),
392 | ]),
393 | ]);
394 | registerTranslationsCall.loc = { start: { line: 1, column: 1 } };
395 |
396 | let ast = visitor.visit(registerTranslationsCall);
397 |
398 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
399 | id: "test",
400 | defaultText: "Test",
401 | usages: [
402 | {
403 | resource: "test.js",
404 | loc: { line: 1, column: 1 },
405 | },
406 | ],
407 | });
408 |
409 | expect(loaderContext.registerTranslation).toHaveBeenCalledWith({
410 | id: "x",
411 | defaultText: "X",
412 | usages: [
413 | {
414 | resource: "test.js",
415 | loc: { line: 1, column: 1 },
416 | },
417 | ],
418 | });
419 |
420 | expect(visitor.changedAst).toBe(true);
421 | expect(n.ArrayExpression.check(ast)).toBe(true);
422 | expect(ast.elements).toHaveLength(2);
423 | expect(n.Literal.check(ast.elements[0])).toBe(true);
424 | expect(n.Literal.check(ast.elements[1])).toBe(true);
425 | expect(ast.elements[0].value).toBe("test");
426 | expect(ast.elements[1].value).toBe("x");
427 | });
428 |
429 | // TODO bad cases
430 | });
431 |
432 | describe("isCommentedWithSuppressError", function () {
433 | "use strict";
434 | let comments;
435 |
436 | beforeEach(function () {
437 | comments = [];
438 | });
439 |
440 | /**
441 | * $translate();
442 | */
443 | it("returns false for a program without a comment", function () {
444 | let program = b.program([
445 | b.expressionStatement(b.callExpression(b.identifier("$translate"), [])),
446 | ]);
447 |
448 | program.loc = {
449 | start: { line: 1, column: 1 },
450 | end: { line: 1, column: 12 },
451 | };
452 | let root = new NodePath({ root: program }).get("root");
453 |
454 | expect(isCommentedWithSuppressError(root, comments)).toBe(false);
455 | });
456 |
457 | /**
458 | * // suppress-dynamic-translation-error: true
459 | * $translate();
460 | */
461 | it("returns true for a program with a comment", function () {
462 | let lineComment = b.line("suppress-dynamic-translation-error: true");
463 | lineComment.loc = {
464 | start: { line: 1, column: 1 },
465 | end: { line: 1, column: 38 },
466 | };
467 | comments.push(lineComment);
468 |
469 | let program = b.program([
470 | b.expressionStatement(b.callExpression(b.identifier("$translate"), [])),
471 | ]);
472 | program.loc = {
473 | start: { line: 1, column: 1 },
474 | end: { line: 2, column: 12 },
475 | };
476 |
477 | let root = new NodePath({ root: program }).get("root");
478 |
479 | expect(isCommentedWithSuppressError(root, comments)).toBe(true);
480 | });
481 |
482 | /**
483 | * // suppress-dynamic-translation-error: true
484 | * $translate();
485 | */
486 | it("returns true if the containing program contains a comment", function () {
487 | let lineComment = b.line("suppress-dynamic-translation-error: true");
488 | lineComment.loc = {
489 | start: { line: 1, column: 1 },
490 | end: { line: 1, column: 38 },
491 | };
492 | comments.push(lineComment);
493 |
494 | let expression = b.expressionStatement(
495 | b.callExpression(b.identifier("$translate"), [])
496 | );
497 | expression.loc = {
498 | start: { line: 2, column: 1 },
499 | end: { line: 2, column: 12 },
500 | };
501 |
502 | let program = b.program([expression]);
503 | program.loc = {
504 | start: { line: 1, column: 1 },
505 | end: { line: 2, column: 12 },
506 | };
507 |
508 | let root = new NodePath({ root: program }).get("root");
509 | expect(
510 | isCommentedWithSuppressError(root.get("body").get(0), comments)
511 | ).toBe(true);
512 | });
513 |
514 | /**
515 | * {
516 | * // suppress-dynamic-translation-error: true
517 | * $translate();
518 | * }
519 | */
520 | it("returns true if a parent block contains a comment", function () {
521 | let lineComment = b.line("suppress-dynamic-translation-error: true");
522 | lineComment.loc = {
523 | start: { line: 2, column: 1 },
524 | end: { line: 2, column: 38 },
525 | };
526 | comments.push(lineComment);
527 |
528 | let expression = b.expressionStatement(
529 | b.callExpression(b.identifier("$translate"), [])
530 | );
531 | expression.loc = {
532 | start: { line: 3, column: 1 },
533 | end: { line: 3, column: 12 },
534 | };
535 |
536 | let block = b.blockStatement([expression]);
537 | block.loc = {
538 | start: { line: 1, column: 1 },
539 | end: { line: 4, column: 1 },
540 | };
541 |
542 | let program = b.program([block]);
543 | program.loc = {
544 | start: { line: 1, column: 1 },
545 | end: { line: 4, column: 1 },
546 | };
547 |
548 | let root = new NodePath({ root: program }).get("root");
549 |
550 | expect(
551 | isCommentedWithSuppressError(
552 | root.get("body").get(0).get("body").get(0),
553 | comments
554 | )
555 | ).toBe(true);
556 | });
557 |
558 | /**
559 | * $translate();
560 | * {
561 | * // suppress-dynamic-translation-error: true
562 | * }
563 | */
564 | it("returns false if a sibling block contains a comment", function () {
565 | let expression = b.expressionStatement(
566 | b.callExpression(b.identifier("$translate"), [])
567 | );
568 | expression.loc = {
569 | start: { line: 1, column: 1 },
570 | end: { line: 1, column: 12 },
571 | };
572 |
573 | let block = b.blockStatement([expression]);
574 | block.loc = {
575 | start: { line: 2, column: 1 },
576 | end: { line: 4, column: 1 },
577 | };
578 |
579 | let lineComment = b.line("suppress-dynamic-translation-error: true");
580 | lineComment.loc = {
581 | start: { line: 3, column: 1 },
582 | end: { line: 3, column: 38 },
583 | };
584 | comments.push(lineComment);
585 |
586 | let program = b.program([expression, block]);
587 | program.loc = {
588 | start: { line: 1, column: 1 },
589 | end: { line: 4, column: 1 },
590 | };
591 |
592 | let root = new NodePath({ root: program }).get("root");
593 |
594 | expect(
595 | isCommentedWithSuppressError(root.get("body").get(0), comments)
596 | ).toBe(false);
597 | });
598 | });
599 | });
600 |
--------------------------------------------------------------------------------
/test/plugin.spec.js:
--------------------------------------------------------------------------------
1 | import webpack from "webpack";
2 | import deepExtend from "deep-extend";
3 | import { Volume, createFsFromVolume } from "memfs";
4 | import { ufs } from "unionfs";
5 | import path from "path";
6 | import fs from "fs";
7 |
8 | // The main plugin can not be imported from the typescript source
9 | // because webpack seems to modify the loader path
10 | import * as WebPackAngularTranslate from "../dist/index";
11 |
12 | import "./translate-jest-matchers";
13 |
14 | /**
15 | * Helper function to implement tests that verify the result in the translations.js
16 | * @param fileName {string} the filename of the input file (the file to process by webpack)
17 | * @param doneCallback the done callback from mocha that is invoked when the test has completed
18 | * @param assertCallback {function({}, {})} Callback that contains the assert statements. the first argument
19 | * is the source of the translations file. The webpack stats (containing warnings and errors) is passed as second argument.
20 | */
21 | async function compileAndGetTranslations(
22 | fileName,
23 | customTranslationExtractors
24 | ) {
25 | if (!customTranslationExtractors) {
26 | customTranslationExtractors = [];
27 | }
28 | var options = webpackOptions(
29 | {
30 | entry: ["./test/cases/" + fileName],
31 | },
32 | customTranslationExtractors
33 | );
34 |
35 | const { error, stats, volume } = await compile(options);
36 | expect(error).toBeFalsy();
37 |
38 | var translations = {};
39 | if (stats.compilation.assets["translations.json"]) {
40 | translations = JSON.parse(
41 | volume.toJSON(__dirname, undefined, true)["dist/translations.json"]
42 | );
43 | }
44 |
45 | return { translations, stats };
46 | }
47 |
48 | function webpackOptions(options, customTranslationExtractors) {
49 | "use strict";
50 | return deepExtend(
51 | {
52 | output: {
53 | path: path.join(__dirname, "dist"),
54 | },
55 | mode: "production",
56 | module: {
57 | rules: [
58 | {
59 | test: /\.html$/,
60 | use: [
61 | {
62 | loader: "html-loader",
63 | options: {
64 | removeEmptyAttributes: false,
65 | attrs: [],
66 | },
67 | },
68 | {
69 | loader: WebPackAngularTranslate.htmlLoader(),
70 | options: {
71 | translationExtractors: customTranslationExtractors,
72 | },
73 | },
74 | ],
75 | },
76 | {
77 | test: /\.js/,
78 | loader: WebPackAngularTranslate.jsLoader(),
79 | },
80 | ],
81 | },
82 |
83 | plugins: [new WebPackAngularTranslate.Plugin()],
84 | },
85 | options
86 | );
87 | }
88 |
89 | function compile(options) {
90 | var compiler = webpack(options);
91 | var volume = new Volume();
92 | compiler.outputFileSystem = new VolumeOutputFileSystem(volume);
93 |
94 | return new Promise((resolve, reject) => {
95 | compiler.run(function (error, stats) {
96 | resolve({ error, stats, volume });
97 | });
98 | });
99 | }
100 |
101 | describe("HTML Loader", function () {
102 | "use strict";
103 |
104 | it("emits a useful error message if the plugin is missing", async function () {
105 | const { error, stats } = await compile({
106 | entry: "./test/cases/simple.html",
107 | output: {
108 | path: path.join(__dirname, "dist"),
109 | },
110 | module: {
111 | rules: [
112 | {
113 | test: /\.html$/,
114 | use: [
115 | {
116 | loader: "html-loader",
117 | options: {
118 | removeEmptyAttributes: false,
119 | attrs: [],
120 | },
121 | },
122 | {
123 | loader: WebPackAngularTranslate.htmlLoader(),
124 | },
125 | ],
126 | },
127 | {
128 | test: /\.js/,
129 | loader: WebPackAngularTranslate.jsLoader(),
130 | },
131 | ],
132 | },
133 | });
134 |
135 | expect(error).toBeNull();
136 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
137 | Array [
138 | ModuleBuildError: "The WebpackAngularTranslate plugin is missing. Add the plugin to your webpack configurations 'plugins' section.",
139 | ]
140 | `);
141 | });
142 |
143 | describe("directive", function () {
144 | "use strict";
145 |
146 | it("extracts the translation id if translate is used as attribute", async function () {
147 | const { translations } = await compileAndGetTranslations("simple.html");
148 |
149 | expect(translations).toMatchObject({
150 | "attribute-translation": "attribute-translation",
151 | });
152 | });
153 |
154 | it("extracts the translation id if translate is used as element", async function () {
155 | const { translations } = await compileAndGetTranslations("simple.html");
156 | expect(translations).toMatchObject({
157 | "element-translation": "element-translation",
158 | });
159 | });
160 |
161 | it("extracts the translation id from the attribute if specified", async function () {
162 | const { translations } = await compileAndGetTranslations("simple.html");
163 | expect(translations).toMatchObject({
164 | "id-in-attribute": "id-in-attribute",
165 | });
166 | });
167 |
168 | it("extracts the default text if translate is used as attribute", async function () {
169 | const { translations } = await compileAndGetTranslations(
170 | "defaultText.html"
171 | );
172 | expect(translations).toMatchObject({ Login: "Anmelden" });
173 | });
174 |
175 | it("extracts the default text if translate is used as element", async function () {
176 | const { translations } = await compileAndGetTranslations(
177 | "defaultText.html"
178 | );
179 |
180 | expect(translations).toMatchObject({ Logout: "Abmelden" });
181 | });
182 |
183 | it("extracts the translation id if a translation for an attribute is defined", async function () {
184 | const { translations } = await compileAndGetTranslations(
185 | "attributes.html"
186 | );
187 | expect(translations).toMatchObject({
188 | "attribute-id": "attribute-id",
189 | });
190 | });
191 |
192 | it("extracts the default text for an attribute translation", async function () {
193 | const { translations } = await compileAndGetTranslations(
194 | "attributes.html"
195 | );
196 | expect(translations).toMatchObject({
197 | "attribute-default-id": "Default text for attribute title",
198 | });
199 | });
200 |
201 | it("emits an error if an angular expression is used as attribute id", async function () {
202 | const { stats } = await compileAndGetTranslations("expressions.html");
203 |
204 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
205 | Array [
206 | ModuleError: "Failed to extract the angular-translate translations from 'expressions.html':8:1: The element '
{{editCtrl.title}}
' uses an angular expression as translation id ('{{editCtrl.title}}') or as default text ('undefined'). This is not supported. Either use a string literal as translation id and default text or suppress this error by adding the 'suppress-dynamic-translation-error' attribute to this element or any of its parents.",
207 | ]
208 | `);
209 | });
210 |
211 | it("emits an error if a translated angular element has multiple child text elements and does not specify an id", async () => {
212 | const { stats } = await compileAndGetTranslations(
213 | "multiple-child-texts.html"
214 | );
215 |
216 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
217 | Array [
218 | ModuleError: "Failed to extract the angular-translate translations from 'multiple-child-texts.html':8:4: The element does not specify a translation id but has multiple child text elements. Specify the translation id on the element to define the translation id.",
219 | ]
220 | `);
221 | });
222 |
223 | it("does suppress errors for dynamic translations if the element is attributed with suppress-dynamic-translation-error", async function () {
224 | const { translations, stats } = await compileAndGetTranslations(
225 | "expressions-suppressed.html"
226 | );
227 | expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
228 | expect(translations).toEqual({});
229 | });
230 |
231 | it("removes the suppress-dynamic-translation-error attribute for non dev build", async function () {
232 | const { stats } = await compileAndGetTranslations(
233 | "expressions-suppressed.html"
234 | );
235 | var output = stats.compilation.assets["main.js"].source();
236 |
237 | expect(output).toMatch("{{editCtrl.title}}");
238 | expect(output).not.toMatch("suppress-dynamic-translation-error");
239 | });
240 | });
241 |
242 | describe("filter", function () {
243 | it("matches a filter in the body of an element", async function () {
244 | const { translations } = await compileAndGetTranslations(
245 | "filter-simple.html"
246 | );
247 | expect(translations).toMatchObject({ Home: "Home" });
248 | });
249 |
250 | it("matches a filter in an attribute of an element", async function () {
251 | const { translations } = await compileAndGetTranslations(
252 | "filter-simple.html"
253 | );
254 | expect(translations).toMatchObject({ Waterfall: "Waterfall" });
255 | });
256 |
257 | it("matches an expression in the middle of the element text content", async function () {
258 | const { translations } = await compileAndGetTranslations(
259 | "filter-simple.html"
260 | );
261 | expect(translations).toMatchObject({ Top: "Top" });
262 | });
263 |
264 | it("matches multiple expressions in a single text", async function () {
265 | const { translations } = await compileAndGetTranslations(
266 | "multiple-filters.html"
267 | );
268 | expect(translations).toMatchObject({
269 | Result: "Result",
270 | of: "of",
271 | });
272 | });
273 |
274 | it("emits an error if a dynamic value is used in the translate filter", async function () {
275 | const { translations, stats } = await compileAndGetTranslations(
276 | "dynamic-filter-expression.html"
277 | );
278 |
279 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
280 | Array [
281 | ModuleError: "Failed to extract the angular-translate translations from 'dynamic-filter-expression.html':8:14: A dynamic filter expression is used in the text or an attribute of the element '
{{ editCtrl.title | translate }}
'. Add the 'suppress-dynamic-translation-error' attribute to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).",
282 | ]
283 | `);
284 | expect(translations).toEqual({});
285 | });
286 |
287 | it("emits an error if a filter is used before the translate filter", async function () {
288 | const { translations, stats } = await compileAndGetTranslations(
289 | "filter-chain.html"
290 | );
291 |
292 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
293 | Array [
294 | ModuleError: "Failed to extract the angular-translate translations from 'filter-chain.html':8:14: Another filter is used before the translate filter in the element
{{ \\"5.0\\" | currency | translate }}
. Add the 'suppress-dynamic-translation-error' to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).",
295 | ]
296 | `);
297 | expect(translations).toEqual({});
298 | });
299 |
300 | it("suppress dynamic translations errors if element or parent is attribute with suppress-dynamic-translation-error", async function () {
301 | const { stats } = await compileAndGetTranslations(
302 | "dynamic-filter-expression-suppressed.html"
303 | );
304 | expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
305 | });
306 |
307 | it("suppress dynamic translations errors for custom elements when attributed with suppress-dynamic-translation-error", async function () {
308 | const { stats } = await compileAndGetTranslations(
309 | "dynamic-filter-custom-element.html"
310 | );
311 | expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
312 | });
313 |
314 | it("can parse an invalid html file", async function () {
315 | const { translations } = await compileAndGetTranslations(
316 | "invalid-html.html"
317 | );
318 | expect(translations).toMatchObject({ Result: "Result" });
319 | });
320 |
321 | it("can parse an html containing an attribute that starts with a $", async function () {
322 | const { translations } = await compileAndGetTranslations(
323 | "html-with-dollar-attribute.html"
324 | );
325 | expect(translations).toMatchObject({ Test: "Test" });
326 | });
327 | });
328 |
329 | it("can be used with the angular i18n translation extractor", async function () {
330 | "use strict";
331 |
332 | const {
333 | translations,
334 | } = await compileAndGetTranslations("translate-and-i18n.html", [
335 | WebPackAngularTranslate.angularI18nTranslationsExtractor,
336 | ]);
337 |
338 | expect(translations).toMatchObject({
339 | translateId: "Translate translation",
340 | i18nId: "I18n translation",
341 | });
342 | });
343 | });
344 |
345 | describe("JSLoader", function () {
346 | "use strict";
347 |
348 | it("emits a useful error message if the plugin is missing", async function () {
349 | const { error, stats } = await compile({
350 | entry: "./test/cases/simple.js",
351 | output: {
352 | path: path.join(__dirname, "dist"),
353 | },
354 | module: {
355 | rules: [
356 | {
357 | test: /\.js/,
358 | loader: WebPackAngularTranslate.jsLoader(),
359 | },
360 | ],
361 | },
362 | });
363 |
364 | expect(error).toBeNull();
365 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
366 | Array [
367 | ModuleBuildError: "The WebpackAngularTranslate plugin is missing. Add the plugin to your webpack configurations 'plugins' section.",
368 | ]
369 | `);
370 | });
371 |
372 | it("passes the acorn parser options to acorn (in this case, allows modules)", async function () {
373 | const { error, stats } = await compile({
374 | entry: "./test/cases/es-module.js",
375 | output: {
376 | path: path.join(__dirname, "dist"),
377 | },
378 | module: {
379 | rules: [
380 | {
381 | test: /\.js/,
382 | loader: WebPackAngularTranslate.jsLoader(),
383 | options: {
384 | parserOptions: {
385 | sourceType: "module",
386 | },
387 | },
388 | },
389 | ],
390 | },
391 | plugins: [new WebPackAngularTranslate.Plugin()],
392 | });
393 | expect(error).toBeNull();
394 | expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
395 | });
396 |
397 | describe("$translate", function () {
398 | it("extracts the translation id when the $translate service is used as global variable ($translate)", async function () {
399 | const { translations } = await compileAndGetTranslations("simple.js");
400 | expect(translations).toMatchObject({
401 | "global variable": "global variable",
402 | });
403 | });
404 |
405 | it("extracts the translation id when the $translate service is used in the constructor", async function () {
406 | const { translations } = await compileAndGetTranslations("simple.js");
407 | expect(translations).toMatchObject({
408 | "translate in constructor": "translate in constructor",
409 | });
410 | });
411 |
412 | it("extracts the translation id when the $translate service is used in an arrow function (() => this.$translate)", async function () {
413 | const { translations } = await compileAndGetTranslations("simple.js");
414 | expect(translations).toMatchObject({
415 | "translate in arrow function": "translate in arrow function",
416 | });
417 | });
418 |
419 | it("extracts the translation id when the $translate service is used in a member function (this.$translate)", async function () {
420 | const { translations } = await compileAndGetTranslations("simple.js");
421 | expect(translations).toMatchObject({
422 | "this-translate": "this-translate",
423 | });
424 | });
425 |
426 | it("extracts multiple translation id's when an array is passed as argument", async function () {
427 | const { translations } = await compileAndGetTranslations("array.js");
428 | expect(translations).toMatchObject({
429 | FIRST_PAGE: "FIRST_PAGE",
430 | Next: "Next",
431 | });
432 | });
433 |
434 | it("extracts instant translation id", async function () {
435 | const { translations } = await compileAndGetTranslations("instant.js");
436 | expect(translations).toMatchObject({
437 | FIRST_TRANSLATION: "FIRST_TRANSLATION",
438 | SECOND_TRANSLATION: "SECOND_TRANSLATION",
439 | });
440 | expect(translations).not.toHaveProperty("SKIPPED_TRANSLATION");
441 | });
442 |
443 | it("extracts the default text", async function () {
444 | const { translations } = await compileAndGetTranslations(
445 | "defaultText.js"
446 | );
447 | expect(translations).toMatchObject({ Next: "Weiter" });
448 | });
449 |
450 | it("extracts the default text when an array is passed for the id's", async function () {
451 | const { translations } = await compileAndGetTranslations(
452 | "defaultText.js"
453 | );
454 | expect(translations).toMatchObject({
455 | FIRST_PAGE: "Missing",
456 | LAST_PAGE: "Missing",
457 | });
458 | });
459 |
460 | it("emits errors if $translate is used with invalid arguments", async function () {
461 | const { translations, stats } = await compileAndGetTranslations(
462 | "invalid$translate.js"
463 | );
464 |
465 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
466 | Array [
467 | ModuleError: "Illegal argument for call to $translate: A call to $translate requires at least one argument that is the translation id. If you have registered the translation manually, you can use a /* suppress-dynamic-translation-error: true */ comment in the block of the function call to suppress this error. (invalid$translate.js:1:0)",
468 | ModuleError: "Illegal argument for call to $translate: The translation id should either be a string literal or an array containing string literals. If you have registered the translation manually, you can use a /* suppress-dynamic-translation-error: true */ comment in the block of the function call to suppress this error. (invalid$translate.js:4:0)",
469 | ]
470 | `);
471 | expect(translations).toEqual({});
472 | });
473 |
474 | it("a comment suppress the dynamic translation errors for $translate", async function () {
475 | const { translations, stats } = await compileAndGetTranslations(
476 | "translateSuppressed.js"
477 | );
478 |
479 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
480 | Array [
481 | ModuleError: "Illegal argument for call to $translate: The translation id should either be a string literal or an array containing string literals. If you have registered the translation manually, you can use a /* suppress-dynamic-translation-error: true */ comment in the block of the function call to suppress this error. (translateSuppressed.js:13:4)",
482 | ]
483 | `);
484 | expect(translations).toEqual({});
485 | });
486 | });
487 |
488 | describe("i18n.registerTranslation", function () {
489 | it("register translation", async function () {
490 | const { translations, stats } = await compileAndGetTranslations(
491 | "registerTranslation.js"
492 | );
493 | expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
494 |
495 | expect(translations).toEqual({
496 | NEW_USER: "New user",
497 | EDIT_USER: "Edit user",
498 | 5: "true",
499 | });
500 | });
501 |
502 | it("register translation with invalid arguments", async function () {
503 | const { translations, stats } = await compileAndGetTranslations(
504 | "registerInvalidTranslation.js"
505 | );
506 |
507 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
508 | Array [
509 | ModuleError: "Illegal argument for call to 'i18n.registerTranslation'. The call requires at least the 'translationId' argument that needs to be a literal (registerInvalidTranslation.js:2:0)",
510 | ModuleError: "Illegal argument for call to 'i18n.registerTranslation'. The call requires at least the 'translationId' argument that needs to be a literal (registerInvalidTranslation.js:4:0)",
511 | ]
512 | `);
513 | expect(translations).toEqual({});
514 | });
515 | });
516 |
517 | describe("i18n.registerTranslations", function () {
518 | it("register translations", async function () {
519 | const { translations, stats } = await compileAndGetTranslations(
520 | "registerTranslations.js"
521 | );
522 | expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
523 |
524 | expect(translations).toEqual({
525 | Login: "Anmelden",
526 | Logout: "Abmelden",
527 | Next: "Weiter",
528 | Back: "Zurück",
529 | });
530 | });
531 |
532 | it("warns about invalid translation registrations", async function () {
533 | const { translations, stats } = await compileAndGetTranslations(
534 | "registerInvalidTranslations.js"
535 | );
536 |
537 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
538 | Array [
539 | ModuleError: "Illegal argument for call to i18n.registerTranslations: The value for the key 'key' needs to be a literal (registerInvalidTranslations.js:5:0)",
540 | ModuleError: "Illegal argument for call to i18n.registerTranslations: requires a single argument that is an object where the key is the translationId and the value is the default text (registerInvalidTranslations.js:1:0)",
541 | ]
542 | `);
543 | expect(translations).toEqual({});
544 | });
545 | });
546 | });
547 |
548 | describe("Plugin", function () {
549 | it("emits an error if the same id with different default texts is used", async function () {
550 | const { translations, stats } = await compileAndGetTranslations(
551 | "differentDefaultTexts.js"
552 | );
553 |
554 | expect(stats.compilation.errors).toMatchInlineSnapshot(`
555 | Array [
556 | [Error: Webpack-Angular-Translate: Two translations with the same id but different default text found.
557 | Existing: {
558 | "id": "Next",
559 | "defaultText": "Weiter",
560 | "usages": [
561 | "differentDefaultTexts.js:5:8"
562 | ]
563 | }
564 | New: {
565 | "id": "Next",
566 | "defaultText": "Missing",
567 | "usages": [
568 | "differentDefaultTexts.js:6:8"
569 | ]
570 | }
571 | Please define the same default text twice or specify the default text only once.],
572 | ]
573 | `);
574 | expect(translations).toEqual({});
575 | });
576 |
577 | it("emits a warning if the translation id is missing", async function () {
578 | const { translations, stats } = await compileAndGetTranslations(
579 | "emptyTranslate.html"
580 | );
581 | expect(stats.compilation.warnings).toHaveLength(1);
582 |
583 | expect(stats.compilation.warnings).toMatchInlineSnapshot(`
584 | Array [
585 | [Error: Invalid angular-translate translation found: The id of the translation is empty. Consider removing the translate attribute (html) or defining the translation id (js).
586 | Translation:
587 | '{
588 | "id": "",
589 | "defaultText": null,
590 | "usages": [
591 | "emptyTranslate.html:5:8"
592 | ]
593 | }'],
594 | ]
595 | `);
596 |
597 | expect(translations).toEqual({});
598 | });
599 |
600 | it("does not add translations twice if file is recompiled after change", async function () {
601 | const projectVolume = Volume.fromJSON(
602 | {
603 | "./fileChange.js":
604 | "require('./otherFile.js');\n" +
605 | "i18n.registerTranslation('NEW_USER', 'New user');\n" +
606 | "i18n.registerTranslation('DELETE_USER', 'Delete User');\n" +
607 | "i18n.registerTranslation('WillBeDeleted', 'Delete');",
608 |
609 | "./otherFile.js":
610 | "i18n.registerTranslation('DELETE_USER', 'Delete User');",
611 | },
612 | path.join(__dirname, "..")
613 | );
614 | const inputFs = ufs.use(fs).use(createFsFromVolume(projectVolume));
615 | const outputVolume = Volume.fromJSON({}, __dirname);
616 |
617 | var options = webpackOptions({
618 | entry: "./fileChange.js",
619 | });
620 | var compiler = webpack(options);
621 | compiler.inputFileSystem = inputFs;
622 | compiler.outputFileSystem = new VolumeOutputFileSystem(outputVolume);
623 |
624 | var secondCompilationStats = await new Promise((resolve, reject) => {
625 | var firstRun = true;
626 | var watching = compiler.watch({}, function (error, stats) {
627 | if (error) {
628 | return reject(error);
629 | }
630 |
631 | if (firstRun) {
632 | if (stats.compilation.errors.length > 0) {
633 | return reject(stats.compilation.errors);
634 | }
635 |
636 | firstRun = false;
637 | projectVolume.writeFileSync(
638 | "./fileChange.js",
639 | "i18n.registerTranslation('NEW_USER', 'Neuer Benutzer');"
640 | );
641 |
642 | watching.invalidate(); // watch doesn't seem to work with memory fs
643 | } else {
644 | watching.close(() => resolve(stats));
645 | }
646 | });
647 | });
648 |
649 | expect(secondCompilationStats.compilation.errors).toHaveLength(0);
650 |
651 | var translations = JSON.parse(
652 | outputVolume.toJSON(__dirname, undefined, true)["dist/translations.json"]
653 | );
654 |
655 | expect(translations).toEqual({
656 | NEW_USER: "Neuer Benutzer",
657 | DELETE_USER: "Delete User",
658 | });
659 | });
660 | });
661 |
662 | class VolumeOutputFileSystem {
663 | constructor(volume) {
664 | const fs = createFsFromVolume(volume);
665 | this.mkdirp = fs.mkdirp;
666 | this.mkdir = fs.mkdir.bind(fs);
667 | this.rmdir = fs.rmdir.bind(fs);
668 | this.unlink = fs.unlink.bind(fs);
669 | this.writeFile = fs.writeFile.bind(fs);
670 | this.join = path.join.bind(path);
671 | }
672 | }
673 |
--------------------------------------------------------------------------------
/test/translate-jest-matchers.js:
--------------------------------------------------------------------------------
1 | import { toMatchSnapshot } from "jest-snapshot";
2 |
3 | // webpack uses special errors that store the original error message
4 | // in error.error.message. This serilaizer unwraps the webpack errors.
5 | expect.addSnapshotSerializer({
6 | test(value) {
7 | return value instanceof Error && typeof value.error === "object";
8 | },
9 | print(value, serialize) {
10 | return `${value.name}: ${serialize(value.error)}`;
11 | }
12 | });
13 |
14 | // Matches the empty error where we need to remove the absolute paths
15 | expect.addSnapshotSerializer({
16 | test(value) {
17 | return value instanceof Error && typeof value.error === "object";
18 | },
19 | print(value, serialize) {
20 | return `${value.name}: ${serialize(value.error.message)}`;
21 | }
22 | });
23 |
24 | expect.extend({
25 | toHaveEmittedErrorMatchingSnapshot(loaderContext) {
26 | return toMatchSnapshot.call(this, loaderContext.emitError.mock.calls);
27 | }
28 | });
29 |
--------------------------------------------------------------------------------
/test/translation.spec.js:
--------------------------------------------------------------------------------
1 | import { Translation } from "../src/translation";
2 |
3 | describe("Translations", function() {
4 | "use strict";
5 |
6 | it("creates a new translation with a resource array if a single resource is passed in", function() {
7 | var translation = new Translation("test", null, "src/main.html");
8 |
9 | expect(translation.usages).toEqual(["src/main.html"]);
10 | });
11 |
12 | it("creates a new translation with a resource array, if a resource array is passed in", function() {
13 | var translation = new Translation("test", null, [
14 | "src/main.html",
15 | "src/login.html"
16 | ]);
17 |
18 | expect(translation.usages).toEqual(["src/main.html", "src/login.html"]);
19 | });
20 |
21 | it("merges the resources when two translations are merged", function() {
22 | var first = new Translation("hallo", null, "src/main.html"),
23 | second = new Translation("hallo", null, "src/login.html");
24 |
25 | var merged = first.merge(second);
26 | expect(merged.usages).toEqual(["src/main.html", "src/login.html"]);
27 | expect(merged.id).toBe("hallo");
28 | expect(merged.defaultText).toBeNull();
29 | });
30 |
31 | it("does not include the same resources twice when merging", function() {
32 | var first = new Translation("hallo", null, "src/main.html"),
33 | second = new Translation("hallo", null, "src/main.html");
34 |
35 | var merged = first.merge(second);
36 | expect(merged.usages).toEqual(["src/main.html"]);
37 | expect(merged.id).toBe("hallo");
38 | expect(merged.defaultText).toBeNull();
39 | });
40 |
41 | it("uses the default text of the translation if one of both translations have a default text", function() {
42 | var first = new Translation("hallo", null, "src/main.html"),
43 | second = new Translation("hallo", "Hallo", "src/main.html");
44 |
45 | var merged = first.merge(second);
46 | expect(merged.id).toBe("hallo");
47 | expect(merged.defaultText).toBe("Hallo");
48 | });
49 |
50 | it("uses the default text of the first translations if both translations have a default text", function() {
51 | var first = new Translation("hallo", "Hello", "src/main.html"),
52 | second = new Translation("hallo", "Hallo", "src/main.html");
53 |
54 | var merged = first.merge(second);
55 | expect(merged.id).toBe("hallo");
56 | expect(merged.defaultText).toBe("Hello");
57 | });
58 |
59 | it("implements to string", function() {
60 | var translation = new Translation("hallo", null, [
61 | { resource: "src/main.html", loc: { line: 10, column: 4 } },
62 | { resource: "src/login.html", loc: undefined }
63 | ]);
64 |
65 | expect(translation.toString()).toMatchInlineSnapshot(`
66 | "{
67 | \\"id\\": \\"hallo\\",
68 | \\"defaultText\\": null,
69 | \\"usages\\": [
70 | \\"src/main.html:10:4\\",
71 | \\"src/login.html:null:null\\"
72 | ]
73 | }"
74 | `);
75 | });
76 |
77 | it("text returns the translation id if the translation has no default text", function() {
78 | var translation = new Translation("Hello");
79 |
80 | expect(translation.text).toBe("Hello");
81 | });
82 |
83 | it("text returns the default text if the translation has a default text", function() {
84 | var translation = new Translation("Hello", "Hallo");
85 |
86 | expect(translation.text).toBe("Hallo");
87 | });
88 |
89 | it("text returns a string, if a non string value is set as translation id or default text", function() {
90 | var translation = new Translation("number", 5);
91 |
92 | expect(translation.text).toBe("5");
93 | });
94 | });
95 |
--------------------------------------------------------------------------------
/test/translations-registry.spec.js:
--------------------------------------------------------------------------------
1 | import Translation from "../src/translation";
2 | import TranslationsRegistry from "../dist/translations-registry";
3 |
4 | describe("TranslationsRegistry", function() {
5 | "use strict";
6 | var registry;
7 | var RESOURCE = "test.js";
8 |
9 | beforeEach(function() {
10 | registry = new TranslationsRegistry();
11 | });
12 |
13 | describe("empty", function() {
14 | it("is true by default", function() {
15 | expect(registry.empty).toBe(true);
16 | });
17 |
18 | it("is false if a translation is registered", function() {
19 | registry.registerTranslation(createTranslation("test"));
20 |
21 | expect(registry.empty).toBe(false);
22 | });
23 | });
24 |
25 | describe("toJSON", function() {
26 | it("returns an empty object by default", function() {
27 | expect(registry.toJSON()).toEqual({});
28 | });
29 |
30 | it("returns the registered translation", function() {
31 | registry.registerTranslation(createTranslation("test", "Test"));
32 |
33 | expect(registry.toJSON()).toEqual({ test: "Test" });
34 | });
35 |
36 | it("returns all registered translations", function() {
37 | registry.registerTranslation(createTranslation("test", "Test"));
38 | registry.registerTranslation(createTranslation("other"));
39 |
40 | expect(registry.toJSON()).toEqual({ test: "Test", other: "other" });
41 | });
42 | });
43 |
44 | describe("registerTranslation", function() {
45 | it("merges translations with the same translation id", function() {
46 | registry.registerTranslation(createTranslation("test", "Test", 1, 1));
47 | registry.registerTranslation(createTranslation("test", "Test", 10, 1));
48 |
49 | var translation = registry.getTranslation("test");
50 | expect(translation).toBeTruthy();
51 |
52 | expect(translation.usages).toEqual([
53 | {
54 | resource: RESOURCE,
55 | loc: {
56 | line: 10,
57 | column: 1
58 | }
59 | },
60 | {
61 | resource: RESOURCE,
62 | loc: {
63 | line: 1,
64 | column: 1
65 | }
66 | }
67 | ]);
68 |
69 | expect(registry.toJSON()).toEqual({ test: "Test" });
70 | });
71 |
72 | it("throws if another translation with a different default text exists", function() {
73 | registry.registerTranslation(createTranslation("test", "Test"));
74 |
75 | expect(function() {
76 | registry.registerTranslation(
77 | createTranslation("test", "Other default text", 10)
78 | );
79 | }).toThrowErrorMatchingInlineSnapshot(`
80 | "Webpack-Angular-Translate: Two translations with the same id but different default text found.
81 | Existing: {
82 | \\"id\\": \\"test\\",
83 | \\"defaultText\\": \\"Test\\",
84 | \\"usages\\": [
85 | \\"test.js:1:1\\"
86 | ]
87 | }
88 | New: {
89 | \\"id\\": \\"test\\",
90 | \\"defaultText\\": \\"Other default text\\",
91 | \\"usages\\": [
92 | \\"test.js:10:1\\"
93 | ]
94 | }
95 | Please define the same default text twice or specify the default text only once."
96 | `);
97 | });
98 |
99 | it("throws if the translation id is an empty string", function() {
100 | expect(function() {
101 | registry.registerTranslation(createTranslation(""));
102 | }).toThrowErrorMatchingInlineSnapshot(`
103 | "Invalid angular-translate translation found: The id of the translation is empty. Consider removing the translate attribute (html) or defining the translation id (js).
104 | Translation:
105 | '{
106 | \\"id\\": \\"\\",
107 | \\"defaultText\\": null,
108 | \\"usages\\": [
109 | \\"test.js:1:1\\"
110 | ]
111 | }'"
112 | `);
113 | });
114 |
115 | it("throws if the translation id is undefined", function() {
116 | expect(function() {
117 | registry.registerTranslation(createTranslation());
118 | }).toThrowErrorMatchingInlineSnapshot(`
119 | "Invalid angular-translate translation found: The id of the translation is empty. Consider removing the translate attribute (html) or defining the translation id (js).
120 | Translation:
121 | '{
122 | \\"id\\": null,
123 | \\"defaultText\\": null,
124 | \\"usages\\": [
125 | \\"test.js:1:1\\"
126 | ]
127 | }'"
128 | `);
129 | });
130 |
131 | it("throws if the translation id is null", function() {
132 | expect(function() {
133 | registry.registerTranslation(createTranslation(null));
134 | }).toThrowErrorMatchingInlineSnapshot(`
135 | "Invalid angular-translate translation found: The id of the translation is empty. Consider removing the translate attribute (html) or defining the translation id (js).
136 | Translation:
137 | '{
138 | \\"id\\": null,
139 | \\"defaultText\\": null,
140 | \\"usages\\": [
141 | \\"test.js:1:1\\"
142 | ]
143 | }'"
144 | `);
145 | });
146 | });
147 |
148 | describe("pruneTranslations", function() {
149 | it("removes all translations from the specified resource", function() {
150 | registry.registerTranslation(createTranslation("test", "Test"));
151 | registry.registerTranslation(createTranslation("other", "Other"));
152 |
153 | registry.pruneTranslations(RESOURCE);
154 |
155 | expect(registry.empty).toBe(true);
156 | expect(registry.toJSON()).toEqual({});
157 | });
158 |
159 | it("does not remove translations from other resources", function() {
160 | registry.registerTranslation(createTranslation("test", "Test"));
161 | registry.registerTranslation(
162 | new Translation("other", "Other", {
163 | resource: "other.js",
164 | loc: { line: 1, column: 1 }
165 | })
166 | );
167 |
168 | registry.pruneTranslations(RESOURCE);
169 |
170 | expect(registry.empty).toBe(false);
171 | expect(registry.toJSON()).toEqual({ other: "Other" });
172 | });
173 |
174 | it("does not remove translation if it is used by another resource", function() {
175 | registry.registerTranslation(createTranslation("test", "Test"));
176 | registry.registerTranslation(
177 | new Translation("test", "Test", {
178 | resource: "other.js",
179 | loc: { line: 1, column: 1 }
180 | })
181 | );
182 |
183 | registry.pruneTranslations(RESOURCE);
184 |
185 | expect(registry.empty).toBe(false);
186 | expect(registry.toJSON()).toEqual({ test: "Test" });
187 | });
188 |
189 | it("removes a translation if all usages have been pruned", function() {
190 | registry.registerTranslation(createTranslation("test", "Test"));
191 | registry.registerTranslation(
192 | new Translation("test", "Test", {
193 | resource: "other.js",
194 | loc: { line: 1, column: 1 }
195 | })
196 | );
197 |
198 | registry.pruneTranslations(RESOURCE);
199 | registry.pruneTranslations("other.js");
200 |
201 | expect(registry.empty).toBe(true);
202 | expect(registry.toJSON()).toEqual({});
203 | });
204 | });
205 |
206 | function createTranslation(id, defaultText, line, column) {
207 | return new Translation(id, defaultText, {
208 | resource: RESOURCE,
209 | loc: { line: line || 1, column: column || 1 }
210 | });
211 | }
212 | });
213 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "es5",
5 | "sourceMap": true,
6 | "noImplicitAny": true,
7 | "outDir": "dist",
8 | "moduleResolution": "node",
9 | "noUnusedLocals": true,
10 | "noUnusedParameters": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "lib": ["es2015"]
14 | },
15 | "files": ["src/index.ts", "src/html/html-loader.ts", "src/js/js-loader.ts"]
16 | }
17 |
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "rules": {
3 | "class-name": true,
4 | "comment-format": [ true, "check-space" ],
5 | "curly": true,
6 | "forin": true,
7 | "indent": [true, "tab"],
8 | "jsdoc-format": true,
9 | "member-ordering": [ true, "static-before-instance", "variables-before-function" ],
10 | "no-arg": true,
11 | "no-console": [true,
12 | "log",
13 | "debug",
14 | "info",
15 | "time",
16 | "timeEnd",
17 | "trace"
18 | ],
19 | "no-debugger": true,
20 | "no-duplicate-keys": true,
21 | "no-duplicate-variable": true,
22 | "no-empty": true,
23 | "no-eval": true,
24 | "no-switch-case-fall-through": true,
25 | "no-trailing-coma": true,
26 | "no-trailing-whitespace": true,
27 | "no-unreachable": true,
28 | "no-unused-expression": true,
29 | "no-unused-variable": true,
30 | "no-use-before-declare": true,
31 | "one-line": [ true, "check-catch", "check-else", "check-open-brace", "check-whitespace" ],
32 | "quotemark": [true, "double"],
33 | "radix": true,
34 | "semicolon": true,
35 | "switch-default": true,
36 | "triple-equals": true,
37 | "typedef": [ true, "call-signature", "property-declaration"],
38 | "whitespace": [true, "check-branch", "check-decl", "check-operator", "check-separator", "check-type", "check-cast" ]
39 | },
40 | "emitErrors": false,
41 | "failOnHint": true
42 | }
43 |
--------------------------------------------------------------------------------