feat(plugin): ShikiCodeblocks (#267)

Co-authored-by: ArjixWasTaken <53124886+ArjixWasTaken@users.noreply.github.com>
Co-authored-by: Ven <vendicated@riseup.net>
This commit is contained in:
Justice Almanzar 2022-12-02 10:43:37 -05:00 committed by GitHub
parent 4760af7f0e
commit 41dddc9eee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1480 additions and 105 deletions

View file

@ -39,11 +39,10 @@ export default function PronounsChatComponentWrapper({ message }: { message: Mes
}
function PronounsChatComponent({ message }: { message: Message; }) {
const [result, , isPending] = useAwaiter(
() => fetchPronouns(message.author.id),
null,
e => console.error("Fetching pronouns failed: ", e)
);
const [result, , isPending] = useAwaiter(() => fetchPronouns(message.author.id), {
fallbackValue: null,
onError: e => console.error("Fetching pronouns failed: ", e)
});
// If the promise completed, the result was not "unspecified", and there is a mapping for the code, then return a span with the pronouns
if (!isPending && result && result !== "unspecified" && PronounMapping[result]) {

View file

@ -45,11 +45,10 @@ function ProfilePronouns(
leProps: UserProfilePronounsProps;
}
) {
const [result, , isPending] = useAwaiter(
() => fetchPronouns(userId),
null,
e => console.error("Fetching pronouns failed: ", e)
);
const [result, , isPending] = useAwaiter(() => fetchPronouns(userId), {
fallbackValue: null,
onError: e => console.error("Fetching pronouns failed: ", e),
});
// If the promise completed, the result was not "unspecified", and there is a mapping for the code, then render
if (!isPending && result && result !== "unspecified" && PronounMapping[result]) {

View file

@ -18,7 +18,7 @@
import { classes, useAwaiter } from "@utils/misc";
import { findLazy } from "@webpack";
import { Forms, Text, UserStore } from "@webpack/common";
import { Forms, React, Text, UserStore } from "@webpack/common";
import type { KeyboardEvent } from "react";
import { addReview, getReviews } from "../Utils/ReviewDBAPI";
@ -27,7 +27,13 @@ import ReviewComponent from "./ReviewComponent";
const Classes = findLazy(m => typeof m.textarea === "string");
export default function ReviewsView({ userId }: { userId: string; }) {
const [reviews, _, isLoading, refetch] = useAwaiter(() => getReviews(userId), []);
const [refetchCount, setRefetchCount] = React.useState(0);
const [reviews, _, isLoading] = useAwaiter(() => getReviews(userId), {
fallbackValue: [],
deps: [refetchCount],
});
const dirtyRefetch = () => setRefetchCount(refetchCount + 1);
if (isLoading) return null;
@ -40,7 +46,7 @@ export default function ReviewsView({ userId }: { userId: string; }) {
}).then(res => {
if (res === 0 || res === 1) {
(target as HTMLInputElement).value = ""; // clear the input
refetch();
dirtyRefetch();
}
});
}
@ -64,7 +70,7 @@ export default function ReviewsView({ userId }: { userId: string; }) {
<ReviewComponent
key={review.id}
review={review}
refetch={refetch}
refetch={dirtyRefetch}
/>
)}
{reviews?.length === 0 && (

View file

@ -0,0 +1,74 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { ILanguageRegistration } from "@vap/shiki";
export const VPC_REPO = "Vap0r1ze/vapcord";
export const VPC_REPO_COMMIT = "88a7032a59cca40da170926651b08201ea3b965a";
export const vpcRepoAssets = `https://raw.githubusercontent.com/${VPC_REPO}/${VPC_REPO_COMMIT}/assets/shiki-codeblocks`;
export const vpcRepoGrammar = (fileName: string) => `${vpcRepoAssets}/${fileName}`;
export const vpcRepoLanguages = `${vpcRepoAssets}/languages.json`;
export interface Language {
name: string;
id: string;
devicon?: string;
grammarUrl: string,
grammar?: ILanguageRegistration["grammar"];
scopeName: string;
aliases?: string[];
custom?: boolean;
}
export interface LanguageJson {
name: string;
id: string;
fileName: string;
devicon?: string;
scopeName: string;
aliases?: string[];
}
export const languages: Record<string, Language> = {};
export const loadLanguages = async () => {
const langsJson: LanguageJson[] = await fetch(vpcRepoLanguages).then(res => res.json());
const loadedLanguages = Object.fromEntries(
langsJson.map(lang => [lang.id, {
...lang,
grammarUrl: vpcRepoGrammar(lang.fileName),
}])
);
Object.assign(languages, loadedLanguages);
};
export const getGrammar = (lang: Language): Promise<NonNullable<ILanguageRegistration["grammar"]>> => {
if (lang.grammar) return Promise.resolve(lang.grammar);
return fetch(lang.grammarUrl).then(res => res.json());
};
const aliasCache = new Map<string, Language>();
export function resolveLang(idOrAlias: string) {
if (Object.prototype.hasOwnProperty.call(languages, idOrAlias)) return languages[idOrAlias];
const lang = Object.values(languages).find(lang => lang.aliases?.includes(idOrAlias));
if (!lang) return null;
aliasCache.set(idOrAlias, lang);
return lang;
}

View file

@ -0,0 +1,119 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { shikiOnigasmSrc, shikiWorkerSrc } from "@utils/dependencies";
import { WorkerClient } from "@vap/core/ipc";
import type { IShikiTheme, IThemedToken } from "@vap/shiki";
import { dispatchTheme } from "../hooks/useTheme";
import type { ShikiSpec } from "../types";
import { getGrammar, languages, loadLanguages, resolveLang } from "./languages";
import { themes } from "./themes";
const themeUrls = Object.values(themes);
let resolveClient: (client: WorkerClient<ShikiSpec>) => void;
export const shiki = {
client: null as WorkerClient<ShikiSpec> | null,
currentTheme: null as IShikiTheme | null,
currentThemeUrl: null as string | null,
timeoutMs: 10000,
languages,
themes,
loadedThemes: new Set<string>(),
loadedLangs: new Set<string>(),
clientPromise: new Promise<WorkerClient<ShikiSpec>>(resolve => resolveClient = resolve),
init: async (initThemeUrl: string | undefined) => {
/** https://stackoverflow.com/q/58098143 */
const workerBlob = await fetch(shikiWorkerSrc).then(res => res.blob());
const client = shiki.client = new WorkerClient<ShikiSpec>(
"shiki-client",
"shiki-host",
workerBlob,
{ name: "ShikiWorker" },
);
await client.init();
const themeUrl = initThemeUrl || themeUrls[0];
await loadLanguages();
await client.run("setOnigasm", { wasm: shikiOnigasmSrc });
await client.run("setHighlighter", { theme: themeUrl, langs: [] });
shiki.loadedThemes.add(themeUrl);
await shiki._setTheme(themeUrl);
resolveClient(client);
},
_setTheme: async (themeUrl: string) => {
shiki.currentThemeUrl = themeUrl;
const { themeData } = await shiki.client!.run("getTheme", { theme: themeUrl });
shiki.currentTheme = JSON.parse(themeData);
dispatchTheme({ id: themeUrl, theme: shiki.currentTheme });
},
loadTheme: async (themeUrl: string) => {
const client = await shiki.clientPromise;
if (shiki.loadedThemes.has(themeUrl)) return;
await client.run("loadTheme", { theme: themeUrl });
shiki.loadedThemes.add(themeUrl);
},
setTheme: async (themeUrl: string) => {
await shiki.clientPromise;
themeUrl ||= themeUrls[0];
if (!shiki.loadedThemes.has(themeUrl)) await shiki.loadTheme(themeUrl);
await shiki._setTheme(themeUrl);
},
loadLang: async (langId: string) => {
const client = await shiki.clientPromise;
const lang = resolveLang(langId);
if (!lang || shiki.loadedLangs.has(lang.id)) return;
await client.run("loadLanguage", {
lang: {
...lang,
grammar: lang.grammar ?? await getGrammar(lang),
}
});
shiki.loadedLangs.add(lang.id);
},
tokenizeCode: async (code: string, langId: string): Promise<IThemedToken[][]> => {
const client = await shiki.clientPromise;
const lang = resolveLang(langId);
if (!lang) return [];
if (!shiki.loadedLangs.has(lang.id)) await shiki.loadLang(lang.id);
return await client.run("codeToThemedTokens", {
code,
lang: langId,
theme: shiki.currentThemeUrl ?? themeUrls[0],
});
},
destroy() {
shiki.currentTheme = null;
shiki.currentThemeUrl = null;
dispatchTheme({ id: null, theme: null });
shiki.client?.destroy();
}
};

View file

@ -0,0 +1,67 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { IShikiTheme } from "@vap/shiki";
export const SHIKI_REPO = "shikijs/shiki";
export const SHIKI_REPO_COMMIT = "0b28ad8ccfbf2615f2d9d38ea8255416b8ac3043";
export const shikiRepoTheme = (name: string) => `https://raw.githubusercontent.com/${SHIKI_REPO}/${SHIKI_REPO_COMMIT}/packages/shiki/themes/${name}.json`;
export const themes = {
// Default
DarkPlus: shikiRepoTheme("dark-plus"),
// Dev Choices
MaterialCandy: "https://raw.githubusercontent.com/millsp/material-candy/master/material-candy.json",
// More from Shiki repo
DraculaSoft: shikiRepoTheme("dracula-soft"),
Dracula: shikiRepoTheme("dracula"),
GithubDarkDimmed: shikiRepoTheme("github-dark-dimmed"),
GithubDark: shikiRepoTheme("github-dark"),
GithubLight: shikiRepoTheme("github-light"),
LightPlus: shikiRepoTheme("light-plus"),
MaterialDarker: shikiRepoTheme("material-darker"),
MaterialDefault: shikiRepoTheme("material-default"),
MaterialLighter: shikiRepoTheme("material-lighter"),
MaterialOcean: shikiRepoTheme("material-ocean"),
MaterialPalenight: shikiRepoTheme("material-palenight"),
MinDark: shikiRepoTheme("min-dark"),
MinLight: shikiRepoTheme("min-light"),
Monokai: shikiRepoTheme("monokai"),
Nord: shikiRepoTheme("nord"),
OneDarkPro: shikiRepoTheme("one-dark-pro"),
Poimandres: shikiRepoTheme("poimandres"),
RosePineDawn: shikiRepoTheme("rose-pine-dawn"),
RosePineMoon: shikiRepoTheme("rose-pine-moon"),
RosePine: shikiRepoTheme("rose-pine"),
SlackDark: shikiRepoTheme("slack-dark"),
SlackOchin: shikiRepoTheme("slack-ochin"),
SolarizedDark: shikiRepoTheme("solarized-dark"),
SolarizedLight: shikiRepoTheme("solarized-light"),
VitesseDark: shikiRepoTheme("vitesse-dark"),
VitesseLight: shikiRepoTheme("vitesse-light"),
CssVariables: shikiRepoTheme("css-variables"),
};
export const themeCache = new Map<string, IShikiTheme>();
export const getTheme = (url: string): Promise<IShikiTheme> => {
if (themeCache.has(url)) return Promise.resolve(themeCache.get(url)!);
return fetch(url).then(res => res.json());
};

View file

@ -0,0 +1,46 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { Clipboard } from "@webpack/common";
import { cl } from "../utils/misc";
import { CopyButton } from "./CopyButton";
export interface ButtonRowProps {
theme: import("./Highlighter").ThemeBase;
content: string;
}
export function ButtonRow({ content, theme }: ButtonRowProps) {
const buttons: JSX.Element[] = [];
if (Clipboard.SUPPORTS_COPY) {
buttons.push(
<CopyButton
content={content}
className={cl("btn")}
style={{
backgroundColor: theme.accentBgColor,
color: theme.accentFgColor,
}}
/>
);
}
return <div className={cl("btns")}>{buttons}</div>;
}

View file

@ -0,0 +1,92 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 type { IThemedToken } from "@vap/shiki";
import { cl } from "../utils/misc";
import { ThemeBase } from "./Highlighter";
export interface CodeProps {
theme: ThemeBase;
useHljs: boolean;
lang?: string;
content: string;
tokens: IThemedToken[][] | null;
}
export const Code = ({
theme,
useHljs,
lang,
content,
tokens,
}: CodeProps) => {
let lines!: JSX.Element[];
if (useHljs) {
try {
const { value: hljsHtml } = hljs.highlight(lang!, content, true);
lines = hljsHtml
.split("\n")
.map((line, i) => <span key={i} dangerouslySetInnerHTML={{ __html: line }} />);
} catch {
lines = content.split("\n").map(line => <span>{line}</span>);
}
} else {
const renderTokens =
tokens ??
content
.split("\n")
.map(line => [{ color: theme.plainColor, content: line } as IThemedToken]);
lines = renderTokens.map(line => {
// [Cynthia] this makes it so when you highlight the codeblock
// empty lines are also selected and copied when you Ctrl+C.
if (line.length === 0) {
return <span>{"\n"}</span>;
}
return (
<>
{line.map(({ content, color, fontStyle }, i) => (
<span
key={i}
style={{
color,
fontStyle: (fontStyle ?? 0) & 1 ? "italic" : undefined,
fontWeight: (fontStyle ?? 0) & 2 ? "bold" : undefined,
textDecoration: (fontStyle ?? 0) & 4 ? "underline" : undefined,
}}
>
{content}
</span>
))}
</>
);
});
}
const codeTableRows = lines.map((line, i) => (
<tr key={i}>
<td style={{ color: theme.plainColor }}>{i + 1}</td>
<td>{line}</td>
</tr>
));
return <table className={cl("table")}>{...codeTableRows}</table>;
};

View file

@ -0,0 +1,41 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { useCopyCooldown } from "../hooks/useCopyCooldown";
export interface CopyButtonProps extends React.DetailedHTMLProps<React.ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement> {
content: string;
}
export function CopyButton({ content, ...props }: CopyButtonProps) {
const [copyCooldown, copy] = useCopyCooldown(1000);
return (
<button
{...props}
style={{
...props.style,
cursor: copyCooldown ? "default" : undefined,
}}
onClick={() => copy(content)}
>
{copyCooldown ? "Copied!" : "Copy"}
</button>
);
}

View file

@ -0,0 +1,42 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { Language } from "../api/languages";
import { DeviconSetting } from "../types";
import { cl } from "../utils/misc";
export interface HeaderProps {
langName?: string;
useDevIcon: DeviconSetting;
shikiLang: Language | null;
}
export function Header({ langName, useDevIcon, shikiLang }: HeaderProps) {
if (!langName) return <></>;
return (
<div className={cl("lang")}>
{useDevIcon !== DeviconSetting.Disabled && shikiLang?.devicon && (
<i
className={`devicon-${shikiLang.devicon}${useDevIcon === DeviconSetting.Color ? " colored" : ""}`}
/>
)}
{langName}
</div>
);
}

View file

@ -0,0 +1,123 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 ErrorBoundary from "@components/ErrorBoundary";
import { useAwaiter } from "@utils/misc";
import { useIntersection } from "@utils/react";
import { hljs, React } from "@webpack/common";
import { resolveLang } from "../api/languages";
import { shiki } from "../api/shiki";
import { useShikiSettings } from "../hooks/useShikiSettings";
import { useTheme } from "../hooks/useTheme";
import { hex2Rgb } from "../utils/color";
import { cl, shouldUseHljs } from "../utils/misc";
import { ButtonRow } from "./ButtonRow";
import { Code } from "./Code";
import { Header } from "./Header";
export interface ThemeBase {
plainColor: string;
accentBgColor: string;
accentFgColor: string;
backgroundColor: string;
}
export interface HighlighterProps {
lang?: string;
content: string;
isPreview: boolean;
}
export const createHighlighter = (props: HighlighterProps) => (
<ErrorBoundary>
<Highlighter {...props} />
</ErrorBoundary>
);
export const Highlighter = ({
lang,
content,
isPreview,
}: HighlighterProps) => {
const { tryHljs, useDevIcon, bgOpacity } = useShikiSettings(["tryHljs", "useDevIcon", "bgOpacity"]);
const { id: currentThemeId, theme: currentTheme } = useTheme();
const shikiLang = lang ? resolveLang(lang) : null;
const useHljs = shouldUseHljs({ lang, tryHljs });
const [preRef, isIntersecting] = useIntersection(true);
const [tokens] = useAwaiter(async () => {
if (!shikiLang || useHljs || !isIntersecting) return null;
return await shiki.tokenizeCode(content, lang!);
}, {
fallbackValue: null,
deps: [lang, content, currentThemeId, isIntersecting],
});
const themeBase: ThemeBase = {
plainColor: currentTheme?.fg || "var(--text-normal)",
accentBgColor:
currentTheme?.colors?.["statusBar.background"] || (useHljs ? "#7289da" : "#007BC8"),
accentFgColor: currentTheme?.colors?.["statusBar.foreground"] || "#FFF",
backgroundColor:
currentTheme?.colors?.["editor.background"] || "var(--background-secondary)",
};
let langName;
if (lang) langName = useHljs ? hljs?.getLanguage?.(lang)?.name : shikiLang?.name;
const preClasses = [cl("root")];
if (!langName) preClasses.push(cl("plain"));
if (isPreview) preClasses.push(cl("preview"));
return (
<pre
ref={preRef}
className={preClasses.join(" ")}
style={{
backgroundColor: useHljs
? themeBase.backgroundColor
: `rgba(${hex2Rgb(themeBase.backgroundColor)
.concat(bgOpacity / 100)
.join(", ")})`,
color: themeBase.plainColor,
}}
>
<code>
<Header
langName={langName}
useDevIcon={useDevIcon}
shikiLang={shikiLang}
/>
<Code
theme={themeBase}
useHljs={useHljs}
lang={lang}
content={content}
tokens={tokens}
/>
{!isPreview && <ButtonRow
content={content}
theme={themeBase}
/>}
</code>
</pre>
);
};

View file

@ -0,0 +1,34 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { Clipboard, React } from "@webpack/common";
export function useCopyCooldown(cooldown: number) {
const [copyCooldown, setCopyCooldown] = React.useState(false);
function copy(text: string) {
Clipboard.copy(text);
setCopyCooldown(true);
setTimeout(() => {
setCopyCooldown(false);
}, cooldown);
}
return [copyCooldown, copy] as const;
}

View file

@ -0,0 +1,25 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { useSettings } from "@api/settings";
import { ShikiSettings } from "../types";
export function useShikiSettings(settings: (keyof ShikiSettings)[]) {
return useSettings(settings.map(setting => `plugins.ShikiCodeblocks.${setting}`)).plugins.ShikiCodeblocks as ShikiSettings;
}

View file

@ -0,0 +1,49 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { React } from "@webpack/common";
type Shiki = typeof import("../api/shiki").shiki;
interface ThemeState {
id: Shiki["currentThemeUrl"],
theme: Shiki["currentTheme"],
}
const currentTheme: ThemeState = {
id: null,
theme: null,
};
const themeSetters = new Set<React.Dispatch<React.SetStateAction<ThemeState>>>();
export const useTheme = (): ThemeState => {
const [, setTheme] = React.useState<ThemeState>(currentTheme);
React.useEffect(() => {
themeSetters.add(setTheme);
return () => void themeSetters.delete(setTheme);
}, []);
return currentTheme;
};
export function dispatchTheme(state: ThemeState) {
if (currentTheme.id === state.id) return;
Object.assign(currentTheme, state);
themeSetters.forEach(setTheme => setTheme(state));
}

View file

@ -0,0 +1,154 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { Devs } from "@utils/constants";
import { parseUrl } from "@utils/misc";
import { wordsFromPascal, wordsToTitle } from "@utils/text";
import definePlugin, { OptionType } from "@utils/types";
import cssText from "~fileContent/style.css";
import { Settings } from "../../Vencord";
import { shiki } from "./api/shiki";
import { themes } from "./api/themes";
import { createHighlighter } from "./components/Highlighter";
import { DeviconSetting, HljsSetting, ShikiSettings, StyleSheets } from "./types";
import { clearStyles, removeStyle, setStyle } from "./utils/createStyle";
const themeNames = Object.keys(themes);
const devIconCss = "@import url('https://cdn.jsdelivr.net/gh/devicons/devicon@v2.10.1/devicon.min.css');";
const getSettings = () => Settings.plugins.ShikiCodeblocks as ShikiSettings;
export default definePlugin({
name: "ShikiCodeblocks",
description: "Brings vscode-style codeblocks into Discord, powered by Shiki",
authors: [Devs.Vap],
patches: [
{
find: "codeBlock:{react:function",
replacement: {
match: /codeBlock:\{react:function\((.),(.),(.)\)\{/,
replace: "$&return Vencord.Plugins.plugins.ShikiCodeblocks.renderHighlighter($1,$2,$3);",
},
},
],
start: async () => {
setStyle(cssText, StyleSheets.Main);
if (getSettings().useDevIcon !== DeviconSetting.Disabled)
setStyle(devIconCss, StyleSheets.DevIcons);
await shiki.init(getSettings().customTheme || getSettings().theme);
},
stop: () => {
shiki.destroy();
clearStyles();
},
options: {
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) removeStyle(StyleSheets.DevIcons);
else setStyle(devIconCss, StyleSheets.DevIcons);
},
},
bgOpacity: {
type: OptionType.SLIDER,
description: "Background opacity",
markers: [0, 20, 40, 60, 80, 100],
default: 100,
stickToMarkers: false,
},
},
// exports
shiki,
createHighlighter,
renderHighlighter: ({ lang, content }: { lang: string; content: string; }) => {
return createHighlighter({
lang,
content,
isPreview: false,
});
},
});

View file

@ -0,0 +1,100 @@
.shiki-root {
border-radius: 4px;
}
.shiki-root code {
display: block;
overflow-x: auto;
padding: 0.5em;
position: relative;
font-size: 0.875rem;
line-height: 1.125rem;
text-indent: 0;
white-space: pre-wrap;
background: transparent;
border: none;
}
.shiki-root [class^='devicon-'],
.shiki-root [class*=' devicon-'] {
margin-right: 8px;
user-select: none;
}
.shiki-plain code {
padding-top: 8px;
}
.shiki-btns {
font-size: 1em;
position: absolute;
right: 0;
bottom: 0;
opacity: 0;
}
.shiki-root:hover .shiki-btns {
opacity: 1;
}
.shiki-btn {
border-radius: 4px 4px 0 0;
padding: 4px 8px;
}
.shiki-btn~.shiki-btn {
margin-left: 4px;
}
.shiki-btn:last-child {
border-radius: 4px 0;
}
.shiki-spinner-container {
align-items: center;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
position: absolute;
justify-content: center;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.shiki-preview {
margin-bottom: 2em;
}
.shiki-lang {
padding: 0 5px;
margin-bottom: 6px;
font-weight: bold;
text-transform: capitalize;
display: flex;
align-items: center;
}
.shiki-table {
border-collapse: collapse;
width: 100%;
}
.shiki-table tr {
height: 19px;
width: 100%;
}
.shiki-root td:first-child {
border-right: 1px solid transparent;
padding-left: 5px;
padding-right: 8px;
user-select: none;
}
.shiki-root td:last-child {
padding-left: 8px;
word-break: break-word;
width: 100%;
}

View file

@ -0,0 +1,78 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 type {
ILanguageRegistration,
IShikiTheme,
IThemedToken,
IThemeRegistration,
} from "@vap/shiki";
import type { Settings } from "../../Vencord";
/** This must be atleast a subset of the `@vap/shiki-worker` spec */
export type ShikiSpec = {
setOnigasm: ({ wasm }: { wasm: string; }) => Promise<void>;
setHighlighter: ({ theme, langs }: {
theme: IThemeRegistration | void;
langs: ILanguageRegistration[];
}) => Promise<void>;
loadTheme: ({ theme }: {
theme: string | IShikiTheme;
}) => Promise<void>;
getTheme: ({ theme }: { theme: string; }) => Promise<{ themeData: string; }>;
loadLanguage: ({ lang }: { lang: ILanguageRegistration; }) => Promise<void>;
codeToThemedTokens: ({
code,
lang,
theme,
}: {
code: string;
lang?: string;
theme?: string;
}) => Promise<IThemedToken[][]>;
};
export enum StyleSheets {
Main = "MAIN",
DevIcons = "DEVICONS",
}
export enum HljsSetting {
Never = "NEVER",
Secondary = "SECONDARY",
Primary = "PRIMARY",
Always = "ALWAYS",
}
export enum DeviconSetting {
Disabled = "DISABLED",
Greyscale = "GREYSCALE",
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;
}

View file

@ -0,0 +1,32 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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/>.
*/
export function hex2Rgb(hex: string) {
hex = hex.slice(1);
if (hex.length < 6)
hex = hex
.split("")
.map(c => c + c)
.join("");
if (hex.length === 6) hex += "ff";
if (hex.length > 6) hex = hex.slice(0, 6);
return hex
.split(/(..)/)
.filter(Boolean)
.map(c => parseInt(c, 16));
}

View file

@ -0,0 +1,36 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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/>.
*/
const styles = new Map<string, HTMLStyleElement>();
export function setStyle(css: string, id: string) {
const style = document.createElement("style");
style.innerText = css;
document.head.appendChild(style);
styles.set(id, style);
}
export function removeStyle(id: string) {
styles.get(id)?.remove();
return styles.delete(id);
}
export const clearStyles = () => {
styles.forEach(style => style.remove());
styles.clear();
};

View file

@ -0,0 +1,50 @@
/*
* Vencord, a modification for Discord's desktop app
* Copyright (c) 2022 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 { hljs } from "@webpack/common";
import { resolveLang } from "../api/languages";
import { HighlighterProps } from "../components/Highlighter";
import { HljsSetting, ShikiSettings } from "../types";
export const cl = (className: string) => `shiki-${className}`;
export const shouldUseHljs = ({
lang,
tryHljs,
}: {
lang: HighlighterProps["lang"],
tryHljs: ShikiSettings["tryHljs"],
}) => {
const hljsLang = lang ? hljs?.getLanguage?.(lang) : null;
const shikiLang = lang ? resolveLang(lang) : null;
const langName = shikiLang?.name;
switch (tryHljs) {
case HljsSetting.Always:
return true;
case HljsSetting.Primary:
return !!hljsLang || lang === "";
case HljsSetting.Secondary:
return !langName && !!hljsLang;
case HljsSetting.Never:
return false;
}
return false;
};