From 723191ba9b7e3b529b57e887060c749e7c7e3207 Mon Sep 17 00:00:00 2001 From: Lewis Crichton Date: Fri, 8 Sep 2023 14:59:41 +0100 Subject: [PATCH] feat: usercss compilation and better settings storage --- package.json | 5 +- patches/@types__less@3.0.4.patch | 13 ++++ pnpm-lock.yaml | 20 +++++ src/api/Settings.ts | 6 +- src/utils/dependencies.ts | 18 +++++ src/utils/quickCss.ts | 41 +++++----- src/utils/themes/usercss/compiler.ts | 88 ++++++++++++++++++++++ src/utils/themes/usercss/index.ts | 10 ++- src/utils/themes/usercss/usercss-meta.d.ts | 6 +- 9 files changed, 176 insertions(+), 31 deletions(-) create mode 100644 patches/@types__less@3.0.4.patch create mode 100644 src/utils/themes/usercss/compiler.ts diff --git a/package.json b/package.json index 4df9ba4e6..da7d7762e 100644 --- a/package.json +++ b/package.json @@ -42,10 +42,12 @@ }, "devDependencies": { "@types/diff": "^5.0.3", + "@types/less": "^3.0.4", "@types/lodash": "^4.14.194", "@types/node": "^18.16.3", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.1", + "@types/stylus": "^0.48.39", "@types/yazl": "^2.4.2", "@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/parser": "^5.59.1", @@ -71,7 +73,8 @@ "pnpm": { "patchedDependencies": { "eslint-plugin-path-alias@1.0.0": "patches/eslint-plugin-path-alias@1.0.0.patch", - "eslint@8.46.0": "patches/eslint@8.46.0.patch" + "eslint@8.46.0": "patches/eslint@8.46.0.patch", + "@types/less@3.0.4": "patches/@types__less@3.0.4.patch" }, "peerDependencyRules": { "ignoreMissing": [ diff --git a/patches/@types__less@3.0.4.patch b/patches/@types__less@3.0.4.patch new file mode 100644 index 000000000..b9791284d --- /dev/null +++ b/patches/@types__less@3.0.4.patch @@ -0,0 +1,13 @@ +diff --git a/index.d.ts b/index.d.ts +index eb4f07d47b932fb9cc8c8cd451ab107f648bd013..18a3e15a1997734e1773718e5be55d252ed9478c 100644 +--- a/index.d.ts ++++ b/index.d.ts +@@ -306,7 +306,5 @@ interface LessStatic { + } + + declare module "less" { +- export = less; ++ export = LessStatic; + } +- +-declare var less: LessStatic; \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbbfd5db9..4e8fe1d46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,6 +1,9 @@ lockfileVersion: '6.0' patchedDependencies: + '@types/less@3.0.4': + hash: krcufrsfhsuxuoj7hocqugs6zi + path: patches/@types__less@3.0.4.patch eslint-plugin-path-alias@1.0.0: hash: m6sma4g6bh67km3q6igf6uxaja path: patches/eslint-plugin-path-alias@1.0.0.patch @@ -38,6 +41,9 @@ devDependencies: '@types/diff': specifier: ^5.0.3 version: 5.0.3 + '@types/less': + specifier: ^3.0.4 + version: 3.0.4(patch_hash=krcufrsfhsuxuoj7hocqugs6zi) '@types/lodash': specifier: ^4.14.194 version: 4.14.194 @@ -50,6 +56,9 @@ devDependencies: '@types/react-dom': specifier: ^18.2.1 version: 18.2.1 + '@types/stylus': + specifier: ^0.48.39 + version: 0.48.39 '@types/yazl': specifier: ^2.4.2 version: 2.4.2 @@ -531,6 +540,11 @@ packages: resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==} dev: true + /@types/less@3.0.4(patch_hash=krcufrsfhsuxuoj7hocqugs6zi): + resolution: {integrity: sha512-djlMpTdDF+tLaqVpK/0DWGNIr7BFjN8ykDLkgS0sQGYYLop51imRRE3foTjl+dMAH1zFE8bMZAG0VbYPEcSgsA==} + dev: true + patched: true + /@types/lodash@4.14.194: resolution: {integrity: sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==} dev: true @@ -580,6 +594,12 @@ packages: resolution: {integrity: sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==} dev: true + /@types/stylus@0.48.39: + resolution: {integrity: sha512-98a0QrJorrq8+Vsan9yfxol2Qr6nvUWBeV3oYnSMks4QdLMebAzZvRd9IuoZOcnB6Erfjcjn1J2J+63MPCxJnw==} + dependencies: + '@types/node': 18.16.3 + dev: true + /@types/yauzl@2.10.0: resolution: {integrity: sha512-Cn6WYCm0tXv8p6k+A8PvbDG763EDpBoTzHdA+Q/MF6H3sapGjCm9NzoaJncJS9tUKSuCoDs9XHxYYsQDgxR6kw==} requiresBuild: true diff --git a/src/api/Settings.ts b/src/api/Settings.ts index 4d8cef6a7..c7aacef90 100644 --- a/src/api/Settings.ts +++ b/src/api/Settings.ts @@ -62,7 +62,11 @@ export interface Settings { settingsSyncVersion: number; }; - userCssVars: Record; + userCssVars: { + [fileName: string]: { + [varName: string]: string; + }; + }; } const DefaultSettings: Settings = { diff --git a/src/utils/dependencies.ts b/src/utils/dependencies.ts index f1a5c2627..94d8b002c 100644 --- a/src/utils/dependencies.ts +++ b/src/utils/dependencies.ts @@ -16,6 +16,9 @@ * along with this program. If not, see . */ +import type StylusRenderer = require("stylus/lib/renderer"); +import type LessStatic from "less"; + import { makeLazy } from "./lazy"; /* @@ -85,3 +88,18 @@ export const rnnoiseWorkletSrc = `${rnnoiseDist}/rnnoise/workletProcessor.js`; // @ts-expect-error SHUT UP export const getStegCloak = makeLazy(() => import("https://unpkg.com/stegcloak-dist@1.0.0/index.js")); + +export const getStylus = makeLazy(async () => { + const stylusScript = await fetch("https://unpkg.com/stylus-lang-bundle@0.58.1/dist/stylus-renderer.min.js").then(r => r.text()); + // the stylus bundle doesn't have a header that checks for export conditions so we can just patch the script to + // return the renderer itself + const patchedScript = stylusScript.replace("var StylusRenderer=", "return "); + return Function(patchedScript)() as typeof StylusRenderer; +}); + +export const getLess = makeLazy(async () => { + const lessScript = await fetch("https://unpkg.com/less@4.2.0/dist/less.min.js").then(r => r.text()); + const module = { exports: {} }; + Function("module", "exports", lessScript)(module, module.exports); + return module.exports as LessStatic; +}); diff --git a/src/utils/quickCss.ts b/src/utils/quickCss.ts index 1cf2c643a..0d02174ee 100644 --- a/src/utils/quickCss.ts +++ b/src/utils/quickCss.ts @@ -17,7 +17,9 @@ */ import { addSettingsListener, Settings } from "@api/Settings"; -import { parse as usercssParse } from "@utils/themes/usercss"; + +import { compileUsercss } from "./themes/usercss/compiler"; + let style: HTMLStyleElement; let themesStyle: HTMLStyleElement; @@ -51,39 +53,30 @@ async function initThemes() { const links: string[] = [...themeLinks]; if (IS_WEB) { - for (const theme of enabledThemes) { + // UserCSS handled separately + for (const theme of enabledThemes) if (!theme.endsWith(".user.css")) { const themeData = await VencordNative.themes.getThemeData(theme); if (!themeData) continue; + const blob = new Blob([themeData], { type: "text/css" }); links.push(URL.createObjectURL(blob)); } } else { - const localThemes = enabledThemes.map(theme => `vencord:///themes/${theme}?v=${Date.now()}`); - links.push(...localThemes); - } - - const cssVars: string[] = []; - - // for UserCSS, we need to inject the variables - for (const theme of enabledThemes) if (theme.endsWith(".user.css")) { - const themeData = await VencordNative.themes.getThemeData(theme); - if (!themeData) continue; - - const { vars } = usercssParse(themeData, theme); - - for (const [id, meta] of Object.entries(vars)) { - let normalizedValue: string = userCssVars[id] ?? meta.default; - - if (meta.type === "range") { - normalizedValue = `${normalizedValue}${meta.units ?? ""}`; - } - - cssVars.push(`--${id}:${normalizedValue};`); + for (const theme of enabledThemes) if (!theme.endsWith(".user.css")) { + links.push(`vencord:///themes/${theme}?v=${Date.now()}`); } } + for (const theme of enabledThemes) if (theme.endsWith(".user.css")) { + // UserCSS goes through a compile step first + const css = await compileUsercss(theme); + if (!css) continue; // something went wrong during the compile step... + + const blob = new Blob([css], { type: "text/css" }); + links.push(URL.createObjectURL(blob)); + } + themesStyle.textContent = links.map(link => `@import url("${link.trim()}");`).join("\n"); - if (cssVars.length > 0) themesStyle.textContent += `:root{${cssVars.join("\n")}}`; } document.addEventListener("DOMContentLoaded", () => { diff --git a/src/utils/themes/usercss/compiler.ts b/src/utils/themes/usercss/compiler.ts new file mode 100644 index 000000000..662ce43ed --- /dev/null +++ b/src/utils/themes/usercss/compiler.ts @@ -0,0 +1,88 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2023 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { Settings } from "@api/Settings"; +import { getLess, getStylus } from "@utils/dependencies"; +import { Logger } from "@utils/Logger"; + +import { parse as usercssParse } from "."; + +const UserCSSLogger = new Logger("UserCSS:Compiler", "#d2acf5"); + +const preprocessors: { [preprocessor: string]: (text: string, vars: Record) => Promise; } = { + async default(text: string, vars: Record) { + const variables = Object.entries(vars) + .map(([name, value]) => `--${name}: ${value}`) + .join("; "); + + return `/* ==Vencord== */\n:root{${variables}}\n/* ==/Vencord== */${text}`; + }, + + async uso(text: string, vars: Record) { + for (const [k, v] of Object.entries(vars)) { + text = text.replace(new RegExp(`\\/\\*\\[\\[${k}\\]\\]\\*\\/`, "g"), v); + } + + return text; + }, + + async stylus(text: string, vars: Record) { + const StylusRenderer = await getStylus(); + + const variables = Object.entries(vars) + .map(([name, value]) => `${name} = ${value}`) + .join("\n"); + + const stylusDoc = `// ==Vencord==\n${variables}\n// ==/Vencord==\n${text}`; + + return new StylusRenderer(stylusDoc).render(); + }, + + async less(text: string, vars: Record) { + const less = await getLess(); + + const variables = Object.entries(vars) + .map(([name, value]) => `@${name}: ${value};`) + .join("\n"); + + const lessDoc = `// ==Vencord==\n${variables}\n// ==/Vencord==\n${text}`; + + return less.render(lessDoc).then(r => r.css); + } +}; + +export async function compileUsercss(fileName: string) { + const themeData = await VencordNative.themes.getThemeData(fileName); + if (!themeData) return null; + + const { preprocessor: definedPreprocessor, vars } = usercssParse(themeData, fileName); + + // UserCSS preprocessor order look like this: + // - use the preprocessor defined + // - if variables are set, `uso` + // - otherwise, `default` + const usedPreprocessor = definedPreprocessor ?? (Object.keys(vars).length > 0 ? "uso" : "default"); + + const preprocessorFn = preprocessors[usedPreprocessor]; + + if (!preprocessorFn) { + UserCSSLogger.error("File", fileName, "requires preprocessor", usedPreprocessor, "which isn't known to Vencord"); + return null; + } + + const varsToPass = {}; + + for (const [k, v] of Object.entries(vars)) { + varsToPass[k] = Settings.userCssVars[fileName]?.[k] ?? v.default; + } + + try { + return await preprocessorFn(themeData, varsToPass); + } catch (error) { + UserCSSLogger.error("File", fileName, "failed to compile with preprocessor", usedPreprocessor, error); + return null; + } +} diff --git a/src/utils/themes/usercss/index.ts b/src/utils/themes/usercss/index.ts index 137b7595e..a5151e75c 100644 --- a/src/utils/themes/usercss/index.ts +++ b/src/utils/themes/usercss/index.ts @@ -4,10 +4,18 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ +import { Logger } from "@utils/Logger"; import { parse as originalParse, UserstyleHeader } from "usercss-meta"; +const UserCSSLogger = new Logger("UserCSS", "#d2acf5"); + export function parse(text: string, fileName: string): UserstyleHeader { - const { metadata } = originalParse(text.replace(/\r/g, "")); + const { metadata, errors } = originalParse(text.replace(/\r/g, "")); + + if (errors.length) { + UserCSSLogger.warn("Parsed", fileName, "with errors:", errors); + } + return { ...metadata, fileName, diff --git a/src/utils/themes/usercss/usercss-meta.d.ts b/src/utils/themes/usercss/usercss-meta.d.ts index 1d6c794f2..7248cbbb9 100644 --- a/src/utils/themes/usercss/usercss-meta.d.ts +++ b/src/utils/themes/usercss/usercss-meta.d.ts @@ -93,10 +93,8 @@ declare module "usercss-meta" { license?: string; /** * The CSS preprocessor used to write this style. - * - * @vencord Unimplemented in Vencord, just part of the metadata. */ - preprocessor?: string; + preprocessor?: "default" | "uso" | "less" | "stylus"; /** * A list of variables the style defines. @@ -104,5 +102,5 @@ declare module "usercss-meta" { vars: Record; } - export function parse(text: string): { metadata: UserstyleHeader; }; + export function parse(text: string): { metadata: UserstyleHeader; errors: { code: string; args: any; }[] }; }