Repository: gilbsgilbs/babel-plugin-i18next-extract
Pull request #11 (2019-07-04 22:01:16)

Prepare 0.1.0-rc.1 release.


Patch:
diff --git a/.eslintrc.yml b/.eslintrc.yml
index e4ece69..1c765b1 100644
--- a/.eslintrc.yml
+++ b/.eslintrc.yml
@@ -22,9 +22,6 @@ rules:
     - allowExpressions: true
       allowTypedFunctionExpressions: true
       allowHigherOrderFunctions: true
-  '@typescript-eslint/no-empty-interface':
-    - error
-    - allowSingleExtends: true
   'prettier/prettier':
     - error
     - singleQuote: true
diff --git a/.gitignore b/.gitignore
index 759b56f..63c4469 100644
--- a/.gitignore
+++ b/.gitignore
@@ -94,4 +94,4 @@ typings/
 /extractedTranslations/
 
 # Test files
-/tests/**/*.extracted/
+.extracted/
\ No newline at end of file
diff --git a/README.md b/README.md
index 21d14f1..4c1f284 100644
--- a/README.md
+++ b/README.md
@@ -92,8 +92,7 @@ https://babeljs.io/docs/en/configuration) documentation to get started.
 | defaultValue | `string` or `null` | Default value for extracted keys. | `''` (empty string) |
 | keyAsDefaultValue | `boolean` or `string[]` | If true, use the extracted key as defaultValue (ignoring `defaultValue` option). You can also specify an array of locales to apply this behavior only to a specific set locales (e.g. if you keys are in plain english, you may want to set this option to `['en']`). | `false` |
 | keyAsDefaultValueForDerivedKeys | `boolean` | If false and `keyAsDefaultValue` is enabled, don't use derived keys (plural forms or contexts) as default value. `defaultValue` will be used instead. | `true` |
-| discardOldKeys | `boolean` | When set to `true`, keys that no longer exist are removed from the JSON files. By default, new keys will be added to the JSON files and never removed. | `false` |
-| jsonSpace | `number` | Number of indentation space to use in extracted JSON files. | `2` |
+| exporterJsonSpace | `number` | Number of indentation space to use in extracted JSON files. | 2 |
 
 ## Comment hints
 
diff --git a/package.json b/package.json
index 6481be2..3b2d99a 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "babel-plugin-i18next-extract",
-  "version": "0.1.0-rc",
+  "version": "0.1.0-rc.1",
   "description": "Statically extract translation keys from i18next application.",
   "repository": {
     "type": "git",
diff --git a/src/config.ts b/src/config.ts
index 6feb35c..b7aac33 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -14,8 +14,7 @@ export interface Config {
   defaultValue: string | null;
   keyAsDefaultValue: boolean | string[];
   keyAsDefaultValueForDerivedKeys: boolean;
-  discardOldKeys: boolean;
-  jsonSpace: string | number;
+  exporterJsonSpace: string | number;
 }
 
 function coalesce<T>(v: T | undefined, defaultVal: T): T {
@@ -51,7 +50,6 @@ export function parseConfig(opts: Partial<Config>): Config {
       opts.keyAsDefaultValueForDerivedKeys,
       true,
     ),
-    discardOldKeys: coalesce(opts.discardOldKeys, false),
-    jsonSpace: coalesce(opts.jsonSpace, 2),
+    exporterJsonSpace: coalesce(opts.exporterJsonSpace, 2),
   };
 }
diff --git a/src/exporter.ts b/src/exporter.ts
index 01ce9eb..ee62c6b 100644
--- a/src/exporter.ts
+++ b/src/exporter.ts
@@ -4,124 +4,20 @@ import { TranslationKey } from './keys';
 import { Config } from './config';
 import { PLUGIN_NAME } from './constants';
 
-/**
- * Thrown when an error happens during the translations export.
- */
 export class ExportError extends Error {}
 
-/**
- * A deep, recursive object which leaves must be of generic type V.
- */
 interface DeepObject<V = string> {
   [k: string]: DeepObject<V> | V;
 }
 
