From c0111169b890aee4610f6ce69ba598e2150fe79b Mon Sep 17 00:00:00 2001 From: Lewis Crichton Date: Sat, 1 Jun 2024 11:48:57 +0100 Subject: [PATCH] feat: translation v2 --- .vscode/extensions.json | 3 +- .vscode/i18n-ally-custom-framework.yml | 10 +++ .vscode/settings.json | 7 +- package.json | 1 + pnpm-lock.yaml | 9 ++ scripts/build/common.mjs | 37 ++++++++- src/modules.d.ts | 5 ++ src/utils/translation.ts | 111 +++++++++++++++++++++++++ translations/de/vencord.json | 3 + translations/en/vencord.json | 3 + 10 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 .vscode/i18n-ally-custom-framework.yml create mode 100644 src/utils/translation.ts create mode 100644 translations/de/vencord.json create mode 100644 translations/en/vencord.json diff --git a/.vscode/extensions.json b/.vscode/extensions.json index e86effb19..f1e629106 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -4,6 +4,7 @@ "EditorConfig.EditorConfig", "GregorBiswanger.json2ts", "stylelint.vscode-stylelint", - "Vendicated.vencord-companion" + "Vendicated.vencord-companion", + "lokalise.i18n-ally" ] } diff --git a/.vscode/i18n-ally-custom-framework.yml b/.vscode/i18n-ally-custom-framework.yml new file mode 100644 index 000000000..5c68edc42 --- /dev/null +++ b/.vscode/i18n-ally-custom-framework.yml @@ -0,0 +1,10 @@ +languageIds: + - javascript + - typescript + - javascriptreact + - typescriptreact + +usageMatchRegex: + - "[^\\w\\d]\\$t\\(['\"`]({key})['\"`]" + +monopoly: true diff --git a/.vscode/settings.json b/.vscode/settings.json index fa543b38c..87978a345 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,5 +19,10 @@ "domain": "codeberg.org", "type": "Gitea" } - ] + ], + "i18n-ally.namespace": true, + "i18n-ally.localesPaths": ["./translations"], + "i18n-ally.sourceLanguage": "en", + "i18n-ally.extract.keygenStyle": "camelCase", + "i18n-ally.sortKeys": true } diff --git a/package.json b/package.json index 01fe3552b..ee836f733 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "testTsc": "tsc --noEmit" }, "dependencies": { + "@fluent/langneg": "^0.7.0", "@sapphi-red/web-noise-suppressor": "0.3.3", "@vap/core": "0.0.12", "@vap/shiki": "0.10.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b03585799..d966cd2f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: .: dependencies: + '@fluent/langneg': + specifier: ^0.7.0 + version: 0.7.0 '@sapphi-red/web-noise-suppressor': specifier: 0.3.3 version: 0.3.3 @@ -385,6 +388,10 @@ packages: resolution: {integrity: sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@fluent/langneg@0.7.0': + resolution: {integrity: sha512-StAM0vgsD1QK+nFikaKs9Rxe3JGNipiXrpmemNGwM4gWERBXPe9gjzsBoKjgBgq1Vyiy+xy/C652QIWY+MPyYw==} + engines: {node: '>=14.0.0', npm: '>=7.0.0'} + '@humanwhocodes/config-array@0.11.10': resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} engines: {node: '>=10.10.0'} @@ -2661,6 +2668,8 @@ snapshots: '@eslint/js@8.46.0': {} + '@fluent/langneg@0.7.0': {} + '@humanwhocodes/config-array@0.11.10': dependencies: '@humanwhocodes/object-schema': 1.2.1 diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index cdbb26eec..c839e6272 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -249,6 +249,39 @@ export const stylePlugin = { } }; +/** + * @type {import("esbuild").Plugin} + */ +export const translationPlugin = { + name: "translation-plugin", + setup: ({ onResolve, onLoad }) => { + const filter = /^~translations$/; + + onResolve({ filter }, ({ path }) => ({ + namespace: "translations", path + })); + onLoad({ filter, namespace: "translations" }, async () => { + const translations = {}; + const locales = await readdir("./translations"); + + for (const locale of locales) { + const translationBundles = await readdir(`./translations/${locale}`); + + for (const bundle of translationBundles) { + const name = bundle.replace(/\.json$/, ""); + + translations[locale] ??= {}; + translations[locale][name] = JSON.parse(await readFile(`./translations/${locale}/${bundle}`, "utf-8")); + } + } + + return { + contents: `export default ${JSON.stringify(translations)}`, + }; + }); + } +}; + /** * @type {import("esbuild").BuildOptions} */ @@ -260,8 +293,8 @@ export const commonOpts = { sourcemap: watch ? "inline" : "", legalComments: "linked", banner, - plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin], - external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"], + plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin, translationPlugin], + external: ["~plugins", "~git-hash", "~git-remote", "~translations", "/assets/*"], inject: ["./scripts/build/inject/react.mjs"], jsxFactory: "VencordCreateElement", jsxFragment: "VencordFragment", diff --git a/src/modules.d.ts b/src/modules.d.ts index 83a512b00..76e94a214 100644 --- a/src/modules.d.ts +++ b/src/modules.d.ts @@ -38,6 +38,11 @@ declare module "~git-remote" { export default remote; } +declare module "~translations" { + const translations: Record>; + export default translations; +} + declare module "file://*" { const content: string; export default content; diff --git a/src/utils/translation.ts b/src/utils/translation.ts new file mode 100644 index 000000000..0fbd7314b --- /dev/null +++ b/src/utils/translation.ts @@ -0,0 +1,111 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { negotiateLanguages } from "@fluent/langneg"; +import { FluxDispatcher, i18n } from "@webpack/common"; + +import translations from "~translations"; + +import { Logger } from "./Logger"; + +const logger = new Logger("Translations", "#7bc876"); + +let loadedLocale: Record; + +let lastDiscordLocale = i18n.getLocale(); +let bestLocale: string; + +FluxDispatcher.subscribe("USER_SETTINGS_PROTO_UPDATE", ({ settings }) => { + if (settings.proto.localization.locale.value !== lastDiscordLocale) { + lastDiscordLocale = settings.proto.localization.locale.value; + + reloadLocale(); + } +}); + +reloadLocale(); + +function reloadLocale() { + // finds the best locale based on the available ones + bestLocale = negotiateLanguages( + [lastDiscordLocale], + Object.keys(translations), + { + defaultLocale: "en", + strategy: "lookup", + } + )[0]; + + loadedLocale = translations[bestLocale]; + + logger.info("Changed locale to", bestLocale); +} + +// derived from stackoverflow's string formatting function +function format(source: string, variables: Record) { + for (const key in variables) { + let formatted: string; + + switch (typeof variables[key]) { + case "number": { + formatted = new Intl.NumberFormat(bestLocale).format(variables[key]); + break; + } + + default: { + formatted = variables[key].toString(); + break; + } + } + + source = source.replace( + new RegExp(`\\{${key}\\}`, "gi"), + formatted + ); + } + + return source; +} + +// converts a dot-notation path to an object value +function getByPath(key: string, object: any) { + try { + return key.split(".").reduce((obj, key) => obj[key], object); + } catch { + // errors if the object doesn't contain the key + return undefined; + } +} + +// translation retrieval function +function _t(key: string, bundle: any): string { + const translation = getByPath(key, bundle); + + if (!translation) { + if (bundle !== translations.en) { + return _t(key, translations.en); + } else { + return key; + } + } + + return translation; +} + +/** + * Translates a key. Soft-fails and returns the key if it is not valid. + * @param key The key to translate. + * @param variables The variables to interpolate into the resultant string. + * @returns A translated string. + */ +export function $t(key: string, variables?: Record): string { + const translation = _t(key, loadedLocale); + + if (!variables) return translation; + return format(translation, variables); +} + +$t("vencord.hello"); diff --git a/translations/de/vencord.json b/translations/de/vencord.json new file mode 100644 index 000000000..e0eb9c77a --- /dev/null +++ b/translations/de/vencord.json @@ -0,0 +1,3 @@ +{ + "hello": "Hallo {name}!" +} diff --git a/translations/en/vencord.json b/translations/en/vencord.json new file mode 100644 index 000000000..0ebe93014 --- /dev/null +++ b/translations/en/vencord.json @@ -0,0 +1,3 @@ +{ + "hello": "Hello {name}!" +}