Compare commits

...

6 commits

Author SHA1 Message Date
Lewis Crichton
969cf241a7
Merge branch 'dev' into feat/translation 2024-01-07 00:21:08 +00:00
Lewis Crichton
8c3aa368d1
Merge branch 'dev' into feat/translation 2023-12-27 16:40:12 +00:00
Lewis Crichton
d977ddc7f4
perf: use global caches so we only have one subscription 2023-12-22 16:29:20 +00:00
Lewis Crichton
88375f61c1
chore: delete leftover test 2023-12-22 16:23:45 +00:00
Lewis Crichton
06d630a4fb
chore: correct typings 2023-12-22 16:23:20 +00:00
Lewis Crichton
75f7d088e4
feat: translation 2023-12-15 11:38:24 +00:00
8 changed files with 207 additions and 7 deletions

View file

@ -6,6 +6,7 @@
"ExodiusStudios.comment-anchors",
"formulahendry.auto-rename-tag",
"GregorBiswanger.json2ts",
"stylelint.vscode-stylelint"
"stylelint.vscode-stylelint",
"macabeus.vscode-fluent"
]
}

View file

@ -31,6 +31,9 @@
"watch": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs --watch"
},
"dependencies": {
"@fluent/bundle": "^0.18.0",
"@fluent/langneg": "^0.7.0",
"@fluent/sequence": "^0.8.0",
"@sapphi-red/web-noise-suppressor": "0.3.3",
"@vap/core": "0.0.12",
"@vap/shiki": "0.10.5",

View file

@ -1,5 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
eslint-plugin-path-alias@1.0.0:
hash: m6sma4g6bh67km3q6igf6uxaja
@ -9,6 +13,15 @@ patchedDependencies:
path: patches/eslint@8.46.0.patch
dependencies:
'@fluent/bundle':
specifier: ^0.18.0
version: 0.18.0
'@fluent/langneg':
specifier: ^0.7.0
version: 0.7.0
'@fluent/sequence':
specifier: ^0.8.0
version: 0.8.0(@fluent/bundle@0.18.0)
'@sapphi-red/web-noise-suppressor':
specifier: 0.3.3
version: 0.3.3
@ -467,6 +480,25 @@ packages:
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
dev: true
/@fluent/bundle@0.18.0:
resolution: {integrity: sha512-8Wfwu9q8F9g2FNnv82g6Ch/E1AW1wwljsUOolH5NEtdJdv0sZTuWvfCM7c3teB9dzNaJA8rn4khpidpozHWYEA==}
engines: {node: '>=14.0.0', npm: '>=7.0.0'}
dev: false
/@fluent/langneg@0.7.0:
resolution: {integrity: sha512-StAM0vgsD1QK+nFikaKs9Rxe3JGNipiXrpmemNGwM4gWERBXPe9gjzsBoKjgBgq1Vyiy+xy/C652QIWY+MPyYw==}
engines: {node: '>=14.0.0', npm: '>=7.0.0'}
dev: false
/@fluent/sequence@0.8.0(@fluent/bundle@0.18.0):
resolution: {integrity: sha512-eV5QlEEVV/wR3AFQLXO67x4yPRPQXyqke0c8yucyMSeW36B3ecZyVFlY1UprzrfFV8iPJB4TAehDy/dLGbvQ1Q==}
engines: {node: '>=14.0.0', npm: '>=7.0.0'}
peerDependencies:
'@fluent/bundle': '>= 0.13.0'
dependencies:
'@fluent/bundle': 0.18.0
dev: false
/@humanwhocodes/config-array@0.11.10:
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
engines: {node: '>=10.10.0'}
@ -3464,7 +3496,3 @@ packages:
name: gifenc
version: 1.0.3
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View file

@ -205,6 +205,42 @@ 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(/\.ftl$/, "");
// we map this in reverse order to the file structure as it's more logical in the code to do it this
// way (translations are retrieved by bundle name, not locale, but on the fs it makes more sense to
// sort them by locale)
translations[name] ??= {};
translations[name][locale] = await readFile(`./translations/${locale}/${bundle}`, "utf-8");
}
}
return {
contents: `export default ${JSON.stringify(translations)}`,
};
});
}
};
/**
* @type {import("esbuild").BuildOptions}
*/
@ -216,8 +252,8 @@ export const commonOpts = {
sourcemap: watch ? "inline" : "",
legalComments: "linked",
banner,
plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin, stylePlugin],
external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"],
plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin, stylePlugin, translationPlugin],
external: ["~plugins", "~git-hash", "~git-remote", "~translations", "/assets/*"],
inject: ["./scripts/build/inject/react.mjs"],
jsxFactory: "VencordCreateElement",
jsxFragment: "VencordFragment",

