feat: Typesafe Settings Definitions (#403)

Co-authored-by: Ven <vendicated@riseup.net>
This commit is contained in:
Justice Almanzar 2023-01-13 17:15:45 -05:00 committed by GitHub
parent 6c5fcc4119
commit ea748dfb60
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 288 additions and 180 deletions

@ -19,7 +19,7 @@
import IpcEvents from "@utils/IpcEvents"; import IpcEvents from "@utils/IpcEvents";
import Logger from "@utils/Logger"; import Logger from "@utils/Logger";
import { mergeDefaults } from "@utils/misc"; import { mergeDefaults } from "@utils/misc";
import { OptionType } from "@utils/types"; import { DefinedSettings, OptionType, SettingsChecks, SettingsDefinition } from "@utils/types";
import { React } from "@webpack/common"; import { React } from "@webpack/common";
import plugins from "~plugins"; import plugins from "~plugins";
@ -146,6 +146,7 @@ export const Settings = makeProxy(settings);
* @param paths An optional list of paths to whitelist for rerenders * @param paths An optional list of paths to whitelist for rerenders
* @returns Settings * @returns Settings
*/ */
// TODO: Representing paths as essentially "string[].join('.')" wont allow dots in paths, change to "paths?: string[][]" later
export function useSettings(paths?: string[]) { export function useSettings(paths?: string[]) {
const [, forceUpdate] = React.useReducer(() => ({}), {}); const [, forceUpdate] = React.useReducer(() => ({}), {});
@ -200,3 +201,19 @@ export function migratePluginSettings(name: string, ...oldNames: string[]) {
} }
} }
} }
export function definePluginSettings<D extends SettingsDefinition, C extends SettingsChecks<D>>(def: D, checks?: C) {
const definedSettings: DefinedSettings<D> = {
get store() {
if (!definedSettings.pluginName) throw new Error("Cannot access settings before plugin is initialized");
return Settings.plugins[definedSettings.pluginName] as any;
},
use: settings => useSettings(
settings?.map(name => `plugins.${definedSettings.pluginName}.${name}`)
).plugins[definedSettings.pluginName] as any,
def,
checks: checks ?? {},
pluginName: "",
};
return definedSettings;
}

@ -144,6 +144,7 @@ export default function PluginModal({ plugin, onRestartNeeded, onClose, transiti
onChange={onChange} onChange={onChange}
onError={onError} onError={onError}
pluginSettings={pluginSettings} pluginSettings={pluginSettings}
definedSettings={plugin.settings}
/> />
); );
}); });

