Skip to content

Commit 632d8f4

Browse files
authored
Update custom translations to support nested fields in structured JSON (matrix-org#11685)
* Update matrix-web-i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix custom translations for structured JSON nested fields Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix import Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Fix export Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update @matrix-org/react-sdk-module-api Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Delint Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Iterate Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update matrix-web-i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> * Update matrix-web-i18n Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --------- Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
1 parent 1897962 commit 632d8f4

File tree

5 files changed

+74
-108
lines changed

5 files changed

+74
-108
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"@matrix-org/analytics-events": "^0.7.0",
6464
"@matrix-org/emojibase-bindings": "^1.1.2",
6565
"@matrix-org/matrix-wysiwyg": "^2.4.1",
66-
"@matrix-org/react-sdk-module-api": "^2.1.0",
66+
"@matrix-org/react-sdk-module-api": "^2.1.1",
6767
"@matrix-org/spec": "^1.7.0",
6868
"@sentry/browser": "^7.0.0",
6969
"@sentry/tracing": "^7.0.0",
@@ -214,7 +214,7 @@
214214
"jsqr": "^1.4.0",
215215
"mailhog": "^4.16.0",
216216
"matrix-mock-request": "^2.5.0",
217-
"matrix-web-i18n": "^2.1.0",
217+
"matrix-web-i18n": "^3.1.3",
218218
"mocha-junit-reporter": "^2.2.0",
219219
"node-fetch": "2",
220220
"postcss-scss": "^4.0.4",

src/@types/common.ts

Lines changed: 1 addition & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,40 +23,7 @@ export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
2323

2424
export type ComponentClass = keyof JSX.IntrinsicElements | JSXElementConstructor<any>;
2525

26-
/**
27-
* Utility type for string dot notation for accessing nested object properties.
28-
* Based on https://stackoverflow.com/a/58436959
29-
* @example
30-
* {
31-
* "a": {
32-
* "b": {
33-
* "c": "value"
34-
* },
35-
* "d": "foobar"
36-
* }
37-
* }
38-
* will yield a type of `"a.b.c" | "a.d"` with Separator="."
39-
* @typeParam Target the target type to generate leaf keys for
40-
* @typeParam Separator the separator to use between key segments when accessing nested objects
41-
* @typeParam LeafType the type which leaves of this object extend, used to determine when to stop recursion
42-
* @typeParam MaxDepth the maximum depth to recurse to
43-
* @returns a union type representing all dot (Separator) string notation keys which can access a Leaf (of LeafType)
44-
*/
45-
export type Leaves<Target, Separator extends string = ".", LeafType = string, MaxDepth extends number = 3> = [
46-
MaxDepth,
47-
] extends [never]
48-
? never
49-
: Target extends LeafType
50-
? ""
51-
: {
52-
[K in keyof Target]-?: Join<K, Leaves<Target[K], Separator, LeafType, Prev[MaxDepth]>, Separator>;
53-
}[keyof Target];
54-
type Prev = [never, 0, 1, 2, 3, ...0[]];
55-
type Join<K, P, S extends string = "."> = K extends string | number
56-
? P extends string | number
57-
? `${K}${"" extends P ? "" : S}${P}`
58-
: never
59-
: never;
26+
export type { Leaves } from "matrix-web-i18n";
6027

6128
export type RecursivePartial<T> = {
6229
[P in keyof T]?: T[P] extends (infer U)[]

src/languageHandler.tsx

Lines changed: 17 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ import counterpart from "counterpart";
2121
import React from "react";
2222
import { logger } from "matrix-js-sdk/src/logger";
2323
import { Optional } from "matrix-events-sdk";
24-
import { MapWithDefault, safeSet } from "matrix-js-sdk/src/utils";
24+
import { MapWithDefault } from "matrix-js-sdk/src/utils";
25+
import { normalizeLanguageKey, TranslationKey as _TranslationKey, KEY_SEPARATOR } from "matrix-web-i18n";
26+
import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api";
27+
import _ from "lodash";
2528

2629
import type Translations from "./i18n/strings/en_EN.json";
2730
import SettingsStore from "./settings/SettingsStore";
@@ -30,19 +33,20 @@ import { SettingLevel } from "./settings/SettingLevel";
3033
import { retry } from "./utils/promise";
3134
import SdkConfig from "./SdkConfig";
3235
import { ModuleRunner } from "./modules/ModuleRunner";
33-
import { Leaves } from "./@types/common";
3436

3537
// @ts-ignore - $webapp is a webpack resolve alias pointing to the output directory, see webpack config
3638
import webpackLangJsonUrl from "$webapp/i18n/languages.json";
3739

40+
export { normalizeLanguageKey, getNormalizedLanguageKeys } from "matrix-web-i18n";
41+
3842
const i18nFolder = "i18n/";
3943

4044
// Control whether to also return original, untranslated strings
4145
// Useful for debugging and testing
4246
const ANNOTATE_STRINGS = false;
4347

4448
// We use english strings as keys, some of which contain full stops
45-
counterpart.setSeparator("|");
49+
counterpart.setSeparator(KEY_SEPARATOR);
4650

4751
// see `translateWithFallback` for an explanation of fallback handling
4852
const FALLBACK_LOCALE = "en";
@@ -110,7 +114,7 @@ export function getUserLanguage(): string {
110114
* }
111115
* }
112116
*/
113-
export type TranslationKey = Leaves<typeof Translations, "|", string | { other: string }, 4>;
117+
export type TranslationKey = _TranslationKey<typeof Translations>;
114118

115119
// Function which only purpose is to mark that a string is translatable
116120
// Does not actually do anything. It's helpful for automatic extraction of translatable strings
@@ -541,41 +545,6 @@ export function getLanguageFromBrowser(): string {
541545
return getLanguagesFromBrowser()[0];
542546
}
543547

544-
/**
545-
* Turns a language string, normalises it,
546-
* (see normalizeLanguageKey) into an array of language strings
547-
* with fallback to generic languages
548-
* (eg. 'pt-BR' => ['pt-br', 'pt'])
549-
*
550-
* @param {string} language The input language string
551-
* @return {string[]} List of normalised languages
552-
*/
553-
export function getNormalizedLanguageKeys(language: string): string[] {
554-
const languageKeys: string[] = [];
555-
const normalizedLanguage = normalizeLanguageKey(language);
556-
const languageParts = normalizedLanguage.split("-");
557-
if (languageParts.length === 2 && languageParts[0] === languageParts[1]) {
558-
languageKeys.push(languageParts[0]);
559-
} else {
560-
languageKeys.push(normalizedLanguage);
561-
if (languageParts.length === 2) {
562-
languageKeys.push(languageParts[0]);
563-
}
564-
}
565-
return languageKeys;
566-
}
567-
568-
/**
569-
* Returns a language string with underscores replaced with
570-
* hyphens, and lowercased.
571-
*
572-
* @param {string} language The language string to be normalized
573-
* @returns {string} The normalized language string
574-
*/
575-
export function normalizeLanguageKey(language: string): string {
576-
return language.toLowerCase().replace("_", "-");
577-
}
578-
579548
export function getCurrentLanguage(): string {
580549
return counterpart.getLocale();
581550
}
@@ -662,34 +631,26 @@ async function getLanguage(langPath: string): Promise<ICounterpartTranslation> {
662631
return res.json();
663632
}
664633

665-
export interface ICustomTranslations {
666-
// Format is a map of english string to language to override
667-
[str: string]: {
668-
[lang: string]: string;
669-
};
670-
}
671-
672-
let cachedCustomTranslations: Optional<ICustomTranslations> = null;
634+
let cachedCustomTranslations: Optional<TranslationStringsObject> = null;
673635
let cachedCustomTranslationsExpire = 0; // zero to trigger expiration right away
674636

675637
// This awkward class exists so the test runner can get at the function. It is
676638
// not intended for practical or realistic usage.
677639
export class CustomTranslationOptions {
678-
public static lookupFn?: (url: string) => ICustomTranslations;
640+
public static lookupFn?: (url: string) => TranslationStringsObject;
679641

680642
private constructor() {
681643
// static access for tests only
682644
}
683645
}
684646

685-
function doRegisterTranslations(customTranslations: ICustomTranslations): void {
686-
// We convert the operator-friendly version into something counterpart can
687-
// consume.
647+
function doRegisterTranslations(customTranslations: TranslationStringsObject): void {
648+
// We convert the operator-friendly version into something counterpart can consume.
688649
// Map: lang → Record: string → translation
689650
const langs: MapWithDefault<string, Record<string, string>> = new MapWithDefault(() => ({}));
690-
for (const [str, translations] of Object.entries(customTranslations)) {
691-
for (const [lang, newStr] of Object.entries(translations)) {
692-
safeSet(langs.getOrCreate(lang), str, newStr);
651+
for (const [translationKey, translations] of Object.entries(customTranslations)) {
652+
for (const [lang, translation] of Object.entries(translations)) {
653+
_.set(langs.getOrCreate(lang), translationKey.split(KEY_SEPARATOR), translation);
693654
}
694655
}
695656

@@ -719,11 +680,11 @@ export async function registerCustomTranslations({
719680
if (!lookupUrl) return; // easy - nothing to do
720681

721682
try {
722-
let json: Optional<ICustomTranslations>;
683+
let json: Optional<TranslationStringsObject>;
723684
if (testOnlyIgnoreCustomTranslationsCache || Date.now() >= cachedCustomTranslationsExpire) {
724685
json = CustomTranslationOptions.lookupFn
725686
? CustomTranslationOptions.lookupFn(lookupUrl)
726-
: ((await (await fetch(lookupUrl)).json()) as ICustomTranslations);
687+
: ((await (await fetch(lookupUrl)).json()) as TranslationStringsObject);
727688
cachedCustomTranslations = json;
728689

729690
// Set expiration to the future, but not too far. Just trying to avoid

test/languageHandler-test.tsx

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ limitations under the License.
1616

1717
import React from "react";
1818
import fetchMock from "fetch-mock-jest";
19+
import { Translation } from "matrix-web-i18n";
20+
import { TranslationStringsObject } from "@matrix-org/react-sdk-module-api";
1921

2022
import SdkConfig from "../src/SdkConfig";
2123
import {
2224
_t,
2325
_tDom,
2426
CustomTranslationOptions,
2527
getAllLanguagesWithLabels,
26-
ICustomTranslations,
2728
registerCustomTranslations,
2829
setLanguage,
2930
setMissingEntryGenerator,
@@ -35,9 +36,9 @@ import {
3536
import { stubClient } from "./test-utils";
3637
import { setupLanguageMock } from "./setup/setupLanguage";
3738

38-
async function setupTranslationOverridesForTests(overrides: ICustomTranslations) {
39+
async function setupTranslationOverridesForTests(overrides: TranslationStringsObject) {
3940
const lookupUrl = "/translations.json";
40-
const fn = (url: string): ICustomTranslations => {
41+
const fn = (url: string): TranslationStringsObject => {
4142
expect(url).toEqual(lookupUrl);
4243
return overrides;
4344
};
@@ -62,15 +63,15 @@ describe("languageHandler", () => {
6263
});
6364

6465
it("should support overriding translations", async () => {
65-
const str = "This is a test string that does not exist in the app." as TranslationKey;
66-
const enOverride = "This is the English version of a custom string." as TranslationKey;
67-
const deOverride = "This is the German version of a custom string." as TranslationKey;
66+
const str: TranslationKey = "power_level|default";
67+
const enOverride: Translation = "Visitor";
68+
const deOverride: Translation = "Besucher";
6869

6970
// First test that overrides aren't being used
7071
await setLanguage("en");
71-
expect(_t(str)).toEqual(str);
72+
expect(_t(str)).toMatchInlineSnapshot(`"Default"`);
7273
await setLanguage("de");
73-
expect(_t(str)).toEqual(str);
74+
expect(_t(str)).toMatchInlineSnapshot(`"Standard"`);
7475

7576
await setupTranslationOverridesForTests({
7677
[str]: {
@@ -87,6 +88,42 @@ describe("languageHandler", () => {
8788
expect(_t(str)).toEqual(deOverride);
8889
});
8990

91+
it("should support overriding plural translations", async () => {
92+
const str: TranslationKey = "voip|n_people_joined";
93+
const enOverride: Translation = {
94+
other: "%(count)s people in the call",
95+
one: "%(count)s person in the call",
96+
};
97+
const deOverride: Translation = {
98+
other: "%(count)s Personen im Anruf",
99+
one: "%(count)s Person im Anruf",
100+
};
101+
102+
// First test that overrides aren't being used
103+
await setLanguage("en");
104+
expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 person joined"`);
105+
expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 people joined"`);
106+
await setLanguage("de");
107+
expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 Person beigetreten"`);
108+
expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 Personen beigetreten"`);
109+
110+
await setupTranslationOverridesForTests({
111+
[str]: {
112+
en: enOverride,
113+
de: deOverride,
114+
},
115+
});
116+
117+
// Now test that they *are* being used
118+
await setLanguage("en");
119+
expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 person in the call"`);
120+
expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 people in the call"`);
121+
122+
await setLanguage("de");
123+
expect(_t(str, { count: 1 })).toMatchInlineSnapshot(`"1 Person im Anruf"`);
124+
expect(_t(str, { count: 5 })).toMatchInlineSnapshot(`"5 Personen im Anruf"`);
125+
});
126+
90127
describe("UserFriendlyError", () => {
91128
const testErrorMessage = "This email address is already in use (%(email)s)" as TranslationKey;
92129
beforeEach(async () => {

yarn.lock

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1916,10 +1916,10 @@
19161916
version "3.2.14"
19171917
resolved "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz#acd96c00a881d0f462e1f97a56c73742c8dbc984"
19181918

1919-
"@matrix-org/react-sdk-module-api@^2.1.0":
1920-
version "2.1.0"
1921-
resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.1.0.tgz#ca9d67853512fda1df2786810b90be31dd8dc7b1"
1922-
integrity sha512-SARD5BsmZYv1hvuezLfBUafJ9+rPLbk5WO0S3vZgkLH3jJQrk7f/65qBB5fLKF2ljprfZ1GTpuBeq04wn7Tnmg==
1919+
"@matrix-org/react-sdk-module-api@^2.1.1":
1920+
version "2.1.1"
1921+
resolved "https://registry.yarnpkg.com/@matrix-org/react-sdk-module-api/-/react-sdk-module-api-2.1.1.tgz#54e8617c15185010d608c0325ecaec8d1574d12b"
1922+
integrity sha512-dYPY3aXtNwPrg2aEmFeWddMdohus/Ha17XES2QH+WMCawt+hH+uq28jH1EmW1RUOOzxVcdY36lRGOwqRtAJbhA==
19231923
dependencies:
19241924
"@babel/runtime" "^7.17.9"
19251925

@@ -7676,14 +7676,15 @@ matrix-mock-request@^2.5.0:
76767676
dependencies:
76777677
expect "^28.1.0"
76787678

7679-
matrix-web-i18n@^2.1.0:
7680-
version "2.1.0"
7681-
resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-2.1.0.tgz#bab2db9ac462773de829053b4b8d43c11154a85b"
7682-
integrity sha512-z+B9D/PkWYB4O9SP4lsG4KNA2V3ypMWstP+lreft1c1wz6L5R1U3ennp+cs3yOsylBfcK+xLRvkwLNZsU6QEUA==
7679+
matrix-web-i18n@^3.1.3:
7680+
version "3.1.3"
7681+
resolved "https://registry.yarnpkg.com/matrix-web-i18n/-/matrix-web-i18n-3.1.3.tgz#b462015b138ebdd288ed945507abea42c896f52d"
7682+
integrity sha512-9JUUTifqS/Xe6YQr5uDbX04xvr5Pxg8aU7tRKx49/ZLqm4dZoJKo4SKpyLEwCQeNjAvjcKuXibWO+2hkZ2/Ojw==
76837683
dependencies:
76847684
"@babel/parser" "^7.18.5"
76857685
"@babel/traverse" "^7.18.5"
76867686
lodash "^4.17.21"
7687+
minimist "^1.2.8"
76877688
walk "^2.3.15"
76887689

76897690
matrix-widget-api@^1.5.0, matrix-widget-api@^1.6.0:

0 commit comments

Comments
 (0)