reactjslocalizationvitebundlevite-plugin-development

How to generate multiple localized bundles using Vite and React?


I have prepared a minimal test project at Github to demonstrate, what I have already tried myself:

three buttons

My goal is to generate one bundle per supported language.

In other words, I do not want let users to switch languages at the runtime in my React app, because my real app is pretty large and country-specific.

Instead I would like to generate these 3 files and then serve them from different URLs at my backend:

So I have tried to create a custom Vite plugin vite-plugin-react-localize.js, which replaces __PLACEHOLDERS__, that can be seen in the above screenshot:

const localizedStrings = {
  en: {
    __YES__: "Yes",
    __NO__: "No",
    __CANCEL__: "Cancel",
  },
  de: {
    __YES__: "Ja",
    __NO__: "Nein",
    __CANCEL__: "Abbrechen",
  },
  fr: {
    __YES__: "Oui",
    __NO__: "Non",
    __CANCEL__: "Annuler",
  },
};

export default function localize(lang) {
  return {
    name: "localize-plugin",
    transform(code, id) {
      console.log(lang, id);
      return code.replaceAll(/__[A-Z]+__/g, function (match) {
        return localizedStrings[lang][match] || match;
      });
    },
  };
}

To activate the plugin I have added it to the vite-config.js:

const lastArg =
  process.argv.length > 0 ? process.argv[process.argv.length - 1] : "";
const matches = lastArg.match(/--lang=(en|de|fr)$/);
const lang = matches ? matches[1] : "en";

export default defineConfig({
  plugins: [react(), localize(lang)],
  build: {
    target: "es2015",
    rollupOptions: {
      output: {
        entryFileNames: `[name].js`,
        chunkFileNames: `[name].js`,
        assetFileNames: `[name].[ext]`,
      },
    },
  },
});

And finally, I have added the 3 build commands to the package.json:

  "scripts": {
    "dev": "vite",
    "build en": "vite build -- --lang=en",
    "build de": "vite build -- --lang=de",
    "build fr": "vite build -- --lang=fr",
    "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
    "preview": "vite preview"
  },

My approach kind of works now and the nice thing is that even the "dev" task preview is properly localized to the default "en" language. Here a screenshot of my VS Code:

VS Code screenshot

However I have not achieved the goal of being able to generate 3 different dist/index-*.js files.

And I have a feeling, that there must be a better way to approach this problem.


Solution

  • Ok, solved my problem by utilizing the "transform" and "generateBundle" hooks:

    localized screenshot

    Here my custom vite-plugin-react-localize.js file:

    "use strict";
    
    import fs from "fs";
    import path from "path";
    
    const localizedStrings = {
      en: {
        __YES__: "Yes",
        __NO__: "No",
        __CANCEL__: "Cancel",
      },
      de: {
        __YES__: "Ja",
        __NO__: "Nein",
        __CANCEL__: "Abbrechen",
      },
      fr: {
        __YES__: "Oui",
        __NO__: "Non",
        __CANCEL__: "Annuler",
      },
    };
    
    function replacePlacesholders(src, lang) {
      return src.replaceAll(/__[A-Z]+__/g, function (match) {
        return localizedStrings[lang][match] || match;
      });
    }
    
    export default function localize(isBuildingBundle) {
      return {
        name: "localize-plugin",
        transform(src, id) {
          // replace placeholders in .jsx files, when not building the bundle
          return id.endsWith(".jsx") && !isBuildingBundle
            ? replacePlacesholders(src, "de")
            : src;
        },
        generateBundle(outputOptions, bundle) {
          for (const [fileName, bundleValue] of Object.entries(bundle)) {
            if (!fileName.endsWith("index.js")) {
              continue;
            }
            const indexJsPath = path.resolve(outputOptions.dir, fileName);
            console.log("\nReplacing placeholders in", indexJsPath);
    
            // create index-XX.js file for each language, in the same folder as index.js
            for (const lang of Object.keys(localizedStrings)) {
              const indexLangPath = path.resolve(
                outputOptions.dir,
                `index-${lang}.js`
              );
              console.log("Creating localized file", indexLangPath);
              fs.writeFileSync(
                indexLangPath,
                replacePlacesholders(bundleValue.code, lang)
              );
            }
          }
        },
      };
    }
    

    It is called with the help of the modified vite.config.js file:

    import { defineConfig } from "vite";
    import react from "@vitejs/plugin-react";
    import localize from "./vite-plugin-react-localize";
    
    // is this a "vite build" command?
    const isBuildingBundle =
      process.argv.length > 0 && process.argv[process.argv.length - 1] === "build";
    
    export default defineConfig({
      plugins: [react(), localize(isBuildingBundle)],
      build: {
        target: "es2015",
        rollupOptions: {
          output: {
            entryFileNames: `[name].js`,
            chunkFileNames: `[name].js`,
            assetFileNames: `[name].[ext]`,
          },
        },
      },
    });
    

    Now the "vite build" works as I wanted and produces 3 additional files:

    VS Code screenshot