@ -21,7 +21,7 @@ import { Forms, React, Select } from "@webpack/common";
import { ISettingElementProps } from "."; import { ISettingElementProps } from ".";
export function SettingBooleanComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionBoolean>) { export function SettingBooleanComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionBoolean>) {
const def = pluginSettings[id] ?? option.default; const def = pluginSettings[id] ?? option.default;
const [state, setState] = React.useState(def ?? false); const [state, setState] = React.useState(def ?? false);
@ -37,7 +37,7 @@ export function SettingBooleanComponent({ option, pluginSettings, id, onChange,
]; ];
function handleChange(newValue: boolean): void { function handleChange(newValue: boolean): void {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else { else {
@ -51,7 +51,7 @@ export function SettingBooleanComponent({ option, pluginSettings, id, onChange,
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Select <Select
isDisabled={option.disabled?.() ?? false} isDisabled={option.disabled?.call(definedSettings) ?? false}
options={options} options={options}
placeholder={option.placeholder ?? "Select an option"} placeholder={option.placeholder ?? "Select an option"}
maxVisibleItems={5} maxVisibleItems={5}

@ -23,7 +23,7 @@ import { ISettingElementProps } from ".";
const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER); const MAX_SAFE_NUMBER = BigInt(Number.MAX_SAFE_INTEGER);
export function SettingNumericComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionNumber>) { export function SettingNumericComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionNumber>) {
function serialize(value: any) { function serialize(value: any) {
if (option.type === OptionType.BIGINT) return BigInt(value); if (option.type === OptionType.BIGINT) return BigInt(value);
return Number(value); return Number(value);
@ -37,7 +37,7 @@ export function SettingNumericComponent({ option, pluginSettings, id, onChange,
}, [error]); }, [error]);
function handleChange(newValue) { function handleChange(newValue) {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) { else if (option.type === OptionType.NUMBER && BigInt(newValue) >= MAX_SAFE_NUMBER) {
@ -58,7 +58,7 @@ export function SettingNumericComponent({ option, pluginSettings, id, onChange,
value={state} value={state}
onChange={handleChange} onChange={handleChange}
placeholder={option.placeholder ?? "Enter a number"} placeholder={option.placeholder ?? "Enter a number"}
disabled={option.disabled?.() ?? false} disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps} {...option.componentProps}
/> />
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>} {error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}

@ -21,7 +21,7 @@ import { Forms, React, Select } from "@webpack/common";
import { ISettingElementProps } from "."; import { ISettingElementProps } from ".";
export function SettingSelectComponent({ option, pluginSettings, onChange, onError, id }: ISettingElementProps<PluginOptionSelect>) { export function SettingSelectComponent({ option, pluginSettings, definedSettings, onChange, onError, id }: ISettingElementProps<PluginOptionSelect>) {
const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value; const def = pluginSettings[id] ?? option.options?.find(o => o.default)?.value;
const [state, setState] = React.useState<any>(def ?? null); const [state, setState] = React.useState<any>(def ?? null);
@ -32,7 +32,7 @@ export function SettingSelectComponent({ option, pluginSettings, onChange, onErr
}, [error]); }, [error]);
function handleChange(newValue) { function handleChange(newValue) {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else { else {
@ -45,7 +45,7 @@ export function SettingSelectComponent({ option, pluginSettings, onChange, onErr
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Select <Select
isDisabled={option.disabled?.() ?? false} isDisabled={option.disabled?.call(definedSettings) ?? false}
options={option.options} options={option.options}
placeholder={option.placeholder ?? "Select an option"} placeholder={option.placeholder ?? "Select an option"}
maxVisibleItems={5} maxVisibleItems={5}

@ -29,7 +29,7 @@ export function makeRange(start: number, end: number, step = 1) {
return ranges; return ranges;
} }
export function SettingSliderComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionSlider>) { export function SettingSliderComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionSlider>) {
const def = pluginSettings[id] ?? option.default; const def = pluginSettings[id] ?? option.default;
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@ -39,7 +39,7 @@ export function SettingSliderComponent({ option, pluginSettings, id, onChange, o
}, [error]); }, [error]);
function handleChange(newValue: number): void { function handleChange(newValue: number): void {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else { else {
@ -52,7 +52,7 @@ export function SettingSliderComponent({ option, pluginSettings, id, onChange, o
<Forms.FormSection> <Forms.FormSection>
<Forms.FormTitle>{option.description}</Forms.FormTitle> <Forms.FormTitle>{option.description}</Forms.FormTitle>
<Slider <Slider
disabled={option.disabled?.() ?? false} disabled={option.disabled?.call(definedSettings) ?? false}
markers={option.markers} markers={option.markers}
minValue={option.markers[0]} minValue={option.markers[0]}
maxValue={option.markers[option.markers.length - 1]} maxValue={option.markers[option.markers.length - 1]}

@ -21,7 +21,7 @@ import { Forms, React, TextInput } from "@webpack/common";
import { ISettingElementProps } from "."; import { ISettingElementProps } from ".";
export function SettingTextComponent({ option, pluginSettings, id, onChange, onError }: ISettingElementProps<PluginOptionString>) { export function SettingTextComponent({ option, pluginSettings, definedSettings, id, onChange, onError }: ISettingElementProps<PluginOptionString>) {
const [state, setState] = React.useState(pluginSettings[id] ?? option.default ?? null); const [state, setState] = React.useState(pluginSettings[id] ?? option.default ?? null);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@ -30,7 +30,7 @@ export function SettingTextComponent({ option, pluginSettings, id, onChange, onE
}, [error]); }, [error]);
function handleChange(newValue) { function handleChange(newValue) {
const isValid = (option.isValid && option.isValid(newValue)) ?? true; const isValid = option.isValid?.call(definedSettings, newValue) ?? true;
if (typeof isValid === "string") setError(isValid); if (typeof isValid === "string") setError(isValid);
else if (!isValid) setError("Invalid input provided."); else if (!isValid) setError("Invalid input provided.");
else { else {
@ -47,7 +47,7 @@ export function SettingTextComponent({ option, pluginSettings, id, onChange, onE
value={state} value={state}
onChange={handleChange} onChange={handleChange}
placeholder={option.placeholder ?? "Enter a value"} placeholder={option.placeholder ?? "Enter a value"}
disabled={option.disabled?.() ?? false} disabled={option.disabled?.call(definedSettings) ?? false}
{...option.componentProps} {...option.componentProps}
/> />
{error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>} {error && <Forms.FormText style={{ color: "var(--text-danger)" }}>{error}</Forms.FormText>}

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { PluginOptionBase } from "@utils/types"; import { DefinedSettings, PluginOptionBase } from "@utils/types";
export interface ISettingElementProps<T extends PluginOptionBase> { export interface ISettingElementProps<T extends PluginOptionBase> {
option: T; option: T;
@ -27,6 +27,7 @@ export interface ISettingElementProps<T extends PluginOptionBase> {
}; };
id: string; id: string;
onError(hasError: boolean): void; onError(hasError: boolean): void;
definedSettings?: DefinedSettings;
} }
export * from "./BadgeComponent"; export * from "./BadgeComponent";

@ -60,7 +60,16 @@ for (const p of pluginsValues) {
}); });
} }
for (const p of pluginsValues) for (const p of pluginsValues) {
if (p.settings) {
p.settings.pluginName = p.name;
p.options ??= {};
for (const [name, def] of Object.entries(p.settings.def)) {
const checks = p.settings.checks?.[name];
p.options[name] = { ...def, ...checks };
}
}
if (p.patches && isPluginEnabled(p.name)) { if (p.patches && isPluginEnabled(p.name)) {
for (const patch of p.patches) { for (const patch of p.patches) {
patch.plugin = p.name; patch.plugin = p.name;
@ -69,6 +78,7 @@ for (const p of pluginsValues)
patches.push(patch); patches.push(patch);
} }
} }
}
export const startAllPlugins = traceFunction("startAllPlugins", function startAllPlugins() { export const startAllPlugins = traceFunction("startAllPlugins", function startAllPlugins() {
for (const name in Plugins) for (const name in Plugins)

@ -16,25 +16,25 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import { useSettings } from "@api/settings"; import { PartialExcept } from "@utils/types";
import { React } from "@webpack/common"; import { React } from "@webpack/common";
import { shiki } from "../api/shiki"; import { shiki } from "../api/shiki";
import { ShikiSettings } from "../types"; import { settings as pluginSettings, ShikiSettings } from "../settings";
export function useShikiSettings(settingKeys: (keyof ShikiSettings)[], overrides?: Record<string, any>) { export function useShikiSettings<F extends keyof ShikiSettings>(settingKeys: F[], overrides?: Partial<ShikiSettings>) {
const settings = useSettings(settingKeys.map(key => `plugins.ShikiCodeblocks.${key}`)).plugins.ShikiCodeblocks as ShikiSettings; const settings: Partial<ShikiSettings> = pluginSettings.use(settingKeys);
const [isLoading, setLoading] = React.useState(false); const [isLoading, setLoading] = React.useState(false);
const withOverrides = { ...settings, ...overrides }; const withOverrides = { ...settings, ...overrides } as PartialExcept<ShikiSettings, F>;
const themeUrl = withOverrides.customTheme || withOverrides.theme; const themeUrl = withOverrides.customTheme || withOverrides.theme;
if (overrides) { if (overrides) {
const willChangeTheme = shiki.currentThemeUrl && themeUrl !== shiki.currentThemeUrl; const willChangeTheme = shiki.currentThemeUrl && themeUrl && themeUrl !== shiki.currentThemeUrl;
const noOverrides = Object.keys(overrides).length === 0; const noOverrides = Object.keys(overrides).length === 0;
if (isLoading && (!willChangeTheme || noOverrides)) setLoading(false); if (isLoading && (!willChangeTheme || noOverrides)) setLoading(false);
if ((!isLoading && willChangeTheme)) { if (!isLoading && willChangeTheme) {
setLoading(true); setLoading(true);
shiki.setTheme(themeUrl); shiki.setTheme(themeUrl);
} }

@ -18,26 +18,19 @@
import "./shiki.css"; import "./shiki.css";
import { disableStyle, enableStyle } from "@api/Styles"; import { enableStyle } from "@api/Styles";
import { Devs } from "@utils/constants"; import { Devs } from "@utils/constants";
import { parseUrl } from "@utils/misc"; import definePlugin from "@utils/types";
import { wordsFromPascal, wordsToTitle } from "@utils/text";
import definePlugin, { OptionType } from "@utils/types";
import previewExampleText from "~fileContent/previewExample.tsx"; import previewExampleText from "~fileContent/previewExample.tsx";
import { Settings } from "../../Vencord";
import { shiki } from "./api/shiki"; import { shiki } from "./api/shiki";
import { themes } from "./api/themes";
import { createHighlighter } from "./components/Highlighter"; import { createHighlighter } from "./components/Highlighter";
import deviconStyle from "./devicon.css?managed"; import deviconStyle from "./devicon.css?managed";
import { DeviconSetting, HljsSetting, ShikiSettings } from "./types"; import { settings } from "./settings";
import { DeviconSetting } from "./types";
import { clearStyles } from "./utils/createStyle"; import { clearStyles } from "./utils/createStyle";
const themeNames = Object.keys(themes);
const getSettings = () => Settings.plugins.ShikiCodeblocks as ShikiSettings;
export default definePlugin({ export default definePlugin({
name: "ShikiCodeblocks", name: "ShikiCodeblocks",
description: "Brings vscode-style codeblocks into Discord, powered by Shiki", description: "Brings vscode-style codeblocks into Discord, powered by Shiki",
@ -52,10 +45,10 @@ export default definePlugin({
}, },
], ],
start: async () => { start: async () => {
if (getSettings().useDevIcon !== DeviconSetting.Disabled) if (settings.store.useDevIcon !== DeviconSetting.Disabled)
enableStyle(deviconStyle); enableStyle(deviconStyle);
await shiki.init(getSettings().customTheme || getSettings().theme); await shiki.init(settings.store.customTheme || settings.store.theme);
}, },
stop: () => { stop: () => {
shiki.destroy(); shiki.destroy();
@ -67,90 +60,7 @@ export default definePlugin({
isPreview: true, isPreview: true,
tempSettings, tempSettings,
}), }),
options: { settings,
theme: {
type: OptionType.SELECT,
description: "Default themes",
options: themeNames.map(themeName => ({
label: wordsToTitle(wordsFromPascal(themeName)),
value: themes[themeName],
default: themes[themeName] === themes.DarkPlus,
})),
disabled: () => !!getSettings().customTheme,
onChange: shiki.setTheme,
},
customTheme: {
type: OptionType.STRING,
description: "A link to a custom vscode theme",
placeholder: themes.MaterialCandy,
isValid: value => {
if (!value) return true;
const url = parseUrl(value);
if (!url) return "Must be a valid URL";
if (!url.pathname.endsWith(".json")) return "Must be a json file";
return true;
},
onChange: value => shiki.setTheme(value || getSettings().theme),
},
tryHljs: {
type: OptionType.SELECT,
description: "Use the more lightweight default Discord highlighter and theme.",
options: [
{
label: "Never",
value: HljsSetting.Never,
},
{
label: "Prefer Shiki instead of Highlight.js",
value: HljsSetting.Secondary,
default: true,
},
{
label: "Prefer Highlight.js instead of Shiki",
value: HljsSetting.Primary,
},
{
label: "Always",
value: HljsSetting.Always,
},
],
},
useDevIcon: {
type: OptionType.SELECT,
description: "How to show language icons on codeblocks",
options: [
{
label: "Disabled",
value: DeviconSetting.Disabled,
},
{
label: "Colorless",
value: DeviconSetting.Greyscale,
default: true,
},
{
label: "Colored",
value: DeviconSetting.Color,
},
],
onChange: (newValue: DeviconSetting) => {
if (newValue === DeviconSetting.Disabled) disableStyle(deviconStyle);
else enableStyle(deviconStyle);
},
},
bgOpacity: {
type: OptionType.SLIDER,
description: "Background opacity",
markers: [0, 20, 40, 60, 80, 100],
default: 100,
componentProps: {
stickToMarkers: false,
onValueRender: null, // Defaults to percentage
},
},
},
// exports // exports
shiki, shiki,

@ -0,0 +1,123 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2023 Vendicated and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { definePluginSettings } from "@api/settings";
import { disableStyle, enableStyle } from "@api/Styles";
import { parseUrl } from "@utils/misc";
import { wordsFromPascal, wordsToTitle } from "@utils/text";
import { OptionType } from "@utils/types";
import { shiki } from "./api/shiki";
import { themes } from "./api/themes";
import deviconStyle from "./devicon.css?managed";
import { DeviconSetting, HljsSetting } from "./types";
const themeNames = Object.keys(themes) as (keyof typeof themes)[];
export type ShikiSettings = typeof settings.store;
export const settings = definePluginSettings({
theme: {
type: OptionType.SELECT,
description: "Default themes",
options: themeNames.map(themeName => ({
label: wordsToTitle(wordsFromPascal(themeName)),
value: themes[themeName],
default: themes[themeName] === themes.DarkPlus,
})),
onChange: shiki.setTheme,
},
customTheme: {
type: OptionType.STRING,
description: "A link to a custom vscode theme",
placeholder: themes.MaterialCandy,
onChange: value => {
shiki.setTheme(value || settings.store.theme);
},
},
tryHljs: {
type: OptionType.SELECT,
description: "Use the more lightweight default Discord highlighter and theme.",
options: [
{
label: "Never",
value: HljsSetting.Never,
},
{
label: "Prefer Shiki instead of Highlight.js",
value: HljsSetting.Secondary,
default: true,
},
{
label: "Prefer Highlight.js instead of Shiki",
value: HljsSetting.Primary,
},
{
label: "Always",
value: HljsSetting.Always,
},
],
},
useDevIcon: {
type: OptionType.SELECT,
description: "How to show language icons on codeblocks",
options: [
{
label: "Disabled",
value: DeviconSetting.Disabled,
},
{
label: "Colorless",
value: DeviconSetting.Greyscale,
default: true,
},
{
label: "Colored",
value: DeviconSetting.Color,
},
],
onChange: (newValue: DeviconSetting) => {
if (newValue === DeviconSetting.Disabled) disableStyle(deviconStyle);
else enableStyle(deviconStyle);
},
},
bgOpacity: {
type: OptionType.SLIDER,
description: "Background opacity",
markers: [0, 20, 40, 60, 80, 100],
default: 100,
componentProps: {
stickToMarkers: false,
onValueRender: null, // Defaults to percentage
},
},
}, {
theme: {
disabled() { return !!this.store.customTheme; },
},
customTheme: {
isValid(value) {
if (!value) return true;
const url = parseUrl(value);
if (!url) return "Must be a valid URL";
if (!url.pathname.endsWith(".json")) return "Must be a json file";
return true;
},
}
});

@ -23,8 +23,6 @@ import type {
IThemeRegistration, IThemeRegistration,
} from "@vap/shiki"; } from "@vap/shiki";
import type { Settings } from "../../Vencord";
/** This must be atleast a subset of the `@vap/shiki-worker` spec */ /** This must be atleast a subset of the `@vap/shiki-worker` spec */
export type ShikiSpec = { export type ShikiSpec = {
setOnigasm: ({ wasm }: { wasm: string; }) => Promise<void>; setOnigasm: ({ wasm }: { wasm: string; }) => Promise<void>;
@ -64,15 +62,3 @@ export enum DeviconSetting {
Greyscale = "GREYSCALE", Greyscale = "GREYSCALE",
Color = "COLOR" Color = "COLOR"
} }
type CommonSettings = {
[K in keyof Settings["plugins"][string]as K extends `${infer V}` ? K : never]: Settings["plugins"][string][K];
};
export interface ShikiSettings extends CommonSettings {
theme: string;
customTheme: string;
tryHljs: HljsSetting;
useDevIcon: DeviconSetting;
bgOpacity: number;
}

@ -21,7 +21,7 @@ import { hljs } from "@webpack/common";
import { resolveLang } from "../api/languages"; import { resolveLang } from "../api/languages";
import { HighlighterProps } from "../components/Highlighter"; import { HighlighterProps } from "../components/Highlighter";
import { HljsSetting, ShikiSettings } from "../types"; import { HljsSetting } from "../types";
export const cl = classNameFactory("shiki-"); export const cl = classNameFactory("shiki-");
@ -30,7 +30,7 @@ export const shouldUseHljs = ({
tryHljs, tryHljs,
}: { }: {
lang: HighlighterProps["lang"], lang: HighlighterProps["lang"],
tryHljs: ShikiSettings["tryHljs"], tryHljs: HljsSetting,
}) => { }) => {
const hljsLang = lang ? hljs?.getLanguage?.(lang) : null; const hljsLang = lang ? hljs?.getLanguage?.(lang) : null;
const shikiLang = lang ? resolveLang(lang) : null; const shikiLang = lang ? resolveLang(lang) : null;
@ -45,7 +45,6 @@ export const shouldUseHljs = ({
return !langName && !!hljsLang; return !langName && !!hljsLang;
case HljsSetting.Never: case HljsSetting.Never:
return false; return false;
default: return false;
} }
return false;
}; };

@ -81,8 +81,14 @@ export interface PluginDef {
target?: "WEB" | "DESKTOP" | "BOTH"; target?: "WEB" | "DESKTOP" | "BOTH";
/** /**
* Optionally provide settings that the user can configure in the Plugins tab of settings. * Optionally provide settings that the user can configure in the Plugins tab of settings.
* @deprecated Use `settings` instead
*/ */
// TODO: Remove when everything is migrated to `settings`
options?: Record<string, PluginOptionsItem>; options?: Record<string, PluginOptionsItem>;
/**
* Optionally provide settings that the user can configure in the Plugins tab of settings.
*/
settings?: DefinedSettings;
/** /**
* Check that this returns true before allowing a save to complete. * Check that this returns true before allowing a save to complete.
* If a string is returned, show the error to the user. * If a string is returned, show the error to the user.
@ -107,19 +113,25 @@ export enum OptionType {
COMPONENT, COMPONENT,
} }
export type PluginOptionsItem = export type SettingsDefinition = Record<string, PluginSettingDef>;
| PluginOptionString export type SettingsChecks<D extends SettingsDefinition> = {
| PluginOptionNumber [K in keyof D]?: D[K] extends PluginSettingComponentDef ? IsDisabled<DefinedSettings<D>> :
| PluginOptionBoolean (IsDisabled<DefinedSettings<D>> & IsValid<PluginSettingType<D[K]>, DefinedSettings<D>>);
| PluginOptionSelect };
| PluginOptionSlider
| PluginOptionComponent;
export interface PluginOptionBase { export type PluginSettingDef = (
| PluginSettingStringDef
| PluginSettingNumberDef
| PluginSettingBooleanDef
| PluginSettingSelectDef
| PluginSettingSliderDef
| PluginSettingComponentDef
) & PluginSettingCommon;
export interface PluginSettingCommon {
description: string; description: string;
placeholder?: string; placeholder?: string;
onChange?(newValue: any): void; onChange?(newValue: any): void;
disabled?(): boolean;
restartNeeded?: boolean; restartNeeded?: boolean;
componentProps?: Record<string, any>; componentProps?: Record<string, any>;
/** /**
@ -127,49 +139,47 @@ export interface PluginOptionBase {
*/ */
target?: "WEB" | "DESKTOP" | "BOTH"; target?: "WEB" | "DESKTOP" | "BOTH";
} }
interface IsDisabled<D = unknown> {
export interface PluginOptionString extends PluginOptionBase { /**
type: OptionType.STRING; * Checks if this setting should be disabled
*/
disabled?(this: D): boolean;
}
interface IsValid<T, D = unknown> {
/** /**
* Prevents the user from saving settings if this is false or a string * Prevents the user from saving settings if this is false or a string
*/ */
isValid?(value: string): boolean | string; isValid?(this: D, value: T): boolean | string;
}
export interface PluginSettingStringDef {
type: OptionType.STRING;
default?: string; default?: string;
} }
export interface PluginSettingNumberDef {
export interface PluginOptionNumber extends PluginOptionBase { type: OptionType.NUMBER;
type: OptionType.NUMBER | OptionType.BIGINT;
/**
* Prevents the user from saving settings if this is false or a string
*/
isValid?(value: number | BigInt): boolean | string;
default?: number; default?: number;
} }
export interface PluginSettingBigIntDef {
export interface PluginOptionBoolean extends PluginOptionBase { type: OptionType.BIGINT;
default?: BigInt;
}
export interface PluginSettingBooleanDef {
type: OptionType.BOOLEAN; type: OptionType.BOOLEAN;
/**
* Prevents the user from saving settings if this is false or a string
*/
isValid?(value: boolean): boolean | string;
default?: boolean; default?: boolean;
} }
export interface PluginOptionSelect extends PluginOptionBase { export interface PluginSettingSelectDef {
type: OptionType.SELECT; type: OptionType.SELECT;
/** options: readonly PluginSettingSelectOption[];
* Prevents the user from saving settings if this is false or a string
*/
isValid?(value: PluginOptionSelectOption): boolean | string;
options: PluginOptionSelectOption[];
} }
export interface PluginOptionSelectOption { export interface PluginSettingSelectOption {
label: string; label: string;
value: string | number | boolean; value: string | number | boolean;
default?: boolean; default?: boolean;
} }
export interface PluginOptionSlider extends PluginOptionBase { export interface PluginSettingSliderDef {
type: OptionType.SLIDER; type: OptionType.SLIDER;
/** /**
* All the possible values in the slider. Needs at least two values. * All the possible values in the slider. Needs at least two values.
@ -183,10 +193,6 @@ export interface PluginOptionSlider extends PluginOptionBase {
* If false, allow users to select values in-between your markers. * If false, allow users to select values in-between your markers.
*/ */
stickToMarkers?: boolean; stickToMarkers?: boolean;
/**
* Prevents the user from saving settings if this is false or a string
*/
isValid?(value: number): boolean | string;
} }
interface IPluginOptionComponentProps { interface IPluginOptionComponentProps {
@ -206,12 +212,67 @@ interface IPluginOptionComponentProps {
/** /**
* The options object * The options object
*/ */
option: PluginOptionComponent; option: PluginSettingComponentDef;
} }
export interface PluginOptionComponent extends PluginOptionBase { export interface PluginSettingComponentDef {
type: OptionType.COMPONENT; type: OptionType.COMPONENT;
component: (props: IPluginOptionComponentProps) => JSX.Element; component: (props: IPluginOptionComponentProps) => JSX.Element;
} }
/** Maps a `PluginSettingDef` to its value type */
type PluginSettingType<O extends PluginSettingDef> = O extends PluginSettingStringDef ? string :
O extends PluginSettingNumberDef ? number :
O extends PluginSettingBigIntDef ? BigInt :
O extends PluginSettingBooleanDef ? boolean :
O extends PluginSettingSelectDef ? O["options"][number]["value"] :
O extends PluginSettingSliderDef ? number :
O extends PluginSettingComponentDef ? any :
never;
type SettingsStore<D extends SettingsDefinition> = {
[K in keyof D]: PluginSettingType<D[K]>;
};
/** An instance of defined plugin settings */
export interface DefinedSettings<D extends SettingsDefinition = SettingsDefinition, C extends SettingsChecks<D> = {}> {
/** Shorthand for `Vencord.Settings.plugins.PluginName`, but with typings */
store: SettingsStore<D>;
/**
* React hook for getting the settings for this plugin
* @param filter optional filter to avoid rerenders for irrelavent settings
*/
use<F extends Extract<keyof D, string>>(filter?: F[]): Pick<SettingsStore<D>, F>;
/** Definitions of each setting */
def: D;
/** Setting methods with return values that could rely on other settings */
checks: C;
/**
* Name of the plugin these settings belong to,
* will be an empty string until plugin is initialized
*/
pluginName: string;
}
export type PartialExcept<T, R extends keyof T> = Partial<T> & Required<Pick<T, R>>;
export type IpcRes<V = any> = { ok: true; value: V; } | { ok: false, error: any; }; export type IpcRes<V = any> = { ok: true; value: V; } | { ok: false, error: any; };
/* -------------------------------------------- */
/* Legacy Options Types */
/* -------------------------------------------- */
export type PluginOptionBase = PluginSettingCommon & IsDisabled;
export type PluginOptionsItem =
| PluginOptionString
| PluginOptionNumber
| PluginOptionBoolean
| PluginOptionSelect
| PluginOptionSlider
| PluginOptionComponent;
export type PluginOptionString = PluginSettingStringDef & PluginSettingCommon & IsDisabled & IsValid<string>;
export type PluginOptionNumber = (PluginSettingNumberDef | PluginSettingBigIntDef) & PluginSettingCommon & IsDisabled & IsValid<number | BigInt>;
export type PluginOptionBoolean = PluginSettingBooleanDef & PluginSettingCommon & IsDisabled & IsValid<boolean>;
export type PluginOptionSelect = PluginSettingSelectDef & PluginSettingCommon & IsDisabled & IsValid<PluginSettingSelectOption>;
export type PluginOptionSlider = PluginSettingSliderDef & PluginSettingCommon & IsDisabled & IsValid<number>;
export type PluginOptionComponent = PluginSettingComponentDef & PluginSettingCommon;