post-build.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473
  1. import { access, readFile, writeFile, constants as fsconst } from "node:fs/promises";
  2. import { dirname, join, relative, resolve } from "node:path";
  3. import { fileURLToPath } from "node:url";
  4. import { exec } from "node:child_process";
  5. import "dotenv/config";
  6. import { outputDir as rollupCfgOutputDir, outputFile as rollupCfgOutputFile } from "../../rollup.config.mjs";
  7. import locales from "../../assets/locales.json" with { type: "json" };
  8. import pkg from "../../package.json" with { type: "json" };
  9. import type { RollupArgs } from "../types.js";
  10. import { createHash } from "node:crypto";
  11. import { createReadStream } from "node:fs";
  12. const { argv, env, exit, stdout } = process;
  13. /** Any type that is either a string or can be implicitly converted to one by having a .toString() method */
  14. type Stringifiable = string | { toString(): string; };
  15. /** An entry in the file `assets/require.json` */
  16. type RequireObj = RequireObjPkg | RequireObjUrl;
  17. type RequireObjUrl = {
  18. url: string;
  19. };
  20. type RequireObjPkg = {
  21. pkgName: keyof (typeof pkg)["dependencies"] | keyof (typeof pkg)["devDependencies"];
  22. baseUrl?: string;
  23. path?: string;
  24. };
  25. type BuildStats = {
  26. sizeKiB: number;
  27. mode: string;
  28. timestamp: number;
  29. };
  30. const buildTs = Date.now();
  31. /** Used to force the browser and userscript extension to refresh resources */
  32. const buildUid = randomId(12, 36);
  33. type CliArg<TName extends keyof Required<RollupArgs>> = Required<RollupArgs>[TName];
  34. const mode = getCliArg<CliArg<"config-mode">>("mode", "development");
  35. const branch = getCliArg<CliArg<"config-branch">>("branch", (mode === "production" ? "main" : "develop"));
  36. const host = getCliArg<CliArg<"config-host">>("host", "github");
  37. const assetSource = getCliArg<CliArg<"config-assetSource">>("assetSource", "github");
  38. const suffix = getCliArg<CliArg<"config-suffix">>("suffix", "");
  39. const envPort = Number(env.DEV_SERVER_PORT);
  40. /** HTTP port of the dev server */
  41. const devServerPort = isNaN(envPort) || envPort === 0 ? 8710 : envPort;
  42. const devServerUserscriptUrl = `http://localhost:${devServerPort}/${rollupCfgOutputFile}`;
  43. const repo = "Sv443/BetterYTM";
  44. const userscriptDistFile = `BetterYTM${suffix}.user.js`;
  45. const distFolderPath = `./${rollupCfgOutputDir}/`;
  46. const assetFolderPath = "./assets/";
  47. // const hostScriptUrl = (() => {
  48. // switch(host) {
  49. // case "greasyfork":
  50. // return "https://update.greasyfork.org/scripts/475682/BetterYTM.user.js";
  51. // case "openuserjs":
  52. // return "https://openuserjs.org/install/Sv443/BetterYTM.user.js";
  53. // case "github":
  54. // default:
  55. // return `https://raw.githubusercontent.com/${repo}/main/dist/${userscriptDistFile}`;
  56. // }
  57. // })();
  58. /** Whether to trigger the bell sound in some terminals when the code has finished compiling */
  59. const ringBell = Boolean(env.RING_BELL && (env.RING_BELL.length > 0 && env.RING_BELL.trim().toLowerCase() === "true"));
  60. /** Directives that are only added in dev mode */
  61. const devDirectives = mode === "development" ? `\
  62. // @grant GM.registerMenuCommand
  63. // @grant GM.listValues\
  64. ` : undefined;
  65. (async () => {
  66. const buildNbr = await getLastCommitSha();
  67. const resourcesDirectives = await getResourceDirectives(buildNbr);
  68. const requireDirectives = await getRequireDirectives();
  69. const localizedDescriptions = getLocalizedDescriptions();
  70. const header = `\
  71. // ==UserScript==
  72. // @name ${pkg.userscriptName}
  73. // @namespace ${pkg.homepage}
  74. // @version ${pkg.version}
  75. // @description ${pkg.description}\
  76. ${localizedDescriptions ? "\n" + localizedDescriptions : ""}\
  77. // @homepageURL ${pkg.homepage}#readme
  78. // @supportURL ${pkg.bugs.url}
  79. // @license ${pkg.license}
  80. // @author ${pkg.author.name}
  81. // @copyright ${pkg.author.name} (${pkg.author.url})
  82. // @icon ${getResourceUrl(`images/logo/logo${mode === "development" ? "_dev" : ""}_48.png`, buildNbr)}
  83. // @match https://music.youtube.com/*
  84. // @match https://www.youtube.com/*
  85. // @run-at document-start
  86. // @connect api.sv443.net
  87. // @connect github.com
  88. // @connect raw.githubusercontent.com
  89. // @connect youtube.com
  90. // @connect returnyoutubedislikeapi.com
  91. // @grant GM.getValue
  92. // @grant GM.setValue
  93. // @grant GM.deleteValue
  94. // @grant GM.getResourceUrl
  95. // @grant GM.setClipboard
  96. // @grant GM.xmlHttpRequest
  97. // @grant GM.openInTab
  98. // @grant unsafeWindow
  99. // @noframes\
  100. ${resourcesDirectives ? "\n" + resourcesDirectives : ""}\
  101. ${requireDirectives ? "\n" + requireDirectives : ""}\
  102. ${devDirectives ? "\n" + devDirectives : ""}
  103. // ==/UserScript==
  104. /*
  105. ▄▄▄ ▄ ▄▄▄▄▄▄ ▄
  106. █ █ ▄▄▄ █ █ ▄▄▄ ▄ ▄█ █ █ █▀▄▀█
  107. █▀▀▄ █▄█ █▀ █▀ █▄█ █▀ █ █ █ █
  108. █▄▄▀ ▀▄▄ ▀▄▄ ▀▄▄ ▀▄▄ █ █ █ █ █
  109. Made with ❤️ by Sv443
  110. I welcome every contribution on GitHub!
  111. https://github.com/Sv443/BetterYTM
  112. */
  113. /* Disclaimer: I am not affiliated with or endorsed by YouTube, Google, Alphabet, Genius or anyone else */
  114. /* C&D this 🖕 */
  115. `;
  116. try {
  117. const rootPath = join(dirname(fileURLToPath(import.meta.url)), "../../");
  118. const scriptPath = join(rootPath, distFolderPath, userscriptDistFile);
  119. // read userscript and inject build number and other values
  120. let userscript = insertValues(
  121. String(await readFile(scriptPath)),
  122. {
  123. MODE: mode,
  124. BRANCH: branch,
  125. HOST: host,
  126. BUILD_NUMBER: buildNbr,
  127. },
  128. );
  129. if(mode === "production")
  130. userscript = remSourcemapComments(userscript);
  131. else
  132. userscript = userscript.replace(/sourceMappingURL=/gm, `sourceMappingURL=http://localhost:${devServerPort}/`);
  133. // insert userscript header and final newline
  134. const finalUserscript = `${header}\n${await getLinkedPkgs()}${userscript}${userscript.endsWith("\n") ? "" : "\n"}`;
  135. await writeFile(scriptPath, finalUserscript);
  136. ringBell && stdout.write("\u0007");
  137. const envText = `${mode === "production" ? "\x1b[32m" : "\x1b[33m"}${mode}`;
  138. const sizeKiB = Number((Buffer.byteLength(finalUserscript, "utf8") / 1024).toFixed(2));
  139. let buildStats: Partial<BuildStats> = {};
  140. if(await exists(".build.json")) {
  141. try {
  142. buildStats = JSON.parse(String(await readFile(".build.json"))) as BuildStats;
  143. }
  144. catch {
  145. void 0;
  146. }
  147. }
  148. let sizeIndicator = "";
  149. if(buildStats.sizeKiB) {
  150. const sizeDiff = sizeKiB - buildStats.sizeKiB;
  151. const sizeDiffTrunc = parseFloat(sizeDiff.toFixed(2));
  152. if(sizeDiffTrunc !== 0)
  153. sizeIndicator = " \x1b[2m(\x1b[0m\x1b[1m" + (sizeDiff > 0 ? "\x1b[33m+" : (sizeDiff !== 0 ? "\x1b[32m-" : "\x1b[32m")) + Math.abs(sizeDiffTrunc) + "\x1b[0m\x1b[2m)\x1b[0m";
  154. }
  155. console.info([
  156. "",
  157. `Successfully built for ${envText}\x1b[0m - build number (last commit SHA): ${buildNbr}`,
  158. `Outputted file '${relative("./", scriptPath)}' with a size of \x1b[32m${sizeKiB} KiB\x1b[0m${sizeIndicator}`,
  159. `Userscript URL: \x1b[34m\x1b[4m${devServerUserscriptUrl}\x1b[0m`,
  160. "",
  161. ].join("\n"));
  162. const buildStatsNew: BuildStats = {
  163. sizeKiB,
  164. mode,
  165. timestamp: buildTs,
  166. };
  167. await writeFile(".build.json", JSON.stringify(buildStatsNew));
  168. schedExit(0);
  169. }
  170. catch(err) {
  171. console.error("\x1b[31mError while adding userscript header:\x1b[0m");
  172. console.error(err);
  173. schedExit(1);
  174. }
  175. })();
  176. /** Replaces tokens in the format `#{{key}}` or `/⋆#{{key}}⋆/` of the `replacements` param with their respective value */
  177. function insertValues(userscript: string, replacements: Record<string, Stringifiable>) {
  178. for(const key in replacements)
  179. userscript = userscript.replace(new RegExp(`(\\/\\*\\s*)?#{{${key}}}(\\s*\\*\\/)?`, "gm"), String(replacements[key]));
  180. return userscript;
  181. }
  182. /** Removes sourcemapping comments */
  183. function remSourcemapComments(input: string) {
  184. return input
  185. .replace(/\/\/\s?#\s?sourceMappingURL\s?=\s?.+$/gm, "");
  186. }
  187. /**
  188. * Used as a kind of "build number", though note it is always behind by at least one commit,
  189. * as the act of putting this number in the userscript and committing it changes the hash again, indefinitely
  190. */
  191. function getLastCommitSha() {
  192. return new Promise<string>((res, rej) => {
  193. exec("git rev-parse --short HEAD", (err, stdout, stderr) => {
  194. if(err) {
  195. console.error("\x1b[31mError while checking for last Git commit. Do you have Git installed?\x1b[0m\n", stderr);
  196. return rej(err);
  197. }
  198. return res(String(stdout).replace(/\r?\n/gm, "").trim());
  199. });
  200. });
  201. }
  202. async function exists(path: string) {
  203. try {
  204. await access(path, fsconst.R_OK | fsconst.W_OK);
  205. return true;
  206. }
  207. catch {
  208. return false;
  209. }
  210. }
  211. /** Resolves the value of an entry in resources.json */
  212. function resolveVal(value: string, buildNbr: string) {
  213. if(!value.includes("$"))
  214. return value;
  215. const replacements = [
  216. ["\\$MODE", mode],
  217. ["\\$BRANCH", branch],
  218. ["\\$HOST", host],
  219. ["\\$BUILD_NUMBER", buildNbr],
  220. ["\\$UID", buildUid],
  221. ];
  222. return replacements.reduce((acc, [key, val]) => acc.replace(new RegExp(key, "g"), val), value);
  223. };
  224. /** Returns a string of resource directives, as defined in `assets/resources.json` or undefined if the file doesn't exist or is invalid */
  225. async function getResourceDirectives(ref: string) {
  226. try {
  227. const directives: string[] = [];
  228. const resourcesFile = String(await readFile(join(assetFolderPath, "resources.json")));
  229. const resources = JSON.parse(resourcesFile) as Record<string, string> | Record<string, { path: string; buildNbr: string }>;
  230. // const resourcesRef = Object.entries(resources).reduce<Record<string, Record<"path" | "ref", string>>>((acc, [key, val]) => {
  231. // acc[key] = {
  232. // ...(typeof val === "object"
  233. // ? { path: resolveVal(val.path, ref), ref: resolveVal(val.ref, ref) }
  234. // : { path: getResourceUrl(resolveVal(val, ref), ref), ref }
  235. // ),
  236. // };
  237. // return acc;
  238. // }, {}) as Record<string, Record<"path" | "ref", string>>;
  239. const resourcesHashed = {} as Record<string, Record<"path" | "ref", string> & Partial<Record<"hash", string>>>;
  240. for(const [name, val] of Object.entries(resources)) {
  241. const pathVal = typeof val === "object" ? val.path : val;
  242. const hash = assetSource !== "local" && !pathVal.match(/^https?:\/\//)
  243. ? await getFileHashSha256(pathVal.replace(/\?.+/g, ""))
  244. : undefined;
  245. resourcesHashed[name] = typeof val === "object"
  246. ? { path: resolveVal(val.path, ref), ref: resolveVal(val.ref, ref), hash }
  247. : { path: getResourceUrl(resolveVal(val, ref), ref), ref, hash };
  248. }
  249. const addResourceHashed = async (name: string, path: string, ref: string) => {
  250. if(assetSource !== "local" || !path.match(/^https?:\/\//))
  251. return;
  252. resourcesHashed[name] = { path: getResourceUrl(path, ref), ref, hash: await getFileHashSha256(path) };
  253. };
  254. await addResourceHashed("css-bundle", "/dist/BetterYTM.css", ref);
  255. for(const [locale] of Object.entries(locales))
  256. await addResourceHashed(`trans-${locale}`, `translations/${locale}.json`, ref);
  257. let longestName = 0;
  258. for(const name of Object.keys(resourcesHashed))
  259. longestName = Math.max(longestName, name.length);
  260. const sortedResourceEntries = Object.entries(resourcesHashed).sort(([a], [b]) => a.localeCompare(b));
  261. for(const [name, { path, ref: entryRef, hash }] of sortedResourceEntries) {
  262. const bufferSpace = " ".repeat(longestName - name.length);
  263. directives.push(`// @resource ${name}${bufferSpace} ${
  264. path.match(/^https?:\/\//)
  265. ? path
  266. : getResourceUrl(path, entryRef, ref === entryRef)
  267. }${hash ? `#sha256=${hash}` : ""}`);
  268. }
  269. return directives.join("\n");
  270. }
  271. catch(err) {
  272. console.warn("No resource directives found:", err);
  273. }
  274. }
  275. async function getRequireDirectives() {
  276. const directives: string[] = [];
  277. const requireFile = String(await readFile(join(assetFolderPath, "require.json")));
  278. const require = JSON.parse(requireFile) as RequireObj[];
  279. for(const entry of require) {
  280. if("link" in entry && typeof entry.link === "string")
  281. continue;
  282. "pkgName" in entry && directives.push(getRequireEntry(entry));
  283. "url" in entry && directives.push(`// @require ${entry.url}`);
  284. }
  285. return directives.length > 0 ? directives.join("\n") : undefined;
  286. }
  287. function getRequireEntry(entry: RequireObjPkg) {
  288. const baseUrl = entry.baseUrl ?? "https://cdn.jsdelivr.net/npm/";
  289. let version: string;
  290. const deps = {
  291. ...pkg.dependencies,
  292. ...pkg.devDependencies,
  293. };
  294. if(entry.pkgName in deps)
  295. version = deps[entry.pkgName].replace(/[^0-9.]/g, "");
  296. else
  297. throw new Error(`Library '${entry.pkgName}', referenced in 'assets/require.json' not found in dependencies or devDependencies`);
  298. return `// @require ${baseUrl}${entry.pkgName}@${version}${entry.path ? `${entry.path.startsWith("/") ? "" : "/"}${entry.path}` : ""}`;
  299. }
  300. /** Returns the @description directive block for each defined locale in `assets/locales.json` */
  301. function getLocalizedDescriptions() {
  302. try {
  303. const descriptions: string[] = [];
  304. for(const [locale, { userscriptDesc, ...rest }] of Object.entries(locales)) {
  305. let loc = locale;
  306. if(loc.length < 5)
  307. loc += " ".repeat(5 - loc.length);
  308. descriptions.push(`// @description:${loc} ${userscriptDesc}`);
  309. if("altLocales" in rest) {
  310. for(const altLoc of rest.altLocales) {
  311. let alt = altLoc.replace(/_/, "-");
  312. if(alt.length < 5)
  313. alt += " ".repeat(5 - alt.length);
  314. descriptions.push(`// @description:${alt} ${userscriptDesc}`);
  315. }
  316. }
  317. }
  318. return descriptions.join("\n") + "\n";
  319. }
  320. catch(err) {
  321. console.warn("\x1b[33mNo localized descriptions found:\x1b[0m", err);
  322. }
  323. }
  324. /**
  325. * Returns the full URL for a given resource path, based on the current mode and branch
  326. * @param path If the path starts with a /, it is treated as an absolute path, starting at project root. Otherwise it will be relative to the assets folder.
  327. * @param ghRef The current build number (last shortened or full-length Git commit SHA1), branch name or tag name to use when fetching the resource when the asset source is GitHub
  328. * @param useTagInProd Whether to use the tag name in production mode (true, default) or the branch name (false)
  329. */
  330. function getResourceUrl(path: string, ghRef: string, useTagInProd = true) {
  331. let assetPath = "/assets/";
  332. if(path.startsWith("/"))
  333. assetPath = "";
  334. return assetSource === "local"
  335. ? `http://localhost:${devServerPort}${assetPath}${path}?b=${buildUid}`
  336. : `https://raw.githubusercontent.com/${repo}/${mode === "development" || !useTagInProd ? ghRef : `v${pkg.version}`}${assetPath}${path}`;
  337. }
  338. /** Returns the value of a CLI argument (in the format `--arg=<value>`) or the value of `defaultVal` if it doesn't exist */
  339. function getCliArg<TReturn extends string = string>(name: string, defaultVal: TReturn | (string & {})): TReturn
  340. /** Returns the value of a CLI argument (in the format `--arg=<value>`) or undefined if it doesn't exist */
  341. function getCliArg<TReturn extends string = string>(name: string, defaultVal?: TReturn | (string & {})): TReturn | undefined
  342. /** Returns the value of a CLI argument (in the format `--arg=<value>`) or the value of `defaultVal` if it doesn't exist */
  343. function getCliArg<TReturn extends string = string>(name: string, defaultVal?: TReturn | (string & {})): TReturn | undefined {
  344. const arg = argv.find((v) => v.trim().match(new RegExp(`^(--)?${name}=.+$`, "i")));
  345. const val = arg?.split("=")?.[1];
  346. return (val && val.length > 0 ? val : defaultVal)?.trim() as TReturn | undefined;
  347. }
  348. async function getLinkedPkgs() {
  349. const requireFile = String(await readFile(join(assetFolderPath, "require.json")));
  350. const require = (JSON.parse(requireFile) as RequireObj[]);
  351. let retStr = "";
  352. for(const entry of require) {
  353. if(!("link" in entry) || typeof entry.link !== "string" || !("pkgName" in entry))
  354. continue;
  355. try {
  356. const scriptCont = String(await readFile(resolve(entry.link)));
  357. const trimmedScript = scriptCont
  358. .replace(/\n?\/\/\s*==.+==[\s\S]+\/\/\s*==\/.+==/gm, "");
  359. retStr += `\n// <link ${entry.pkgName}>\n${trimmedScript}\n// </link ${entry.pkgName}>\n\n`;
  360. }
  361. catch(err) {
  362. console.error(`Couldn't read linked package at '${entry.link}':`, err);
  363. schedExit(1);
  364. }
  365. }
  366. return retStr;
  367. }
  368. /** Schedules an exit after I/O events finish */
  369. function schedExit(code: number) {
  370. setImmediate(() => exit(code));
  371. }
  372. /** Generates a random ID of the given {@linkcode length} and {@linkcode radix} */
  373. function randomId(length = 16, radix = 16, randomCase = true) {
  374. const arr = Array.from(
  375. { length },
  376. () => Math.floor(Math.random() * radix).toString(radix)
  377. );
  378. randomCase && arr.forEach((v, i) => {
  379. arr[i] = v[Math.random() > 0.5 ? "toUpperCase" : "toLowerCase"]();
  380. });
  381. return arr.join("");
  382. }
  383. /**
  384. * Calculates the SHA-256 hash of the file at the given path.
  385. * Uses {@linkcode resolveResourcePath()} to resolve the path, meaning paths prefixed with a slash are relative to the repository root, otherwise they are relative to the `assets` directory.
  386. */
  387. function getFileHashSha256(path: string): Promise<string> {
  388. path = resolveResourcePath(path);
  389. return new Promise((res, rej) => {
  390. const hash = createHash("sha256");
  391. const stream = createReadStream(resolve(path));
  392. stream.on("data", data => hash.update(data));
  393. stream.on("end", () => res(hash.digest("base64")));
  394. stream.on("error", rej);
  395. });
  396. }
  397. /**
  398. * Resolves the path to a resource.
  399. * If prefixed with a slash, the path is relative to the repository root, otherwise it is relative to the `assets` directory.
  400. */
  401. function resolveResourcePath(path: string): string {
  402. if(path.startsWith("/"))
  403. return path.slice(1);
  404. return `assets/${path}`;
  405. }