├── .gitignore ├── .npmignore ├── .travis.yml ├── .vscode └── launch.json ├── CHANGELOG.md ├── LICENSE ├── README.MD ├── babel.config.js ├── example ├── app.js ├── home.html ├── index.html ├── package.json ├── user.html ├── webpack.config.js └── yarn.lock ├── i18n.d.ts ├── jest.config.js ├── package.json ├── src ├── angular-translate-plugin.ts ├── html │ ├── angular-i18n-translations-extractor.ts │ ├── element-context.ts │ ├── html-loader.ts │ ├── html-translation-extractor.ts │ ├── ng-filters.ts │ ├── translate-directive-translation-extractor.ts │ └── translate-html-parser.ts ├── index.ts ├── js │ ├── js-loader.ts │ └── translate-visitor.ts ├── translate-loader-context.ts ├── translation.ts └── translations-registry.ts ├── test ├── cases │ ├── array.js │ ├── attributes.html │ ├── defaultText.html │ ├── defaultText.js │ ├── differentDefaultTexts.js │ ├── dynamic-filter-custom-element.html │ ├── dynamic-filter-expression-suppressed.html │ ├── dynamic-filter-expression.html │ ├── emptyTranslate.html │ ├── es-module.js │ ├── expressions-suppressed.html │ ├── expressions.html │ ├── filter-chain.html │ ├── filter-simple.html │ ├── html-simple.js │ ├── html-with-dollar-attribute.html │ ├── instant.js │ ├── invalid$translate.js │ ├── invalid-html.html │ ├── multiple-child-texts.html │ ├── multiple-filters.html │ ├── registerInvalidTranslation.js │ ├── registerInvalidTranslations.js │ ├── registerTranslation.html │ ├── registerTranslation.js │ ├── registerTranslations.js │ ├── simple.html │ ├── simple.js │ ├── translate-and-i18n.html │ └── translateSuppressed.js ├── html │ ├── __snapshots__ │ │ ├── angular-i18n-translations-extractor.spec.js.snap │ │ └── translate-html-parser.spec.js.snap │ ├── angular-i18n-translations-extractor.spec.js │ ├── element-context.spec.js │ ├── ng-filters.spec.js │ └── translate-html-parser.spec.js ├── js │ ├── __snapshots__ │ │ └── translate-visitor.spec.js.snap │ └── translate-visitor.spec.js ├── plugin.spec.js ├── translate-jest-matchers.js ├── translation.spec.js └── translations-registry.spec.js ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | coverage 4 | test/dist 5 | example/dist 6 | example/node_modules 7 | dist 8 | npm-debug.log 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | coverage 4 | test/dist 5 | example/dist 6 | example/node_modules 7 | npm-debug.log 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | - 14 5 | - 12 6 | - 10 7 | 8 | script: 9 | - npm test 10 | 11 | cache: yarn 12 | 13 | after_success: 14 | - npm run coveralljs 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Jest All", 11 | "program": "${workspaceFolder}/node_modules/.bin/jest", 12 | "args": ["--runInBand"], 13 | "console": "integratedTerminal", 14 | "internalConsoleOptions": "neverOpen", 15 | "disableOptimisticBPs": true, 16 | "windows": { 17 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 18 | } 19 | }, 20 | { 21 | "type": "node", 22 | "request": "launch", 23 | "name": "Jest Current File", 24 | "program": "${workspaceFolder}/node_modules/.bin/jest", 25 | "args": ["${relativeFile}", "--config", "jest.config.js"], 26 | "console": "integratedTerminal", 27 | "internalConsoleOptions": "neverOpen", 28 | "disableOptimisticBPs": true, 29 | "windows": { 30 | "program": "${workspaceFolder}/node_modules/jest/bin/jest" 31 | } 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.0 (2017-04-08) 2 | - Added support for $translate.instant ([@setor](https://github.com/setor). This might lead to new warnings in case dynamic translation keys are used. 3 | 4 | ## 1.0.2 (2017-03-09) 5 | - Suppress Deprecation Warning [#27](https://github.com/MichaReiser/webpack-angular-translate/issues/27) ([@BenDiuguid](https://github.com/BenDiuguid)) 6 | 7 | ## 1.0.0 (2017-01-12) 8 | 9 | - Upgrade Dependencies (@BenDiuguid) 10 | - Add Support for ES6 Modules (@BenDiuguid) 11 | - Drop Node < 4.0 12 | 13 | 14 | ## 0.1.3 (2016-03-05) 15 | 16 | Upgrade dependencies 17 | 18 | ## 0.1.2 (2015-12-23) 19 | 20 | Bugfixes: 21 | - Only extract translation from attributes that use real expressions (0369ca011c9b7c958ca7cb10a4bb334ec072565d) 22 | 23 | ## 0.1.1 (2015-12-21) 24 | 25 | Bugfixes: 26 | - Correctly handle elements with translated element content and attribute (c1656320c1bafbe1ee8c7a2094dbca89ec2610b5) 27 | 28 | ## 0.1.0 (2015-12-19) 29 | 30 | Features: 31 | - Remove translations during recompilation, avoids *duplicate* translations error when translation has been changed 32 | - Emit a warning if a translation without an id is used (e.g. `
text
`) 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 | [![NPM](https://nodei.co/npm/webpack-angular-translate.png?downloads=true&downloadRank=true&stars=true)](https://nodei.co/npm/webpack-angular-translate/) 4 | 5 | [![Build Status](https://travis-ci.org/MichaReiser/webpack-angular-translate.svg?branch=master)](https://travis-ci.org/MichaReiser/webpack-angular-translate) 6 | [![Coverage Status](https://coveralls.io/repos/MichaReiser/webpack-angular-translate/badge.svg?branch=master&service=github)](https://coveralls.io/github/MichaReiser/webpack-angular-translate?branch=master) 7 | [![Dependency Status](https://gemnasium.com/DatenMetgzerX/webpack-angular-translate.svg)](https://gemnasium.com/DatenMetgzerX/webpack-angular-translate) 8 | [![npm version](https://badge.fury.io/js/webpack-angular-translate.svg)](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 |
Translation-ID
152 | Translation-ID 153 | 154 |
155 | 156 | 157 |
Translation-ID
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 |

{{ home.title }}

3 | 4 |

HOME_MSG

5 | 6 | Download translations.json -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Hello World, AngularJS 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "app.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "webpack" 9 | }, 10 | "author": "", 11 | "license": "ISC", 12 | "devDependencies": { 13 | "html-loader": "^0.5.5", 14 | "webpack": "^4.23.1", 15 | "webpack-cli": "^3.1.2" 16 | }, 17 | "dependencies": { 18 | "angular": "^1.4.3", 19 | "angular-translate": "^2.7.2", 20 | "angular-translate-loader-url": "^2.7.2", 21 | "angular-ui-router": "^1.0.20" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/user.html: -------------------------------------------------------------------------------- 1 |

Show User

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}`; 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 |

