feat: translation
This commit is contained in:
parent
2cd82944e3
commit
75f7d088e4
9 changed files with 196 additions and 8 deletions
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
|
@ -6,6 +6,7 @@
|
|||
"ExodiusStudios.comment-anchors",
|
||||
"formulahendry.auto-rename-tag",
|
||||
"GregorBiswanger.json2ts",
|
||||
"stylelint.vscode-stylelint"
|
||||
"stylelint.vscode-stylelint",
|
||||
"macabeus.vscode-fluent"
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -145,4 +145,3 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||
}));
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
|
|
5
src/modules.d.ts
vendored
5
src/modules.d.ts
vendored
|
@ -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;
|
||||
|
|
94
src/utils/translation.ts
Normal file
94
src/utils/translation.ts
Normal file
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { FluentBundle, FluentResource } 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");
|
||||
|
||||
/**
|
||||
* 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}`);
|
||||
|
||||
let localeCache: FluentBundle[] = [];
|
||||
let messageCache: Record<string, FluentBundle> = {};
|
||||
|
||||
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;
|
||||
|
||||
localeCache = [];
|
||||
messageCache = {};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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, any>): string {
|
||||
// adding the caching here speeds up retrieving translations for this key later
|
||||
if (messageCache[key]) {
|
||||
const bundle = messageCache[key];
|
||||
return bundle.formatPattern(bundle.getMessage(key)!.value!, variables);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
messageCache[key] = bundle;
|
||||
return bundle.formatPattern(message.value, variables);
|
||||
}
|
||||
|
||||
return "Could not get translation for " + key;
|
||||
};
|
||||
}
|
21
translations/en-US/glossary.ftl
Normal file
21
translations/en-US/glossary.ftl
Normal 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
|
1
translations/en-US/vencord.ftl
Normal file
1
translations/en-US/vencord.ftl
Normal file
|
@ -0,0 +1 @@
|
|||
hello = Hello my beautiful {$worldName}! And yes this works because {-vencord} is awesome!
|
Loading…
Reference in a new issue