Repository: lingui/js-lingui (ts)

Issue:
 Generate aggregated, compiled messages from multiple po-source files in custom location
Hi,

and thanks for your hard work on linguijs!

We are working in a monorepo and are in the process of changing how handle the locale files. At the moment we use extract and compile on the whole project to generate two files, `/locales/sv.js` and `/locales/en.js`. Using react contex we handle the language switching. But as the project has grown and we are about to engage other teams to work on the code we would like to keep the translations as close to each package and component as possible. 

Looking at https://github.com/lingui/js-lingui/issues/257 and https://lingui-docs-git-next.lingui-js.now.sh/rfc/002_catalogs.html I've done a test setup so that when running extract and compile all locale files are placed in each package and component. I'm using the _lingui@next_ version for this. The structure looks something like this:

```
/
  web/
    locales/
      en.js en.po sv.js sv.po
    src/
      index.js app.js routes.js
  layouts/
    common/
      header/
        locales/
          en.js en.po sv.js sv.po     
        src/
          index.js
    private/
      accounts/
        locales/
          en.js en.po sv.js sv.po
        src/
          index.js
    public/
      contact/
        locales/
          en.js en.po sv.js sv.po
        src/
          index.js
  packages
    package-a/
      locales/
        en.js en.po sv.js sv.po
      src/
        index.js container.js style.scss
    package-b/
      locales/
        en.js en.po sv.js sv.po
      src/
        index.js container.js style.scss
  components/
    component-a/
      locales/
        en.js en.po sv.js sv.po
      src/
        index.js container.js style.scss
    component-b/
      locales/
        en.js en.po sv.js sv.po
      src/
        index.js container.js style.scss      
```

By default all compiled js-files end up in the same `locales` directories as their corresponding po-files. 

Is it possible to compile all po-files in the whole project to two files for example at `/locales/sv.js` and `/locales/en.js`?

That would mean the file tree would look like this:

```
/
  locales/
    en.js sv.js    
  web/
    locales/
      en.po sv.po
    ...
  layouts/
    ...
  packages
    ...
  components/
    ...      
```

The other way forward that comes to my mind is to handle the import of locale files in each and every package/component. I tried to make a stab at it but at the moment the `I18nProvider` does not render any children so I haven't been able to proceed with that.


Pull request description:
568 merge catalogs
Resolves #568

Continuation of https://github.com/lingui/js-lingui/pull/658 - I have no write access ofc so I started a new PR

Just thinking if we want to apply this proposition https://github.com/lingui/js-lingui/pull/658#issuecomment-706675746

Patch:
diff --git a/docs/tutorials/cli.rst b/docs/tutorials/cli.rst
index bd312f09..6c1aad75 100644
--- a/docs/tutorials/cli.rst
+++ b/docs/tutorials/cli.rst
@@ -103,6 +103,11 @@ plural rules to a production ready catalog::
 The ``locale`` directory now contains the source catalogs (``messages.json``) and
 the compiled ones (``messages.js``).
 
+It is also possible to merge the translated catalogs into a single file per locale
+by specifying :conf:`catalogsMergePath`. For example if :conf:`catalogsMergePath` is assigned
+``locales/{locale}`` then catalogs will be compiled to ``/locales/cs.js`` and 
+``/locales/en.js``.
+
 Cleaning up obsolete messages
 =============================
 
diff --git a/packages/cli/src/api/catalog.ts b/packages/cli/src/api/catalog.ts
index cf1cf193..bc4dff1c 100644
--- a/packages/cli/src/api/catalog.ts
+++ b/packages/cli/src/api/catalog.ts
@@ -410,6 +410,48 @@ export function getCatalogForFile(file: string, catalogs: Array<Catalog>) {
   return null
 }
 