Benutzer bearbeiten

2 |

Neuen Benuzer erstellen

3 |
4 | 5 | 13 |
14 |
15 |
16 |
17 | -------------------------------------------------------------------------------- /test/cases/dynamic-filter-expression-suppressed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

{{ editCtrl.title | translate }}

9 | 10 | 11 | -------------------------------------------------------------------------------- /test/cases/dynamic-filter-expression.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

{{ editCtrl.title | translate }}

9 | 10 | 11 | -------------------------------------------------------------------------------- /test/cases/emptyTranslate.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {{ '' | translate }} 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /test/cases/es-module.js: -------------------------------------------------------------------------------- 1 | $translate("global variable"); 2 | export var Test = (function() { 3 | function Test($translate, $http) { 4 | var _this = this; 5 | this.$translate = $translate; 6 | this.$http = $http; 7 | this.$translate("translate in constructor"); 8 | $http.get("xy").then(function() { 9 | return _this.$translate("translate in arrow function"); 10 | }); 11 | } 12 | Test.prototype.onClick = function() { 13 | this.$translate("this-translate"); 14 | }; 15 | return Test; 16 | })(); 17 | -------------------------------------------------------------------------------- /test/cases/expressions-suppressed.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

{{editCtrl.title}}

9 | 10 | -------------------------------------------------------------------------------- /test/cases/expressions.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

{{editCtrl.title}}

9 | 10 | -------------------------------------------------------------------------------- /test/cases/filter-chain.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

{{ "5.0" | currency | translate }}

9 | 10 | -------------------------------------------------------------------------------- /test/cases/filter-simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

{{ 'Home' | translate }}