-/**
- * Content of a translation file. Just a deep JSON with only objects.
- */
-interface TranslationFile extends DeepObject<string | null> {}
-
-/**
- * Flat version of TranslationFile. This is mainly used to simplify merging
- * translations.
- */
-interface FlatTranslationFile {
-  [keyPathAsJSON: string]: string | null;
-}
-
-/**
- * An instance of exporter cache.
- *
- * See createExporterCache for details.
- */
-export interface ExporterCache {
-  originalTranslationFiles: { [path: string]: FlatTranslationFile };
-  currentTranslationFiles: { [path: string]: FlatTranslationFile };
-}
-
-/**
- * This creates a new empty cache for the exporter.
- *
- * The cache is required by the exporter and is used to merge the translations
- * from the original translation file. It will be  mutated by the exporter
- * and the same instance must be given untouched across export calls.
- */
-export function createExporterCache(): ExporterCache {
-  return {
-    originalTranslationFiles: {},
-    currentTranslationFiles: {},
-  };
-}
-
-/**
- * Take a deep translation file and flatten it.
- * This is to simplify merging keys later on.
- *
- * @param deep Deep translation file.
- */
-function flattenTranslationFile(deep: TranslationFile): FlatTranslationFile {
-  const result: FlatTranslationFile = {};
-
-  for (const [k, v] of Object.entries(deep)) {
-    if (typeof v === 'object' && v !== null) {
-      // Nested case, we must recurse
-      const flat = flattenTranslationFile(v);
-      for (const [flatK, flatV] of Object.entries(flat)) {
-        result[JSON.stringify([k, ...JSON.parse(flatK)])] = flatV;
-      }
-    } else {
-      // Leaf, just set the value.
-      result[JSON.stringify([k])] = v;
-    }
-  }
-
-  return result;
-}
-
-/**
- * Take a flat translation file and make it deep.
- *
- * This is to make a flat translation file ready to be exported to a file.
- *
- * @param flat Flat translation file as returned by flattenTranslationFile
- */
-function unflattenTranslationFile(flat: FlatTranslationFile): TranslationFile {
-  const result: TranslationFile = {};
-
-  for (const [k, v] of Object.entries(flat)) {
-    const keyPath = JSON.parse(k);
-    const cleanKey = keyPath.pop();
-    const error = new ExportError(
-      `${PLUGIN_NAME}: Couldn't export translations. Key "${keyPath}" ` +
-        `has a conflict.`,
-    );
-
-    let obj = result;
-    for (const p of keyPath) {
-      const currentValue = obj[p];
-      if (typeof currentValue === 'string' || currentValue === null) {
-        throw error;
-      }
-      obj = obj[p] = currentValue || {};
-    }
-
-    const currentValue = obj[cleanKey];
-    if (typeof currentValue === 'object' && currentValue !== null) {
-      throw error;
-    }
-    obj[cleanKey] = v;
-  }
-
-  return result;
-}
-
 /**
  * Load translation given a file. If the file is not found, default to empty
- * object.
+ * object
  * @param filePath Path of the JSON translation file to load.
  */
