Repository: gilbsgilbs/babel-plugin-i18next-extract
Pull request #48 (2019-08-21 18:15:27)

Refactor exporters.
- Make a more generic interface to allow new exporters in the future
- Make code more readable and intelligible
- Make exporters code more testable


Patch:
diff --git a/src/exporter.ts b/src/exporter.ts
deleted file mode 100644
index e96e171..0000000
--- a/src/exporter.ts
+++ /dev/null
@@ -1,265 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-import stringify from 'json-stable-stringify';
-
-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.
- * @param filePath Path of the JSON translation file to load.
- */
-function loadTranslationFile(filePath: string): FlatTranslationFile {
-  let content: string;
-  try {
-    content = fs.readFileSync(filePath, { encoding: 'utf8' });
-  } catch (err) {
-    if (err.code === 'ENOENT') return {};
-    throw err;
-  }
-
-  return flattenTranslationFile(JSON.parse(content));
-}
-
-/**
- * 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 {
-  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;
-  }
-
-  const useI18nextDefaultValueEnabled =
-    config.useI18nextDefaultValue === true ||
-    (Array.isArray(config.useI18nextDefaultValue) &&
-      config.useI18nextDefaultValue.includes(locale));
-  const useI18nextDefaultValueForDerivedKeys =
-    config.useI18nextDefaultValueForDerivedKeys;
-  if (
-    useI18nextDefaultValueEnabled &&
-    key.parsedOptions.defaultValue !== null &&
-    (useI18nextDefaultValueForDerivedKeys || !key.isDerivedKey)
-  ) {
-    defaultValue = key.parsedOptions.defaultValue;
-  }
-
-  return {
-    [JSON.stringify([...key.keyPath, key.cleanKey])]: defaultValue,
-    ...translationFile,
-  };
-}
-
-/**
- * Exports all given translation keys 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[] } = {};
-
-  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);
-    keysPerFilepath[filePath] = [...(keysPerFilepath[filePath] || []), key];
-  }
-
-  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] || {};
-
-    for (const k of keysForFilepath) {
-      translationFile = addKeyToTranslationFile(
-        translationFile,
-        k,
-        locale,
-        config,
-      );
-    }
-
-    cache.currentTranslationFiles[filePath] = translationFile;
-
-    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],
-      });
-    }
-
-    // Finally do the export
-    const directoryPath = path.dirname(filePath);
-    fs.mkdirSync(directoryPath, { recursive: true });
-    fs.writeFileSync(
-      filePath,
-      stringify(deepTranslationFile, { space: config.jsonSpace }),
-      {
-        encoding: 'utf8',
-      },
-    );
-  }
-}
diff --git a/src/exporters/commons.ts b/src/exporters/commons.ts
new file mode 100644
index 0000000..a7c6295
--- /dev/null
+++ b/src/exporters/commons.ts
@@ -0,0 +1,52 @@
+import { Config } from '../config';
+import { TranslationKey } from '../keys';
+
+/**
+ * Generic class thrown by exporters in case of error.
+ */
+export class ExportError extends Error {}
+
+/**
+ * Thrown by exporters when an existing value in a translation
+ * file is incompatible with the value we're trying to set.
+ *
+ * For instance, if the translation file contains a deep key named `foo.bar`
+ * but we extracted a (not deep) `foo` key, this error may be thrown.
+ */
+export class ConflictError extends ExportError {}
+
+/**
+ * Interface implented by exporters.
+ */
+export interface Exporter<F, K> {
+  /**
+   * Initialize a new, empty translation file.
+   */
+  init: (params: { config: Config }) => F;
+  /**
+   * Parse a translation file givent its content.
+   */
+  parse: (params: { config: Config; content: string }) => F;
+  /**
+   * Serialize the translation file before saving it.
+   */
+  stringify: (params: { config: Config; file: F }) => string;
+  /**
+   * Get a key given a translation file.
+   */
+  getKey: (params: {
+    config: Config;
+    file: F;
+    keyPath: string[];
+    cleanKey: string;
+  }) => K | undefined;
+  /**
+   * Add a key to a translation file.
+   */
+  addKey: (params: {
+    config: Config;
+    file: F;
+    key: TranslationKey;
+    value: string;
+  }) => F;
+}
diff --git a/src/exporters/index.ts b/src/exporters/index.ts
new file mode 100644
index 0000000..973b80d
--- /dev/null
+++ b/src/exporters/index.ts
@@ -0,0 +1,169 @@
+import fs from 'fs';
+import path from 'path';
+
+import { TranslationKey } from '../keys';
+import { Config } from '../config';
+import { ConflictError, ExportError } from './commons';
+import jsonv3Exporter from './jsonv3';
+
+export { ConflictError, ExportError };
+
+/**
+ * An instance of exporter cache.
+ *
+ * See createExporterCache for details.
+ */
+export interface ExporterCache {
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  originalTranslationFiles: { [path: string]: any };
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  currentTranslationFiles: { [path: string]: any };
+}
+
+/**
+ * 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: {},
+  };
+}
+
+/**
+ * Load a translation file.
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+function loadTranslationFile(config: Config, filePath: string): any {
+  let content: string;
+  try {
+    content = fs.readFileSync(filePath, { encoding: 'utf8' });
+  } catch (err) {
+    if (err.code === 'ENOENT') return jsonv3Exporter.init({ config });
+    throw err;
+  }
+
+  return jsonv3Exporter.parse({ config, content });
+}
+
+/**
+ * Get the default value for a key.
+ */
+function getDefaultValue(
+  key: TranslationKey,
+  locale: string,
+  config: Config,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+): any {
+  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;
+  }
+
+  const useI18nextDefaultValueEnabled =
+    config.useI18nextDefaultValue === true ||
+    (Array.isArray(config.useI18nextDefaultValue) &&
+      config.useI18nextDefaultValue.includes(locale));
+  const useI18nextDefaultValueForDerivedKeys =
+    config.useI18nextDefaultValueForDerivedKeys;
+  if (
+    useI18nextDefaultValueEnabled &&
+    key.parsedOptions.defaultValue !== null &&
+    (useI18nextDefaultValueForDerivedKeys || !key.isDerivedKey)
+  ) {
+    defaultValue = key.parsedOptions.defaultValue;
+  }
+
+  return defaultValue;
+}
+
+/**
+ * Exports all given translation keys 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[] } = {};
+
+  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);
+    keysPerFilepath[filePath] = [...(keysPerFilepath[filePath] || []), key];
+  }
+
+  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(
+        config,
+        filePath,
+      );
+    }
+
+    const originalTranslationFile = cache.originalTranslationFiles[filePath];
+    let translationFile =
+      cache.currentTranslationFiles[filePath] ||
+      (config.discardOldKeys
+        ? jsonv3Exporter.init({ config })
+        : originalTranslationFile);
+
+    for (const k of keysForFilepath) {
+      const previousValue = jsonv3Exporter.getKey({
+        config,
+        file: originalTranslationFile,
+        keyPath: k.keyPath,
+        cleanKey: k.cleanKey,
+      });
+      translationFile = jsonv3Exporter.addKey({
+        config,
+        file: translationFile,
+        key: k,
+        value:
+          previousValue === undefined
+            ? getDefaultValue(k, locale, config)
+            : previousValue,
+      });
+    }
+
+    cache.currentTranslationFiles[filePath] = translationFile;
+
+    // Finally do the export
+    const directoryPath = path.dirname(filePath);
+    fs.mkdirSync(directoryPath, { recursive: true });
+    fs.writeFileSync(
+      filePath,
+      jsonv3Exporter.stringify({
+        config,
+        file: translationFile,
+      }),
+      {
+        encoding: 'utf8',
+      },
+    );
+  }
+}
diff --git a/src/exporters/jsonv3.ts b/src/exporters/jsonv3.ts
new file mode 100644
index 0000000..5eb5fa6
--- /dev/null
+++ b/src/exporters/jsonv3.ts
@@ -0,0 +1,96 @@
+import stringify from 'json-stable-stringify';
+
+import { Exporter, ConflictError } from './commons';
+
+/**
+ * A deep, recursive object which leaves must be of generic type V.
+ */
+interface DeepObject<V = string> {
+  [k: string]: DeepObject<V> | V;
+}
+
+/**
+ * JSON keys can either be strings or null.
+ */
+type JsonV3Key = string | null;
+
+/**
+ * Content of a JSON v3 file.
+ */
+interface JsonV3File extends DeepObject<JsonV3Key> {}
+
+/**
+ * Add a key recursively to a JSONv3 file.
+ *
+ * @param file JSONv3 file
+ * @param keyPath keyPath of the key to add
+ * @param cleanKey key without path
+ * @param value Value to set for the key.
+ */
+function recursiveAddKey(
+  file: JsonV3File,
+  keyPath: string[],
+  cleanKey: string,
+  value: string | null,
+): JsonV3File {
+  if (keyPath.length === 0) {
+    return {
+      ...file,
+      [cleanKey]: value,
+    };
+  }
+
+  const currentKeyPath = keyPath[0];
+  let current = file[currentKeyPath];
+  if (current === undefined) {
+    current = {};
+  } else if (current === null || typeof current !== 'object') {
+    throw new ConflictError();
+  }
+
+  return {
+    ...file,
+    [currentKeyPath]: recursiveAddKey(
+      current,
+      keyPath.slice(1),
+      cleanKey,
+      value,
+    ),
+  };
+}
+
+const jsonv3Exporter: Exporter<JsonV3File, JsonV3Key> = {
+  init: () => {
+    return {};
+  },
+  parse: ({ content }) => {
+    return JSON.parse(content);
+  },
+  stringify: ({ config, file }) => {
+    return stringify(file, { space: config.jsonSpace });
+  },
+  getKey: ({ file, keyPath, cleanKey }) => {
+    let current = file;
+    for (const p of keyPath) {
+      const val = current[p];
+      if (val === undefined) {
+        return undefined;
+      }
+      if (typeof val === 'string' || val === null) {
+        throw new ConflictError();
+      }
+      current = val;
+    }
+    const val = current[cleanKey];
+    if (typeof val !== 'string' && val !== null && val !== undefined) {
+      throw new ConflictError();
+    }
+    return val;
+  },
+  addKey: params => {
+    const { key, file, value } = params;
+    return recursiveAddKey(file, key.keyPath, key.cleanKey, value);
+  },
+};
+
+export default jsonv3Exporter;
diff --git a/src/plugin.ts b/src/plugin.ts
index edea371..f701b91 100644
--- a/src/plugin.ts
+++ b/src/plugin.ts
@@ -11,7 +11,7 @@ import { Config, parseConfig } from './config';
 import exportTranslationKeys, {
   ExporterCache,
   createExporterCache,
-} from './exporter';
+} from './exporters';
 import { PLUGIN_NAME } from './constants';
 import extractWithTranslationHOC from './extractors/withTranslationHOC';
 


