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:
Lewis Crichton 2023-09-02 17:51:17 +01:00
parent c165725297
commit 2ef2baafbe
No known key found for this signature in database
10 changed files with 318 additions and 100 deletions

View file

@ -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": {

View file

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

View file

@ -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)
},

View file

@ -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,19 +300,32 @@ function ThemesTab() {
</Card>
<div className={cl("grid")}>
{userThemes?.map(theme => (
<ThemeCard
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}
/>
))}
{userThemes?.map(({ type, header: theme }: ThemeHeader) => (
type === "bd" ? (
<BDThemeCard
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 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>
</>

View file

@ -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;

View 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);
}

View file

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

View 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,
};
}

View 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; };
}