post-build.ts 18 KB

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