Test patch:
diff --git a/tests/exporter.test.ts b/tests/exporters/exporter.test.ts
similarity index 89%
rename from tests/exporter.test.ts
rename to tests/exporters/exporter.test.ts
index f928e08..4a713a0 100644
--- a/tests/exporter.test.ts
+++ b/tests/exporters/exporter.test.ts
@@ -5,27 +5,9 @@ import { sync as rimraf } from 'rimraf';
 import exportTranslationKeys, {
   ExportError,
   createExporterCache,
-} from '../src/exporter';
-import { parseConfig } from '../src/config';
-import { TranslationKey } from '../src/keys';
-
-function createSimpleKey(key: string, keyPath: string[] = []): TranslationKey {
-  return {
-    key,
-    keyPath,
-    sourceNodes: [],
-    extractorName: 'anonymous',
-    isDerivedKey: false,
-    parsedOptions: {
-      contexts: false,
-      hasCount: false,
-      ns: null,
-      defaultValue: null,
-    },
-    cleanKey: key,
-    ns: 'translation',
-  };
-}
+} from '../../src/exporters';
+import { parseConfig } from '../../src/config';
+import { createSimpleKey } from './helpers';
 
 describe('Test exporter works', () => {
   const outputDir = path.join(__dirname, '.exporter.extracted');
diff --git a/tests/exporters/helpers.ts b/tests/exporters/helpers.ts
new file mode 100644
index 0000000..92f1068
--- /dev/null
+++ b/tests/exporters/helpers.ts
@@ -0,0 +1,22 @@
+import { TranslationKey } from '../../src/keys';
+
+export function createSimpleKey(
+  key: string,
+  keyPath: string[] = [],
+): TranslationKey {
+  return {
+    key,
+    keyPath,
+    sourceNodes: [],
+    extractorName: 'anonymous',
+    isDerivedKey: false,
+    parsedOptions: {
+      contexts: false,
+      hasCount: false,
+      ns: null,
+      defaultValue: null,
+    },
+    cleanKey: key,
+    ns: 'translation',
+  };
+}
diff --git a/tests/exporters/jsonv3.test.ts b/tests/exporters/jsonv3.test.ts
new file mode 100644
index 0000000..2df3151
--- /dev/null
+++ b/tests/exporters/jsonv3.test.ts
@@ -0,0 +1,126 @@
+import { parseConfig } from '../../src/config';
+import jsonv3Exporter from '../../src/exporters/jsonv3';
+import { createSimpleKey } from './helpers';
+import { ConflictError } from '../../src/exporters';
+
+describe('Test JSONv3 exporter', () => {
+  const config = parseConfig({ jsonSpace: 0 });
+
+  it('can init', () => {
+    expect(jsonv3Exporter.init({ config })).toEqual({});
+  });
+
+  it('can parse', () => {
+    expect(
+      jsonv3Exporter.parse({
+        config,
+        content: '{"hello": "world"}',
+      }),
+    ).toEqual({ hello: 'world' });
+  });
+
+  it('can stringify', () => {
+    expect(
+      jsonv3Exporter.stringify({
+        config,
+        file: { hello: 'world' },
+      }),
+    ).toEqual('{"hello":"world"}');
+  });
+
+  it('can stringify with custom spacing', () => {
+    const config = parseConfig({ jsonSpace: 2 });
+    expect(
+      jsonv3Exporter.stringify({
+        config,
+        file: { hello: 'world' },
+      }),
+    ).toEqual('{\n  "hello": "world"\n}');
+  });
+
+  it('can get simple key', () => {
+    expect(
+      jsonv3Exporter.getKey({
+        config,
+        keyPath: [],
+        cleanKey: 'hello',
+        file: { hello: 'world' },
+      }),
+    ).toEqual('world');
+  });
+
+  it('can get nested keys', () => {
+    expect(
+      jsonv3Exporter.getKey({
+        config,
+        keyPath: ['hello'],
+        cleanKey: 'new',
+        file: { hello: { new: 'world' } },
+      }),
+    ).toEqual('world');
+  });
+
+  it('can get undefined key', () => {
+    expect(
+      jsonv3Exporter.getKey({
+        config,
+        keyPath: ['what'],
+        cleanKey: 'new',
+        file: { hello: { notSoNew: 'world' } },
+      }),
+    ).toBeUndefined();
+  });
+
+  it('cannot get conflicting key', () => {
+    expect(() =>
+      jsonv3Exporter.getKey({
+        config,
+        keyPath: [],
+        cleanKey: 'hello',
+        file: { hello: { new: 'world' } },
+      }),
+    ).toThrow(ConflictError);
+
+    expect(() =>
+      jsonv3Exporter.getKey({
+        config,
+        keyPath: ['hello'],
+        cleanKey: 'world',
+        file: { hello: null },
+      }),
+    ).toThrow(ConflictError);
+  });
+
+  it('can add key', () => {
+    expect(
+      jsonv3Exporter.addKey({
+        config,
+        file: {},
+        key: createSimpleKey('hello'),
+        value: 'world',
+      }),
+    ).toEqual({ hello: 'world' });
+  });
+
+  it('can add deep key', () => {
+    expect(
+      jsonv3Exporter.addKey({
+        config,
+        file: {},
+        key: createSimpleKey('new', ['hello', 'brave']),
+        value: 'world',
+      }),
+    ).toEqual({ hello: { brave: { new: 'world' } } });
+  });
+
+  it('cannot add key in case of conflict', () => {
+    expect(() =>
+      jsonv3Exporter.addKey({
+        config,
+        file: { hello: { brave: 'world' } },
+        key: createSimpleKey('new', ['hello', 'brave']),
+        value: 'world',
+      }),
+    ).toThrow(ConflictError);
+  });
+});