post-build.ts 19 KB

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