Repository: gilbsgilbs/babel-plugin-i18next-extract (ts)

Issue:
Non-destructive JSON serialization
Currently, keys in the JSON file may be reordered by the plugin. Similarly, trailing line returns and whitespaces are always discarded when exported (which is not POSIX compliant, yet arguably a _de facto_ standard imposed by JSON serializers in many languages and tools). This can lead to edition wars and merge conflicts in translation files, where an external tool (such as Weblate or even vim) would use a different order or would append a newline at the end. 

The ideal solution would be to make the exporter preserve everything and just add keys like a human would do. We could get closer to this by relying on a lexer such as [moo](https://github.com/no-context/moo/blob/master/test/json.js) in order to keep a tokenized version of the original JSON file that we could use to re-export it just as it was before.

The problem of trailing whitespaces and line breaks could be addressed with way less effort just by copying in memory the trailing whitespaces and line breaks in the original file and re-adding them before exporting.

See discussions in #58 

Pull request:
Make JSONv3 exporter preserve whitespaces.
By default, JSON files created by the JSON exporter will also have a
trailing newline in order to be POSIX compliant. Though if the
whitespace is stripped at a later point in time by user, the exporter
will leave it as is.

Fixes #59
Closes #58

ping @michelts 

Patch:
diff --git a/src/exporters/jsonv3.ts b/src/exporters/jsonv3.ts
index 5eb5fa6..5ea4a1a 100644
--- a/src/exporters/jsonv3.ts
+++ b/src/exporters/jsonv3.ts
@@ -17,31 +17,35 @@ type JsonV3Key = string | null;
 /**
  * Content of a JSON v3 file.
  */
-interface JsonV3File extends DeepObject<JsonV3Key> {}
+interface JsonV3File {
+  whitespacesBefore: string;
+  whitespacesAfter: string;
+  content: DeepObject<JsonV3Key>;
+}
 
 /**
  * Add a key recursively to a JSONv3 file.
  *
- * @param file JSONv3 file
+ * @param fileContent JSONv3 file content
  * @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,
+  fileContent: JsonV3File['content'],
   keyPath: string[],
   cleanKey: string,
   value: string | null,
-): JsonV3File {
+): JsonV3File['content'] {
   if (keyPath.length === 0) {
     return {
-      ...file,
+      ...fileContent,
       [cleanKey]: value,
     };
   }
 
   const currentKeyPath = keyPath[0];
-  let current = file[currentKeyPath];
+  let current = fileContent[currentKeyPath];
   if (current === undefined) {
     current = {};
   } else if (current === null || typeof current !== 'object') {
@@ -49,7 +53,7 @@ function recursiveAddKey(
   }
 
   return {
-    ...file,
+    ...fileContent,
     [currentKeyPath]: recursiveAddKey(
       current,
       keyPath.slice(1),
@@ -61,16 +65,32 @@ function recursiveAddKey(
 
 const jsonv3Exporter: Exporter<JsonV3File, JsonV3Key> = {
   init: () => {
-    return {};
+    return {
+      whitespacesBefore: '',
+      whitespacesAfter: '\n',
+      content: {},
+    };
   },
   parse: ({ content }) => {
-    return JSON.parse(content);
+    const whitespacesBeforeMatch = content.match(/^(\s*)/);
+    const whitespacesAfterMatch = content.match(/(\s*)$/);
+    return {
+      whitespacesBefore:
+        whitespacesBeforeMatch === null ? '' : whitespacesBeforeMatch[0],
+      whitespacesAfter:
+        whitespacesAfterMatch === null ? '' : whitespacesAfterMatch[0],
+      content: JSON.parse(content),
+    };
   },
   stringify: ({ config, file }) => {
-    return stringify(file, { space: config.jsonSpace });
+    return (
+      file.whitespacesBefore +
+      stringify(file.content, { space: config.jsonSpace }) +
+      file.whitespacesAfter
+    );
   },
   getKey: ({ file, keyPath, cleanKey }) => {
-    let current = file;
+    let current = file.content;
     for (const p of keyPath) {
       const val = current[p];
       if (val === undefined) {
@@ -89,7 +109,10 @@ const jsonv3Exporter: Exporter<JsonV3File, JsonV3Key> = {
   },
   addKey: params => {
     const { key, file, value } = params;
-    return recursiveAddKey(file, key.keyPath, key.cleanKey, value);
+    return {
+      ...file,
+      content: recursiveAddKey(file.content, key.keyPath, key.cleanKey, value),
+    };
   },
 };
 


Test patch:
diff --git a/tests/exporters/jsonv3.test.ts b/tests/exporters/jsonv3.test.ts
index 2df3151..2f9f297 100644
--- a/tests/exporters/jsonv3.test.ts
+++ b/tests/exporters/jsonv3.test.ts
@@ -7,25 +7,37 @@ describe('Test JSONv3 exporter', () => {
   const config = parseConfig({ jsonSpace: 0 });
 
   it('can init', () => {
-    expect(jsonv3Exporter.init({ config })).toEqual({});
+    expect(jsonv3Exporter.init({ config })).toEqual({
+      whitespacesBefore: '',
+      whitespacesAfter: '\n',
+      content: {},
+    });
   });
 
   it('can parse', () => {
     expect(
       jsonv3Exporter.parse({
         config,
-        content: '{"hello": "world"}',
+        content: '\n\n\t\r\n  {"hello": "world"}\n\t  \r\n',
       }),
-    ).toEqual({ hello: 'world' });
+    ).toEqual({
+      whitespacesBefore: '\n\n\t\r\n  ',
+      whitespacesAfter: '\n\t  \r\n',
+      content: { hello: 'world' },
+    });
   });
 
   it('can stringify', () => {
     expect(
       jsonv3Exporter.stringify({
         config,
-        file: { hello: 'world' },
+        file: {
+          whitespacesBefore: '\n\n\t\r\n  ',
+          whitespacesAfter: '\n\t  \r\n',
+          content: { hello: 'world' },
+        },
       }),
-    ).toEqual('{"hello":"world"}');
+    ).toEqual('\n\n\t\r\n  {"hello":"world"}\n\t  \r\n');
   });
 
   it('can stringify with custom spacing', () => {
@@ -33,7 +45,11 @@ describe('Test JSONv3 exporter', () => {
     expect(
       jsonv3Exporter.stringify({
         config,
-        file: { hello: 'world' },
+        file: {
+          whitespacesBefore: '',
+          whitespacesAfter: '',
+          content: { hello: 'world' },
+        },
       }),
     ).toEqual('{\n  "hello": "world"\n}');
   });
@@ -44,7 +60,11 @@ describe('Test JSONv3 exporter', () => {
         config,
         keyPath: [],
         cleanKey: 'hello',
-        file: { hello: 'world' },
+        file: {
+          whitespacesBefore: '',
+          whitespacesAfter: '',
+          content: { hello: 'world' },
+        },
       }),
     ).toEqual('world');
   });
@@ -55,7 +75,11 @@ describe('Test JSONv3 exporter', () => {
         config,
         keyPath: ['hello'],
         cleanKey: 'new',
-        file: { hello: { new: 'world' } },
+        file: {
+          whitespacesBefore: '',
+          whitespacesAfter: '',
+          content: { hello: { new: 'world' } },
+        },
       }),
     ).toEqual('world');
   });
@@ -66,7 +90,11 @@ describe('Test JSONv3 exporter', () => {
         config,
         keyPath: ['what'],
         cleanKey: 'new',
-        file: { hello: { notSoNew: 'world' } },
+        file: {
+          whitespacesBefore: '',
+          whitespacesAfter: '',
+          content: { hello: { notSoNew: 'world' } },
+        },
       }),
     ).toBeUndefined();
   });
@@ -77,7 +105,11 @@ describe('Test JSONv3 exporter', () => {
         config,
         keyPath: [],
         cleanKey: 'hello',
-        file: { hello: { new: 'world' } },
+        file: {
+          whitespacesBefore: '',
+          whitespacesAfter: '',
+          content: { hello: { new: 'world' } },
+        },
       }),
     ).toThrow(ConflictError);
 
@@ -86,7 +118,11 @@ describe('Test JSONv3 exporter', () => {
         config,
         keyPath: ['hello'],
         cleanKey: 'world',
-        file: { hello: null },
+        file: {
+          whitespacesBefore: '',
+          whitespacesAfter: '',
+          content: { hello: null },
+        },
       }),
     ).toThrow(ConflictError);
   });
@@ -95,10 +131,10 @@ describe('Test JSONv3 exporter', () => {
     expect(
       jsonv3Exporter.addKey({
         config,
-        file: {},
+        file: { whitespacesBefore: '', whitespacesAfter: '', content: {} },
         key: createSimpleKey('hello'),
         value: 'world',
-      }),
+      }).content,
     ).toEqual({ hello: 'world' });
   });
 
@@ -106,10 +142,10 @@ describe('Test JSONv3 exporter', () => {
     expect(
       jsonv3Exporter.addKey({
         config,
-        file: {},
+        file: { whitespacesBefore: '', whitespacesAfter: '', content: {} },
         key: createSimpleKey('new', ['hello', 'brave']),
         value: 'world',
-      }),
+      }).content,
     ).toEqual({ hello: { brave: { new: 'world' } } });
   });
 
@@ -117,7 +153,11 @@ describe('Test JSONv3 exporter', () => {
     expect(() =>
       jsonv3Exporter.addKey({
         config,
-        file: { hello: { brave: 'world' } },
+        file: {
+          whitespacesBefore: '',
+          whitespacesAfter: '',
+          content: { hello: { brave: 'world' } },
+        },
         key: createSimpleKey('new', ['hello', 'brave']),
         value: 'world',
       }),