feat: initial usercss support
Parses UserCSS/UserStyle files (.user.css) but doesn't do anything special yet with the variables. This is a first step towards supporting UserCSS themes.
This commit is contained in:
parent
c165725297
commit
2ef2baafbe
|
@ -37,6 +37,7 @@
|
|||
"eslint-plugin-simple-header": "^1.0.2",
|
||||
"fflate": "^0.7.4",
|
||||
"nanoid": "^4.0.2",
|
||||
"usercss-meta": "^0.12.0",
|
||||
"virtual-merge": "^1.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
@ -27,6 +27,9 @@ dependencies:
|
|||
nanoid:
|
||||
specifier: ^4.0.2
|
||||
version: 4.0.2
|
||||
usercss-meta:
|
||||
specifier: ^0.12.0
|
||||
version: 0.12.0
|
||||
virtual-merge:
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1
|
||||
|
@ -3246,6 +3249,11 @@ packages:
|
|||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/usercss-meta@0.12.0:
|
||||
resolution: {integrity: sha512-zKrXCKdpeIwtVe87omxGo9URf+7mbozduMZEg79dmT4KB3XJwfIkEi/Uk0PcTwR/nZLtAK1+k7isgbGB/g6E7Q==}
|
||||
engines: {node: '>=8.3'}
|
||||
dev: false
|
||||
|
||||
/util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
dev: true
|
||||
|
|
|
@ -7,7 +7,8 @@
|
|||
import { IpcEvents } from "@utils/IpcEvents";
|
||||
import { IpcRes } from "@utils/types";
|
||||
import { ipcRenderer } from "electron";
|
||||
import type { UserThemeHeader } from "main/themes";
|
||||
|
||||
import type { ThemeHeader } from "./main/themes";
|
||||
|
||||
function invoke<T = any>(event: IpcEvents, ...args: any[]) {
|
||||
return ipcRenderer.invoke(event, ...args) as Promise<T>;
|
||||
|
@ -22,7 +23,7 @@ export default {
|
|||
uploadTheme: (fileName: string, fileData: string) => invoke<void>(IpcEvents.UPLOAD_THEME, fileName, fileData),
|
||||
deleteTheme: (fileName: string) => invoke<void>(IpcEvents.DELETE_THEME, fileName),
|
||||
getThemesDir: () => invoke<string>(IpcEvents.GET_THEMES_DIR),
|
||||
getThemesList: () => invoke<Array<UserThemeHeader>>(IpcEvents.GET_THEMES_LIST),
|
||||
getThemesList: () => invoke<Array<ThemeHeader>>(IpcEvents.GET_THEMES_LIST),
|
||||
getThemeData: (fileName: string) => invoke<string | undefined>(IpcEvents.GET_THEME_DATA, fileName)
|
||||
},
|
||||
|
||||
|
|
|
@ -26,9 +26,11 @@ import { showItemInFolder } from "@utils/native";
|
|||
import { useAwaiter } from "@utils/react";
|
||||
import { findByCodeLazy, findByPropsLazy, findLazy } from "@webpack";
|
||||
import { Button, Card, FluxDispatcher, Forms, React, showToast, TabBar, TextArea, useEffect, useRef, useState } from "@webpack/common";
|
||||
import { UserThemeHeader } from "main/themes";
|
||||
import { UserThemeHeader } from "main/themes/bd";
|
||||
import type { ComponentType, Ref, SyntheticEvent } from "react";
|
||||
import { UserstyleHeader } from "usercss-meta";
|
||||
|
||||
import type { ThemeHeader } from "../../main/themes";
|
||||
import { AddonCard } from "./AddonCard";
|
||||
import { SettingsTab, wrapTab } from "./shared";
|
||||
|
||||
|
@ -41,6 +43,7 @@ type FileInput = ComponentType<{
|
|||
|
||||
const InviteActions = findByPropsLazy("resolveInvite");
|
||||
const TrashIcon = findByCodeLazy("M5 6.99902V18.999C5 20.101 5.897 20.999");
|
||||
const CogWheel = findByCodeLazy("18.564C15.797 19.099 14.932 19.498 14 19.738V22H10V19.738C9.069");
|
||||
const FileInput: FileInput = findByCodeLazy("activateUploadDialogue=");
|
||||
const TextAreaProps = findLazy(m => typeof m.textarea === "string");
|
||||
|
||||
|
@ -94,14 +97,52 @@ function Validators({ themeLinks }: { themeLinks: string[]; }) {
|
|||
);
|
||||
}
|
||||
|
||||
interface ThemeCardProps {
|
||||
interface BDThemeCardProps {
|
||||
theme: UserThemeHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function ThemeCard({ theme, enabled, onChange, onDelete }: ThemeCardProps) {
|
||||
interface UserCSSCardProps {
|
||||
theme: UserstyleHeader;
|
||||
enabled: boolean;
|
||||
onChange: (enabled: boolean) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function UserCSSThemeCard({ theme, enabled, onChange, onDelete }: UserCSSCardProps) {
|
||||
return (
|
||||
<AddonCard
|
||||
name={theme.name}
|
||||
description={theme.description}
|
||||
author={theme.author ?? "Unknown"}
|
||||
enabled={enabled}
|
||||
setEnabled={onChange}
|
||||
infoButton={
|
||||
<>
|
||||
<div style={{ cursor: "pointer" }}>
|
||||
<CogWheel />
|
||||
</div>
|
||||
{IS_WEB && (
|
||||
<div style={{ cursor: "pointer", color: "var(--status-danger" }} onClick={onDelete}>
|
||||
<TrashIcon />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
footer={
|
||||
<Flex flexDirection="row" style={{ gap: "0.2em" }}>
|
||||
{!!theme.homepageURL && <Link href={theme.homepageURL}>Homepage</Link>}
|
||||
{!!(theme.homepageURL && theme.supportURL) && " • "}
|
||||
{!!theme.supportURL && <Link href={theme.supportURL}>Support</Link>}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BDThemeCard({ theme, enabled, onChange, onDelete }: BDThemeCardProps) {
|
||||
return (
|
||||
<AddonCard
|
||||
name={theme.name}
|
||||
|
@ -156,7 +197,7 @@ function ThemesTab() {
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [currentTab, setCurrentTab] = useState(ThemeTab.LOCAL);
|
||||
const [themeText, setThemeText] = useState(settings.themeLinks.join("\n"));
|
||||
const [userThemes, setUserThemes] = useState<UserThemeHeader[] | null>(null);
|
||||
const [userThemes, setUserThemes] = useState<ThemeHeader[] | null>(null);
|
||||
const [themeDir, , themeDirPending] = useAwaiter(VencordNative.themes.getThemesDir);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -259,8 +300,9 @@ function ThemesTab() {
|
|||
</Card>
|
||||
|
||||
<div className={cl("grid")}>
|
||||
{userThemes?.map(theme => (
|
||||
<ThemeCard
|
||||
{userThemes?.map(({ type, header: theme }: ThemeHeader) => (
|
||||
type === "bd" ? (
|
||||
<BDThemeCard
|
||||
key={theme.fileName}
|
||||
enabled={settings.enabledThemes.includes(theme.fileName)}
|
||||
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
|
||||
|
@ -269,9 +311,21 @@ function ThemesTab() {
|
|||
await VencordNative.themes.deleteTheme(theme.fileName);
|
||||
refreshLocalThemes();
|
||||
}}
|
||||
theme={theme}
|
||||
theme={theme as UserThemeHeader}
|
||||
/>
|
||||
))}
|
||||
) : (
|
||||
<UserCSSThemeCard
|
||||
key={theme.fileName}
|
||||
enabled={settings.enabledThemes.includes(theme.fileName)}
|
||||
onChange={enabled => onLocalThemeChange(theme.fileName, enabled)}
|
||||
onDelete={async () => {
|
||||
onLocalThemeChange(theme.fileName, false);
|
||||
await VencordNative.themes.deleteTheme(theme.fileName);
|
||||
refreshLocalThemes();
|
||||
}}
|
||||
theme={theme as UserstyleHeader}
|
||||
/>
|
||||
)))}
|
||||
</div>
|
||||
</Forms.FormSection>
|
||||
</>
|
||||
|
|
|
@ -29,7 +29,9 @@ import { join, normalize } from "path";
|
|||
|
||||
import monacoHtml from "~fileContent/../components/monacoWin.html;base64";
|
||||
|
||||
import { getThemeInfo, stripBOM, UserThemeHeader } from "./themes";
|
||||
import type { ThemeHeader } from "./themes";
|
||||
import { getThemeInfo, stripBOM } from "./themes/bd";
|
||||
import { parse as usercssParse } from "./themes/usercss";
|
||||
import { ALLOWED_PROTOCOLS, QUICKCSS_PATH, SETTINGS_DIR, SETTINGS_FILE, THEMES_DIR } from "./utils/constants";
|
||||
import { makeLinksOpenExternally } from "./utils/externalLinks";
|
||||
|
||||
|
@ -47,10 +49,10 @@ function readCss() {
|
|||
return readFile(QUICKCSS_PATH, "utf-8").catch(() => "");
|
||||
}
|
||||
|
||||
async function listThemes(): Promise<UserThemeHeader[]> {
|
||||
async function listThemes(): Promise<ThemeHeader[]> {
|
||||
const files = await readdir(THEMES_DIR).catch(() => []);
|
||||
|
||||
const themeInfo: UserThemeHeader[] = [];
|
||||
const themeInfo: ThemeHeader[] = [];
|
||||
|
||||
for (const fileName of files) {
|
||||
if (!fileName.endsWith(".css")) continue;
|
||||
|
@ -58,7 +60,19 @@ async function listThemes(): Promise<UserThemeHeader[]> {
|
|||
const data = await getThemeData(fileName).then(stripBOM).catch(() => null);
|
||||
if (data == null) continue;
|
||||
|
||||
themeInfo.push(getThemeInfo(data, fileName));
|
||||
if (fileName.endsWith(".user.css")) {
|
||||
// handle it as usercss
|
||||
themeInfo.push({
|
||||
type: "usercss",
|
||||
header: usercssParse(data, fileName)
|
||||
});
|
||||
} else {
|
||||
// presumably BD but could also be plain css
|
||||
themeInfo.push({
|
||||
type: "bd",
|
||||
header: getThemeInfo(data, fileName)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return themeInfo;
|
||||
|
|
81
src/main/themes/bd/index.ts
Normal file
81
src/main/themes/bd/index.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
/* eslint-disable simple-header/header */
|
||||
|
||||
/*!
|
||||
* BetterDiscord addon meta parser
|
||||
* Copyright 2023 BetterDiscord contributors
|
||||
* Copyright 2023 Vendicated and Vencord contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const splitRegex = /[^\S\r\n]*?\r?(?:\r\n|\n)[^\S\r\n]*?\*[^\S\r\n]?/;
|
||||
const escapedAtRegex = /^\\@/;
|
||||
|
||||
export interface UserThemeHeader {
|
||||
fileName: string;
|
||||
name: string;
|
||||
author: string;
|
||||
description: string;
|
||||
version?: string;
|
||||
license?: string;
|
||||
source?: string;
|
||||
website?: string;
|
||||
invite?: string;
|
||||
}
|
||||
|
||||
function makeHeader(fileName: string, opts: Partial<UserThemeHeader> = {}): UserThemeHeader {
|
||||
return {
|
||||
fileName,
|
||||
name: opts.name ?? fileName.replace(/\.css$/i, ""),
|
||||
author: opts.author ?? "Unknown Author",
|
||||
description: opts.description ?? "A Discord Theme.",
|
||||
version: opts.version,
|
||||
license: opts.license,
|
||||
source: opts.source,
|
||||
website: opts.website,
|
||||
invite: opts.invite
|
||||
};
|
||||
}
|
||||
|
||||
export function stripBOM(fileContent: string) {
|
||||
if (fileContent.charCodeAt(0) === 0xFEFF) {
|
||||
fileContent = fileContent.slice(1);
|
||||
}
|
||||
return fileContent;
|
||||
}
|
||||
|
||||
export function getThemeInfo(css: string, fileName: string): UserThemeHeader {
|
||||
if (!css) return makeHeader(fileName);
|
||||
|
||||
const block = css.split("/**", 2)?.[1]?.split("*/", 1)?.[0];
|
||||
if (!block) return makeHeader(fileName);
|
||||
|
||||
const header: Partial<UserThemeHeader> = {};
|
||||
let field = "";
|
||||
let accum = "";
|
||||
for (const line of block.split(splitRegex)) {
|
||||
if (line.length === 0) continue;
|
||||
if (line.charAt(0) === "@" && line.charAt(1) !== " ") {
|
||||
header[field] = accum.trim();
|
||||
const l = line.indexOf(" ");
|
||||
field = line.substring(1, l);
|
||||
accum = line.substring(l + 1);
|
||||
}
|
||||
else {
|
||||
accum += " " + line.replace("\\n", "\n").replace(escapedAtRegex, "@");
|
||||
}
|
||||
}
|
||||
header[field] = accum.trim();
|
||||
delete header[""];
|
||||
return makeHeader(fileName, header);
|
||||
}
|
|
@ -1,81 +1,17 @@
|
|||
/* eslint-disable simple-header/header */
|
||||
|
||||
/*!
|
||||
* BetterDiscord addon meta parser
|
||||
* Copyright 2023 BetterDiscord contributors
|
||||
* Copyright 2023 Vendicated and Vencord contributors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
const splitRegex = /[^\S\r\n]*?\r?(?:\r\n|\n)[^\S\r\n]*?\*[^\S\r\n]?/;
|
||||
const escapedAtRegex = /^\\@/;
|
||||
import type { UserstyleHeader } from "usercss-meta";
|
||||
|
||||
export interface UserThemeHeader {
|
||||
fileName: string;
|
||||
name: string;
|
||||
author: string;
|
||||
description: string;
|
||||
version?: string;
|
||||
license?: string;
|
||||
source?: string;
|
||||
website?: string;
|
||||
invite?: string;
|
||||
}
|
||||
import type { UserThemeHeader } from "./bd";
|
||||
|
||||
function makeHeader(fileName: string, opts: Partial<UserThemeHeader> = {}): UserThemeHeader {
|
||||
return {
|
||||
fileName,
|
||||
name: opts.name ?? fileName.replace(/\.css$/i, ""),
|
||||
author: opts.author ?? "Unknown Author",
|
||||
description: opts.description ?? "A Discord Theme.",
|
||||
version: opts.version,
|
||||
license: opts.license,
|
||||
source: opts.source,
|
||||
website: opts.website,
|
||||
invite: opts.invite
|
||||
};
|
||||
}
|
||||
|
||||
export function stripBOM(fileContent: string) {
|
||||
if (fileContent.charCodeAt(0) === 0xFEFF) {
|
||||
fileContent = fileContent.slice(1);
|
||||
}
|
||||
return fileContent;
|
||||
}
|
||||
|
||||
export function getThemeInfo(css: string, fileName: string): UserThemeHeader {
|
||||
if (!css) return makeHeader(fileName);
|
||||
|
||||
const block = css.split("/**", 2)?.[1]?.split("*/", 1)?.[0];
|
||||
if (!block) return makeHeader(fileName);
|
||||
|
||||
const header: Partial<UserThemeHeader> = {};
|
||||
let field = "";
|
||||
let accum = "";
|
||||
for (const line of block.split(splitRegex)) {
|
||||
if (line.length === 0) continue;
|
||||
if (line.charAt(0) === "@" && line.charAt(1) !== " ") {
|
||||
header[field] = accum.trim();
|
||||
const l = line.indexOf(" ");
|
||||
field = line.substring(1, l);
|
||||
accum = line.substring(l + 1);
|
||||
}
|
||||
else {
|
||||
accum += " " + line.replace("\\n", "\n").replace(escapedAtRegex, "@");
|
||||
}
|
||||
}
|
||||
header[field] = accum.trim();
|
||||
delete header[""];
|
||||
return makeHeader(fileName, header);
|
||||
}
|
||||
export type ThemeHeader = {
|
||||
type: "bd";
|
||||
header: UserThemeHeader;
|
||||
} | {
|
||||
type: "usercss";
|
||||
header: UserstyleHeader;
|
||||
};
|
||||
|
|
15
src/main/themes/usercss/index.ts
Normal file
15
src/main/themes/usercss/index.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { parse as originalParse, UserstyleHeader } from "usercss-meta";
|
||||
|
||||
export function parse(text: string, fileName: string): UserstyleHeader {
|
||||
const { metadata } = originalParse(text.replace(/\r/g, ""));
|
||||
return {
|
||||
...metadata,
|
||||
fileName,
|
||||
};
|
||||
}
|
108
src/main/themes/usercss/usercss-meta.d.ts
vendored
Normal file
108
src/main/themes/usercss/usercss-meta.d.ts
vendored
Normal file
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
declare module "usercss-meta" {
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
export type UserCSSVariable = Simplify<{ name: string; label: string; } & (
|
||||
| {
|
||||
type: "text";
|
||||
default: string;
|
||||
}
|
||||
| {
|
||||
type: "color";
|
||||
// Hex, rgb(), rgba()
|
||||
default: string;
|
||||
}
|
||||
| {
|
||||
type: "checkbox";
|
||||
default: boolean;
|
||||
}
|
||||
| {
|
||||
type: "range";
|
||||
default: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
units?: string;
|
||||
}
|
||||
| {
|
||||
type: "number";
|
||||
default: number;
|
||||
}
|
||||
| {
|
||||
type: "select";
|
||||
default: string;
|
||||
options: Record<string, string>;
|
||||
}
|
||||
)>;
|
||||
|
||||
export interface UserstyleHeader {
|
||||
/**
|
||||
* The file name of the UserCSS style.
|
||||
*
|
||||
* @vencord Specific to Vencord, not part of the original module.
|
||||
*/
|
||||
fileName: string;
|
||||
|
||||
/**
|
||||
* The name of your style.
|
||||
*
|
||||
* The combination of {@link name} and {@link namespace} must be unique.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* The namespace of the style. Helps to distinguish between styles with the same name.
|
||||
*
|
||||
* The combination of {@link name} and {@link namespace} must be unique.
|
||||
*/
|
||||
namespace: string;
|
||||
/**
|
||||
* The version of your style.
|
||||
*/
|
||||
version: string;
|
||||
|
||||
/**
|
||||
* A short significant description.
|
||||
*/
|
||||
description?: string;
|
||||
/**
|
||||
* The author of the style.
|
||||
*/
|
||||
author?: string;
|
||||
/**
|
||||
* The project's homepage.
|
||||
*
|
||||
* This is not an update URL. See {@link updateURL}.
|
||||
*/
|
||||
homepageURL?: string;
|
||||
/**
|
||||
* The URL the user can report issues to the style author.
|
||||
*/
|
||||
supportURL?: string;
|
||||
/**
|
||||
* The URL used when updating the style.
|
||||
*/
|
||||
updateURL?: string;
|
||||
/**
|
||||
* The SPDX license identifier for this style. If none is included, the style is assumed to be All Rights Reserved.
|
||||
*/
|
||||
license?: string;
|
||||
/**
|
||||
* The CSS preprocessor used to write this style.
|
||||
*
|
||||
* @vencord Unimplemented in Vencord, just part of the metadata.
|
||||
*/
|
||||
preprocessor?: string;
|
||||
|
||||
/**
|
||||
* A list of variables the style defines.
|
||||
*/
|
||||
vars: Record<string, UserCSSVariable>;
|
||||
}
|
||||
|
||||
export function parse(text: string): { metadata: UserstyleHeader; };
|
||||
}
|
Loading…
Reference in a new issue