9 | 10 | 11 | 12 | - {{'Top' | translate }} - 13 | 14 | -------------------------------------------------------------------------------- /test/cases/html-simple.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by micha on 07.07.15. 3 | */ 4 | var html = require("./simple.html"); 5 | 6 | $translate('js-translate-id'); -------------------------------------------------------------------------------- /test/cases/html-with-dollar-attribute.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 |
10 | 12 | Test 13 | 14 |
15 | 16 | -------------------------------------------------------------------------------- /test/cases/instant.js: -------------------------------------------------------------------------------- 1 | var Test = (function () { 2 | function Test($translate) { 3 | this.$translate = $translate; 4 | this.$translate.instant("FIRST_TRANSLATION"); 5 | $translate.instant("SECOND_TRANSLATION"); 6 | 7 | let skipTranslate = $translate; 8 | skipTranslate.instant("SKIPPED_TRANSLATION"); 9 | } 10 | return Test; 11 | })(); -------------------------------------------------------------------------------- /test/cases/invalid$translate.js: -------------------------------------------------------------------------------- 1 | $translate(); 2 | 3 | var x; 4 | $translate(x); 5 | 6 | -------------------------------------------------------------------------------- /test/cases/invalid-html.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |

10 | {{ 'Result' | translate }} hallo welt 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/cases/multiple-child-texts.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Good Morning Donald. How are you? 9 | 10 | 11 | -------------------------------------------------------------------------------- /test/cases/multiple-filters.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {{ 'Result' | translate }} 3 {{ 'of' | translate }} 1 + 2 9 | 10 | -------------------------------------------------------------------------------- /test/cases/registerInvalidTranslation.js: -------------------------------------------------------------------------------- 1 | var x = "test"; 2 | i18n.registerTranslation(); 3 | 4 | i18n.registerTranslation(x); -------------------------------------------------------------------------------- /test/cases/registerInvalidTranslations.js: -------------------------------------------------------------------------------- 1 | i18n.registerTranslations(); 2 | 3 | 4 | var value = "other"; 5 | i18n.registerTranslations({ 6 | "key": value 7 | }); -------------------------------------------------------------------------------- /test/cases/registerTranslation.html: -------------------------------------------------------------------------------- 1 |

{{editCtrl.title}}

-------------------------------------------------------------------------------- /test/cases/registerTranslation.js: -------------------------------------------------------------------------------- 1 | function EditController(user) { 2 | if (user.isNew()) { 3 | this.title = i18n.registerTranslation("NEW_USER", "New user"); 4 | } else { 5 | this.title = i18n.registerTranslation("EDIT_USER", "Edit user"); 6 | } 7 | } 8 | 9 | var editState = { 10 | name: "edit", 11 | url: "/{id:int}/edit", 12 | template: require("./registerTranslation.html"), 13 | controller: EditController, 14 | controllerAs: "editCtrl" 15 | }; 16 | 17 | 18 | i18n.registerTranslation(5, true); -------------------------------------------------------------------------------- /test/cases/registerTranslations.js: -------------------------------------------------------------------------------- 1 | i18n.registerTranslations({ 2 | "Login": "Anmelden", 3 | "Logout": "Abmelden" 4 | }); 5 | 6 | 7 | i18n.registerTranslations({ 8 | Next: "Weiter", 9 | Back: "Zurück" 10 | }); -------------------------------------------------------------------------------- /test/cases/simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | attribute-translation 9 | element-translation 10 | 11 | Content von span 12 | 13 | -------------------------------------------------------------------------------- /test/cases/simple.js: -------------------------------------------------------------------------------- 1 | $translate('global variable'); 2 | var Test = (function () { 3 | function Test($translate, $http) { 4 | var _this = this; 5 | this.$translate = $translate; 6 | this.$http = $http; 7 | this.$translate('translate in constructor'); 8 | $http.get('xy').then(function () { return _this.$translate("translate in arrow function"); }); 9 | } 10 | Test.prototype.onClick = function () { 11 | this.$translate('this-translate'); 12 | }; 13 | return Test; 14 | })(); -------------------------------------------------------------------------------- /test/cases/translate-and-i18n.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | translateId 9 | I18n translation 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /test/cases/translateSuppressed.js: -------------------------------------------------------------------------------- 1 | /** Test if it's possible to suppress dynamic $translate calls */ 2 | 3 | function errorShouldBeSuppressed() { 4 | /* suppress-dynamic-translation-error: true */ 5 | 6 | var x; 7 | 8 | $translate(x); 9 | } 10 | 11 | function noSuppressionHere() { 12 | "use strict"; 13 | $translate(1+3); 14 | } -------------------------------------------------------------------------------- /test/html/__snapshots__/angular-i18n-translations-extractor.spec.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`StatefulHtmlParserSpecs emits an error if no default translation is provided 1`] = ` 4 | Array [ 5 | Array [ 6 | [Error: Failed to extract the angular-translate translations from 'test.html':1:0: The element
...
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 {{ "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).], 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 '{{ ctrl.imgAlt | translate }}...'. 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("{{ \"title\" | uppercase | translate }}"); 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("{{ ctrl.imgAlt | translate }}"); 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 | --------------------------------------------------------------------------------