diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b637d6af..577a54dde 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,11 +38,11 @@ jobs: - name: Build run: pnpm buildStandalone - - name: Generate plugin list - run: pnpm generatePluginJson dist/plugins.json - - - name: Generate Equicord plugin list - run: pnpm generateEquicordPluginJson dist/equicordplugins.json + - name: Generate plugin lists + run: | + pnpm generatePluginJson dist/plugins.json + pnpm generateEquicordPluginJson dist/equicordplugins.json + pnpm generateVencordPluginJson dist/vencordplugins.json - name: Collect files to be released run: | diff --git a/.github/workflows/nixosBuild.yml b/.github/workflows/nixosBuild.yml index 934b73eb7..c0bafc766 100644 --- a/.github/workflows/nixosBuild.yml +++ b/.github/workflows/nixosBuild.yml @@ -34,11 +34,11 @@ jobs: - name: Build run: pnpm buildStandalone - - name: Generate plugin list - run: pnpm generatePluginJson dist/vencordplugins.json - - - name: Generate Equicord plugin list - run: pnpm generateEquicordPluginJson dist/equicordplugins.json + - name: Generate plugin lists + run: | + pnpm generatePluginJson dist/plugins.json + pnpm generateEquicordPluginJson dist/equicordplugins.json + pnpm generateVencordPluginJson dist/vencordplugins.json - name: Collect files to be released run: | diff --git a/package.json b/package.json index 4e5718edc..ba6e845ef 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "watchWeb": "pnpm buildWeb --watch", "generatePluginJson": "tsx scripts/generatePluginList.ts", "generateEquicordPluginJson": "tsx scripts/generateEquicordPluginList.ts", + "generateVencordPluginJson": "tsx scripts/generateVencordPluginList.ts", "generateTypes": "tspc --emitDeclarationOnly --declaration --outDir packages/vencord-types --allowJs false", "inject": "node scripts/runInstaller.mjs -- --install", "uninject": "node scripts/runInstaller.mjs -- --uninstall", @@ -119,4 +120,4 @@ "engines": { "node": ">=18" } -} +} \ No newline at end of file diff --git a/scripts/generateEquicordPluginList.ts b/scripts/generateEquicordPluginList.ts index c52bb2a7d..ea1aced80 100644 --- a/scripts/generateEquicordPluginList.ts +++ b/scripts/generateEquicordPluginList.ts @@ -45,7 +45,7 @@ interface PluginData { commands: Command[]; required: boolean; enabledByDefault: boolean; - target: "discordDesktop" | "vencordDesktop" | "equibop" | "desktop" | "web" | "dev"; + target: "discordDesktop" | "vesktop" | "equibop" | "desktop" | "web" | "dev"; filePath: string; } diff --git a/scripts/generateVencordPluginList.ts b/scripts/generateVencordPluginList.ts new file mode 100644 index 000000000..41620ba10 --- /dev/null +++ b/scripts/generateVencordPluginList.ts @@ -0,0 +1,249 @@ +/* + * 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 . +*/ + +import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs"; +import { access, readFile } from "fs/promises"; +import { join, sep } from "path"; +import { normalize as posixNormalize, sep as posixSep } from "path/posix"; +import { BigIntLiteral, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, NamedDeclaration, NodeArray, ObjectLiteralExpression, PropertyAssignment, ScriptTarget, StringLiteral, SyntaxKind } from "typescript"; + +import { getPluginTarget } from "./utils.mjs"; + +interface Dev { + name: string; + id: string; +} + +interface Command { + name: string; + description: string; +} + +interface PluginData { + name: string; + description: string; + tags: string[]; + authors: Dev[]; + dependencies: string[]; + hasPatches: boolean; + hasCommands: boolean; + commands: Command[]; + required: boolean; + enabledByDefault: boolean; + target: "discordDesktop" | "vesktop" | "equibop" | "desktop" | "web" | "dev"; + filePath: string; +} +const devs = {} as Record; + +function getName(node: NamedDeclaration) { + return node.name && isIdentifier(node.name) ? node.name.text : undefined; +} + +function hasName(node: NamedDeclaration, name: string) { + return getName(node) === name; +} + +function getObjectProp(node: ObjectLiteralExpression, name: string) { + const prop = node.properties.find(p => hasName(p, name)); + if (prop && isPropertyAssignment(prop)) return prop.initializer; + return prop; +} + +function parseDevs() { + const file = createSourceFile("constants.ts", readFileSync("src/utils/constants.ts", "utf8"), ScriptTarget.Latest); + + for (const child of file.getChildAt(0).getChildren()) { + if (!isVariableStatement(child)) continue; + + const devsDeclaration = child.declarationList.declarations.find(d => hasName(d, "Devs")); + if (!devsDeclaration?.initializer || !isCallExpression(devsDeclaration.initializer)) continue; + + const value = devsDeclaration.initializer.arguments[0]; + + if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse devs: not an object literal"); + + for (const prop of value.expression.properties) { + const name = (prop.name as Identifier).text; + const value = isPropertyAssignment(prop) ? prop.initializer : prop; + + if (!isObjectLiteralExpression(value)) throw new Error(`Failed to parse devs: ${name} is not an object literal`); + + devs[name] = { + name: (getObjectProp(value, "name") as StringLiteral).text, + id: (getObjectProp(value, "id") as BigIntLiteral).text.slice(0, -1) + }; + } + + return; + } + + throw new Error("Could not find Devs constant"); +} + +async function parseFile(fileName: string) { + const file = createSourceFile(fileName, await readFile(fileName, "utf8"), ScriptTarget.Latest); + + const fail = (reason: string) => { + return new Error(`Invalid plugin ${fileName}, because ${reason}`); + }; + + for (const node of file.getChildAt(0).getChildren()) { + if (!isExportAssignment(node) || !isCallExpression(node.expression)) continue; + + const call = node.expression; + if (!isIdentifier(call.expression) || call.expression.text !== "definePlugin") continue; + + const pluginObj = node.expression.arguments[0]; + if (!isObjectLiteralExpression(pluginObj)) throw fail("no object literal passed to definePlugin"); + + const data = { + hasPatches: false, + hasCommands: false, + enabledByDefault: false, + required: false, + tags: [] as string[] + } as PluginData; + + for (const prop of pluginObj.properties) { + const key = getName(prop); + const value = isPropertyAssignment(prop) ? prop.initializer : prop; + + switch (key) { + case "name": + case "description": + if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`); + data[key] = value.text; + break; + case "patches": + data.hasPatches = true; + break; + case "commands": + data.hasCommands = true; + if (!isArrayLiteralExpression(value)) throw fail("commands is not an array literal"); + data.commands = value.elements.map((e) => { + if (isObjectLiteralExpression(e)) { + const nameProperty = e.properties.find((p): p is PropertyAssignment => { + return isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === 'name'; + }); + const descriptionProperty = e.properties.find((p): p is PropertyAssignment => { + return isPropertyAssignment(p) && isIdentifier(p.name) && p.name.escapedText === 'description'; + }); + if (!nameProperty || !descriptionProperty) throw fail("command missing required properties"); + const name = isStringLiteral(nameProperty.initializer) ? nameProperty.initializer.text : ''; + const description = isStringLiteral(descriptionProperty.initializer) ? descriptionProperty.initializer.text : ''; + return { name, description }; + } else if (isCallExpression(e) && isIdentifier(e.expression)) { + const [nameArg] = e.arguments; + if (!isStringLiteral(nameArg)) throw fail("first argument must be a string"); + const name = nameArg.text; + return { name, description: "" }; + } + throw fail("commands array contains invalid elements"); + }); + break; + case "authors": + if (!isArrayLiteralExpression(value)) throw fail("authors is not an array literal"); + data.authors = value.elements.map(e => { + if (!isPropertyAccessExpression(e)) throw fail("authors array contains non-property access expressions"); + const d = devs[getName(e)!]; + if (!d) throw fail(`couldn't look up author ${getName(e)}`); + return d; + }); + break; + case "tags": + if (!isArrayLiteralExpression(value)) throw fail("tags is not an array literal"); + data.tags = value.elements.map(e => { + if (!isStringLiteral(e)) throw fail("tags array contains non-string literals"); + return e.text; + }); + break; + case "dependencies": + if (!isArrayLiteralExpression(value)) throw fail("dependencies is not an array literal"); + const { elements } = value; + if (elements.some(e => !isStringLiteral(e))) throw fail("dependencies array contains non-string elements"); + data.dependencies = (elements as NodeArray).map(e => e.text); + break; + case "required": + case "enabledByDefault": + data[key] = value.kind === SyntaxKind.TrueKeyword; + break; + } + } + + if (!data.name || !data.description || !data.authors) throw fail("name, description or authors are missing"); + + const target = getPluginTarget(fileName); + if (target) { + if (!["web", "discordDesktop", "vesktop", "equibop", "desktop", "dev"].includes(target)) throw fail(`invalid target ${target}`); + data.target = target as any; + } + + data.filePath = posixNormalize(fileName) + .split(sep) + .join(posixSep) + .replace(/\/index\.([jt]sx?)$/, "") + .replace(/^src\/plugins\//, ""); + + return [data] as const; + } + + throw fail("no default export called 'definePlugin' found"); +} + +async function getEntryPoint(dir: string, dirent: Dirent) { + const base = join(dir, dirent.name); + if (!dirent.isDirectory()) return base; + + for (const name of ["index.ts", "index.tsx"]) { + const full = join(base, name); + try { + await access(full); + return full; + } catch { } + } + + throw new Error(`${dirent.name}: Couldn't find entry point`); +} + +function isPluginFile({ name }: { name: string; }) { + if (name === "index.ts") return false; + return !name.startsWith("_") && !name.startsWith("."); +} + +(async () => { + parseDevs(); + + const plugins = [] as PluginData[]; + + await Promise.all(["src/plugins", "src/plugins/_core"].flatMap(dir => + readdirSync(dir, { withFileTypes: true }) + .filter(isPluginFile) + .map(async dirent => { + const [data] = await parseFile(await getEntryPoint(dir, dirent)); + plugins.sort().push(data); + }) + )); + + const data = JSON.stringify(plugins); + + if (process.argv.length > 2) { + writeFileSync(process.argv[2], data); + } else { + console.log(data); + } +})();