This commit is contained in:
thororen1234 2025-11-26 12:33:51 -05:00
parent 2f23da64a0
commit e38ee7aa9c
No known key found for this signature in database
19 changed files with 557 additions and 93 deletions

View file

@ -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"
}
}

97
i18n/en-US/vencord.json Normal file
View file

@ -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</0>!",
"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</0> to Equicord in other ways!",
"plugins_one": "This person has <0>contributed</0> to {count} plugin!",
"plugins_other": "This person has <0>contributed</0> 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"
}
}

View file

@ -41,6 +41,7 @@
"dependencies": { "dependencies": {
"@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/ffmpeg": "^0.12.10",
"@ffmpeg/util": "^0.12.1", "@ffmpeg/util": "^0.12.1",
"@fluent/langneg": "^0.7.0",
"@intrnl/xxhash64": "^0.1.2", "@intrnl/xxhash64": "^0.1.2",
"@types/less": "^3.0.8", "@types/less": "^3.0.8",
"@types/stylus": "^0.48.42", "@types/stylus": "^0.48.42",

9
pnpm-lock.yaml generated
View file

@ -22,6 +22,9 @@ importers:
'@ffmpeg/util': '@ffmpeg/util':
specifier: ^0.12.1 specifier: ^0.12.1
version: 0.12.1 version: 0.12.1
'@fluent/langneg':
specifier: ^0.7.0
version: 0.7.0
'@intrnl/xxhash64': '@intrnl/xxhash64':
specifier: ^0.1.2 specifier: ^0.1.2
version: 0.1.2 version: 0.1.2
@ -630,6 +633,10 @@ packages:
resolution: {integrity: sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==} resolution: {integrity: sha512-10jjfAKWaDyb8+nAkijcsi9wgz/y26LOc1NKJradNMyCIl6usQcBbhkjX5qhALrSBcOy6TOeksunTYa+a03qNQ==}
engines: {node: '>=18.x'} 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': '@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'} engines: {node: '>=18.18.0'}
@ -2889,6 +2896,8 @@ snapshots:
'@ffmpeg/util@0.12.1': {} '@ffmpeg/util@0.12.1': {}
'@fluent/langneg@0.7.0': {}
'@humanfs/core@0.19.1': {} '@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.6': '@humanfs/node@0.16.6':

View file

@ -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} * @type {import("esbuild").BuildOptions}
*/ */
@ -353,8 +388,20 @@ export const commonOpts = {
sourcemap: watch ? "inline" : "external", sourcemap: watch ? "inline" : "external",
legalComments: "linked", legalComments: "linked",
banner, banner,
plugins: [fileUrlPlugin, gitHashPlugin, gitRemotePlugin, stylePlugin], plugins: [
external: ["~plugins", "~git-hash", "~git-remote", "/assets/*"], fileUrlPlugin,
gitHashPlugin,
gitRemotePlugin,
stylePlugin,
translationPlugin,
],
external: [
"~plugins",
"~git-hash",
"~git-remote",
"~translations",
"/assets/*",
],
inject: [join(dirname(fileURLToPath(import.meta.url)), "inject/react.mjs")], inject: [join(dirname(fileURLToPath(import.meta.url)), "inject/react.mjs")],
jsx: "transform", jsx: "transform",
jsxFactory: "VencordCreateElement", jsxFactory: "VencordCreateElement",

View file

@ -20,7 +20,7 @@ import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs";
import { access, readFile } from "fs/promises"; import { access, readFile } from "fs/promises";
import { join, sep } from "path"; import { join, sep } from "path";
import { normalize as posixNormalize, sep as posixSep } from "path/posix"; 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"; import { getPluginTarget } from "./utils.mjs";
@ -130,6 +130,38 @@ export function parseEquicordDevs() {
throw new Error("Could not find EquicordDevs constant"); 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<string | null> {
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) { export async function parseFile(fileName: string) {
const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest); const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest);
@ -162,8 +194,11 @@ export async function parseFile(fileName: string) {
switch (key) { switch (key) {
case "name": case "name":
case "description": case "description":
if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`); if (isStringLiteral(value))
data[key] = value.text; 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; break;
case "patches": case "patches":
data.hasPatches = true; data.hasPatches = true;

View file

@ -33,6 +33,7 @@ import { openSettingsTabModal, UpdaterTab } from "@components/settings";
import { debounce } from "@shared/debounce"; import { debounce } from "@shared/debounce";
import { IS_WINDOWS } from "@utils/constants"; import { IS_WINDOWS } from "@utils/constants";
import { createAndAppendStyle } from "@utils/css"; import { createAndAppendStyle } from "@utils/css";
import { t } from "@utils/translation";
import { StartAt } from "@utils/types"; import { StartAt } from "@utils/types";
import { get as dsGet } from "./api/DataStore"; 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 // show a notification letting them know and tell them how to fix it
showNotification({ showNotification({
title: "Cloud Integrations", title: t("vencord.cloud.integrations.header"),
body: "We've noticed you have cloud integrations enabled in another client! Due to limitations, you will " + body: t("vencord.cloud.integrations.reauthenticateOtherClient"),
"need to re-authenticate to continue using them. Click here to go to the settings page to do so!",
color: "var(--yellow-360)", color: "var(--yellow-360)",
onClick: () => SettingsRouter.open("EquicordCloud") 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 // 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). // of the possible ones it has (such as when your settings are newer).
showNotification({ showNotification({
title: "Cloud Settings", title: t("vencord.cloud.settings.header"),
body: "Your settings have been updated! Click here to restart to fully apply changes!", body: t("vencord.cloud.settings.updatedRemote"),
color: "var(--green-360)", color: "var(--green-360)",
onClick: relaunch onClick: relaunch
}); });

View file

@ -41,6 +41,7 @@ import { useAwaiter, useForceUpdater } from "@utils/react";
import type { ThemeHeader } from "@utils/themes"; import type { ThemeHeader } from "@utils/themes";
import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd"; import { getThemeInfo, stripBOM, type UserThemeHeader } from "@utils/themes/bd";
import { usercssParse } from "@utils/themes/usercss"; import { usercssParse } from "@utils/themes/usercss";
import { t } from "@utils/translation";
import { getStylusWebStoreUrl } from "@utils/web"; import { getStylusWebStoreUrl } from "@utils/web";
import { findLazy } from "@webpack"; import { findLazy } from "@webpack";
import { Alerts, Button, React, showToast, TabBar, TextInput, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common"; import { Alerts, Button, React, showToast, TabBar, TextInput, Tooltip, useEffect, useMemo, useRef, useState } from "@webpack/common";
@ -573,10 +574,10 @@ export function CspErrorCard() {
forceUpdate(); forceUpdate();
Alerts.show({ Alerts.show({
title: "Restart Required", title: t("vencord.settings.restartRequired.title"),
body: "A restart is required to apply this change", body: t("vencord.settings.restartRequired.body"),
confirmText: "Restart now", confirmText: t("vencord.settings.restartRequired.confirm"),
cancelText: "Later!", cancelText: t("vencord.settings.restartRequired.cancel"),
onConfirm: relaunch onConfirm: relaunch
}); });
}; };

View file

@ -22,6 +22,7 @@ import { classNameFactory } from "@api/Styles";
import { BaseText } from "@components/BaseText"; import { BaseText } from "@components/BaseText";
import { AddonBadge } from "@components/settings/PluginBadge"; import { AddonBadge } from "@components/settings/PluginBadge";
import { Switch } from "@components/Switch"; import { Switch } from "@components/Switch";
import { t } from "@utils/translation";
import { Tooltip, useRef } from "@webpack/common"; import { Tooltip, useRef } from "@webpack/common";
import type { MouseEventHandler, ReactNode } from "react"; import type { MouseEventHandler, ReactNode } from "react";
@ -75,7 +76,7 @@ export function AddonCard({ disabled, isNew, sourceBadge, tooltip, name, infoBut
{name} {name}
</div> </div>
</div> </div>
{isNew && <AddonBadge text="NEW" color="#ED4245" />} {isNew && <AddonBadge text={t("vencord.addonCard.new")} color="#ED4245" />}
</BaseText> </BaseText>
{!!author && ( {!!author && (

View file

@ -18,43 +18,26 @@
import { Heart } from "@components/Heart"; import { Heart } from "@components/Heart";
import { openInviteModal } from "@utils/discord"; import { openInviteModal } from "@utils/discord";
import { t } from "@utils/translation";
import { ButtonProps } from "@vencord/discord-types"; import { ButtonProps } from "@vencord/discord-types";
import { Button, showToast } from "@webpack/common"; import { Button, showToast } from "@webpack/common";
export function VCDonateButton({
look = Button.Looks.LINK,
color = Button.Colors.TRANSPARENT,
...props
}: Partial<ButtonProps>) {
return (
<Button
{...props}
look={look}
color={color}
onClick={() => VencordNative.native.openExternal("https://github.com/sponsors/Vendicated")}
className="vc-donate-button"
>
<Heart />
Donate
</Button>
);
}
export function DonateButton({ export function DonateButton({
look = Button.Looks.LINK, look = Button.Looks.LINK,
color = Button.Colors.TRANSPARENT, color = Button.Colors.TRANSPARENT,
link,
...props ...props
}: Partial<ButtonProps>) { }: Partial<ButtonProps> & { link: string; }) {
return ( return (
<Button <Button
{...props} {...props}
look={look} look={look}
color={color} color={color}
onClick={() => VencordNative.native.openExternal("https://github.com/sponsors/thororen1234")} onClick={() => VencordNative.native.openExternal(link)}
className="vc-donate-button" className="vc-donate-button"
> >
<Heart /> <Heart />
Donate {t("vencord.donate")}
</Button> </Button>
); );
} }
@ -77,7 +60,7 @@ export function InviteButton({
}} }}
className="vc-donate-button" className="vc-donate-button"
> >
Invite {t("vencord.donate")}
</Button> </Button>
); );
} }

View file

@ -22,6 +22,7 @@ import { handleComponentFailed } from "@components/handleComponentFailed";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { onlyOnce } from "@utils/onlyOnce"; import { onlyOnce } from "@utils/onlyOnce";
import { t } from "@utils/translation";
import type { ComponentType, PropsWithChildren } from "react"; import type { ComponentType, PropsWithChildren } from "react";
export function SettingsTab({ title, children }: PropsWithChildren<{ title: string; }>) { export function SettingsTab({ title, children }: PropsWithChildren<{ title: string; }>) {
@ -38,7 +39,7 @@ export const handleSettingsTabError = onlyOnce(handleComponentFailed);
export function wrapTab(component: ComponentType<any>, tab: string) { export function wrapTab(component: ComponentType<any>, tab: string) {
const wrapped = ErrorBoundary.wrap(component, { const wrapped = ErrorBoundary.wrap(component, {
displayName: `${tab}SettingsTab`, 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, onError: handleSettingsTabError,
}); });

View file

@ -11,11 +11,11 @@ import { classNameFactory } from "@api/Styles";
import ErrorBoundary from "@components/ErrorBoundary"; import ErrorBoundary from "@components/ErrorBoundary";
import { HeadingPrimary } from "@components/Heading"; import { HeadingPrimary } from "@components/Heading";
import { Link } from "@components/Link"; import { Link } from "@components/Link";
import { Paragraph } from "@components/Paragraph";
import { EquicordDevsById, VencordDevsById } from "@utils/constants"; import { EquicordDevsById, VencordDevsById } from "@utils/constants";
import { fetchUserProfile } from "@utils/discord"; import { fetchUserProfile } from "@utils/discord";
import { classes, pluralise } from "@utils/misc"; import { classes } from "@utils/misc";
import { ModalContent, ModalRoot, openModal } from "@utils/modal"; import { ModalContent, ModalRoot, openModal } from "@utils/modal";
import { Translate } from "@utils/translation";
import { User } from "@vencord/discord-types"; import { User } from "@vencord/discord-types";
import { showToast, useEffect, useMemo, UserProfileStore, useStateFromStores } from "@webpack/common"; 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)); .sort((a, b) => Number(a.required ?? false) - Number(b.required ?? false));
}, [user.id, user.username]); }, [user.id, user.username]);
const ContributedHyperLink = <Link href="https://github.com/Equicord/Equicord">contributed</Link>; const ContributedHyperLink = <Link href="https://equicord.org/projects">contributed</Link>;
return ( return (
<> <>
@ -94,13 +94,16 @@ function ContributorModal({ user }: { user: User; }) {
</div> </div>
{plugins.length ? ( {plugins.length ? (
<Paragraph> <Translate
{user.username} has {ContributedHyperLink} to {pluralise(plugins.length, "plugin")}! i18nKey="vencord.settings.plugins.contributorModal.contributorInfo.plugins"
</Paragraph> variables={{ count: plugins.length }}>
This person has {ContributedHyperLink} to some plugins!
</Translate>
) : ( ) : (
<Paragraph> <Translate i18nKey="vencord.settings.plugins.contributorModal.contributorInfo.noPlugins">
{user.username} has not made any plugins. They likely {ContributedHyperLink} in other ways! This person has not made any plugins. They likely {ContributedHyperLink}
</Paragraph> to Vencord in other ways!
</Translate>
)} )}
{!!plugins.length && ( {!!plugins.length && (

View file

@ -9,6 +9,7 @@ import ErrorBoundary from "@components/ErrorBoundary";
import { Heading } from "@components/Heading"; import { Heading } from "@components/Heading";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { identity } from "@utils/misc"; import { identity } from "@utils/misc";
import { t } from "@utils/translation";
import { Select } from "@webpack/common"; import { Select } from "@webpack/common";
export function VibrancySettings() { export function VibrancySettings() {
@ -16,11 +17,11 @@ export function VibrancySettings() {
return ( return (
<> <>
<Heading>Window vibrancy style (requires restart)</Heading> <Heading>{t("vencord.settings.macosVibrancyStyle.title")}</Heading>
<ErrorBoundary noop> <ErrorBoundary noop>
<Select <Select
className={Margins.bottom20} className={Margins.bottom20}
placeholder="Window vibrancy style" placeholder={t("vencord.settings.macosVibrancyStyle.placeholder")}
options={[ options={[
// Sorted from most opaque to most transparent // Sorted from most opaque to most transparent
{ {

View file

@ -15,6 +15,7 @@ import { Paragraph } from "@components/Paragraph";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { identity } from "@utils/misc"; import { identity } from "@utils/misc";
import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal"; import { ModalCloseButton, ModalContent, ModalHeader, ModalRoot, ModalSize, openModal } from "@utils/modal";
import { t } from "@utils/translation";
import { Button, Select, Slider } from "@webpack/common"; import { Button, Select, Slider } from "@webpack/common";
export function NotificationSection() { export function NotificationSection() {
@ -27,10 +28,10 @@ export function NotificationSection() {
</Paragraph> </Paragraph>
<Flex> <Flex>
<Button onClick={openNotificationSettingsModal}> <Button onClick={openNotificationSettingsModal}>
Notification Settings {t("vencord.settings.notification.settingsButton")}
</Button> </Button>
<Button onClick={openNotificationLogModal}> <Button onClick={openNotificationLogModal}>
View Notification Log {t("vencord.settings.notification.logButton")}
</Button> </Button>
</Flex> </Flex>
</section> </section>

View file

@ -22,6 +22,7 @@ import { DONOR_ROLE_ID, GUILD_ID, VC_DONOR_ROLE_ID, VC_GUILD_ID } from "@utils/c
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { identity, isAnyPluginDev } from "@utils/misc"; import { identity, isAnyPluginDev } from "@utils/misc";
import { relaunch } from "@utils/native"; import { relaunch } from "@utils/native";
import { t, Translate } from "@utils/translation";
import { Button, GuildMemberStore, React, Select, UserStore } from "@webpack/common"; import { Button, GuildMemberStore, React, Select, UserStore } from "@webpack/common";
import BadgeAPI from "plugins/_api/badges"; import BadgeAPI from "plugins/_api/badges";
@ -64,13 +65,14 @@ function EquicordSettings() {
> = [ > = [
{ {
key: "useQuickCss", key: "useQuickCss",
title: "Enable Custom CSS", title: t("vencord.settings.useQuickCss.title"),
description: t("vencord.settings.useQuickCss.description"),
restartRequired: true, restartRequired: true,
warning: { enabled: false }, warning: { enabled: false },
}, },
!IS_WEB && { !IS_WEB && {
key: "enableReactDevtools", key: "enableReactDevtools",
title: "Enable React Developer Tools", title: t("vencord.settings.enableReactDevtools.title"),
restartRequired: true, restartRequired: true,
warning: { enabled: false }, warning: { enabled: false },
}, },
@ -78,21 +80,20 @@ function EquicordSettings() {
(!IS_DISCORD_DESKTOP || !isWindows (!IS_DISCORD_DESKTOP || !isWindows
? { ? {
key: "frameless", key: "frameless",
title: "Disable the Window Frame", title: t("vencord.settings.frameless.title"),
restartRequired: true, restartRequired: true,
warning: { enabled: false }, warning: { enabled: false },
} }
: { : {
key: "winNativeTitleBar", key: "winNativeTitleBar",
title: title: t("vencord.settings.winNativeTitleBar.title"),
"Use Windows' native title bar instead of Discord's custom one",
restartRequired: true, restartRequired: true,
warning: { enabled: false }, warning: { enabled: false },
}), }),
!IS_WEB && { !IS_WEB && {
key: "transparent", key: "transparent",
title: "Enable Window Transparency", title: t("vencord.settings.transparent.title"),
description: "A theme that supports transparency is required or this will do nothing. Stops the window from being resizable as a side effect", description: t("vencord.settings.transparent.description"),
restartRequired: true, restartRequired: true,
warning: { warning: {
enabled: isWindows, enabled: isWindows,
@ -101,32 +102,31 @@ function EquicordSettings() {
}, },
IS_DISCORD_DESKTOP && { IS_DISCORD_DESKTOP && {
key: "disableMinSize", key: "disableMinSize",
title: "Disable Minimum Window Size", title: t("vencord.settings.disableMinSize.title"),
restartRequired: true, restartRequired: true,
warning: { enabled: false }, warning: { enabled: false },
}, },
!IS_WEB && !IS_WEB &&
isWindows && { isWindows && {
key: "winCtrlQ", key: "winCtrlQ",
title: title: t("vencord.settings.winCtrlQ.title"),
"Register Ctrl+Q as shortcut to close Discord (Alternative to Alt+F4)",
restartRequired: true, restartRequired: true,
warning: { enabled: false }, warning: { enabled: false },
}, },
]; ];
return ( return (
<SettingsTab title="Equicord Settings"> <SettingsTab title={t("vencord.tabs.settings")}>
{(isEquicordDonor(user?.id) || isVencordDonor(user?.id)) ? ( {(isEquicordDonor(user?.id) || isVencordDonor(user?.id)) ? (
<SpecialCard <SpecialCard
title="Donations" title={t("vencord.donorCard.donated.title")}
subtitle="Thank you for donating!" subtitle={t("vencord.donorCard.donated.subtitle")}
description={ description={
isEquicordDonor(user?.id) && isVencordDonor(user?.id) isEquicordDonor(user?.id) && isVencordDonor(user?.id)
? "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." ? t("vencord.donorCard.donated.description")
: isVencordDonor(user?.id) : isVencordDonor(user?.id)
? "All Vencord users can see your badge! You can manage your perks by messaging @vending.machine." ? t("vencord.donorCard.donated.vencordDescription", { v: "vending.machine" })
: "All Equicord users can see your badge! You can manage your perks by making a ticket in Equicord's server." : t("vencord.donorCard.donated.equicordDescription")
} }
cardImage={VENNIE_DONATOR_IMAGE} cardImage={VENNIE_DONATOR_IMAGE}
backgroundImage={DONOR_BACKGROUND_IMAGE} backgroundImage={DONOR_BACKGROUND_IMAGE}
@ -136,8 +136,8 @@ function EquicordSettings() {
</SpecialCard> </SpecialCard>
) : ( ) : (
<SpecialCard <SpecialCard
title="Support the Project" title={t("vencord.donorCard.notDonated.title")}
description="Please consider supporting the development of Equicord by donating!" description={t("vencord.donorCard.notDonated.description")}
cardImage={donateImage} cardImage={donateImage}
backgroundImage={DONOR_BACKGROUND_IMAGE} backgroundImage={DONOR_BACKGROUND_IMAGE}
backgroundColor="#c3a3ce" backgroundColor="#c3a3ce"
@ -147,13 +147,13 @@ function EquicordSettings() {
)} )}
{isAnyPluginDev(user?.id) && ( {isAnyPluginDev(user?.id) && (
<SpecialCard <SpecialCard
title="Contributions" title={t("vencord.contributorCard.title")}
subtitle="Thank you for contributing!" subtitle={t("vencord.contributorCard.subtitle")}
description="Since you've contributed to Equicord you now have a cool new badge!" description={t("vencord.contributorCard.description")}
cardImage={COZY_CONTRIB_IMAGE} cardImage={COZY_CONTRIB_IMAGE}
backgroundImage={CONTRIB_BACKGROUND_IMAGE} backgroundImage={CONTRIB_BACKGROUND_IMAGE}
backgroundColor="#EDCC87" backgroundColor="#EDCC87"
buttonTitle="See what you've contributed to" buttonTitle={t("vencord.contributorCard.contributionsButton")}
buttonOnClick={() => openContributorModal(user)} buttonOnClick={() => openContributorModal(user)}
/> />
)} )}
@ -164,31 +164,31 @@ function EquicordSettings() {
<QuickActionCard> <QuickActionCard>
<QuickAction <QuickAction
Icon={LogIcon} Icon={LogIcon}
text="Notification Log" text={t("vencord.quickActions.notificationLog")}
action={openNotificationLogModal} action={openNotificationLogModal}
/> />
<QuickAction <QuickAction
Icon={PaintbrushIcon} Icon={PaintbrushIcon}
text="Edit QuickCSS" text={t("vencord.quickActions.editQuickCSS")}
action={() => VencordNative.quickCss.openEditor()} action={() => VencordNative.quickCss.openEditor()}
/> />
{!IS_WEB && ( {!IS_WEB && (
<QuickAction <QuickAction
Icon={RestartIcon} Icon={RestartIcon}
text="Relaunch Discord" text={t("vencord.quickActions.relaunchDiscord")}
action={relaunch} action={relaunch}
/> />
)} )}
{!IS_WEB && ( {!IS_WEB && (
<QuickAction <QuickAction
Icon={FolderIcon} Icon={FolderIcon}
text="Open Settings Folder" text={t("vencord.quickActions.openSettings")}
action={() => VencordNative.settings.openFolder()} action={() => VencordNative.settings.openFolder()}
/> />
)} )}
<QuickAction <QuickAction
Icon={GithubIcon} Icon={GithubIcon}
text="View Source Code" text={t("vencord.quickActions.viewSource")}
action={() => action={() =>
VencordNative.native.openExternal( VencordNative.native.openExternal(
"https://github.com/" + gitRemote, "https://github.com/" + gitRemote,
@ -201,17 +201,19 @@ function EquicordSettings() {
<Divider /> <Divider />
<section className={Margins.top16}> <section className={Margins.top16}>
<Heading>Settings</Heading> <Heading>{t("vencord.settings.title")}</Heading>
<Paragraph className={Margins.bottom20} style={{ color: "var(--text-muted)" }}> <Paragraph className={Margins.bottom20} style={{ color: "var(--text-muted)" }}>
Hint: You can change the position of this settings section in the{" "} <Translate i18nKey="vencord.settings.hint">
<Button Hint: You can change the position of this settings section in the
look={Button.Looks.LINK} <Button
style={{ color: "var(--text-link)", display: "inline-block" }} look={Button.Looks.LINK}
onClick={() => openPluginModal(Vencord.Plugins.plugins.Settings)} style={{ color: "var(--text-link)", display: "inline-block" }}
> onClick={() => openPluginModal(Vencord.Plugins.plugins.Settings)}
settings of the Settings plugin >
</Button> settings of the Settings plugin
! </Button>
!
</Translate>
</Paragraph> </Paragraph>
{Switches.map( {Switches.map(
@ -341,7 +343,7 @@ function DonateButtonComponent() {
); );
} }
export default wrapTab(EquicordSettings, "Equicord Settings"); export default wrapTab(EquicordSettings, t("vencord.tabs.settings"));
export function isEquicordDonor(userId: string): boolean { export function isEquicordDonor(userId: string): boolean {
const donorBadges = BadgeAPI.getEquicordDonorBadges(userId); const donorBadges = BadgeAPI.getEquicordDonorBadges(userId);

12
src/modules.d.ts vendored
View file

@ -35,6 +35,18 @@ declare module "~git-remote" {
export default remote; export default remote;
} }
declare module "~translations" {
type BaseTranslation = string | { [key: string]: BaseTranslation; };
export type TranslationBundle = {
[namespace: string]: Record<string, BaseTranslation>;
};
const translations: {
[locale: string]: TranslationBundle;
};
export default translations;
}
declare module "file://*" { declare module "file://*" {
const content: string; const content: string;
export default content; export default content;

View file

@ -6,7 +6,7 @@
import { Flex } from "@components/Flex"; import { Flex } from "@components/Flex";
import { Heart } from "@components/Heart"; import { Heart } from "@components/Heart";
import { DonateButton, ErrorBoundary, HeadingPrimary, Paragraph, VCDonateButton } from "@components/index"; import { DonateButton, ErrorBoundary, HeadingPrimary, Paragraph } from "@components/index";
import { Margins } from "@utils/margins"; import { Margins } from "@utils/margins";
import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalRoot, openModal } from "@utils/modal"; import { closeModal, ModalContent, ModalFooter, ModalHeader, ModalRoot, openModal } from "@utils/modal";
@ -57,7 +57,7 @@ export function VencordDonorModal() {
</ModalContent> </ModalContent>
<ModalFooter> <ModalFooter>
<Flex justifyContent="center" style={{ width: "100%" }}> <Flex justifyContent="center" style={{ width: "100%" }}>
<VCDonateButton /> <DonateButton link="https://github.com/sponsors/Vendicated" />
</Flex> </Flex>
</ModalFooter> </ModalFooter>
</ModalRoot> </ModalRoot>
@ -113,7 +113,7 @@ export function EquicordDonorModal() {
</ModalContent> </ModalContent>
<ModalFooter> <ModalFooter>
<Flex justifyContent="center" style={{ width: "100%" }}> <Flex justifyContent="center" style={{ width: "100%" }}>
<DonateButton /> <DonateButton link="https://github.com/sponsors/thororen1234" />
</Flex> </Flex>
</ModalFooter> </ModalFooter>
</ModalRoot> </ModalRoot>

View file

@ -0,0 +1,57 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
import { Devs } from "@utils/constants";
import { availableLocales, setLocale, t } from "@utils/translation";
import definePlugin, { OptionType } from "@utils/types";
import { i18n } from "@webpack/common";
const settings = definePluginSettings({
forceLocale: {
type: OptionType.SELECT,
description: t("translation.settings.forceLocale.description"),
options: [
{
label: t("translation.settings.forceLocale.followDiscord"),
value: "",
default: true,
},
...availableLocales.map(l => ({
label: l,
value: l
}))
],
onChange: (value: string) => {
setLocale(value || i18n.intl.currentLocale);
}
},
showMissingKeys: {
type: OptionType.BOOLEAN,
description: t("translation.settings.showMissingKeys"),
default: false
}
});
export default definePlugin({
name: "Translation",
required: true,
description: t("translation.description"),
authors: [Devs.lewisakura],
settings,
flux: {
USER_SETTINGS_PROTO_UPDATE({ settings }) {
if (settings.proto.localization) // sometimes this apparently doesn't exist? not sure why
setLocale(settings.proto.localization.locale.value);
}
},
start() {
setLocale(i18n.intl.currentLocale);
}
});

203
src/utils/translation.tsx Normal file
View file

@ -0,0 +1,203 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Settings } from "@api/Settings";
import { negotiateLanguages } from "@fluent/langneg";
import { React } from "@webpack/common";
import type { JSX } from "react";
import translations, { type TranslationBundle } from "~translations";
import { Logger } from "./Logger";
const logger = new Logger("Translations", "#7bc876");
export const availableLocales = Object.keys(translations);
let loadedLocale = translations.en; // english is the default fallback anyway
let bestLocale: string;
let lastDiscordLocale: string;
export function setLocale(locale: string) {
// discord sometimes sends the locale update multiple times so this just prevents us from constantly renegotiating
if (locale === lastDiscordLocale) return;
lastDiscordLocale = locale;
// finds the best locale based on the available ones
bestLocale = negotiateLanguages(
[locale],
Object.keys(translations),
{
defaultLocale: "en",
strategy: "lookup",
}
)[0];
loadedLocale = translations[bestLocale];
logger.info(`Locale was updated (wanted ${locale}, negotiated to ${bestLocale})`);
}
// derived from stackoverflow's string formatting function
function format(source: string, variables: Record<string, any>) {
for (const key in variables) {
let formatted: string;
switch (typeof variables[key]) {
case "number": {
formatted = new Intl.NumberFormat(bestLocale).format(variables[key]);
break;
}
default: {
formatted = variables[key].toString();
break;
}
}
source = source.replace(
new RegExp(`\\{${key}\\}`, "gi"),
formatted
);
}
return source;
}
// converts a dot-notation path to an object value
const dotProp = (key: string, object: any) =>
key.split(".").reduce((obj, key) => obj?.[key], object);
// translation retrieval function
function _t(key: string, bundle: TranslationBundle): string | undefined {
const translation = dotProp(key, bundle);
if (translation && typeof translation !== "string")
throw new Error(`Translation for key "${key}" is not a string!`);
if (!translation) {
if (bundle !== translations.en && !Settings.plugins.Translation.showMissingKeys) {
return _t(key, translations.en);
} else {
return undefined;
}
}
return translation;
}
/**
* Translates a key. Soft-fails and returns the key if it is not valid.
* @param key The key to translate.
* @param variables The variables to interpolate into the resultant string. If dealing with plurals, `count` must be set.
* @returns A translated string, or the translation key if it is not valid.
*/
export function t(key: string, variables?: Record<string, any>): string {
const getter = (): string => {
if (typeof variables?.count === "number") {
// this seems plural!
const pluralTag: Intl.LDMLPluralRule = variables.count === 0 ? "zero" :
new Intl.PluralRules(bestLocale).select(variables.count);
const translation = _t(`${key}_${pluralTag}`, loadedLocale);
if (typeof translation === "string") {
return format(translation, variables);
}
// ...okay, maybe this form just isn't in this translation, try the "other" tag
const otherTranslation = _t(`${key}_other`, loadedLocale);
if (typeof otherTranslation === "string") {
return format(otherTranslation, variables);
}
// okay then, nevermind...
}
const translation = _t(key, loadedLocale);
if (!translation) return key; // for easier debugging
if (!variables) return translation;
return format(translation, variables);
};
// top level support hax (thank you vee!!)
// tl;dr: this lets you use t at the top level in objects by simulating a string, a la:
// {
// description: t("somePlugin.description")
// }
// and any future accesses of the description prop will result in an up to date translation
const descriptor = {
configurable: true,
enumerable: false,
writable: false,
value: getter
};
return Object.create(String.prototype, {
toString: descriptor,
valueOf: descriptor
});
}
type TranslatableChild = (string | JSX.Element);
interface TranslateProps {
/** The key to translate. */
i18nKey: string;
/** The variables to interpolate into the resultant string. If dealing with plurals, `count` must be set. */
variables?: Record<string, any>;
/** The component(s) to interpolate into the resultant string. */
children: TranslatableChild | TranslatableChild[];
}
/**
* A translation component. Follows the same rules as {@link t}, but lets you add components to strings.
*
* It's worth noting components cannot have components inside of them as their children will be replaced with the string
* contents of the translation. If you want to do this, you will need to make a wrapper component that accepts a string
* child.
*
* @example
* ```
* // you can either do it plainly:
* <Translate i18nKey="vencord.website">
* <Link href="https://vencord.dev" />
* </Translate>
*
* // or, if you want to add some context:
* <Translate i18nKey="vencord.website">
* Would you like to see our <Link href="https://vencord.dev">website</Link>?
* </Translate>
*
* // string children are simply ignored by the component, so you don't have to keep it up to date with the translation
* ```
* @param param0 Component props.
*/
export function Translate({ i18nKey, variables, children: trueChildren }: TranslateProps): JSX.Element {
const children = [trueChildren].flat().filter(child => typeof child !== "string");
const translation = t(i18nKey, variables);
const parts = translation.split(/(<\d+>.*?<\/\d+>)/g);
const finalChildren = parts.map((part, index) => {
const match = part.match(/<(\d+)>(.*?)<\/\d+>/);
if (match) {
const childIndex = parseInt(match[1], 10);
return React.cloneElement(children[childIndex], { key: index }, match[2]);
}
return part;
});
return <>{finalChildren}</>;
}