Repository: ember-intl/ember-intl (ts)

Issue:
Ignore certain "missing message" warnings
In our app we have several translations that are only used on specific locales, but `ember-intl` is constantly warning about them being missing in the other locales, which makes the signal-to-noise ratio quite bad. It would be great if there was an API that would allow us to filter out these messages before they are being passed to the console.

Maybe something like this:

```js
let app = new EmberApp(defaults, {
  'ember-intl': {
    ignoreMissingMessage(message, locale) {
      // your custom logic here
    },
  },
});
```

Pull request description:
feat: add failOnMissingTranslation and allowMissingTranslation option for additional missing translations handling
fixes #547 and #553

Please don't merge this yet. I'll add tests and documentation once we're ok with the general concept.
This PR introduces two new `config/ember-intl.js` fields:

* `failOnMissingTranslation`: boolean
  * this causes the build pipeline to throw an error if a missing translation was found
* `allowMissingTranslation`: (key, locale) => boolean
  * this allows to whitelist some missing translations

![20180626-220626](https://user-images.githubusercontent.com/1205444/41937433-8e32bbda-7990-11e8-9cd9-8a6d3502e770.png)
![20180626-220621](https://user-images.githubusercontent.com/1205444/41937435-8e5ba2e8-7990-11e8-97b2-4cd0682d903b.png)

Everything is open for discussion.

Patch:
diff --git a/blueprints/ember-intl/files/config/ember-intl.js b/blueprints/ember-intl/files/config/ember-intl.js
index ee46368b..ee59c430 100644
--- a/blueprints/ember-intl/files/config/ember-intl.js
+++ b/blueprints/ember-intl/files/config/ember-intl.js
@@ -58,6 +58,28 @@ module.exports = function(/* env */) {
      * @type {String}
      * @default "translations"
      */
-    inputPath: 'translations'
+    inputPath: 'translations',
+
+    /**
+     * cause a build error if missing translations are detected.
+     *
+     * See https://github.com/jasonmit/ember-intl/blob/master/docs/missing-translations.md#throwing-a-build-error-on-missing-required-translation
+     *
+     * @property throwMissingTranslations
+     * @type {Boolean}
+     * @default "false"
+     */
+    throwMissingTranslations: false,
+
+    /**
+     * filter missing translations to ignore expected missing translations.
+     *
+     * See https://github.com/jasonmit/ember-intl/blob/master/docs/missing-translations.md#requiring-translations
+     *
+     * @property requiresTranslation
+     * @type {Function?}
+     * @default "function(){return true}"
+     */
+    requiresTranslation: undefined
   };
 };
diff --git a/config/ember-intl.js b/config/ember-intl.js
index ef0b0f3d..7ebda57a 100644
--- a/config/ember-intl.js
+++ b/config/ember-intl.js
@@ -47,6 +47,28 @@ module.exports = function(/* environment */) {
      * @type {Boolean}
      * @default "false"
      */
-    publicOnly: false
+    publicOnly: false,
+
+    /**
+     * cause a build error if missing translations are detected.
+     *
+     * See https://github.com/jasonmit/ember-intl/blob/master/docs/missing-translations.md#throwing-a-build-error-on-missing-required-translation
+     *
+     * @property throwMissingTranslations
+     * @type {Boolean}
+     * @default "false"
+     */
+    throwMissingTranslations: false,
+
+    /**
+     * filter missing translations to ignore expected missing translations.
+     *
+     * See https://github.com/jasonmit/ember-intl/blob/master/docs/missing-translations.md#requiring-translations
+     *
+     * @property requiresTranslation
+     * @type {Function?}
+     * @default "function(){return true}"
+     */
+    requiresTranslation: undefined
   };
 };
diff --git a/docs/missing-translations.md b/docs/missing-translations.md
index 9cc9947d..0ffd4cf3 100644
--- a/docs/missing-translations.md
+++ b/docs/missing-translations.md
@@ -1,6 +1,7 @@
 
-Missing translations
-==============================================================================
+# Missing translations
+
+## At runtime
 
 When a translation key does not resolve to a translation, ember-intl invokes the function from `app/utils/intl/missing-message.js` with a `key`, `locales` and `options` as arguments.
 
