diff --git a/i18n/en-US/translation.json b/i18n/en-US/translation.json new file mode 100644 index 000000000..b51c4dd25 --- /dev/null +++ b/i18n/en-US/translation.json @@ -0,0 +1,10 @@ +{ + "description": "Assists with translating Vencord", + "settings": { + "forceLocale": { + "description": "Force a different locale for Vencord to use", + "followDiscord": "Default (follow Discord)" + }, + "showMissingKeys": "If the current locale is missing translations, show the keys instead of falling back to English" + } +} \ No newline at end of file diff --git a/i18n/en-US/vencord.json b/i18n/en-US/vencord.json new file mode 100644 index 000000000..027b1d055 --- /dev/null +++ b/i18n/en-US/vencord.json @@ -0,0 +1,97 @@ +{ + "addonCard": { + "new": "NEW" + }, + "cloud": { + "integrations": { + "header": "Cloud Integrations", + "reauthenticateOtherClient": "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will need to re-authenticate to continue using them. Click here to go to the settings page to do so!" + }, + "settings": { + "header": "Cloud Settings", + "updatedRemote": "Your settings have been updated! Click here to restart to fully apply changes!" + } + }, + "contributorCard": { + "contributionsButton": "See what you've contributed to", + "description": "Since you've contributed to Equicord you now have a cool new badge!", + "subtitle": "Thank you for contributing!", + "title": "Contributions" + }, + "donate": "Donate", + "donorCard": { + "donated": { + "vencordDescription": "All Vencord users can see your badge! You can manage your perks by messaging @{v}.", + "equicordDescription": "All Equicord users can see your badge! You can manage your perks by making a ticket in Equicord's server.", + "description": "All Vencord users can see your Vencord donor badge, and Equicord users can see your Equicord donor badge. To change your Vencord donor badge, contact @vending.machine. For your Equicord donor badge, make a ticket in Equicord's server..", + "subtitle": "Thank you for donating!", + "title": "Donations" + }, + "notDonated": { + "description": "Please consider supporting the development of Equicord by donating!", + "title": "Support the Project" + } + }, + "failedToRender": "Failed to render the {tab} tab. If this issue persists, try using the installer to reinstall!", + "quickActions": { + "editQuickCSS": "Edit QuickCSS", + "notificationLog": "Notification Log", + "openSettings": "Open Settings Folder", + "relaunchDiscord": "Relaunch Discord", + "title": "Quick Actions", + "viewSource": "View Source Code" + }, + "settings": { + "disableMinSize": { + "title": "Disable minimum window size" + }, + "enableReactDevtools": { + "title": "Enable React Developer Tools" + }, + "frameless": { + "title": "Disable the window frame" + }, + "hint": "Hint: You can change the position of this settings section in the <0>settings of the Settings plugin!", + "macosVibrancyStyle": { + "placeholder": "Window vibrancy style", + "title": "Window vibrancy style (requires restart)" + }, + "notification": { + "logButton": "View Notification Log", + "settingsButton": "Notification Settings" + }, + "plugins": { + "contributorModal": { + "contributorInfo": { + "noPlugins": "This person has not made any plugins. They likely <0>contributed to Equicord in other ways!", + "plugins_one": "This person has <0>contributed to {count} plugin!", + "plugins_other": "This person has <0>contributed to {count} plugins!" + } + } + }, + "restartRequired": { + "body": "A restart is required to apply this change", + "cancel": "Later!", + "confirm": "Restart now", + "title": "Restart Required" + }, + "title": "Settings", + "transparent": { + "description": "You need a theme that supports transparency or this will do nothing. WILL STOP THE WINDOW FROM BEING RESIZABLE!! Requires a full restart", + "title": "Enable window transparency" + }, + "useQuickCss": { + "description": "Loads your Custom CSS", + "title": "Enable Custom CSS" + }, + "winCtrlQ": { + "title": "Register Ctrl+Q as shortcut to close Discord (alternative to Alt+F4)" + }, + "winNativeTitleBar": { + "title": "Use Windows' native title bar instead of Discord's custom one" + } + }, + "tabs": { + "settings": "Equicord Settings" + } +} \ No newline at end of file diff --git a/package.json b/package.json index df95c1c8b..ff4ddc402 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "dependencies": { "@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/util": "^0.12.1", + "@fluent/langneg": "^0.7.0", "@intrnl/xxhash64": "^0.1.2", "@types/less": "^3.0.8", "@types/stylus": "^0.48.42", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e4b4ce56..5e383cd57 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,6 +22,9 @@ importers: '@ffmpeg/util': specifier: ^0.12.1 version: 0.12.1 + '@fluent/langneg': + specifier: ^0.7.0 + version: 0.7.0 '@intrnl/xxhash64': specifier: ^0.1.2 version: 0.1.2 @@ -630,6 +633,10 @@ packages: resolution: {integrity: sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==} engines: {node: '>=18.x'} + '@fluent/langneg@0.7.0': + resolution: {integrity: sha512-StAM0vgsD1QK+nFikaKs9Rxe3JGNipiXrpmemNGwM4gWERBXPe9gjzsBoKjgBgq1Vyiy+xy/C652QIWY+MPyYw==} + engines: {node: '>=14.0.0', npm: '>=7.0.0'} + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -2889,6 +2896,8 @@ snapshots: '@ffmpeg/util@0.12.1': {} + '@fluent/langneg@0.7.0': {} + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.6': diff --git a/scripts/build/common.mjs b/scripts/build/common.mjs index da858e2ec..44ec58f2d 100644 --- a/scripts/build/common.mjs +++ b/scripts/build/common.mjs @@ -343,6 +343,41 @@ 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("./i18n"); + + for (const locale of locales) { + const translationBundles = await readdir(`./i18n/${locale}`); + + for (const bundle of translationBundles) { + const name = bundle.replace(/\.json$/, ""); + + translations[locale] ??= {}; + translations[locale][name] = JSON.parse( + await readFile(`./i18n/${locale}/${bundle}`, "utf-8") + ); + } + } + + return { + contents: `export default ${JSON.stringify(translations)}`, + }; + }); + } +}; + /** * @type {import("esbuild").BuildOptions} */ @@ -353,8 +388,20 @@ export const commonOpts = { sourcemap: watch ? "inline" : "external", legalComments: "linked", banner, - plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin], - external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"], + plugins: [ + fileUrlPlugin, + gitHashPlugin, + gitRemotePlugin, + stylePlugin, + translationPlugin, + ], + external: [ + "~plugins", + "~git-hash", + "~git-remote", + "~translations", + "/assets/*", + ], inject: [join(dirname(fileURLToPath(import.meta.url)), "inject/react.mjs")], jsx: "transform", jsxFactory: "VencordCreateElement", diff --git a/scripts/utils.ts b/scripts/utils.ts index 701919c8a..607edbfc2 100644 --- a/scripts/utils.ts +++ b/scripts/utils.ts @@ -20,7 +20,7 @@ import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs"; import { access, readFile } from "fs/promises"; import { join, sep } from "path"; import { normalize as posixNormalize, sep as posixSep } from "path/posix"; -import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, PropertyAssignment, ScriptTarget, StringLiteral, SyntaxKind } from "typescript"; +import { BigIntLiteral, CallExpression, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, LiteralExpression, NamedDeclaration, Node, NodeArray, ObjectLiteralExpression, PropertyAssignment, ScriptTarget, StringLiteral, SyntaxKind } from "typescript"; import { getPluginTarget } from "./utils.mjs"; @@ -130,6 +130,38 @@ export function parseEquicordDevs() { throw new Error("Could not find EquicordDevs constant"); } +function isTranslationExpression(node: Node): node is CallExpression { + if (!isCallExpression(node)) return false; + + const literal = node.expression as LiteralExpression; + + if (literal.text !== "t") return false; + + return true; +} + +async function getTranslation(node: Node): Promise { + if (!isTranslationExpression(node)) return null; + + const translationString = node.arguments[0]; + + if (!isStringLiteral(translationString)) return null; + + const splitPath = translationString.text.split("."); + const namespace = splitPath.shift(); + const path = splitPath.join("."); + + // load the namespace + const bundle = JSON.parse( + await readFile(`./i18n/en/${namespace}.json`, "utf-8") + ); + + const dotProp = (key: string, object: any) => + key.split(".").reduce((obj, key) => obj?.[key], object); + + return dotProp(path, bundle); +} + export async function parseFile(fileName: string) { const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest); @@ -162,8 +194,11 @@ export async function parseFile(fileName: string) { switch (key) { case "name": case "description": - if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`); - data[key] = value.text; + if (isStringLiteral(value)) + data[key] = value.text; + else if (isTranslationExpression(value)) + data[key] = (await getTranslation(value))!; + else throw fail(`${key} is not a string literal or a translation function call`); break; case "patches": data.hasPatches = true; diff --git a/src/Vencord.ts b/src/Vencord.ts index 3f1576f69..71ec368bd 100644 --- a/src/Vencord.ts +++ b/src/Vencord.ts @@ -33,6 +33,7 @@ import { openSettingsTabModal, UpdaterTab } from "@components/settings"; import { debounce } from "@shared/debounce"; import { IS_WINDOWS } from "@utils/constants"; import { createAndAppendStyle } from "@utils/css"; +import { t } from "@utils/translation"; import { StartAt } from "@utils/types"; import { get as dsGet } from "./api/DataStore"; @@ -76,9 +77,8 @@ async function syncSettings() { ) { // show a notification letting them know and tell them how to fix it showNotification({ - title: "Cloud Integrations", - body: "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will " + - "need to re-authenticate to continue using them. Click here to go to the settings page to do so!", + title: t("vencord.cloud.integrations.header"), + body: t("vencord.cloud.integrations.reauthenticateOtherClient"), color: "var(--yellow-360)", onClick: () => SettingsRouter.open("EquicordCloud") }); @@ -99,8 +99,8 @@ async function syncSettings() { // there was an error to notify the user, but besides that we only want to show one notification instead of all // of the possible ones it has (such as when your settings are newer). showNotification({ - title: "Cloud Settings", - body: "Your settings have been updated! Click here to restart to fully apply changes!", + title: t("vencord.cloud.settings.header"), + body: t("vencord.cloud.settings.updatedRemote"), color: "var(--green-360)", onClick: relaunch }); diff --git a/src/components/ThemeSettings/ThemesTab.tsx b/src/components/ThemeSettings/ThemesTab.tsx index 279b9d862..001541216 100644 --- a/src/components/ThemeSettings/ThemesTab.tsx +++ b/src/components/ThemeSettings/ThemesTab.tsx @@ -41,6 +41,7 @@ import { useAwaiter, useForceUpdater } from "@utils/react"; import type { ThemeHeader } from "@utils/themes"; import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd"; import { usercssParse } from "@utils/themes/usercss"; +import { t } from "@utils/translation"; import { getStylusWebStoreUrl } from "@utils/web"; import { findLazy } from "@webpack"; import { Alerts, Button, React, showToast, TabBar, TextInput, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common"; @@ -573,10 +574,10 @@ export function CspErrorCard() { forceUpdate(); Alerts.show({ - title: "Restart Required", - body: "A restart is required to apply this change", - confirmText: "Restart now", - cancelText: "Later!", + title: t("vencord.settings.restartRequired.title"), + body: t("vencord.settings.restartRequired.body"), + confirmText: t("vencord.settings.restartRequired.confirm"), + cancelText: t("vencord.settings.restartRequired.cancel"), onConfirm: relaunch }); }; diff --git a/src/components/settings/AddonCard.tsx b/src/components/settings/AddonCard.tsx index b7acd68b5..f4ed00bcf 100644 --- a/src/components/settings/AddonCard.tsx +++ b/src/components/settings/AddonCard.tsx @@ -22,6 +22,7 @@ import { classNameFactory } from "@api/Styles"; import { BaseText } from "@components/BaseText"; import { AddonBadge } from "@components/settings/PluginBadge"; import { Switch } from "@components/Switch"; +import { t } from "@utils/translation"; import { Tooltip, useRef } from "@webpack/common"; import type { MouseEventHandler, ReactNode } from "react"; @@ -75,7 +76,7 @@ export function AddonCard({ disabled, isNew, sourceBadge, tooltip, name, infoBut {name} - {isNew && } + {isNew && } {!!author && ( diff --git a/src/components/settings/DonateButton.tsx b/src/components/settings/DonateButton.tsx index eb2a91e25..07b43db40 100644 --- a/src/components/settings/DonateButton.tsx +++ b/src/components/settings/DonateButton.tsx @@ -18,43 +18,26 @@ import { Heart } from "@components/Heart"; import { openInviteModal } from "@utils/discord"; +import { t } from "@utils/translation"; import { ButtonProps } from "@vencord/discord-types"; import { Button, showToast } from "@webpack/common"; -export function VCDonateButton({ - look = Button.Looks.LINK, - color = Button.Colors.TRANSPARENT, - ...props -}: Partial) { - return ( - - ); -} - export function DonateButton({ look = Button.Looks.LINK, color = Button.Colors.TRANSPARENT, + link, ...props -}: Partial) { +}: Partial & { link: string; }) { return ( ); } @@ -77,7 +60,7 @@ export function InviteButton({ }} className="vc-donate-button" > - Invite + {t("vencord.donate")} ); } diff --git a/src/components/settings/tabs/BaseTab.tsx b/src/components/settings/tabs/BaseTab.tsx index 5d9914c3d..b61682641 100644 --- a/src/components/settings/tabs/BaseTab.tsx +++ b/src/components/settings/tabs/BaseTab.tsx @@ -22,6 +22,7 @@ import { handleComponentFailed } from "@components/handleComponentFailed"; import { Margins } from "@utils/margins"; import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { onlyOnce } from "@utils/onlyOnce"; +import { t } from "@utils/translation"; import type { ComponentType, PropsWithChildren } from "react"; export function SettingsTab({ title, children }: PropsWithChildren<{ title: string; }>) { @@ -38,7 +39,7 @@ export const handleSettingsTabError = onlyOnce(handleComponentFailed); export function wrapTab(component: ComponentType, tab: string) { const wrapped = ErrorBoundary.wrap(component, { displayName: `${tab}SettingsTab`, - message: `Failed to render the ${tab} tab. If this issue persists, try using the installer to reinstall!`, + message: t("vencord.failedToRender", { tab }), onError: handleSettingsTabError, }); diff --git a/src/components/settings/tabs/plugins/ContributorModal.tsx b/src/components/settings/tabs/plugins/ContributorModal.tsx index 8bdfbbd13..dfa5ce347 100644 --- a/src/components/settings/tabs/plugins/ContributorModal.tsx +++ b/src/components/settings/tabs/plugins/ContributorModal.tsx @@ -11,11 +11,11 @@ import { classNameFactory } from "@api/Styles"; import ErrorBoundary from "@components/ErrorBoundary"; import { HeadingPrimary } from "@components/Heading"; import { Link } from "@components/Link"; -import { Paragraph } from "@components/Paragraph"; import { EquicordDevsById, VencordDevsById } from "@utils/constants"; import { fetchUserProfile } from "@utils/discord"; -import { classes, pluralise } from "@utils/misc"; +import { classes } from "@utils/misc"; import { ModalContent, ModalRoot, openModal } from "@utils/modal"; +import { Translate } from "@utils/translation"; import { User } from "@vencord/discord-types"; import { showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common"; @@ -65,7 +65,7 @@ function ContributorModal({ user }: { user: User; }) { .sort((a, b) => Number(a.required ?? false) - Number(b.required ?? false)); }, [user.id, user.username]); - const ContributedHyperLink = contributed; + const ContributedHyperLink = contributed; return ( <> @@ -94,13 +94,16 @@ function ContributorModal({ user }: { user: User; }) { {plugins.length ? ( - - {user.username} has {ContributedHyperLink} to {pluralise(plugins.length, "plugin")}! - + + This person has {ContributedHyperLink} to some plugins! + ) : ( - - {user.username} has not made any plugins. They likely {ContributedHyperLink} in other ways! - + + This person has not made any plugins. They likely {ContributedHyperLink} + to Vencord in other ways! + )} {!!plugins.length && ( diff --git a/src/components/settings/tabs/vencord/MacVibrancySettings.tsx b/src/components/settings/tabs/vencord/MacVibrancySettings.tsx index dfa116246..015f27588 100644 --- a/src/components/settings/tabs/vencord/MacVibrancySettings.tsx +++ b/src/components/settings/tabs/vencord/MacVibrancySettings.tsx @@ -9,6 +9,7 @@ import ErrorBoundary from "@components/ErrorBoundary"; import { Heading } from "@components/Heading"; import { Margins } from "@utils/margins"; import { identity } from "@utils/misc"; +import { t } from "@utils/translation"; import { Select } from "@webpack/common"; export function VibrancySettings() { @@ -16,11 +17,11 @@ export function VibrancySettings() { return ( <> - Window vibrancy style (requires restart) + {t("vencord.settings.macosVibrancyStyle.title")}