+/**
+ * Create catalog for merged messages.
+ */
+export function getCatalogForMerge(config: LinguiConfig) {
+  const catalogConfig = config
+
+  if (catalogConfig.catalogsMergePath.endsWith(PATHSEP)) {
+    const extension = getFormat(config.format).catalogExtension
+    const correctPath = catalogConfig.catalogsMergePath.slice(0, -1)
+    const examplePath =
+      correctPath.replace(
+        LOCALE,
+        // Show example using one of configured locales (if any)
+        (config.locales || [])[0] || "en"
+      ) + extension
+    throw new Error(
+      // prettier-ignore
+      `Remove trailing slash from "${catalogConfig.catalogsMergePath}". Catalog path isn't a directory,` +
+          ` but translation file without extension. For example, catalog path "${correctPath}"` +
+          ` results in translation file "${examplePath}".`
+    )
+  }
+
+  // catalog name is the last directory of catalogPath.
+  // If the last part is {locale}, then catalog doesn't have an explicit name
+  const name = (function () {
+    const _name = path.basename(normalizeRelativePath(catalogConfig.catalogsMergePath))
+    return _name !== LOCALE ? _name : null
+  })()
+
+  const catalog = new Catalog(
+    {
+      name,
+      path: normalizeRelativePath(catalogConfig.catalogsMergePath),
+      include: [],
+      exclude: [],
+    },
+    config
+  )
+  return catalog
+}
+
 /**
  * Merge origins for messages found in different places. All other attributes
  * should be the same (raise an error if defaults are different).
diff --git a/packages/cli/src/lingui-compile.ts b/packages/cli/src/lingui-compile.ts
index e8c412c2..867fc0ff 100644
--- a/packages/cli/src/lingui-compile.ts
+++ b/packages/cli/src/lingui-compile.ts
@@ -6,7 +6,7 @@ import * as plurals from "make-plural"
 
 import { getConfig } from "@lingui/conf"
 
-import { getCatalogs } from "./api/catalog"
+import { getCatalogs, getCatalogForMerge } from "./api/catalog"
 import { createCompiledCatalog } from "./api/compile"
 import { helpRun } from "./api/help"
 
@@ -29,9 +29,13 @@ function command(config, options) {
     return false
   }
 
+  // Check config.compile.merge if catalogs for current locale are to be merged into a single compiled file
+  const doMerge = !!config.catalogsMergePath
+  let mergedCatalogs = {}
+
   console.error("Compiling message catalogs…")
 
-  if(config.pseudoLocale) {
+  if (config.pseudoLocale) {
     config.locales.push(config.pseudoLocale)
   }
   config.locales.forEach((locale) => {
@@ -48,7 +52,7 @@ function command(config, options) {
 
     catalogs.forEach((catalog) => {
       const messages = catalog.getTranslations(
-        locale === config.pseudoLocale ? config.sourceLocale: locale,
+        locale === config.pseudoLocale ? config.sourceLocale : locale,
         {
           fallbackLocale: config.fallbackLocale,
           sourceLocale: config.sourceLocale,
@@ -78,32 +82,40 @@ function command(config, options) {
         }
       }
 
-      const namespace = options.namespace || config.compileNamespace
-      const compiledCatalog = createCompiledCatalog(locale, messages, {
-        strict: false,
-        namespace,
-        pseudoLocale: config.pseudoLocale,
-      })
-
-      const compiledPath = catalog.writeCompiled(locale, compiledCatalog, namespace)
-
-      if (options.typescript) {
-        const typescriptPath = compiledPath.replace(/\.jsx?$/, "") + ".d.ts"
-        fs.writeFileSync(
-          typescriptPath,
-          `import { Messages } from '@lingui/core';
-declare const messages: Messages;
-export { messages };
-`
+      if (doMerge) {
+        mergedCatalogs = { ...mergedCatalogs, ...messages }
+      } else {
+        const namespace = options.namespace || config.compileNamespace
+        const compiledCatalog = createCompiledCatalog(locale, messages, {
+          strict: false,
+          namespace,
+          pseudoLocale: config.pseudoLocale,
+        })
+
+        const compiledPath = catalog.writeCompiled(
+          locale,
+          compiledCatalog,
+          namespace
         )
-      }
 
-      options.verbose &&
-        console.error(chalk.green(`${locale} ⇒ ${compiledPath}`))
+        if (options.typescript) {
+          const typescriptPath = compiledPath.replace(/\.jsx?$/, "") + ".d.ts"
+          fs.writeFileSync(
+            typescriptPath,
+            `import { Messages } from '@lingui/core';
+          declare const messages: Messages;
+          export { messages };
+          `
+          )
+        }
+
+        options.verbose &&
+          console.error(chalk.green(`${locale} ⇒ ${compiledPath}`))
+      }
     })
-  })
 
-  return true
+    return true
+  })
 }
 
 if (require.main === module) {
diff --git a/packages/conf/index.d.ts b/packages/conf/index.d.ts
index e59fb734..39d27e94 100644
--- a/packages/conf/index.d.ts
+++ b/packages/conf/index.d.ts
@@ -17,6 +17,7 @@ export declare type LinguiConfig = {
     format: CatalogFormat;
     formatOptions: CatalogFormatOptions;
     locales: string[];
+    catalogsMergePath?: string;
     orderBy: OrderBy;
     pseudoLocale: string;
     rootDir: string;
diff --git a/packages/conf/src/index.ts b/packages/conf/src/index.ts
index ecd564e0..7999bc8a 100644
--- a/packages/conf/src/index.ts
+++ b/packages/conf/src/index.ts
@@ -27,6 +27,7 @@ export type LinguiConfig = {
   format: CatalogFormat
   formatOptions: CatalogFormatOptions
   locales: string[]
+  catalogsMergePath?: string
   orderBy: OrderBy
   pseudoLocale: string
   rootDir: string


Test patch:
diff --git a/packages/cli/src/api/catalog.test.ts b/packages/cli/src/api/catalog.test.ts
index fcfafaca..64c8310a 100644
--- a/packages/cli/src/api/catalog.test.ts
+++ b/packages/cli/src/api/catalog.test.ts
@@ -7,6 +7,7 @@ import {
   MakeOptions,
   getCatalogs,
   getCatalogForFile,
+  getCatalogForMerge,
   Catalog,
   cleanObsolete,
   MergeOptions,
@@ -732,6 +733,74 @@ describe("getCatalogForFile", function () {
   })
 })
 
+describe("getCatalogForMerge", function () {
+  afterEach(() => {
+    mockFs.restore()
+  })
+
+  it("should return catalog for merged messages", function () {
+    const config = mockConfig({
+      catalogsMergePath: "locales/{locale}",
+    })
+    expect(getCatalogForMerge(config)).toEqual(
+      new Catalog(
+        {
+          name: null,
+          path: "locales/{locale}",
+          include: [],
+          exclude: [],
+        },
+        config
+      )
+    )
+  })
+
+  it("should return catalog with custom name for merged messages", function () {
+    const config = mockConfig({
+      catalogsMergePath: "locales/{locale}/my/dir",
+    })
+    expect(getCatalogForMerge(config)).toEqual(
+      new Catalog(
+        {
+          name: "dir",
+          path: "locales/{locale}/my/dir",
+          include: [],
+          exclude: [],
+        },
+        config
+      )
+    )
+  })
+
+  it("should throw error if catalogsMergePath ends with slash", function () {
+    const config = mockConfig({
+      catalogsMergePath: "locales/{locale}/bad/path/",
+    })
+    expect.assertions(1)
+    try {
+      getCatalogForMerge(config)
+    } catch (e) {
+      expect(e.message).toBe(
+        'Remove trailing slash from "locales/{locale}/bad/path/". Catalog path isn\'t a directory, but translation file without extension. For example, catalog path "locales/{locale}/bad/path" results in translation file "locales/en/bad/path.po".'
+      )
+    }
+  })
+
+  it("should throw error if {locale} is omitted from catalogsMergePath", function () {
+    const config = mockConfig({
+      catalogsMergePath: "locales/bad/path",
+    })
+    expect.assertions(1)
+    try {
+      getCatalogForMerge(config)
+    } catch (e) {
+      expect(e.message).toBe(
+        "Invalid catalog path: {locale} variable is missing"
+      )
+    }
+  })
+})
+
 describe("cleanObsolete", function () {
   it("should remove obsolete messages from catalog", function () {
     const catalog = {