diff --git a/.vscode/extensions.json b/.vscode/extensions.json index f16f1e273..ce51cd4cf 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -6,6 +6,7 @@ "ExodiusStudios.comment-anchors", "formulahendry.auto-rename-tag", "GregorBiswanger.json2ts", - "stylelint.vscode-stylelint" + "stylelint.vscode-stylelint", + "macabeus.vscode-fluent" ] } diff --git a/package.json b/package.json index b59fbafe7..8e10f3bb2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43866f50b..e21560f3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index 5c34ad038..5f3598ea5 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -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", diff --git a/src/Vencord.ts b/src/Vencord.ts index a106a0b7d..24a7681b7 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -145,4 +145,3 @@ document.addEventListener("DOMContentLoaded", () => { })); } }, { once: true }); - diff --git a/src/modules.d.ts b/src/modules.d.ts index 24f34664d..e30ae4b42 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 "~fileContent/*" { const content: string; export default content; diff --git a/src/utils/translation.ts b/src/utils/translation.ts new file mode 100644 index 000000000..d59ddf6fc --- /dev/null +++ b/src/utils/translation.ts @@ -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 = {}; + + 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 { + // 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; + }; +} diff --git a/translations/en-US/glossary.ftl b/translations/en-US/glossary.ftl new file mode 100644 index 000000000..af29f78e4 --- /dev/null +++ b/translations/en-US/glossary.ftl @@ -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 diff --git a/translations/en-US/vencord.ftl b/translations/en-US/vencord.ftl new file mode 100644 index 000000000..8daaba109 --- /dev/null +++ b/translations/en-US/vencord.ftl @@ -0,0 +1 @@ +hello = Hello my beautiful {$worldName}! And yes this works because {-vencord} is awesome!