@@ -17,3 +18,75 @@ export default function missingMessage(key, locales, options) {
 ```
 
 The feature, and the documentation, is based entirely off ember-i18n's. The `options` hash is a new addition.
+
+## At build-time
+
+Ember Intl automatically detects missing translations when building the app.
+
+You can control the detection behavior by configuring the `throwMissingTranslations` and `requiresTranslation` options in your `config/ember-intl.js`.
+
+### Requiring translations
+
+By setting a `requiresTranslation` function, it's possible to filter what translations are required.
+The default implementation requires all keys to be translated in all locales.
+
+The provided function will be called for any translation key that is missing in any locale.
+This means it won't be called for any key that exists in all configured locales.
+
+The following missing translations will be logged, If an ember-intl project is configured with the following configuration.
+
+* `page.description` is missing in `it`
+
+```js
+// config/ember-intl.js
+module.exports = function(/* environment */) {
+  return {
+    requiresTranslation(key, locale) {
+      if (key.startsWith('wip.')) {
+        // ignore any missing translations for keys starting with 'wip.'.
+        return false;
+      }
+      
+      if (locale === 'de') {
+        // ignore any missing german translations.
+        return false;
+      }
+      
+      return true;
+    }
+  };
+};
+```
+
+
+```yaml
+# translations/en.yaml
+page:
+  title: Page title
+  description: Page description
+wip:
+  title: WIP title
+
+# translations/de.yaml
+# nothing to see here
+
+# translations/it.yaml
+page:
+  title: Titolo della pagina
+```
+
+### Throwing a build error on missing, required translation
+
+Setting `throwMissingTranslations` to `true` will cause ember-intl to throw a build error if missing, required translations were detected.
+This changes the default behavior where missing translations are only logged as build warnings.
+
+Given the following configuration, any missing translation in any locale will cause a build error to be thrown.
+
+```js
+// config/ember-intl.js
+module.exports = function(/* environment */) {
+  return {
+    throwMissingTranslations: true,
+  };
+};
+```
diff --git a/index.js b/index.js
index 2677c447..6d2a15da 100644
--- a/index.js
+++ b/index.js
@@ -44,7 +44,9 @@ module.exports = {
       wrapEntry: _bundlerOptions.wrapEntry,
       log() {
         return addon.log.apply(addon, arguments);
-      }
+      },
+      requiresTranslation: this.opts.requiresTranslation,
+      throwMissingTranslations: this.opts.throwMissingTranslations
     });
   },
 
@@ -169,7 +171,9 @@ module.exports = {
         disablePolyfill: false,
         autoPolyfill: false,
         inputPath: 'translations',
-        outputPath: 'translations'
+        outputPath: 'translations',
+        throwMissingTranslations: false,
+        requiresTranslation: (/* key, locale */) => true
       },
       this.readConfig(environment)
     );
diff --git a/lib/broccoli/translation-reducer.js b/lib/broccoli/translation-reducer.js
index 1b5c4de1..d4b98688 100644
--- a/lib/broccoli/translation-reducer.js
+++ b/lib/broccoli/translation-reducer.js
@@ -46,7 +46,8 @@ class TranslationReducer extends CachingWriter {
 
     this.options = Object.assign(
       {
-        log() {}
+        log() {},
+        requiresTranslation: (/* key, locale */) => true
       },
       options
     );
@@ -89,15 +90,21 @@ class TranslationReducer extends CachingWriter {
     // create flattened list of all unique translation keys
     const allTranslationKeys = new Set(Array.prototype.concat(...localeKeys.map(([, keys]) => keys)));
 
+    const missingTranslations = [];
+
     allTranslationKeys.forEach(key => {
       const notInLocales = localeKeys
-        .filter(([, translationKeys]) => !translationKeys.includes(key))
-        .map(([locale]) => `"${locale}"`);
+        .filter(
+          ([locale, translationKeys]) => !translationKeys.includes(key) && this.options.requiresTranslation(key, locale)
+        )
+        .map(([locale]) => locale);
 
       if (notInLocales.length) {
-        this._log(`"${key}" was not found in ${notInLocales.join(', ')}`);
+        missingTranslations.push([key, notInLocales]);
       }
     });
+
+    return missingTranslations;
   }
 
   readDirectory(inputPath, listFiles) {
@@ -137,15 +144,33 @@ class TranslationReducer extends CachingWriter {
     return stringify(obj);
   }
 
+  handleMissingTranslations(missingTranslations) {
+    if (missingTranslations.length) {
+      const missingTranslationMessages = missingTranslations.map(
+        ([key, notInLocales]) => `"${key}" was not found in ${notInLocales.map(locale => `"${locale}"`).join(', ')}`
+      );
+
+      if (
+        this.options.verbose &&
+        // log messages if not failing as it's duplicated console output
+        !this.options.throwMissingTranslations
+      ) {
+        missingTranslationMessages.forEach(message => this._log(message));
+      }
+
+      if (this.options.throwMissingTranslations) {
+        throw new Error('Missing translations:\n' + missingTranslationMessages.map(text => `- ${text}`).join('\n'));
+      }
+    }
+  }
+
   build() {
     let outputPath = `${this.outputPath}/${this.options.outputPath}`;
     let translations = this.readDirectory(this.inputPaths[0], this.listFiles());
     let translation;
     mkdirp.sync(outputPath);
 
-    if (this.options.verbose) {
-      this.findMissingTranslations(translations);
-    }
+    this.handleMissingTranslations(this.findMissingTranslations(translations));
 
     for (let locale in translations) {
       if (translations.hasOwnProperty(locale)) {


Test patch:
diff --git a/tests-node/unit/broccoli/translation-reducer-test.js b/tests-node/unit/broccoli/translation-reducer-test.js
index 0748f37a..969064e0 100644
--- a/tests-node/unit/broccoli/translation-reducer-test.js
+++ b/tests-node/unit/broccoli/translation-reducer-test.js
@@ -6,13 +6,8 @@ let expect = require('chai').expect;
 let subject = require('../../../lib/broccoli/translation-reducer');
 
 describe('translation-reducer', function() {
-  it('logs missing translations', function() {
-    const logs = [];
-    const reducer = new subject('src');
-
-    reducer._log = msg => logs.push(msg);
-
-    reducer.findMissingTranslations({
+  beforeEach(function() {
+    this.fixture = {
       de: {
         foo: 'FOO',
         bar: 'BAR',
@@ -38,7 +33,30 @@ describe('translation-reducer', function() {
         }
       },
       it: {}
-    });
+    };
+  });
+  it('findMissingTranslations returns list of missing translations', function() {
+    const reducer = new subject('src');
+    const missing = reducer.findMissingTranslations(this.fixture);
+
+    expect(missing).to.deep.equal([
+      ['foo', ['it']],
+      ['bar', ['en', 'fr', 'it']],
+      ['nested.translation.key', ['en', 'fr', 'it']],
+      ['io', ['de', 'it']],
+      ['baz', ['de', 'en', 'it']],
+      ['nested.translation.lock', ['de', 'en', 'it']]
+    ]);
+  });
+
+  it('_handleMissingTranslations logs translation if verbose', function() {
+    const logs = [];
+    const reducer = new subject('src');
+    reducer.options.verbose = true;
+
+    reducer._log = msg => logs.push(msg);
+
+    reducer.handleMissingTranslations(reducer.findMissingTranslations(this.fixture));
 
     expect(logs).to.deep.equal([
       '"foo" was not found in "it"',
@@ -49,4 +67,37 @@ describe('translation-reducer', function() {
       '"nested.translation.lock" was not found in "de", "en", "it"'
     ]);
   });
+
+  it('handleMissingTranslations throws if throwMissingTranslations is set', function() {
+    const logs = [];
+    const reducer = new subject('src');
+    reducer.options.throwMissingTranslations = true;
+
+    reducer._log = msg => logs.push(msg);
+
+    expect(() => reducer.handleMissingTranslations(reducer.findMissingTranslations(this.fixture)))
+      .throws(`Missing translations:
+- "foo" was not found in "it"
+- "bar" was not found in "en", "fr", "it"
+- "nested.translation.key" was not found in "en", "fr", "it"
+- "io" was not found in "de", "it"
+- "baz" was not found in "de", "en", "it"
+- "nested.translation.lock" was not found in "de", "en", "it"`);
+  });
+
+  it('requiresTranslation allows to ignore missing translations', function() {
+    const reducer = new subject('src');
+    reducer.options.requiresTranslation = (key, locale) =>
+      !((key === 'nested.translation.key' && locale === 'fr') || key === 'baz');
+
+    const missing = reducer.findMissingTranslations(this.fixture);
+
+    expect(missing).to.deep.equal([
+      ['foo', ['it']],
+      ['bar', ['en', 'fr', 'it']],
+      ['nested.translation.key', ['en', 'it']],
+      ['io', ['de', 'it']],
+      ['nested.translation.lock', ['de', 'en', 'it']]
+    ]);
+  });
 });