5
src/modules.d.ts vendored
View file

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

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

@ -0,0 +1,105 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2023 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { FluentBundle, FluentResource, type FluentVariable } from "@fluent/bundle";
import { negotiateLanguages } from "@fluent/langneg";
import { mapBundleSync } from "@fluent/sequence";
import { FluxDispatcher, i18n } from "@webpack/common";
import translations from "~translations";
import { Logger } from "./Logger";
// same color as pontoon's logo
const logger = new Logger("Translations", "#7bc876");
let subscribed = false;
let bundleCache: Record<string, FluentBundle[]> = {};
let messageCache: Record<string, Record<string, FluentBundle>> = {};
/**
* Gets a function that translates strings.
* @param context The context to use for translation (e.g., `vencord`).
* @returns A function that allows translation.
*/
export function getTranslations(context: string) {
if (!translations[context]) throw new Error(`No translations for ${context}`);
if (!subscribed) {
let lastLocale = i18n.getLocale();
FluxDispatcher.subscribe("USER_SETTINGS_PROTO_UPDATE", ({ settings }) => {
if (settings.proto.localization.locale.value !== lastLocale) {
// locale was updated, clear our caches
lastLocale = settings.proto.localization.locale.value;
bundleCache = {};
messageCache = {};
}
});
subscribed = true;
}
/**
* Translates a key. Soft-fails and returns a fallback error string if the key could not be loaded.
* @param key The key to translate.
* @param variables The variables to interpolate into the resultant string.
* @returns A translated string.
*/
return function t(key: string, variables?: Record<string, FluentVariable>): string {
const msgCache = messageCache[context] ??= {};
// adding the caching here speeds up retrieving translations for this key later
if (msgCache[key]) {
const bundle = msgCache[key];
return bundle.formatPattern(bundle.getMessage(key)!.value!, variables);
}
const localeCache = bundleCache[context] ??= [];
// we've never loaded this context's translations
if (localeCache.length === 0) {
const availableLocales = Object.keys(translations[context]);
const locale = i18n.getLocale();
const supportedLocales = negotiateLanguages([locale], availableLocales, { defaultLocale: "en-US" });
for (const locale of supportedLocales) {
const glossaryResource = new FluentResource(translations.glossary[locale]);
const resource = new FluentResource(translations[context][locale]);
const fluentBundle = new FluentBundle(locale);
// the glossary is always loaded first
fluentBundle.addResource(glossaryResource);
const errors = fluentBundle.addResource(resource);
if (errors.length) {
logger.warn("Translations for", context, "in locale", locale, "loaded with errors:", errors);
}
localeCache.push(fluentBundle);
}
}
const bundle = mapBundleSync(localeCache, key);
if (!bundle) return "Could not get translation for " + key;
const message = bundle.getMessage(key);
if (message?.value) {
msgCache[key] = bundle;
return bundle.formatPattern(message.value, variables);
}
return "Could not get translation for " + key;
};
}

View file

@ -0,0 +1,21 @@
# The glossary contains commonly used or agreed translations for words. This is used to cut down on the amount of
# repeated strings shared between Vencord and plugins, and makes reusing them easy.
#
# Since this is a glossary for other translations and are loaded with every context, they are made into terms so that
# they cannot be used by developers directly, but rather need to be interpolated into messages. For example:
#
# vencord-appreciation = I love {-vencord}!
#
# is the correct way of using the `-vencord` term since `-vencord` is not accessible from the translation function.
#
# This glossary is the reference glossary. Since languages are complex, some glossaries may have a different set of
# facets or terms to make it more compatible with that language (not one size fits all after all!) and the appropriate
# translation files will need to account for that. Every language, however, should have at least a minimal glossary.
#
# For translators, if a glossary contains the word in the context you need it in, use the glossary. If it doesn't due to
# a grammatical issue, it is preferred to extend the glossary with a new facet for the context you need to use it in for
# future use in other translations. If you see a commonly repeated word or phrase that might benefit from being in the
# glossary, please open an issue on GitHub to discuss it since we need to look into moving it into the glossary for
# other languages as well.
-vencord = Vencord

View file

@ -0,0 +1 @@
hello = Hello my beautiful {$worldName}! And yes this works because {-vencord} is awesome!