-function loadTranslationFile(filePath: string): FlatTranslationFile {
+function loadTranslationFile(filePath: string): DeepObject<string | null> {
   let content: string;
+
   try {
     content = fs.readFileSync(filePath, { encoding: 'utf8' });
   } catch (err) {
@@ -129,63 +25,25 @@ function loadTranslationFile(filePath: string): FlatTranslationFile {
     throw err;
   }
 
-  return flattenTranslationFile(JSON.parse(content));
+  const obj = JSON.parse(content);
+  return obj;
 }
 
 /**
- * Create a new translationFile with a key added to it.
- *
- * @param translationFile The translation file to add the key to.
- * @param key The translation key to add.
- * @param locale Current locale being exported
- * @param config Configuration (that will help setting the proper default
- *   value)
- */
-function addKeyToTranslationFile(
-  translationFile: FlatTranslationFile,
-  key: TranslationKey,
-  locale: string,
-  config: Config,
-): FlatTranslationFile {
-  // compute the default value
-  let defaultValue = config.defaultValue;
-  const keyAsDefaultValueEnabled =
-    config.keyAsDefaultValue === true ||
-    (Array.isArray(config.keyAsDefaultValue) &&
-      config.keyAsDefaultValue.includes(locale));
-  const keyAsDefaultValueForDerivedKeys =
-    config.keyAsDefaultValueForDerivedKeys;
-  if (
-    keyAsDefaultValueEnabled &&
-    (keyAsDefaultValueForDerivedKeys || !key.isDerivedKey)
-  ) {
-    defaultValue = key.cleanKey;
-  }
-
-  return {
-    [JSON.stringify([...key.keyPath, key.cleanKey])]: defaultValue,
-    ...translationFile,
-  };
-}
-
-/**
- * Exports all given translation keys as JSON.
+ * Exports all given translation keyr as JSON.
  *
  * @param keys: translation keys to export
  * @param locale: the locale to export
  * @param config: plugin configuration
- * @param cache: cache instance to use (see createExporterCache)
  */
 export default function exportTranslationKeys(
   keys: TranslationKey[],
   locale: string,
   config: Config,
-  cache: ExporterCache,
 ): void {
-  const keysPerFilepath: { [path: string]: TranslationKey[] } = {};
+  const keysPerFilepath: { [filename: string]: TranslationKey[] } = {};
 
   for (const key of keys) {
-    // Figure out in which path each key should go.
     const filePath = config.outputPath
       .replace('{{locale}}', locale)
       .replace('{{ns}}', key.ns);
@@ -193,46 +51,46 @@ export default function exportTranslationKeys(
   }
 
   for (const [filePath, keysForFilepath] of Object.entries(keysPerFilepath)) {
-    if (!(filePath in cache.originalTranslationFiles)) {
-      // Cache original translation file so that we don't loose it across babel
-      // passes.
-      cache.originalTranslationFiles[filePath] = loadTranslationFile(filePath);
-    }
-
-    let translationFile = cache.currentTranslationFiles[filePath] || {};
+    let obj: DeepObject<string | null> = {};
+    obj = loadTranslationFile(filePath);
+    const originalObject = obj;
 
     for (const k of keysForFilepath) {
-      translationFile = addKeyToTranslationFile(
-        translationFile,
-        k,
-        locale,
-        config,
-      );
-    }
+      // resolve key path
+      for (const p of k.keyPath) {
+        let value = obj[p];
+        if (value === undefined) {
+          value = obj[p] = {};
+        }
+        if (typeof value === 'string' || value === null) {
+          throw new ExportError(
+            `${PLUGIN_NAME}: Couldn't export translations. Key "${k.key}" ` +
+              `has conflict.`,
+          );
+        }
+        obj = value;
+      }
 
-    cache.currentTranslationFiles[filePath] = translationFile;
+      // The key was already exported.
+      if (obj[k.cleanKey] !== undefined) {
+        continue;
+      }
 
-    let deepTranslationFile: TranslationFile;
-    if (config.discardOldKeys) {
-      const originalTranslationFile = cache.originalTranslationFiles[filePath];
-      const alreadyTranslated = Object.keys(originalTranslationFile)
-        .filter(k => k in translationFile)
-        .reduce<FlatTranslationFile>(
-          (accumulator, k) => ({
-            ...accumulator,
-            [k]: originalTranslationFile[k],
-          }),
-          {},
-        );
-      deepTranslationFile = unflattenTranslationFile({
-        ...translationFile,
-        ...alreadyTranslated,
-      });
-    } else {
-      deepTranslationFile = unflattenTranslationFile({
-        ...translationFile,
-        ...cache.originalTranslationFiles[filePath],
-      });
+      // Set the default values for the path
+      let defaultValue = config.defaultValue;
+      const keyAsDefaultValueEnabled =
+        config.keyAsDefaultValue === true ||
+        (Array.isArray(config.keyAsDefaultValue) &&
+          config.keyAsDefaultValue.includes(locale));
+      const keyAsDefaultValueForDerivedKeys =
+        config.keyAsDefaultValueForDerivedKeys;
+      if (
+        keyAsDefaultValueEnabled &&
+        (keyAsDefaultValueForDerivedKeys || !k.isDerivedKey)
+      ) {
+        defaultValue = k.cleanKey;
+      }
+      obj[k.cleanKey] = defaultValue;
     }
 
     // Finally do the export
@@ -240,7 +98,7 @@ export default function exportTranslationKeys(
     fs.mkdirSync(directoryPath, { recursive: true });
     fs.writeFileSync(
       filePath,
-      JSON.stringify(deepTranslationFile, null, config.jsonSpace),
+      JSON.stringify(originalObject, null, config.exporterJsonSpace),
       {
         encoding: 'utf8',
       },
diff --git a/src/plugin.ts b/src/plugin.ts
index fdcd377..4222954 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -9,10 +9,7 @@ import extractTranslationRenderProp from './extractors/translationRenderProp';
 import extractTransComponent from './extractors/transComponent';
 import { computeDerivedKeys, ExtractedKey, TranslationKey } from './keys';
 import { Config, parseConfig } from './config';
-import exportTranslationKeys, {
-  ExporterCache,
-  createExporterCache,
-} from './exporter';
+import exportTranslationKeys from './exporter';
 import { PLUGIN_NAME } from './constants';
 import extractWithTranslationHOC from './extractors/withTranslationHOC';
 
@@ -29,10 +26,12 @@ interface I18NextExtractState {
   extractedKeys: ExtractedKey[];
   commentHints: CommentHint[];
   config: Config;
-  extractedNodes: WeakSet<BabelTypes.Node>;
-  exporterCache: ExporterCache;
 }
 
+// We have to store which nodes were extracted because the plugin might be called multiple times
+// by Babel and the state would be lost across calls.
+const extractedNodes = new WeakSet<BabelTypes.Node>();
+
 /**
  * Handle the extraction.
  *
@@ -50,15 +49,14 @@ function handleExtraction<T>(
 ): T | undefined {
   const filename = (state.file && state.file.opts.filename) || '???';
   const lineNumber = (path.node.loc && path.node.loc.start.line) || '???';
-  const extractState = state.I18NextExtract;
 
   const collect = (keys: ExtractedKey[]): void => {
     for (const key of keys) {
-      if (extractState.extractedNodes.has(key.nodePath.node)) {
+      if (extractedNodes.has(key.nodePath.node)) {
         // The node was already extracted. Skip it.
         continue;
       }
-      extractState.extractedNodes.add(key.nodePath.node);
+      extractedNodes.add(key.nodePath.node);
       state.I18NextExtract.extractedKeys.push(key);
     }
   };
@@ -151,22 +149,12 @@ export default function(
 ): BabelCore.PluginObj<VisitorState> {
   api.assertVersion(7);
 
-  // We have to store which nodes were extracted because the visitor might be
-  // called multiple times by Babel and the state would be lost across calls.
-  const extractedNodes = new WeakSet<BabelTypes.Node>();
-
-  // This is a cache for the exporter to keep track of the translation files.
-  // It must remain global and persist across transpiled files.
-  const exporterCache = createExporterCache();
-
   return {
     pre() {
       this.I18NextExtract = {
         config: parseConfig(this.opts),
         extractedKeys: [],
         commentHints: [],
-        extractedNodes,
-        exporterCache,
       };
     },
 
@@ -183,12 +171,7 @@ export default function(
           ],
           Array<TranslationKey>(),
         );
-        exportTranslationKeys(
-          derivedKeys,
-          locale,
-          extractState.config,
-          extractState.exporterCache,
-        );
+        exportTranslationKeys(derivedKeys, locale, extractState.config);
       }
     },
 


Test patch:
diff --git a/tests/exporter.test.ts b/tests/exporter.test.ts
index a72d4fc..77c6763 100644
--- a/tests/exporter.test.ts
+++ b/tests/exporter.test.ts
@@ -2,10 +2,7 @@ import fs from 'fs-extra';
 import path from 'path';
 import { sync as rimraf } from 'rimraf';
 
-import exportTranslationKeys, {
-  ExportError,
-  createExporterCache,
-} from '../src/exporter';
+import exportTranslationKeys, { ExportError } from '../src/exporter';
 import { parseConfig } from '../src/config';
 import { TranslationKey } from '../src/keys';
 
@@ -21,21 +18,20 @@ function createSimpleKey(key: string, keyPath: string[] = []): TranslationKey {
       hasCount: false,
       ns: null,
     },
-    cleanKey: key,
+    cleanKey: 'key0',
     ns: 'translation',
   };
 }
 
 describe('Test exporter works', () => {
-  const outputDir = path.join(__dirname, '.exporter.extracted');
+  const outputDir = path.join(__dirname, 'exporter', '.extracted');
   rimraf(outputDir);
-  fs.mkdirSync(outputDir, { recursive: true });
 
   it('can export simple key', () => {
     const outputPath = path.join(outputDir, 'simple.json');
     const config = parseConfig({ outputPath });
     const key = createSimpleKey('key0');
-    exportTranslationKeys([key], 'fr', config, createExporterCache());
+    exportTranslationKeys([key], 'fr', config);
     expect(fs.readJSONSync(outputPath)).toEqual({ key0: '' });
   });
 
@@ -45,7 +41,7 @@ describe('Test exporter works', () => {
 
     const config = parseConfig({ outputPath });
     const key = createSimpleKey('key0');
-    exportTranslationKeys([key], 'fr', config, createExporterCache());
+    exportTranslationKeys([key], 'fr', config);
     expect(fs.readJSONSync(outputPath)).toEqual({ key0: 'has value' });
   });
 
@@ -53,7 +49,7 @@ describe('Test exporter works', () => {
     const outputPath = path.join(outputDir, 'deep.json');
     const config = parseConfig({ outputPath });
     const key = createSimpleKey('key0', ['deep']);
-    exportTranslationKeys([key], 'fr', config, createExporterCache());
+    exportTranslationKeys([key], 'fr', config);
     expect(fs.readJSONSync(outputPath)).toEqual({ deep: { key0: '' } });
   });
 
@@ -66,7 +62,7 @@ describe('Test exporter works', () => {
 
     let hasThrown = false;
     try {
-      exportTranslationKeys([key], 'fr', config, createExporterCache());
+      exportTranslationKeys([key], 'fr', config);
     } catch (err) {
       if (!(err instanceof ExportError)) throw err;
       hasThrown = true;
@@ -78,57 +74,4 @@ describe('Test exporter works', () => {
     ).toBe(true);
     expect(fs.readJSONSync(outputPath)).toEqual({ deep: 'has value' });
   });
-
-  it('throws an ExportError when the value is already an object', () => {
-    const outputPath = path.join(
-      outputDir,
-      'throws_if_already_an_object.json',
-    );
-    fs.writeJSONSync(outputPath, { alreadyDeep: { someKey: 'foo' } });
-
-    const config = parseConfig({ outputPath });
-    const key = createSimpleKey('alreadyDeep');
-
-    let hasThrown = false;
-    try {
-      exportTranslationKeys([key], 'fr', config, createExporterCache());
-    } catch (err) {
-      if (!(err instanceof ExportError)) throw err;
-      hasThrown = true;
-    }
-
-    expect(
-      hasThrown,
-      'Expected ExportError, but no exception was thrown',
-    ).toBe(true);
-    expect(fs.readJSONSync(outputPath)).toEqual({
-      alreadyDeep: { someKey: 'foo' },
-    });
-  });
-
-  it('can discard old keys', () => {
-    const outputPath = path.join(outputDir, 'discard_old_keys.json');
-    fs.writeJSONSync(outputPath, { oldKey: 'foo' });
-
-    const config = parseConfig({ outputPath, discardOldKeys: true });
-    const key = createSimpleKey('newKey');
-    exportTranslationKeys([key], 'fr', config, createExporterCache());
-    expect(fs.readJSONSync(outputPath)).toEqual({ newKey: '' });
-  });
-
-  it('can discard old keys across runs', () => {
-    const outputPath = path.join(outputDir, 'discard_old_keys.json');
-    fs.writeJSONSync(outputPath, { oldKey: 'foo', newKey: 'with value' });
-
-    const config = parseConfig({ outputPath, discardOldKeys: true });
-    const key0 = createSimpleKey('newKey');
-    const key1 = createSimpleKey('newKey2');
-    const cache = createExporterCache();
-    exportTranslationKeys([key0], 'fr', config, cache);
-    exportTranslationKeys([key1], 'fr', config, cache);
-    expect(fs.readJSONSync(outputPath)).toEqual({
-      newKey: 'with value',
-      newKey2: '',
-    });
-  });
 });