2023-03-23 03:37:32 +00:00
|
|
|
/*
|
|
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { Dirent, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
|
|
import { access, readFile } from "fs/promises";
|
2023-09-22 02:48:54 +00:00
|
|
|
import { join, sep } from "path";
|
|
|
|
import { normalize as posixNormalize, sep as posixSep } from "path/posix";
|
2024-06-11 21:21:19 +00:00
|
|
|
import { BigIntLiteral, CallExpression, createSourceFile, Identifier, isArrayLiteralExpression, isCallExpression, isExportAssignment, isIdentifier, isObjectLiteralExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isStringLiteral, isVariableStatement, LiteralExpression, NamedDeclaration, Node, NodeArray, ObjectLiteralExpression, ScriptTarget, StringLiteral, SyntaxKind } from "typescript";
|
2023-03-23 03:37:32 +00:00
|
|
|
|
2023-08-15 23:32:11 +00:00
|
|
|
import { getPluginTarget } from "./utils.mjs";
|
|
|
|
|
2023-03-23 03:37:32 +00:00
|
|
|
interface Dev {
|
|
|
|
name: string;
|
|
|
|
id: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface PluginData {
|
|
|
|
name: string;
|
|
|
|
description: string;
|
2023-05-12 01:41:00 +00:00
|
|
|
tags: string[];
|
2023-03-23 03:37:32 +00:00
|
|
|
authors: Dev[];
|
|
|
|
dependencies: string[];
|
|
|
|
hasPatches: boolean;
|
|
|
|
hasCommands: boolean;
|
|
|
|
required: boolean;
|
|
|
|
enabledByDefault: boolean;
|
2023-04-05 02:54:54 +00:00
|
|
|
target: "discordDesktop" | "vencordDesktop" | "web" | "dev";
|
2023-09-22 02:48:54 +00:00
|
|
|
filePath: string;
|
2023-03-23 03:37:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
const devs = {} as Record<string, Dev>;
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
2023-05-23 02:42:06 +00:00
|
|
|
if (!isSatisfiesExpression(value) || !isObjectLiteralExpression(value.expression)) throw new Error("Failed to parse devs: not an object literal");
|
2023-03-23 03:37:32 +00:00
|
|
|
|
2023-05-23 02:42:06 +00:00
|
|
|
for (const prop of value.expression.properties) {
|
2023-03-23 03:37:32 +00:00
|
|
|
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");
|
|
|
|
}
|
|
|
|
|
2024-06-11 21:21:19 +00:00
|
|
|
function isTranslationExpression(node: Node): node is CallExpression {
|
|
|
|
if (!isCallExpression(node)) return false;
|
|
|
|
|
|
|
|
const literal = node.expression as LiteralExpression;
|
|
|
|
|
2024-06-11 21:31:06 +00:00
|
|
|
if (literal.text !== "t") return false;
|
2024-06-11 21:21:19 +00:00
|
|
|
|
|
|
|
return true;
|
|
|
|
}
|
|
|
|
|
|
|
|
async function getTranslation(node: Node): Promise<string | null> {
|
|
|
|
if (!isTranslationExpression(node)) return null;
|
|
|
|
|
|
|
|
const translationString = node.arguments[0];
|
|
|
|
|
|
|
|
if (!isStringLiteral(translationString)) return null;
|
|
|
|
|
|
|
|
const splitPath = translationString.text.split(".");
|
|
|
|
const namespace = splitPath.shift();
|
|
|
|
const path = splitPath.join(".");
|
|
|
|
|
|
|
|
// load the namespace
|
|
|
|
const bundle = JSON.parse(
|
|
|
|
await readFile(`./translations/en/${namespace}.json`, "utf-8")
|
|
|
|
);
|
|
|
|
|
2024-06-11 21:25:09 +00:00
|
|
|
const dotProp = (key: string, object: any) =>
|
|
|
|
key.split(".").reduce((obj, key) => obj?.[key], object);
|
2024-06-11 21:21:19 +00:00
|
|
|
|
2024-06-11 21:25:09 +00:00
|
|
|
return dotProp(path, bundle);
|
2024-06-11 21:21:19 +00:00
|
|
|
}
|
|
|
|
|
2023-03-23 03:37:32 +00:00
|
|
|
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,
|
2023-05-12 01:41:00 +00:00
|
|
|
tags: [] as string[]
|
2023-03-23 03:37:32 +00:00
|
|
|
} as PluginData;
|
|
|
|
|
|
|
|
for (const prop of pluginObj.properties) {
|
|
|
|
const key = getName(prop);
|
|
|
|
const value = isPropertyAssignment(prop) ? prop.initializer : prop;
|
|
|
|
|
|
|
|
switch (key) {
|
|
|
|
case "name":
|
|
|
|
if (!isStringLiteral(value)) throw fail(`${key} is not a string literal`);
|
|
|
|
data[key] = value.text;
|
|
|
|
break;
|
2024-06-11 21:21:19 +00:00
|
|
|
case "description":
|
|
|
|
if (isStringLiteral(value))
|
|
|
|
data[key] = value.text;
|
|
|
|
else if (isTranslationExpression(value))
|
|
|
|
data[key] = (await getTranslation(value))!;
|
|
|
|
else throw fail(`${key} is not a string literal or a translation function call`);
|
|
|
|
break;
|
2023-03-23 03:37:32 +00:00
|
|
|
case "patches":
|
|
|
|
data.hasPatches = true;
|
|
|
|
break;
|
|
|
|
case "commands":
|
|
|
|
data.hasCommands = true;
|
|
|
|
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");
|
2023-05-23 03:25:48 +00:00
|
|
|
const d = devs[getName(e)!];
|
|
|
|
if (!d) throw fail(`couldn't look up author ${getName(e)}`);
|
|
|
|
return d;
|
2023-03-23 03:37:32 +00:00
|
|
|
});
|
|
|
|
break;
|
2023-05-12 01:41:00 +00:00
|
|
|
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;
|
2023-03-23 03:37:32 +00:00
|
|
|
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<StringLiteral>).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");
|
|
|
|
|
2023-08-15 23:32:11 +00:00
|
|
|
const target = getPluginTarget(fileName);
|
|
|
|
if (target) {
|
2023-08-25 12:33:33 +00:00
|
|
|
if (!["web", "discordDesktop", "vencordDesktop", "desktop", "dev"].includes(target)) throw fail(`invalid target ${target}`);
|
2023-08-15 23:32:11 +00:00
|
|
|
data.target = target as any;
|
2023-03-23 03:37:32 +00:00
|
|
|
}
|
|
|
|
|
2023-09-22 02:48:54 +00:00
|
|
|
data.filePath = posixNormalize(fileName)
|
|
|
|
.split(sep)
|
|
|
|
.join(posixSep)
|
|
|
|
.replace(/\/index\.([jt]sx?)$/, "")
|
|
|
|
.replace(/^src\/plugins\//, "");
|
|
|
|
|
2023-09-08 00:26:22 +00:00
|
|
|
let readme = "";
|
|
|
|
try {
|
|
|
|
readme = readFileSync(join(fileName, "..", "README.md"), "utf-8");
|
|
|
|
} catch { }
|
|
|
|
return [data, readme] as const;
|
2023-03-23 03:37:32 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
throw fail("no default export called 'definePlugin' found");
|
|
|
|
}
|
|
|
|
|
2023-06-13 00:36:25 +00:00
|
|
|
async function getEntryPoint(dir: string, dirent: Dirent) {
|
|
|
|
const base = join(dir, dirent.name);
|
2023-03-23 03:37:32 +00:00
|
|
|
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`);
|
|
|
|
}
|
|
|
|
|
2023-06-13 00:36:25 +00:00
|
|
|
function isPluginFile({ name }: { name: string; }) {
|
|
|
|
if (name === "index.ts") return false;
|
|
|
|
return !name.startsWith("_") && !name.startsWith(".");
|
|
|
|
}
|
|
|
|
|
2023-03-23 03:37:32 +00:00
|
|
|
(async () => {
|
|
|
|
parseDevs();
|
|
|
|
|
2023-09-08 00:26:22 +00:00
|
|
|
const plugins = [] as PluginData[];
|
|
|
|
const readmes = {} as Record<string, string>;
|
|
|
|
|
|
|
|
await Promise.all(["src/plugins", "src/plugins/_core"].flatMap(dir =>
|
2023-06-13 00:36:25 +00:00
|
|
|
readdirSync(dir, { withFileTypes: true })
|
|
|
|
.filter(isPluginFile)
|
2023-09-08 00:26:22 +00:00
|
|
|
.map(async dirent => {
|
|
|
|
const [data, readme] = await parseFile(await getEntryPoint(dir, dirent));
|
|
|
|
plugins.push(data);
|
|
|
|
if (readme) readmes[data.name] = readme;
|
|
|
|
})
|
|
|
|
));
|
2023-03-23 03:37:32 +00:00
|
|
|
|
2023-09-08 00:26:22 +00:00
|
|
|
const data = JSON.stringify(plugins);
|
2023-03-23 03:37:32 +00:00
|
|
|
|
2023-09-08 00:26:22 +00:00
|
|
|
if (process.argv.length > 3) {
|
2023-03-23 03:37:32 +00:00
|
|
|
writeFileSync(process.argv[2], data);
|
2023-09-08 00:26:22 +00:00
|
|
|
writeFileSync(process.argv[3], JSON.stringify(readmes));
|
2023-03-23 03:37:32 +00:00
|
|
|
} else {
|
|
|
|
console.log(data);
|
|
|
|
}
|
|
|
|
})();
|