From ea4da5bd94f82c53537ffbfa2600271aab5b9c5f Mon Sep 17 00:00:00 2001 From: jkalberer Date: Wed, 5 Mar 2025 09:08:12 -0800 Subject: [PATCH 1/5] PORTAL-6292 | @jkalberer | Add plural support to babel i18n extractor --- .gitignore | 1 + rollup.config.mjs | 2 +- src/exporters/index.ts | 18 ++++++++++++- src/extractors/tFunction.ts | 25 +++++++++++++++++ src/extractors/transComponent.ts | 2 ++ src/keys.ts | 16 ++++++----- .../defaultValueForDerivedKeys.json | 2 +- ...extDefaultValueForDerivedKeysDisabled.json | 2 +- tests/__fixtures__/testPlural/default.js | 9 +++++++ tests/__fixtures__/testPlural/default.json | 24 +++++++++++++++++ .../testPlural/defaultValueForDerivedKeys.js | 16 +++++++++++ .../defaultValueForDerivedKeys.json | 27 +++++++++++++++++++ .../keyAsDefaultValueForDerivedKeys.js | 16 +++++++++++ .../keyAsDefaultValueForDerivedKeys.json | 26 ++++++++++++++++++ 14 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 tests/__fixtures__/testPlural/default.js create mode 100644 tests/__fixtures__/testPlural/default.json create mode 100644 tests/__fixtures__/testPlural/defaultValueForDerivedKeys.js create mode 100644 tests/__fixtures__/testPlural/defaultValueForDerivedKeys.json create mode 100644 tests/__fixtures__/testPlural/keyAsDefaultValueForDerivedKeys.js create mode 100644 tests/__fixtures__/testPlural/keyAsDefaultValueForDerivedKeys.json diff --git a/.gitignore b/.gitignore index 7e993bd..149d229 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /.yarn/ /.pnp* +/.vscode/ # Created by https://www.gitignore.io/api/node # Edit at https://www.gitignore.io/?templates=node diff --git a/rollup.config.mjs b/rollup.config.mjs index e250e83..2eb5e4b 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,4 +1,4 @@ -import pkg from "./package.json" with { type: "json" }; +import pkg from "./package.json" assert { type: "json" }; import { babel } from "@rollup/plugin-babel"; import { nodeResolve } from "@rollup/plugin-node-resolve"; diff --git a/src/exporters/index.ts b/src/exporters/index.ts index 6064b55..1d8bede 100644 --- a/src/exporters/index.ts +++ b/src/exporters/index.ts @@ -83,7 +83,7 @@ function getDefaultValue( keyAsDefaultValueEnabled && (keyAsDefaultValueForDerivedKeys || !key.isDerivedKey) ) { - defaultValue = key.cleanKey; + defaultValue = key.key; } const useI18nextDefaultValueEnabled = @@ -100,6 +100,22 @@ function getDefaultValue( defaultValue = key.parsedOptions.defaultValue; } + // Use defaultValue_somekey + if ( + key.parsedOptions?.defaultValues?.length > 0 && + useI18nextDefaultValueForDerivedKeys && + key.isDerivedKey + ) { + const foundValue = key.parsedOptions.defaultValues.find( + ([defaultValueKey]) => + defaultValueKey === key.cleanKey.replace(key.key, ""), + ); + + if (key.parsedOptions.defaultValues.length && foundValue != null) { + defaultValue = foundValue[1]; + } + } + return defaultValue; } diff --git a/src/extractors/tFunction.ts b/src/extractors/tFunction.ts index 6029de2..28fde0b 100644 --- a/src/extractors/tFunction.ts +++ b/src/extractors/tFunction.ts @@ -15,6 +15,7 @@ import { evaluateIfConfident, findKeyInObjectExpression, parseI18NextOptionsFromCommentHints, + iterateObjectExpression, } from "./commons"; /** @@ -49,9 +50,11 @@ function parseTCallOptions( const res: ExtractedKey["parsedOptions"] = { contexts: false, hasCount: false, + ordinal: false, ns: null, keyPrefix: null, defaultValue: null, + defaultValues: [], }; if (!path) return res; @@ -83,6 +86,28 @@ function parseTCallOptions( const keyPrefixNodeValue = keyPrefixNode.get("value"); res.keyPrefix = evaluateIfConfident(keyPrefixNodeValue); } + + const ordinalNode = findKeyInObjectExpression(path, "ordinal"); + if (ordinalNode != null && ordinalNode.isObjectProperty()) { + res.ordinal = evaluateIfConfident(ordinalNode.get("value")); + } + + // Support defaultValue_someKey + res.defaultValues = Array.from(iterateObjectExpression(path)).reduce( + (accumulator, [key, node]) => { + if (!key.startsWith("defaultValue_") || !node.isObjectProperty()) { + return accumulator; + } + return [ + ...accumulator, + [ + key.replace("defaultValue", ""), + evaluateIfConfident(node.get("value")), + ], + ]; + }, + [] as [string, string][], + ); } return res; diff --git a/src/extractors/transComponent.ts b/src/extractors/transComponent.ts index eedb57d..7cf47c8 100644 --- a/src/extractors/transComponent.ts +++ b/src/extractors/transComponent.ts @@ -46,9 +46,11 @@ function parseTransComponentOptions( const res: ExtractedKey["parsedOptions"] = { contexts: false, hasCount: false, + ordinal: false, keyPrefix: null, ns: null, defaultValue: null, + defaultValues: [], // unused in trans component }; const countAttr = findJSXAttributeByName(path, "count"); diff --git a/src/keys.ts b/src/keys.ts index 4662654..fea8b7c 100644 --- a/src/keys.ts +++ b/src/keys.ts @@ -10,9 +10,11 @@ interface I18NextParsedOptions { // If contexts is false, context are disable. contexts: string[] | boolean; hasCount: boolean; + ordinal: boolean; ns: string | null; keyPrefix: string | null; defaultValue: string | null; + defaultValues: [string, string][]; } /** @@ -156,8 +158,9 @@ export function computeDerivedKeys( locale, // TODO: a comment hint should allow to override cardinality/ordinality. // It defaults to cardinal, but this is not correct. - { ordinal: false }, + { ordinal: extractedKey.parsedOptions.ordinal }, ); + if (pluralRule === undefined || !(pluralRule instanceof Intl.PluralRules)) { throw unknownLocaleError; } else { @@ -175,8 +178,11 @@ export function computeDerivedKeys( pluralCategories = pluralRulesOptions.pluralCategories; } + const pluralSeparator = extractedKey.parsedOptions.ordinal + ? config.pluralSeparator + "ordinal" + config.pluralSeparator + : config.pluralSeparator; const pluralSuffixes = pluralCategories.map((cat) => - cat.length === 0 ? "" : config.pluralSeparator + cat, + cat.length === 0 ? "" : pluralSeparator + cat, ); keys = keys.reduce( (accumulator, k) => [ @@ -184,11 +190,7 @@ export function computeDerivedKeys( ...pluralSuffixes.map((suffix) => ({ ...k, cleanKey: k.cleanKey + suffix, - // Let's not consider singular a derived key. This is useful if one - // want to use default values for singular. - isDerivedKey: - k.isDerivedKey || - !["", config.pluralSeparator + "one"].includes(suffix), + isDerivedKey: true, })), ], Array(), diff --git a/tests/__fixtures__/testConfig/defaultValueForDerivedKeys.json b/tests/__fixtures__/testConfig/defaultValueForDerivedKeys.json index 8f4f7d8..e54d419 100644 --- a/tests/__fixtures__/testConfig/defaultValueForDerivedKeys.json +++ b/tests/__fixtures__/testConfig/defaultValueForDerivedKeys.json @@ -6,7 +6,7 @@ "keyAsDefaultValueForDerivedKeys": false }, "expectValues": { - "key0_one": "key0_one", + "key0_one": null, "key0_other": null } } diff --git a/tests/__fixtures__/testConfig/i18nextDefaultValueForDerivedKeysDisabled.json b/tests/__fixtures__/testConfig/i18nextDefaultValueForDerivedKeysDisabled.json index 722b872..35770b7 100644 --- a/tests/__fixtures__/testConfig/i18nextDefaultValueForDerivedKeysDisabled.json +++ b/tests/__fixtures__/testConfig/i18nextDefaultValueForDerivedKeysDisabled.json @@ -3,7 +3,7 @@ "description": "test that i18next default value is not set for derived keys", "pluginOptions": {}, "expectValues": { - "key0_one": "a default value", + "key0_one": "", "key0_other": "" } } diff --git a/tests/__fixtures__/testPlural/default.js b/tests/__fixtures__/testPlural/default.js new file mode 100644 index 0000000..b16eb46 --- /dev/null +++ b/tests/__fixtures__/testPlural/default.js @@ -0,0 +1,9 @@ +i18next.t("myKey", { count: 22 }); + +i18next.t("pluralDefaultValues", { + count: 22, + defaultValue_one: "one", + defaultValue_other: "other", +}); + +i18next.t("ordinalValues", { count: 22, ordinal: true }); diff --git a/tests/__fixtures__/testPlural/default.json b/tests/__fixtures__/testPlural/default.json new file mode 100644 index 0000000..92f0932 --- /dev/null +++ b/tests/__fixtures__/testPlural/default.json @@ -0,0 +1,24 @@ +{ + "description": "plural tests in different languages", + "comment": "ms has 1 plural, en has 2 plurals, ar has 6 plurals", + "pluginOptions": { + "locales": ["en"] + }, + "expectValues": [ + [ + { + "myKey_one": "", + "myKey_other": "", + "ordinalValues_ordinal_few": "", + "ordinalValues_ordinal_one": "", + "ordinalValues_ordinal_other": "", + "ordinalValues_ordinal_two": "", + "pluralDefaultValues_one": "", + "pluralDefaultValues_other": "" + }, + { + "locale": "en" + } + ] + ] +} diff --git a/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.js b/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.js new file mode 100644 index 0000000..c7cf9f2 --- /dev/null +++ b/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.js @@ -0,0 +1,16 @@ +i18next.t("myKey", { count: 22 }); + +i18next.t("pluralDefaultValues", { + count: 22, + defaultValue_one: "custom key one", + defaultValue_other: "custom key other", +}); + +i18next.t("ordinalValues", { + count: 22, + ordinal: true, + defaultValue_ordinal_few: "custom key few", + defaultValue_ordinal_one: "custom key one", + defaultValue_ordinal_other: "custom key other", + defaultValue_ordinal_two: "custom key two", +}); diff --git a/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.json b/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.json new file mode 100644 index 0000000..8458337 --- /dev/null +++ b/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.json @@ -0,0 +1,27 @@ +{ + "description": "plural tests for derived keys", + "comment": "if default values have been defined, use them. otherwise fall back to using the key as the default value", + "pluginOptions": { + "locales": ["en"], + "useI18nextDefaultValueForDerivedKeys": true, + "keyAsDefaultValue": true, + "keyAsDefaultValueForDerivedKeys": true + }, + "expectValues": [ + [ + { + "myKey_one": "myKey", + "myKey_other": "myKey", + "ordinalValues_ordinal_few": "custom key few", + "ordinalValues_ordinal_one": "custom key one", + "ordinalValues_ordinal_other": "custom key other", + "ordinalValues_ordinal_two": "custom key two", + "pluralDefaultValues_one": "custom key one", + "pluralDefaultValues_other": "custom key other" + }, + { + "locale": "en" + } + ] + ] +} diff --git a/tests/__fixtures__/testPlural/keyAsDefaultValueForDerivedKeys.js b/tests/__fixtures__/testPlural/keyAsDefaultValueForDerivedKeys.js new file mode 100644 index 0000000..c03dfdb --- /dev/null +++ b/tests/__fixtures__/testPlural/keyAsDefaultValueForDerivedKeys.js @@ -0,0 +1,16 @@ +i18next.t("myKey", { count: 22 }); + +i18next.t("pluralDefaultValues", { + count: 22, + defaultValue_one: "custom key one", + defaultValue_other: "custom key other", +}); + +i18next.t("ordinalValues", { + count: 22, + ordinal: true, + defaultValue_few: "custom key few", + defaultValue_one: "custom key one", + defaultValue_other: "custom key other", + defaultValue_two: "custom key two", +}); diff --git a/tests/__fixtures__/testPlural/keyAsDefaultValueForDerivedKeys.json b/tests/__fixtures__/testPlural/keyAsDefaultValueForDerivedKeys.json new file mode 100644 index 0000000..ca414fb --- /dev/null +++ b/tests/__fixtures__/testPlural/keyAsDefaultValueForDerivedKeys.json @@ -0,0 +1,26 @@ +{ + "description": "plural tests for derived keys", + "comment": "if default values have been defined, use them. otherwise fall back to using the key as the default value", + "pluginOptions": { + "locales": ["en"], + "keyAsDefaultValue": true, + "keyAsDefaultValueForDerivedKeys": true + }, + "expectValues": [ + [ + { + "myKey_one": "myKey", + "myKey_other": "myKey", + "ordinalValues_ordinal_few": "ordinalValues", + "ordinalValues_ordinal_one": "ordinalValues", + "ordinalValues_ordinal_other": "ordinalValues", + "ordinalValues_ordinal_two": "ordinalValues", + "pluralDefaultValues_one": "pluralDefaultValues", + "pluralDefaultValues_other": "pluralDefaultValues" + }, + { + "locale": "en" + } + ] + ] +} From 031c7e41d3d644f360fb8f635331574ddf0e5e92 Mon Sep 17 00:00:00 2001 From: jkalberer <90280207+jkalberer@users.noreply.github.com> Date: Wed, 5 Mar 2025 09:24:11 -0800 Subject: [PATCH 2/5] Apply suggestions from code review --- src/exporters/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/exporters/index.ts b/src/exporters/index.ts index 1d8bede..423bf51 100644 --- a/src/exporters/index.ts +++ b/src/exporters/index.ts @@ -111,7 +111,7 @@ function getDefaultValue( defaultValueKey === key.cleanKey.replace(key.key, ""), ); - if (key.parsedOptions.defaultValues.length && foundValue != null) { + if (foundValue != null) { defaultValue = foundValue[1]; } } From e97575c434e82e0273e80d4acec3cf2418412acd Mon Sep 17 00:00:00 2001 From: jkalberer Date: Mon, 24 Mar 2025 14:56:12 -0700 Subject: [PATCH 3/5] PORTAL-6292 | @jkalberer | Support plurals with keys and default values --- src/exporters/index.ts | 4 +-- src/extractors/tFunction.ts | 14 +++++++-- tests/__fixtures__/testPlural/default.js | 5 +++ tests/__fixtures__/testPlural/default.json | 4 +++ .../testPlural/defaultValueForDerivedKeys.js | 9 ++++++ .../defaultValueForDerivedKeys.json | 8 +++++ .../testPlural/keyWithDefaultValue.js | 21 +++++++++++++ .../testPlural/keyWithDefaultValue.json | 31 +++++++++++++++++++ tests/helpers.ts | 2 ++ 9 files changed, 94 insertions(+), 4 deletions(-) create mode 100644 tests/__fixtures__/testPlural/keyWithDefaultValue.js create mode 100644 tests/__fixtures__/testPlural/keyWithDefaultValue.json diff --git a/src/exporters/index.ts b/src/exporters/index.ts index 423bf51..055da86 100644 --- a/src/exporters/index.ts +++ b/src/exporters/index.ts @@ -106,9 +106,9 @@ function getDefaultValue( useI18nextDefaultValueForDerivedKeys && key.isDerivedKey ) { + const derivedIdentifier = `${config.pluralSeparator}${key.cleanKey.split(config.pluralSeparator).pop()}`; const foundValue = key.parsedOptions.defaultValues.find( - ([defaultValueKey]) => - defaultValueKey === key.cleanKey.replace(key.key, ""), + ([defaultValueKey]) => defaultValueKey === derivedIdentifier, ); if (foundValue != null) { diff --git a/src/extractors/tFunction.ts b/src/extractors/tFunction.ts index 28fde0b..2a93fe9 100644 --- a/src/extractors/tFunction.ts +++ b/src/extractors/tFunction.ts @@ -101,7 +101,7 @@ function parseTCallOptions( return [ ...accumulator, [ - key.replace("defaultValue", ""), + key.replace("defaultValue", "").replace("_ordinal", ""), evaluateIfConfident(node.get("value")), ], ]; @@ -137,10 +137,20 @@ function extractTCall( ); } + const tSecondParamValue = evaluateIfConfident(args[1]); + + let parsedTCallOptions; + if (typeof tSecondParamValue === "string") { + parsedTCallOptions = parseTCallOptions(args[2]); + parsedTCallOptions.defaultValue = tSecondParamValue; + } else { + parsedTCallOptions = parseTCallOptions(args[1]); + } + return { key: keyEvaluation, parsedOptions: { - ...parseTCallOptions(args[1]), + ...parsedTCallOptions, ...parseI18NextOptionsFromCommentHints(path, commentHints), }, sourceNodes: [path.node], diff --git a/tests/__fixtures__/testPlural/default.js b/tests/__fixtures__/testPlural/default.js index b16eb46..fb0745d 100644 --- a/tests/__fixtures__/testPlural/default.js +++ b/tests/__fixtures__/testPlural/default.js @@ -5,5 +5,10 @@ i18next.t("pluralDefaultValues", { defaultValue_one: "one", defaultValue_other: "other", }); +i18next.t("pluralDefaultValues.subkey", "custom key one", { + count: 22, + defaultValue_one: "custom key one", + defaultValue_other: "custom key other", +}); i18next.t("ordinalValues", { count: 22, ordinal: true }); diff --git a/tests/__fixtures__/testPlural/default.json b/tests/__fixtures__/testPlural/default.json index 92f0932..e9f4412 100644 --- a/tests/__fixtures__/testPlural/default.json +++ b/tests/__fixtures__/testPlural/default.json @@ -13,6 +13,10 @@ "ordinalValues_ordinal_one": "", "ordinalValues_ordinal_other": "", "ordinalValues_ordinal_two": "", + "pluralDefaultValues": { + "subkey_one": "", + "subkey_other": "" + }, "pluralDefaultValues_one": "", "pluralDefaultValues_other": "" }, diff --git a/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.js b/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.js index c7cf9f2..f092874 100644 --- a/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.js +++ b/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.js @@ -5,6 +5,11 @@ i18next.t("pluralDefaultValues", { defaultValue_one: "custom key one", defaultValue_other: "custom key other", }); +i18next.t("pluralDefaultValues.subkey", "custom key one", { + count: 22, + defaultValue_one: "custom key one", + defaultValue_other: "custom key other", +}); i18next.t("ordinalValues", { count: 22, @@ -14,3 +19,7 @@ i18next.t("ordinalValues", { defaultValue_ordinal_other: "custom key other", defaultValue_ordinal_two: "custom key two", }); +i18next.t("ordinalValues without Defaults", { + count: 22, + ordinal: true, +}); diff --git a/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.json b/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.json index 8458337..6d6467d 100644 --- a/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.json +++ b/tests/__fixtures__/testPlural/defaultValueForDerivedKeys.json @@ -12,10 +12,18 @@ { "myKey_one": "myKey", "myKey_other": "myKey", + "ordinalValues without Defaults_ordinal_few": "ordinalValues without Defaults", + "ordinalValues without Defaults_ordinal_one": "ordinalValues without Defaults", + "ordinalValues without Defaults_ordinal_other": "ordinalValues without Defaults", + "ordinalValues without Defaults_ordinal_two": "ordinalValues without Defaults", "ordinalValues_ordinal_few": "custom key few", "ordinalValues_ordinal_one": "custom key one", "ordinalValues_ordinal_other": "custom key other", "ordinalValues_ordinal_two": "custom key two", + "pluralDefaultValues": { + "subkey_one": "custom key one", + "subkey_other": "custom key other" + }, "pluralDefaultValues_one": "custom key one", "pluralDefaultValues_other": "custom key other" }, diff --git a/tests/__fixtures__/testPlural/keyWithDefaultValue.js b/tests/__fixtures__/testPlural/keyWithDefaultValue.js new file mode 100644 index 0000000..7a11117 --- /dev/null +++ b/tests/__fixtures__/testPlural/keyWithDefaultValue.js @@ -0,0 +1,21 @@ +i18next.t("myKey", "items: {{count}}", { count: 22 }); + +i18next.t("pluralDefaultValues", "custom key one", { + count: 22, + defaultValue_one: "custom key one", + defaultValue_other: "custom key other", +}); +i18next.t("pluralDefaultValues.subkey", "custom key one", { + count: 22, + defaultValue_one: "custom key one", + defaultValue_other: "custom key other", +}); + +i18next.t("ordinalValues", "custom key one", { + count: 22, + ordinal: true, + defaultValue_few: "custom key few", + defaultValue_one: "custom key one", + defaultValue_other: "custom key other", + defaultValue_two: "custom key two", +}); diff --git a/tests/__fixtures__/testPlural/keyWithDefaultValue.json b/tests/__fixtures__/testPlural/keyWithDefaultValue.json new file mode 100644 index 0000000..d636cb3 --- /dev/null +++ b/tests/__fixtures__/testPlural/keyWithDefaultValue.json @@ -0,0 +1,31 @@ +{ + "description": "plural tests for derived keys", + "comment": "if default values have been defined, use them. otherwise fall back to using the key as the default value", + "pluginOptions": { + "locales": ["en"], + "keyAsDefaultValue": true, + "keyAsDefaultValueForDerivedKeys": true, + "useI18nextDefaultValueForDerivedKeys": true + }, + "expectValues": [ + [ + { + "myKey_one": "items: {{count}}", + "myKey_other": "items: {{count}}", + "ordinalValues_ordinal_few": "custom key few", + "ordinalValues_ordinal_one": "custom key one", + "ordinalValues_ordinal_other": "custom key other", + "ordinalValues_ordinal_two": "custom key two", + "pluralDefaultValues_one": "custom key one", + "pluralDefaultValues_other": "custom key other", + "pluralDefaultValues": { + "subkey_one": "custom key one", + "subkey_other": "custom key other" + } + }, + { + "locale": "en" + } + ] + ] +} diff --git a/tests/helpers.ts b/tests/helpers.ts index 042722c..a67c987 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -16,6 +16,8 @@ export function createTranslationKey( keyPrefix: null, ns: null, defaultValue: null, + ordinal: false, + defaultValues: [], }, cleanKey: key, ns: "translation", From ff41d287627d383b8c5ec4bb99e5771f825c0a59 Mon Sep 17 00:00:00 2001 From: jkalberer Date: Tue, 25 Mar 2025 08:39:21 -0700 Subject: [PATCH 4/5] PORTAL-6292 | @jkalberer | Refresh values when useI18nextDefaultValue is true --- src/config.ts | 2 ++ src/exporters/index.ts | 2 +- tests/exporters/exporter.test.ts | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/config.ts b/src/config.ts index 5ddcb12..b0fca43 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ export interface Config { keyAsDefaultValue: boolean | string[]; keyAsDefaultValueForDerivedKeys: boolean; discardOldKeys: boolean; + ignoreExistingValues: boolean; jsonSpace: string | number; customTransComponents: readonly [string, string][]; customUseTranslationHooks: readonly [string, string][]; @@ -102,6 +103,7 @@ export function parseConfig(opts: Partial): Config { true, ), discardOldKeys: coalesce(opts.discardOldKeys, false), + ignoreExistingValues: coalesce(opts.ignoreExistingValues, false), jsonSpace: coalesce(opts.jsonSpace, 2), customTransComponents, customUseTranslationHooks, diff --git a/src/exporters/index.ts b/src/exporters/index.ts index 055da86..35ca98e 100644 --- a/src/exporters/index.ts +++ b/src/exporters/index.ts @@ -178,7 +178,7 @@ export default function exportTranslationKeys( file: translationFile, key: k, value: - previousValue === undefined + config.ignoreExistingValues || previousValue === undefined ? getDefaultValue(k, locale, config) : previousValue, }); diff --git a/tests/exporters/exporter.test.ts b/tests/exporters/exporter.test.ts index f21a478..2ec7b08 100644 --- a/tests/exporters/exporter.test.ts +++ b/tests/exporters/exporter.test.ts @@ -88,6 +88,40 @@ describe("Test exporter works", () => { }); }); + it("does not discard old values across runs when useI18nextDefaultValue is false", () => { + const outputPath = path.join(outputDir, "discard_old_keys.json"); + fs.writeJSONSync(outputPath, { key: "foo" }); + + const config = parseConfig({ + outputPath, + ignoreExistingValues: false, + }); + const key0 = createTranslationKey("key"); + key0.parsedOptions.defaultValue = "bar"; + const cache = createExporterCache(); + exportTranslationKeys([key0], "en", config, cache); + expect(fs.readJSONSync(outputPath)).toEqual({ + key: "foo", + }); + }); + + it("can discard old values across runs", () => { + const outputPath = path.join(outputDir, "discard_old_keys.json"); + fs.writeJSONSync(outputPath, { key: "foo" }); + + const config = parseConfig({ + outputPath, + ignoreExistingValues: true, + }); + const key0 = createTranslationKey("key"); + key0.parsedOptions.defaultValue = "bar"; + const cache = createExporterCache(); + exportTranslationKeys([key0], "en", config, cache); + expect(fs.readJSONSync(outputPath)).toEqual({ + key: "bar", + }); + }); + it("reload translation file and merge with actual cache", () => { const outputPath = path.join(outputDir, "if_locale_file_changes.json"); fs.writeJSONSync(outputPath, { presentAtInit: "foo" }); From 1104ec56296357eaa2245b524c2484d90dc17f77 Mon Sep 17 00:00:00 2001 From: jkalberer Date: Wed, 26 Mar 2025 09:27:57 -0700 Subject: [PATCH 5/5] PORTAL-6292 | @jkalberer | Add extraction for class members with the i18n object --- src/extractors/commons.ts | 154 +++++++++++++++++ src/extractors/getClassMember.ts | 67 ++++++++ src/extractors/index.ts | 3 + src/extractors/withTranslationHOC.ts | 157 +----------------- src/plugin.ts | 8 + .../testGetClassMember/customInstanceName.js | 16 ++ .../customInstanceName.json | 10 ++ .../testGetClassMember/namespace.js | 20 +++ .../testGetClassMember/namespace.json | 9 + 9 files changed, 290 insertions(+), 154 deletions(-) create mode 100644 src/extractors/getClassMember.ts create mode 100644 tests/__fixtures__/testGetClassMember/customInstanceName.js create mode 100644 tests/__fixtures__/testGetClassMember/customInstanceName.json create mode 100644 tests/__fixtures__/testGetClassMember/namespace.js create mode 100644 tests/__fixtures__/testGetClassMember/namespace.json diff --git a/src/extractors/commons.ts b/src/extractors/commons.ts index eca64ba..e0980b0 100644 --- a/src/extractors/commons.ts +++ b/src/extractors/commons.ts @@ -336,3 +336,157 @@ export function getAliasedTBindingName( return tFunctionNames.find((name) => path.scope.bindings[name]); } + +/** + * Try to find "t" in an object spread. Useful when looking for the "t" key + * in a spread object. e.g. const {t} = props; + * + * @param path object pattern + * @returns t identifier or null of it was not found in the object pattern. + */ +function findTFunctionIdentifierInObjectPattern( + path: BabelCore.NodePath, +): BabelCore.NodePath | null { + const props = path.get("properties"); + + for (const prop of props) { + if (prop.isObjectProperty()) { + const key = prop.get("key"); + if (!Array.isArray(key) && key.isIdentifier() && key.node.name === "t") { + return key; + } + } + } + + return null; +} + +/** + * Check whether a node path is the callee of a call expression. + * + * @param path the node to check. + * @returns true if the path is the callee of a call expression. + */ +function isCallee(path: BabelCore.NodePath): path is BabelCore.NodePath & { + parentPath: BabelCore.NodePath; +} { + return !!( + path.parentPath?.isCallExpression() && + path === path.parentPath.get("callee") + ); +} + +/** + * Find T function calls from a props assignment. Prop assignment can occur + * in function parameters (i.e. "function Component(props)" or + * "function Component({t})") or in a variable declarator (i.e. + * "const props = …" or "const {t} = props"). + * + * @param propsId identifier for the prop assignment. e.g. "props" or "{t}" + * @returns Call expressions to t function. + */ +export function findTFunctionCallsFromPropsAssignment( + propsId: BabelCore.NodePath, +): BabelCore.NodePath[] { + const tReferences = Array(); + + const body = propsId.parentPath?.get("body"); + if (body === undefined || Array.isArray(body)) return []; + const scope = body.scope; + + if (propsId.isObjectPattern()) { + // got "function MyComponent({t, other, props})" + // or "const {t, other, props} = this.props" + // we want to find references to "t" + const tFunctionIdentifier = findTFunctionIdentifierInObjectPattern(propsId); + if (tFunctionIdentifier === null) return []; + const tBinding = scope.bindings[tFunctionIdentifier.node.name]; + tReferences.push(...tBinding.referencePaths); + } else if (propsId.isIdentifier()) { + // got "function MyComponent(props)" + // or "const props = this.props" + // we want to find references to props.t + const references = scope.bindings[propsId.node.name].referencePaths; + for (const reference of references) { + if (reference.parentPath?.isMemberExpression()) { + const prop = reference.parentPath.get("property"); + if ( + !Array.isArray(prop) && + prop.isIdentifier() && + prop.node.name === "t" + ) { + tReferences.push(reference.parentPath); + } + } + } + } + + // We have candidates. Let's see if t references are actual calls to the t + // function + const tCalls = Array>(); + for (const tCall of tReferences) { + if (isCallee(tCall)) { + tCalls.push(tCall.parentPath); + } + } + + return tCalls; +} + +/** + * Find all t function calls in a class component. + * @param path node path to the class component. + */ +export function findTFunctionCallsInClassComponent( + path: BabelCore.NodePath, + propertyName: string, +): BabelCore.NodePath[] { + const result = Array>(); + + const thisVisitor: BabelCore.Visitor = { + ThisExpression(path) { + if (!path.parentPath.isMemberExpression()) return; + + const propProperty = path.parentPath.get("property"); + if (Array.isArray(propProperty) || !propProperty.isIdentifier()) return; + if (propProperty.node.name !== propertyName) return; + + // Ok, this is interesting, we have something with "this.props" + + if (path.parentPath.parentPath.isMemberExpression()) { + // We have something in the form "this.props.xxxx". + + const tIdentifier = path.parentPath.parentPath.get("property"); + if (Array.isArray(tIdentifier) || !tIdentifier.isIdentifier()) return; + if (tIdentifier.node.name !== "t") return; + + // We have something in the form "this.props.t". Let's see if it's an + // actual function call or an assignment. + const tExpression = path.parentPath.parentPath; + if (isCallee(tExpression)) { + // Simple case. Direct call to "this.props.t()" + result.push(tExpression.parentPath); + } else if (tExpression.parentPath.isVariableDeclarator()) { + // Hard case. const t = this.props.t; + // Let's loop through all references to t. + const id = tExpression.parentPath.get("id"); + if (!id.isIdentifier()) return; + for (const reference of id.scope.bindings[id.node.name] + .referencePaths) { + if (isCallee(reference)) { + result.push(reference.parentPath); + } + } + } + } else if (path.parentPath.parentPath.isVariableDeclarator()) { + // We have something in the form "const props = this.props" + // Or "const {t} = this.props" + const id = path.parentPath.parentPath.get("id"); + result.push(...findTFunctionCallsFromPropsAssignment(id)); + } + }, + }; + path.traverse(thisVisitor); + + return result; +} diff --git a/src/extractors/getClassMember.ts b/src/extractors/getClassMember.ts new file mode 100644 index 0000000..665986b --- /dev/null +++ b/src/extractors/getClassMember.ts @@ -0,0 +1,67 @@ +import * as BabelCore from "@babel/core"; +import * as BabelTypes from "@babel/types"; + +import { CommentHint, getCommentHintForPath } from "../comments"; +import { Config } from "../config"; +import { ExtractedKey } from "../keys"; + +import { findTFunctionCallsInClassComponent } from "./commons"; +import extractTFunction from "./tFunction"; + +/** + * Parse function or class declaration (likely components) to find whether + * they are wrapped with "withTranslation()" HOC, and if so, extract all the + * translations that come from the "t" function injected in the component + * properties. + * + * @param path node path to the component + * @param config plugin configuration + * @param commentHints parsed comment hints + */ +export default function extractGetClassMember( + path: BabelCore.NodePath, + config: Config, + commentHints: CommentHint[] = [], +): ExtractedKey[] { + if (!path.isClassDeclaration()) { + return []; + } + const tCalls = config.i18nextInstanceNames.reduce( + (accumulator, instanceName) => [ + ...accumulator, + ...findTFunctionCallsInClassComponent(path, instanceName), + ], + [] as BabelCore.NodePath[], + ); + + // Extract namespace + let ns: string | null; + const nsCommentHint = getCommentHintForPath(path, "NAMESPACE", commentHints); + if (nsCommentHint) { + // We got a comment hint, take its value as namespace. + ns = nsCommentHint.value; + } else { + // TODO - extract from constructor parameter with reflection + } + + let keys = Array(); + for (const tCall of tCalls) { + keys = [ + ...keys, + ...extractTFunction(tCall, config, commentHints, true).map((k) => ({ + // Add namespace if it was not explicitely set in t() call. + ...k, + parsedOptions: { + ...k.parsedOptions, + ns: k.parsedOptions.ns || ns, + }, + })), + ]; + } + + return keys.map((k) => ({ + ...k, + sourceNodes: [path.node, ...k.sourceNodes], + extractorName: extractGetClassMember.name, + })); +} diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 5483ee3..61f2b81 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -1,6 +1,7 @@ import { ExtractionError } from "./commons"; import extractCustomTransComponent from "./customTransComponent"; import extractCustomUseTranslationHook from "./customUseTranslationHook"; +import extractGetClassMember from "./getClassMember"; import extractGetFixedTFunction from "./getFixedTFunction"; import extractI18nextInstance from "./i18nextInstance"; import extractTFunction from "./tFunction"; @@ -22,6 +23,7 @@ export const EXTRACTORS_PRIORITIES = [ extractGetFixedTFunction.name, extractTranslationRenderProp.name, extractWithTranslationHOC.name, + extractGetClassMember.name, extractI18nextInstance.name, extractTFunction.name, ]; @@ -34,6 +36,7 @@ export default { extractGetFixedTFunction, extractTranslationRenderProp, extractWithTranslationHOC, + extractGetClassMember, extractI18nextInstance, extractTFunction, }; diff --git a/src/extractors/withTranslationHOC.ts b/src/extractors/withTranslationHOC.ts index a2280ae..bc9f209 100644 --- a/src/extractors/withTranslationHOC.ts +++ b/src/extractors/withTranslationHOC.ts @@ -9,6 +9,8 @@ import { getFirstOrNull, evaluateIfConfident, referencesImport, + findTFunctionCallsInClassComponent, + findTFunctionCallsFromPropsAssignment, } from "./commons"; import extractTFunction from "./tFunction"; @@ -148,159 +150,6 @@ function findWithTranslationHOCCallExpression( return null; } -/** - * Try to find "t" in an object spread. Useful when looking for the "t" key - * in a spread object. e.g. const {t} = props; - * - * @param path object pattern - * @returns t identifier or null of it was not found in the object pattern. - */ -function findTFunctionIdentifierInObjectPattern( - path: BabelCore.NodePath, -): BabelCore.NodePath | null { - const props = path.get("properties"); - - for (const prop of props) { - if (prop.isObjectProperty()) { - const key = prop.get("key"); - if (!Array.isArray(key) && key.isIdentifier() && key.node.name === "t") { - return key; - } - } - } - - return null; -} - -/** - * Check whether a node path is the callee of a call expression. - * - * @param path the node to check. - * @returns true if the path is the callee of a call expression. - */ -function isCallee(path: BabelCore.NodePath): path is BabelCore.NodePath & { - parentPath: BabelCore.NodePath; -} { - return !!( - path.parentPath?.isCallExpression() && - path === path.parentPath.get("callee") - ); -} - -/** - * Find T function calls from a props assignment. Prop assignment can occur - * in function parameters (i.e. "function Component(props)" or - * "function Component({t})") or in a variable declarator (i.e. - * "const props = …" or "const {t} = props"). - * - * @param propsId identifier for the prop assignment. e.g. "props" or "{t}" - * @returns Call expressions to t function. - */ -function findTFunctionCallsFromPropsAssignment( - propsId: BabelCore.NodePath, -): BabelCore.NodePath[] { - const tReferences = Array(); - - const body = propsId.parentPath?.get("body"); - if (body === undefined || Array.isArray(body)) return []; - const scope = body.scope; - - if (propsId.isObjectPattern()) { - // got "function MyComponent({t, other, props})" - // or "const {t, other, props} = this.props" - // we want to find references to "t" - const tFunctionIdentifier = findTFunctionIdentifierInObjectPattern(propsId); - if (tFunctionIdentifier === null) return []; - const tBinding = scope.bindings[tFunctionIdentifier.node.name]; - tReferences.push(...tBinding.referencePaths); - } else if (propsId.isIdentifier()) { - // got "function MyComponent(props)" - // or "const props = this.props" - // we want to find references to props.t - const references = scope.bindings[propsId.node.name].referencePaths; - for (const reference of references) { - if (reference.parentPath?.isMemberExpression()) { - const prop = reference.parentPath.get("property"); - if ( - !Array.isArray(prop) && - prop.isIdentifier() && - prop.node.name === "t" - ) { - tReferences.push(reference.parentPath); - } - } - } - } - - // We have candidates. Let's see if t references are actual calls to the t - // function - const tCalls = Array>(); - for (const tCall of tReferences) { - if (isCallee(tCall)) { - tCalls.push(tCall.parentPath); - } - } - - return tCalls; -} - -/** - * Find all t function calls in a class component. - * @param path node path to the class component. - */ -function findTFunctionCallsInClassComponent( - path: BabelCore.NodePath, -): BabelCore.NodePath[] { - const result = Array>(); - - const thisVisitor: BabelCore.Visitor = { - ThisExpression(path) { - if (!path.parentPath.isMemberExpression()) return; - - const propProperty = path.parentPath.get("property"); - if (Array.isArray(propProperty) || !propProperty.isIdentifier()) return; - if (propProperty.node.name !== "props") return; - - // Ok, this is interesting, we have something with "this.props" - - if (path.parentPath.parentPath.isMemberExpression()) { - // We have something in the form "this.props.xxxx". - - const tIdentifier = path.parentPath.parentPath.get("property"); - if (Array.isArray(tIdentifier) || !tIdentifier.isIdentifier()) return; - if (tIdentifier.node.name !== "t") return; - - // We have something in the form "this.props.t". Let's see if it's an - // actual function call or an assignment. - const tExpression = path.parentPath.parentPath; - if (isCallee(tExpression)) { - // Simple case. Direct call to "this.props.t()" - result.push(tExpression.parentPath); - } else if (tExpression.parentPath.isVariableDeclarator()) { - // Hard case. const t = this.props.t; - // Let's loop through all references to t. - const id = tExpression.parentPath.get("id"); - if (!id.isIdentifier()) return; - for (const reference of id.scope.bindings[id.node.name] - .referencePaths) { - if (isCallee(reference)) { - result.push(reference.parentPath); - } - } - } - } else if (path.parentPath.parentPath.isVariableDeclarator()) { - // We have something in the form "const props = this.props" - // Or "const {t} = this.props" - const id = path.parentPath.parentPath.get("id"); - result.push(...findTFunctionCallsFromPropsAssignment(id)); - } - }, - }; - path.traverse(thisVisitor); - - return result; -} - /** * Find t function calls in a function component. * @param path node path to the function component. @@ -335,7 +184,7 @@ export default function extractWithTranslationHOC( let tCalls: BabelCore.NodePath[]; if (path.isClassDeclaration()) { - tCalls = findTFunctionCallsInClassComponent(path); + tCalls = findTFunctionCallsInClassComponent(path, "props"); } else { tCalls = findTFunctionCallsInFunctionComponent( path as BabelCore.NodePath, diff --git a/src/plugin.ts b/src/plugin.ts index f0026ec..a8e4054 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -11,6 +11,7 @@ import Extractors, { EXTRACTORS_PRIORITIES, ExtractionError, } from "./extractors"; +import extractGetClassMember from "./extractors/getClassMember"; import extractWithTranslationHOC from "./extractors/withTranslationHOC"; import { computeDerivedKeys, ExtractedKey, TranslationKey } from "./keys"; @@ -180,6 +181,13 @@ const Visitor: BabelCore.Visitor = { extractState.commentHints, ), ); + collect( + extractGetClassMember( + path, + extractState.config, + extractState.commentHints, + ), + ); }); }, diff --git a/tests/__fixtures__/testGetClassMember/customInstanceName.js b/tests/__fixtures__/testGetClassMember/customInstanceName.js new file mode 100644 index 0000000..f4d5fee --- /dev/null +++ b/tests/__fixtures__/testGetClassMember/customInstanceName.js @@ -0,0 +1,16 @@ +class Clazz1 { + getTranslation() { + return this.pgm.t("key1", "some value"); + } +} + +class Clazz2 { + getTranslation() { + return this._.t("key2", "some value"); + } +} +class Clazz3 { + getTranslation() { + return this.foo.t("key3", "some value"); + } +} diff --git a/tests/__fixtures__/testGetClassMember/customInstanceName.json b/tests/__fixtures__/testGetClassMember/customInstanceName.json new file mode 100644 index 0000000..e31bc06 --- /dev/null +++ b/tests/__fixtures__/testGetClassMember/customInstanceName.json @@ -0,0 +1,10 @@ +{ + "description": "test giving custom instance name to i18next", + "pluginOptions": { + "i18nextInstanceNames": ["pgm", "_"] + }, + "expectValues": { + "key1": "some value", + "key2": "some value" + } +} diff --git a/tests/__fixtures__/testGetClassMember/namespace.js b/tests/__fixtures__/testGetClassMember/namespace.js new file mode 100644 index 0000000..dc14730 --- /dev/null +++ b/tests/__fixtures__/testGetClassMember/namespace.js @@ -0,0 +1,20 @@ +/* + i18next-extract-mark-ns-next-line secret-ns +*/ +class Clazz1 { + getTranslation() { + return this.i18n.t("key", "some value"); + } +} + +class Clazz2 { + getTranslation() { + return this.i18n.t("key", "some value", { ns: "secret-ns-2" }); + } +} + +class Clazz3 { + getTranslation() { + return this.i18n.t("secret-ns-3:key", "some value"); + } +} diff --git a/tests/__fixtures__/testGetClassMember/namespace.json b/tests/__fixtures__/testGetClassMember/namespace.json new file mode 100644 index 0000000..0106e74 --- /dev/null +++ b/tests/__fixtures__/testGetClassMember/namespace.json @@ -0,0 +1,9 @@ +{ + "description": "test simple extractions of getClassMember with namespace set", + "pluginOptions": {}, + "expectValues": [ + [{ "key": "some value" }, { "ns": "secret-ns" }], + [{ "key": "some value" }, { "ns": "secret-ns-2" }], + [{ "key": "some value" }, { "ns": "secret-ns-3" }] + ] +}