i18next-scanner icon indicating copy to clipboard operation
i18next-scanner copied to clipboard

Support for react-i18next namespaces

Open rchinerman opened this issue 5 years ago • 6 comments

In react-i18next, the default namespace can be either the namespace defined during init, or the first namespace of the array in the arguments (withTranslation([namespace1, namespace2]) or useTranslation([namespace1, namespace2])) .

As far as I can tell, i18next-scanner only supports the namespace defined in the init. Would it be possible to support this? Or am I doing something incorrect in my config and this is already supported?

Version

  • i18next: 15.0.4
  • i18next-scanner: 2.10.1

Configuration

const fs = require('fs');

module.exports = {
    input: [
        'src/client/**/*.{js,jsx}',
        // Use ! to filter out files or directories
        '!**/node_modules/**',
    ],
    output: './',
    options: {
        removeUnusedKeys: true,
        plural: true,
        debug: true,
        func: {
            list: ['i18next.t', 'i18n.t', 't'],
            extensions: ['.js', '.jsx']
        },
        trans: {
            component: 'Trans',
            i18nKey: 'i18nKey',
            defaultsKey: 'defaults',
            extensions: ['.js', '.jsx'],
            fallbackKey: function(ns, value) {
                return value;
            }
        },
        lngs: ['en'],
        ns: [
            'common',
            'home',
            'lol',
            'interpolate',
            'missing'
        ],
        defaultLng: 'en',
        defaultNs: 'resource',
        defaultValue: '',
        resource: {
            loadPath: `i18n/{{lng}}/{{ns}}.json`,
            savePath: 'i18n2/{{lng}}/{{ns}}.json',
            jsonIndent: 2,
            lineEnding: '\n'
        },
        nsSeparator: ':',
        keySeparator: '.',
        interpolation: {
            prefix: '{{',
            suffix: '}}'
        }
    },
};

rchinerman avatar Mar 22 '19 23:03 rchinerman

I'm interested in this feature too. From what I can, to implement it you'd need to look for withTranslation and useTranslation within the parseFuncFromString and parseTransFromString methods in https://github.com/i18next/i18next-scanner/blob/master/src/parser.js.

The tricky bit is if you have multiple withTranslation or useTranslation calls in a single file, it'd be hard to know which namespace belongs to which part of the file.

jamesknelson avatar May 22 '19 11:05 jamesknelson

No improvements on this? When scanning the files, i18next-scanner adds all keys to the defaultNs, and the other namespaces are empty. Am I missing something or is it not possible to use i18next-scanner when using i18-next with hooks: useTranslation(ns)?

My config file is this one:

const fs = require("fs");
const _ = require("lodash");
const eol = require("eol");
const VirtualFile = require("vinyl");
const flattenObjectKeys = require("i18next-scanner/lib/flatten-object-keys")
    .default;
const omitEmptyObject = require("i18next-scanner/lib/omit-empty-object")
    .default;

function getFileJSON(resPath) {
    try {
        return JSON.parse(
            fs
                .readFileSync(fs.realpathSync(path.join("src", resPath)))
                .toString("utf-8")
        );
    } catch (e) {
        return {};
    }
}

function flush(done) {
    const { parser } = this;
    const { options } = parser;

    // Flush to resource store
    const resStore = parser.get({ sort: options.sort });
    const { jsonIndent } = options.resource;
    const lineEnding = String(options.resource.lineEnding).toLowerCase();

    Object.keys(resStore).forEach(lng => {
        const namespaces = resStore[lng];

        Object.keys(namespaces).forEach(ns => {
            let obj = namespaces[ns];

            const resPath = parser.formatResourceSavePath(lng, ns);

            // if not defaultLng then Get, Merge & removeUnusedKeys of old JSON content
            if (lng !== options.defaultLng) {
                let resContent = getFileJSON(resPath);

                if (options.removeUnusedKeys) {
                    const namespaceKeys = flattenObjectKeys(obj);
                    const resContentKeys = flattenObjectKeys(resContent);
                    const unusedKeys = _.differenceWith(
                        resContentKeys,
                        namespaceKeys,
                        _.isEqual
                    );

                    for (let i = 0; i < unusedKeys.length; ++i) {
                        _.unset(resContent, unusedKeys[i]);
                    }

                    resContent = omitEmptyObject(resContent);
                }

                obj = { ...obj, ...resContent };
            }

            let text = `${JSON.stringify(obj, null, jsonIndent)}\n`;

            if (lineEnding === "auto") {
                text = eol.auto(text);
            } else if (lineEnding === "\r\n" || lineEnding === "crlf") {
                text = eol.crlf(text);
            } else if (lineEnding === "\n" || lineEnding === "lf") {
                text = eol.lf(text);
            } else if (lineEnding === "\r" || lineEnding === "cr") {
                text = eol.cr(text);
            } else {
                // Defaults to LF
                text = eol.lf(text);
            }

            this.push(
                new VirtualFile({
                    path: resPath,
                    contents: Buffer.from(text)
                })
            );
        });
    });

    done();
}

