webpack-angular-translate
Version:
Webpack plugin that extracts the translation-ids with the default texts.
673 lines (587 loc) • 23.9 kB
JavaScript
import webpack from "webpack";
import deepExtend from "deep-extend";
import { Volume, createFsFromVolume } from "memfs";
import { ufs } from "unionfs";
import path from "path";
import fs from "fs";
// The main plugin can not be imported from the typescript source
// because webpack seems to modify the loader path
import * as WebPackAngularTranslate from "../dist/index";
import "./translate-jest-matchers";
/**
* Helper function to implement tests that verify the result in the translations.js
* @param fileName {string} the filename of the input file (the file to process by webpack)
* @param doneCallback the done callback from mocha that is invoked when the test has completed
* @param assertCallback {function({}, {})} Callback that contains the assert statements. the first argument
* is the source of the translations file. The webpack stats (containing warnings and errors) is passed as second argument.
*/
async function compileAndGetTranslations(
fileName,
customTranslationExtractors
) {
if (!customTranslationExtractors) {
customTranslationExtractors = [];
}
var options = webpackOptions(
{
entry: ["./test/cases/" + fileName],
},
customTranslationExtractors
);
const { error, stats, volume } = await compile(options);
expect(error).toBeFalsy();
var translations = {};
if (stats.compilation.assets["translations.json"]) {
translations = JSON.parse(
volume.toJSON(__dirname, undefined, true)["dist/translations.json"]
);
}
return { translations, stats };
}
function webpackOptions(options, customTranslationExtractors) {
"use strict";
return deepExtend(
{
output: {
path: path.join(__dirname, "dist"),
},
mode: "production",
module: {
rules: [
{
test: /\.html$/,
use: [
{
loader: "html-loader",
options: {
removeEmptyAttributes: false,
attrs: [],
},
},
{
loader: WebPackAngularTranslate.htmlLoader(),
options: {
translationExtractors: customTranslationExtractors,
},
},
],
},
{
test: /\.js/,
loader: WebPackAngularTranslate.jsLoader(),
},
],
},
plugins: [new WebPackAngularTranslate.Plugin()],
},
options
);
}
function compile(options) {
var compiler = webpack(options);
var volume = new Volume();
compiler.outputFileSystem = new VolumeOutputFileSystem(volume);
return new Promise((resolve, reject) => {
compiler.run(function (error, stats) {
resolve({ error, stats, volume });
});
});
}
describe("HTML Loader", function () {
"use strict";
it("emits a useful error message if the plugin is missing", async function () {
const { error, stats } = await compile({
entry: "./test/cases/simple.html",
output: {
path: path.join(__dirname, "dist"),
},
module: {
rules: [
{
test: /\.html$/,
use: [
{
loader: "html-loader",
options: {
removeEmptyAttributes: false,
attrs: [],
},
},
{
loader: WebPackAngularTranslate.htmlLoader(),
},
],
},
{
test: /\.js/,
loader: WebPackAngularTranslate.jsLoader(),
},
],
},
});
expect(error).toBeNull();
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
ModuleBuildError: "The WebpackAngularTranslate plugin is missing. Add the plugin to your webpack configurations 'plugins' section.",
]
`);
});
describe("directive", function () {
"use strict";
it("extracts the translation id if translate is used as attribute", async function () {
const { translations } = await compileAndGetTranslations("simple.html");
expect(translations).toMatchObject({
"attribute-translation": "attribute-translation",
});
});
it("extracts the translation id if translate is used as element", async function () {
const { translations } = await compileAndGetTranslations("simple.html");
expect(translations).toMatchObject({
"element-translation": "element-translation",
});
});
it("extracts the translation id from the attribute if specified", async function () {
const { translations } = await compileAndGetTranslations("simple.html");
expect(translations).toMatchObject({
"id-in-attribute": "id-in-attribute",
});
});
it("extracts the default text if translate is used as attribute", async function () {
const { translations } = await compileAndGetTranslations(
"defaultText.html"
);
expect(translations).toMatchObject({ Login: "Anmelden" });
});
it("extracts the default text if translate is used as element", async function () {
const { translations } = await compileAndGetTranslations(
"defaultText.html"
);
expect(translations).toMatchObject({ Logout: "Abmelden" });
});
it("extracts the translation id if a translation for an attribute is defined", async function () {
const { translations } = await compileAndGetTranslations(
"attributes.html"
);
expect(translations).toMatchObject({
"attribute-id": "attribute-id",
});
});
it("extracts the default text for an attribute translation", async function () {
const { translations } = await compileAndGetTranslations(
"attributes.html"
);
expect(translations).toMatchObject({
"attribute-default-id": "Default text for attribute title",
});
});
it("emits an error if an angular expression is used as attribute id", async function () {
const { stats } = await compileAndGetTranslations("expressions.html");
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
ModuleError: "Failed to extract the angular-translate translations from 'expressions.html':8:1: The element '<h1 translate=''>{{editCtrl.title}}</h1>' 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.",
]
`);
});
it("emits an error if a translated angular element has multiple child text elements and does not specify an id", async () => {
const { stats } = await compileAndGetTranslations(
"multiple-child-texts.html"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
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.",
]
`);
});
it("does suppress errors for dynamic translations if the element is attributed with suppress-dynamic-translation-error", async function () {
const { translations, stats } = await compileAndGetTranslations(
"expressions-suppressed.html"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
expect(translations).toEqual({});
});
it("removes the suppress-dynamic-translation-error attribute for non dev build", async function () {
const { stats } = await compileAndGetTranslations(
"expressions-suppressed.html"
);
var output = stats.compilation.assets["main.js"].source();
expect(output).toMatch("{{editCtrl.title}}");
expect(output).not.toMatch("suppress-dynamic-translation-error");
});
});
describe("filter", function () {
it("matches a filter in the body of an element", async function () {
const { translations } = await compileAndGetTranslations(
"filter-simple.html"
);
expect(translations).toMatchObject({ Home: "Home" });
});
it("matches a filter in an attribute of an element", async function () {
const { translations } = await compileAndGetTranslations(
"filter-simple.html"
);
expect(translations).toMatchObject({ Waterfall: "Waterfall" });
});
it("matches an expression in the middle of the element text content", async function () {
const { translations } = await compileAndGetTranslations(
"filter-simple.html"
);
expect(translations).toMatchObject({ Top: "Top" });
});
it("matches multiple expressions in a single text", async function () {
const { translations } = await compileAndGetTranslations(
"multiple-filters.html"
);
expect(translations).toMatchObject({
Result: "Result",
of: "of",
});
});
it("emits an error if a dynamic value is used in the translate filter", async function () {
const { translations, stats } = await compileAndGetTranslations(
"dynamic-filter-expression.html"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
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 '<h1 id='top'>{{ editCtrl.title | translate }}</h1>'. Add the 'suppress-dynamic-translation-error' attribute to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).",
]
`);
expect(translations).toEqual({});
});
it("emits an error if a filter is used before the translate filter", async function () {
const { translations, stats } = await compileAndGetTranslations(
"filter-chain.html"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
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 <h1 id='top'>{{ \\"5.0\\" | currency | translate }}</h1>. Add the 'suppress-dynamic-translation-error' to suppress the error (ensure that you have registered the translation manually, consider using i18n.registerTranslation).",
]
`);
expect(translations).toEqual({});
});
it("suppress dynamic translations errors if element or parent is attribute with suppress-dynamic-translation-error", async function () {
const { stats } = await compileAndGetTranslations(
"dynamic-filter-expression-suppressed.html"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
});
it("suppress dynamic translations errors for custom elements when attributed with suppress-dynamic-translation-error", async function () {
const { stats } = await compileAndGetTranslations(
"dynamic-filter-custom-element.html"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
});
it("can parse an invalid html file", async function () {
const { translations } = await compileAndGetTranslations(
"invalid-html.html"
);
expect(translations).toMatchObject({ Result: "Result" });
});
it("can parse an html containing an attribute that starts with a $", async function () {
const { translations } = await compileAndGetTranslations(
"html-with-dollar-attribute.html"
);
expect(translations).toMatchObject({ Test: "Test" });
});
});
it("can be used with the angular i18n translation extractor", async function () {
"use strict";
const {
translations,
} = await compileAndGetTranslations("translate-and-i18n.html", [
WebPackAngularTranslate.angularI18nTranslationsExtractor,
]);
expect(translations).toMatchObject({
translateId: "Translate translation",
i18nId: "I18n translation",
});
});
});
describe("JSLoader", function () {
"use strict";
it("emits a useful error message if the plugin is missing", async function () {
const { error, stats } = await compile({
entry: "./test/cases/simple.js",
output: {
path: path.join(__dirname, "dist"),
},
module: {
rules: [
{
test: /\.js/,
loader: WebPackAngularTranslate.jsLoader(),
},
],
},
});
expect(error).toBeNull();
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
ModuleBuildError: "The WebpackAngularTranslate plugin is missing. Add the plugin to your webpack configurations 'plugins' section.",
]
`);
});
it("passes the acorn parser options to acorn (in this case, allows modules)", async function () {
const { error, stats } = await compile({
entry: "./test/cases/es-module.js",
output: {
path: path.join(__dirname, "dist"),
},
module: {
rules: [
{
test: /\.js/,
loader: WebPackAngularTranslate.jsLoader(),
options: {
parserOptions: {
sourceType: "module",
},
},
},
],
},
plugins: [new WebPackAngularTranslate.Plugin()],
});
expect(error).toBeNull();
expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
});
describe("$translate", function () {
it("extracts the translation id when the $translate service is used as global variable ($translate)", async function () {
const { translations } = await compileAndGetTranslations("simple.js");
expect(translations).toMatchObject({
"global variable": "global variable",
});
});
it("extracts the translation id when the $translate service is used in the constructor", async function () {
const { translations } = await compileAndGetTranslations("simple.js");
expect(translations).toMatchObject({
"translate in constructor": "translate in constructor",
});
});
it("extracts the translation id when the $translate service is used in an arrow function (() => this.$translate)", async function () {
const { translations } = await compileAndGetTranslations("simple.js");
expect(translations).toMatchObject({
"translate in arrow function": "translate in arrow function",
});
});
it("extracts the translation id when the $translate service is used in a member function (this.$translate)", async function () {
const { translations } = await compileAndGetTranslations("simple.js");
expect(translations).toMatchObject({
"this-translate": "this-translate",
});
});
it("extracts multiple translation id's when an array is passed as argument", async function () {
const { translations } = await compileAndGetTranslations("array.js");
expect(translations).toMatchObject({
FIRST_PAGE: "FIRST_PAGE",
Next: "Next",
});
});
it("extracts instant translation id", async function () {
const { translations } = await compileAndGetTranslations("instant.js");
expect(translations).toMatchObject({
FIRST_TRANSLATION: "FIRST_TRANSLATION",
SECOND_TRANSLATION: "SECOND_TRANSLATION",
});
expect(translations).not.toHaveProperty("SKIPPED_TRANSLATION");
});
it("extracts the default text", async function () {
const { translations } = await compileAndGetTranslations(
"defaultText.js"
);
expect(translations).toMatchObject({ Next: "Weiter" });
});
it("extracts the default text when an array is passed for the id's", async function () {
const { translations } = await compileAndGetTranslations(
"defaultText.js"
);
expect(translations).toMatchObject({
FIRST_PAGE: "Missing",
LAST_PAGE: "Missing",
});
});
it("emits errors if $translate is used with invalid arguments", async function () {
const { translations, stats } = await compileAndGetTranslations(
"invalid$translate.js"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
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)",
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)",
]
`);
expect(translations).toEqual({});
});
it("a comment suppress the dynamic translation errors for $translate", async function () {
const { translations, stats } = await compileAndGetTranslations(
"translateSuppressed.js"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
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)",
]
`);
expect(translations).toEqual({});
});
});
describe("i18n.registerTranslation", function () {
it("register translation", async function () {
const { translations, stats } = await compileAndGetTranslations(
"registerTranslation.js"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
expect(translations).toEqual({
NEW_USER: "New user",
EDIT_USER: "Edit user",
5: "true",
});
});
it("register translation with invalid arguments", async function () {
const { translations, stats } = await compileAndGetTranslations(
"registerInvalidTranslation.js"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
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)",
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)",
]
`);
expect(translations).toEqual({});
});
});
describe("i18n.registerTranslations", function () {
it("register translations", async function () {
const { translations, stats } = await compileAndGetTranslations(
"registerTranslations.js"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`Array []`);
expect(translations).toEqual({
Login: "Anmelden",
Logout: "Abmelden",
Next: "Weiter",
Back: "Zurück",
});
});
it("warns about invalid translation registrations", async function () {
const { translations, stats } = await compileAndGetTranslations(
"registerInvalidTranslations.js"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
ModuleError: "Illegal argument for call to i18n.registerTranslations: The value for the key 'key' needs to be a literal (registerInvalidTranslations.js:5:0)",
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)",
]
`);
expect(translations).toEqual({});
});
});
});
describe("Plugin", function () {
it("emits an error if the same id with different default texts is used", async function () {
const { translations, stats } = await compileAndGetTranslations(
"differentDefaultTexts.js"
);
expect(stats.compilation.errors).toMatchInlineSnapshot(`
Array [
[Error: Webpack-Angular-Translate: Two translations with the same id but different default text found.
Existing: {
"id": "Next",
"defaultText": "Weiter",
"usages": [
"differentDefaultTexts.js:5:8"
]
}
New: {
"id": "Next",
"defaultText": "Missing",
"usages": [
"differentDefaultTexts.js:6:8"
]
}
Please define the same default text twice or specify the default text only once.],
]
`);
expect(translations).toEqual({});
});
it("emits a warning if the translation id is missing", async function () {
const { translations, stats } = await compileAndGetTranslations(
"emptyTranslate.html"
);
expect(stats.compilation.warnings).toHaveLength(1);
expect(stats.compilation.warnings).toMatchInlineSnapshot(`
Array [
[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).
Translation:
'{
"id": "",
"defaultText": null,
"usages": [
"emptyTranslate.html:5:8"
]
}'],
]
`);
expect(translations).toEqual({});
});
it("does not add translations twice if file is recompiled after change", async function () {
const projectVolume = Volume.fromJSON(
{
"./fileChange.js":
"require('./otherFile.js');\n" +
"i18n.registerTranslation('NEW_USER', 'New user');\n" +
"i18n.registerTranslation('DELETE_USER', 'Delete User');\n" +
"i18n.registerTranslation('WillBeDeleted', 'Delete');",
"./otherFile.js":
"i18n.registerTranslation('DELETE_USER', 'Delete User');",
},
path.join(__dirname, "..")
);
const inputFs = ufs.use(fs).use(createFsFromVolume(projectVolume));
const outputVolume = Volume.fromJSON({}, __dirname);
var options = webpackOptions({
entry: "./fileChange.js",
});
var compiler = webpack(options);
compiler.inputFileSystem = inputFs;
compiler.outputFileSystem = new VolumeOutputFileSystem(outputVolume);
var secondCompilationStats = await new Promise((resolve, reject) => {
var firstRun = true;
var watching = compiler.watch({}, function (error, stats) {
if (error) {
return reject(error);
}
if (firstRun) {
if (stats.compilation.errors.length > 0) {
return reject(stats.compilation.errors);
}
firstRun = false;
projectVolume.writeFileSync(
"./fileChange.js",
"i18n.registerTranslation('NEW_USER', 'Neuer Benutzer');"
);
watching.invalidate(); // watch doesn't seem to work with memory fs
} else {
watching.close(() => resolve(stats));
}
});
});
expect(secondCompilationStats.compilation.errors).toHaveLength(0);
var translations = JSON.parse(
outputVolume.toJSON(__dirname, undefined, true)["dist/translations.json"]
);
expect(translations).toEqual({
NEW_USER: "Neuer Benutzer",
DELETE_USER: "Delete User",
});
});
});
class VolumeOutputFileSystem {
constructor(volume) {
const fs = createFsFromVolume(volume);
this.mkdirp = fs.mkdirp;
this.mkdir = fs.mkdir.bind(fs);
this.rmdir = fs.rmdir.bind(fs);
this.unlink = fs.unlink.bind(fs);
this.writeFile = fs.writeFile.bind(fs);
this.join = path.join.bind(path);
}
}