feat: translation v2

This commit is contained in:
Lewis Crichton 2024-06-01 11:48:57 +01:00
parent 2dc0c20462
commit c0111169b8
No known key found for this signature in database
10 changed files with 185 additions and 4 deletions

View file

@ -4,6 +4,7 @@
"EditorConfig.EditorConfig", "EditorConfig.EditorConfig",
"GregorBiswanger.json2ts", "GregorBiswanger.json2ts",
"stylelint.vscode-stylelint", "stylelint.vscode-stylelint",
"Vendicated.vencord-companion" "Vendicated.vencord-companion",
"lokalise.i18n-ally"
] ]
} }

10
.vscode/i18n-ally-custom-framework.yml vendored Normal file
View file

@ -0,0 +1,10 @@
languageIds:
- javascript
- typescript
- javascriptreact
- typescriptreact
usageMatchRegex:
- "[^\\w\\d]\\$t\\(['\"`]({key})['\"`]"
monopoly: true

View file

@ -19,5 +19,10 @@
"domain": "codeberg.org", "domain": "codeberg.org",
"type": "Gitea" "type": "Gitea"
} }
] ],
"i18n-ally.namespace": true,
"i18n-ally.localesPaths": ["./translations"],
"i18n-ally.sourceLanguage": "en",
"i18n-ally.extract.keygenStyle": "camelCase",
"i18n-ally.sortKeys": true
} }

View file

@ -37,6 +37,7 @@
"testTsc": "tsc --noEmit" "testTsc": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@fluent/langneg": "^0.7.0",
"@sapphi-red/web-noise-suppressor": "0.3.3", "@sapphi-red/web-noise-suppressor": "0.3.3",
"@vap/core": "0.0.12", "@vap/core": "0.0.12",
"@vap/shiki": "0.10.5", "@vap/shiki": "0.10.5",

View file

@ -16,6 +16,9 @@ importers:
.: .:
dependencies: dependencies:
'@fluent/langneg':
specifier: ^0.7.0
version: 0.7.0
'@sapphi-red/web-noise-suppressor': '@sapphi-red/web-noise-suppressor':
specifier: 0.3.3 specifier: 0.3.3
version: 0.3.3 version: 0.3.3
@ -385,6 +388,10 @@ packages:
resolution: {integrity: sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==} resolution: {integrity: sha512-a8TLtmPi8xzPkCbp/OGFUo5yhRkHM2Ko9kOWP4znJr0WAhWyThaw3PnwX4vOTWOAMsV2uRt32PPDcEz63esSaA==}
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} 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': '@humanwhocodes/config-array@0.11.10':
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
engines: {node: '>=10.10.0'} engines: {node: '>=10.10.0'}
@ -2661,6 +2668,8 @@ snapshots:
'@eslint/js@8.46.0': {} '@eslint/js@8.46.0': {}
'@fluent/langneg@0.7.0': {}
'@humanwhocodes/config-array@0.11.10': '@humanwhocodes/config-array@0.11.10':
dependencies: dependencies:
'@humanwhocodes/object-schema': 1.2.1 '@humanwhocodes/object-schema': 1.2.1

View file

@ -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} * @type {import("esbuild").BuildOptions}
*/ */
@ -260,8 +293,8 @@ export const commonOpts = {
sourcemap: watch ? "inline" : "", sourcemap: watch ? "inline" : "",
legalComments: "linked", legalComments: "linked",
banner, banner,
plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin], plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin, translationPlugin],
external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"], external: ["~plugins", "~git-hash", "~git-remote", "~translations", "/assets/*"],
inject: ["./scripts/build/inject/react.mjs"], inject: ["./scripts/build/inject/react.mjs"],
jsxFactory: "VencordCreateElement", jsxFactory: "VencordCreateElement",
jsxFragment: "VencordFragment", jsxFragment: "VencordFragment",

5
src/modules.d.ts vendored
View file

@ -38,6 +38,11 @@ declare module "~git-remote" {
export default remote; export default remote;
} }
declare module "~translations" {
const translations: Record<string, Record<string, any>>;
export default translations;
}
declare module "file://*" { declare module "file://*" {
const content: string; const content: string;
export default content; export default content;

111
src/utils/translation.ts Normal file
View file

@ -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<string, any>;
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<string, any>) {
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, any>): string {
const translation = _t(key, loadedLocale);
if (!variables) return translation;
return format(translation, variables);
}
$t("vencord.hello");

View file

@ -0,0 +1,3 @@
{
"hello": "Hallo {name}!"
}

View file

@ -0,0 +1,3 @@
{
"hello": "Hello {name}!"
}