module.exports = {
    input: [
        "src/**/*.{js,jsx}",
        // Use ! to filter out files or directories
        "!src/**/*.spec.{js,jsx}",
        "!src/i18n/**",
        "!**/node_modules/**",
        "!build/**"
    ],
    output: "./src",
    options: {
        debug: false,
        removeUnusedKeys: true,
        sort: true,
        func: {
            list: ["i18next.t", "i18n.t", "t"],
            extensions: [".js", ".jsx"]
        },
        lngs: ["en", "es", "pt"],
        ns: ["main", "navbar", "signup", "rate-provider"],
        defaultNs: "main",
        defaultLng: "en",
        resource: {
            loadPath: "locales/{{lng}}/{{ns}}.json",
            savePath: "locales/{{lng}}/{{ns}}.json"
        },
        trans: {
            component: 'Trans',
            i18nKey: 'i18nKey',
            defaultsKey: 'defaults',
            extensions: ['.js', '.jsx'],
            fallbackKey: function(ns, value) {
                return value;
            }
        },
        keySeparator: false // key separator
    },
    flush
};

otaviobonder-deel avatar Feb 10 '20 19:02 otaviobonder-deel

@otaviobps @rchinerman I came up with a config to solve this problem.

For <Trans>, my files look like this:

<Trans i18nKey="view:dontateDaiFromInterest">
          Donate {{ donateNum }} DAI from interest
</Trans>

Which will output in the "view" namespace as desired

{
  "dontateDaiFromInterest": "Donate <1>{{donateNum}}</1> DAI from interest"
}

For const { t } = useTranslation("namespace") I had to search for useTranslation in the file and find the namespace as @jamesknelson suggested

Here is my final config. The relevant parts are mostly in parser.parseFuncFromString and parser.parseTransFromString.

var fs = require('fs');
var chalk = require('chalk');
var { languages } = require('./language-list.js');

const STRING_NOT_TRANSLATED = '';
const DEFAULT_NS = 'namespace-undefined'

