feat: translation
This commit is contained in:
parent
2cd82944e3
commit
75f7d088e4
3
.vscode/extensions.json
vendored
3
.vscode/extensions.json
vendored
|
@ -6,6 +6,7 @@
|
||||||
"ExodiusStudios.comment-anchors",
|
"ExodiusStudios.comment-anchors",
|
||||||
"formulahendry.auto-rename-tag",
|
"formulahendry.auto-rename-tag",
|
||||||
"GregorBiswanger.json2ts",
|
"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"
|
"watch": "node --require=./scripts/suppressExperimentalWarnings.js scripts/build/build.mjs --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@fluent/bundle": "^0.18.0",
|
||||||
|
"@fluent/langneg": "^0.7.0",
|
||||||
|
"@fluent/sequence": "^0.8.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",
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
lockfileVersion: '6.0'
|
lockfileVersion: '6.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
patchedDependencies:
|
patchedDependencies:
|
||||||
eslint-plugin-path-alias@1.0.0:
|
eslint-plugin-path-alias@1.0.0:
|
||||||
hash: m6sma4g6bh67km3q6igf6uxaja
|
hash: m6sma4g6bh67km3q6igf6uxaja
|
||||||
|
@ -9,6 +13,15 @@ patchedDependencies:
|
||||||
path: patches/eslint@8.46.0.patch
|
path: patches/eslint@8.46.0.patch
|
||||||
|
|
||||||
dependencies:
|
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':
|
'@sapphi-red/web-noise-suppressor':
|
||||||
specifier: 0.3.3
|
specifier: 0.3.3
|
||||||
version: 0.3.3
|
version: 0.3.3
|
||||||
|
@ -467,6 +480,25 @@ packages:
|
||||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
dev: true
|
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:
|
/@humanwhocodes/config-array@0.11.10:
|
||||||
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
|
resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==}
|
||||||
engines: {node: '>=10.10.0'}
|
engines: {node: '>=10.10.0'}
|
||||||
|
@ -3464,7 +3496,3 @@ packages:
|
||||||
name: gifenc
|
name: gifenc
|
||||||
version: 1.0.3
|
version: 1.0.3
|
||||||
dev: false
|
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}
|
* @type {import("esbuild").BuildOptions}
|
||||||
*/
|
*/
|
||||||
|
@ -216,8 +252,8 @@ export const commonOpts = {
|
||||||
sourcemap: watch ? "inline" : "",
|
sourcemap: watch ? "inline" : "",
|
||||||
legalComments: "linked",
|
legalComments: "linked",
|
||||||
banner,
|
banner,
|
||||||
plugins: [fileIncludePlugin, gitHashPlugin, gitRemotePlugin, stylePlugin],
|
plugins: [fileIncludePlugin, 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",
|
||||||
|
|
|
@ -145,4 +145,3 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}, { once: true });
|
}, { 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;
|
export default remote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare module "~translations" {
|
||||||
|
const translations: Record<string, Record<string, string>>;
|
||||||
|
export default translations;
|
||||||
|
}
|
||||||
|
|
||||||
declare module "~fileContent/*" {
|
declare module "~fileContent/*" {
|
||||||
const content: string;
|
const content: string;
|
||||||
export default content;
|
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