module.exports = {
  input: [
    'src/**/*.{js,jsx}',
    'styleguide/**/*.{js,jsx}',
    // Use ! to filter out files or directories
    '!src/i18n/**',
    '!**/node_modules/**',
    '!**/dist/**'
  ],
  output: './',
  options: {
    debug: true,
    removeUnusedKeys: true,
    plural: true,
    func: {
      list: ['i18next.t', 'i18n.t', 't'],
      extensions: ['.js', '.jsx']
    },
    trans: {
      component: 'Trans',
      i18nKey: 'i18nKey',
      defaultsKey: 'defaults',
      extensions: ['.js', '.jsx'],
      fallbackKey: function(ns, value) {
        return value;
      },
      acorn: {
        ecmaVersion: 10, // defaults to 10
        sourceType: 'module' // defaults to 'module'
        // Check out https://github.com/acornjs/acorn/tree/master/acorn#interface for additional options
      }
    },
    lngs: languages.map(language => language.code),
    ns: ['component', 'foundation', 'ui-kit', 'view'],
    defaultLng: 'en',
    defaultNs: DEFAULT_NS,
    defaultValue: STRING_NOT_TRANSLATED,
    resource: {
      loadPath: 'src/i18n/locales/{{lng}}/{{ns}}.json',
      savePath: 'src/i18n/locales/{{lng}}/{{ns}}.json',
      jsonIndent: 2,
      lineEnding: '\n'
    },
    nsSeparator: false, // namespace separator
    keySeparator: false, // key separator
    interpolation: {
      prefix: '{{',
      suffix: '}}'
    }
  },
  transform: function customTransform(file, enc, done) {
    'use strict';
    const parser = this.parser;
    const content = fs.readFileSync(file.path, enc);
    let ns;
    const match = content.match(/useTranslation\(.+\)/);
    if (match) ns = match[0].split(/(\'|\")/)[2];
    let count = 0;
    parser.parseFuncFromString(content, { list: ['t'] }, function(
      key,
      options
    ) {
      parser.set(
        key,
        Object.assign({}, options, {
          ns: ns ? ns : DEFAULT_NS,
          nsSeparator: false,
          keySeparator: false
        })
      );
      ++count;
    });
    parser.parseTransFromString(
      content,
      { component: 'Trans', i18nKey: 'i18nKey' },
      function(key, options) {
        parser.set(
          key.split(':')[1],
          Object.assign({}, options, {
            ns: key.split(':')[0],
            nsSeparator: false,
            keySeparator: false
          })
        );
        ++count;
      }
    );
    if (count > 0) {
      console.log(
        `i18next-scanner: count=${chalk.cyan(count)}, file=${chalk.yellow(
          JSON.stringify(file.relative)
        )}`
      );
    }

    done();
  }
};

The only remaining issue is that I cannot override the defaultNs setting. Each item will appear in both the desired namespace, and the default one. Idk how to solve this so I just made the default something called "namespace-undefined" because its trash. Happy to hear your ideas!!

pi0neerpat avatar Apr 07 '20 21:04 pi0neerpat

I added a couple lines to the above fix to allow using multiple namespaces with useTranslation(), as described here.

If you want to do this:

const { t } = useTranslation(["foundation", "app"])
// then
t("app:someKey")
t("foundation:anotherKey")

Then add these lines in my fix above

      parser.parseFuncFromString(content, { list: ['t'] }, function(
        key,
        options
      ) {
         // New code to handle multiple namespaces like t("app:someKey")
        let thisNs = ns;
        if (/.+:.+/.test(key)) {
          [thisNs, key] = key.split(':');
        }
        parser.set(
          key,
          Object.assign({}, options, {
            ns: thisNs ? thisNs : defaultNs,
         // ...

pi0neerpat avatar May 05 '20 04:05 pi0neerpat

@pi0neerpat thanks for your work to get namespace awareness with useTranslation from i18next-scanner.

Could we get this merged in the code to automatically use this parser with react hooks?

Also, I found a small issue with the following code

const Home: React.FC = () => {
  const { t } = useTranslation("custom_namespace");
  return (
    <div className={container}>
        {t("logged_in")}
    </div>
  );
};

i18next-scanner with the custom parser will still add this "logged_in" key to the default namespace in addition to the "custom_namespace" one.

AdrienLemaire avatar Jul 07 '20 07:07 AdrienLemaire

@AdrienLemaire

I think the problem here is the double parsing of strings. In the first case, it's done by the default func and in the second run it is done by the custom parser. You can solve it by omitting the func parameter.

I'm using useTranslation() hook like this:

...
const { t } = useTranslation(['navigation', 'common', 'test'])
 
const options = [
    ...(isActivated
      ? [
          {
            icon: documents,
            title: isMobile ? t('Docs') : t('common:Documents'),
            path: '/documents',
          },
          { icon: archive, title: t('test:Archive'), path: '/archive' },
...

and in this case I want to add all translations by default to the first namespace navigation and to others if this is explicitly defined by pointing the namespace in the t function.

So here's the working configuration:

var fs = require('fs');
var chalk = require('chalk');

module.exports = {
    input: [
        'app/**/*.{js,jsx}',
        // Use ! to filter out files or directories
        '!app/**/*.spec.{js,jsx}',
        '!app/i18n/**',
        '!**/node_modules/**',
    ],
    output: './',
    options: {
        debug: true,
        lngs: ['en-US','bg-BG'],
        ns: [
            'navigation',
            'common',
            'test'
        ],
        defaultLng: (lng, ns, key) => lng,
        defaultNs: 'common',
        defaultKey: (lng, ns, key) => `${ns}-${key}`,
        defaultValue: (lng, ns, key) => lng === 'en-US'? key : '__STRING_NOT_TRANSLATED__',
        resource: {
            loadPath: 'app/public/locales/{{lng}}/{{ns}}.json',
            savePath: 'app/public/locales/{{lng}}/{{ns}}.json',
            jsonIndent: 2,
            lineEnding: '\n'
        },
        interpolation: {
            prefix: '{{',
            suffix: '}}'
        }
    },
    transform: function customTransform(file, enc, done) {
        'use strict';
        const parser = this.parser;
        const content = fs.readFileSync(file.path, enc);
        let ns;
        const match = content.match(/useTranslation\(.+\)/);
        if (match) ns = match[0].split(/(\'|\")/)[2];
        let count = 0;
        parser.parseFuncFromString(content, { list: ['t'] }, function(
          key,
          options
        ) {
          parser.set(
            key,
            Object.assign({}, options, {
              ns: ns ? ns : DEFAULT_NS,
              nsSeparator: ':',
              keySeparator: '.'
            })
          );
          ++count;
        });
        if (count > 0) {
          console.log(
            `i18next-scanner: count=${chalk.cyan(count)}, file=${chalk.yellow(
              JSON.stringify(file.relative)
            )}`
          );
        }
    
        done();
      }
};

NB: I'm running i18next-scanner outside of my react app directory.

GoranStoyanov avatar Oct 30 '20 07:10 GoranStoyanov