post-build.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  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", "jsdelivr");
  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. ASSET_SOURCE: assetSource,
  130. DEV_SERVER_PORT: devServerPort,
  131. },
  132. );
  133. if(mode === "production")
  134. userscript = remSourcemapComments(userscript);
  135. else
  136. userscript = userscript.replace(/sourceMappingURL=/gm, `sourceMappingURL=http://localhost:${devServerPort}/`);
  137. // insert userscript header and final newline
  138. const finalUserscript = `${header}\n${await getLinkedPkgs()}${userscript}${userscript.endsWith("\n") ? "" : "\n"}`;
  139. await writeFile(scriptPath, finalUserscript);
  140. ringBell && stdout.write("\u0007");
  141. const envText = (mode === "production" ? k.magenta : k.blue)(mode);
  142. const sizeKiB = Number((Buffer.byteLength(finalUserscript, "utf8") / 1024).toFixed(2));
  143. let buildStats: Partial<BuildStats>[] = [];
  144. if(await exists(".build.json")) {
  145. try {
  146. const buildJsonParsed = JSON.parse(String(await readFile(".build.json")));
  147. buildStats = (Array.isArray(buildJsonParsed) ? buildJsonParsed : []) as Partial<BuildStats>[];
  148. }
  149. catch {
  150. void 0;
  151. }
  152. }
  153. const prevBuildStats = buildStats.find((v) => v.mode === mode);
  154. let sizeIndicator = "";
  155. if(prevBuildStats?.sizeKiB) {
  156. const sizeDiff = sizeKiB - prevBuildStats.sizeKiB;
  157. const sizeDiffTrunc = parseFloat(sizeDiff.toFixed(2));
  158. if(sizeDiffTrunc !== 0) {
  159. const sizeDiffCol = (sizeDiff > 0 ? k.yellow : k.green)().bold;
  160. const sizeDiffNum = `${(sizeDiff > 0 ? "+" : (sizeDiff !== 0 ? "-" : ""))}${Math.abs(sizeDiffTrunc)}`;
  161. sizeIndicator = ` ${k.gray("(")}${sizeDiffCol(sizeDiffNum)}${k.gray(")")}`;
  162. }
  163. }
  164. console.info([
  165. "",
  166. `Successfully built for ${envText} - build number (last commit SHA): ${buildNbr}`,
  167. `Outputted file '${relative("./", scriptPath)}' with a size of ${k.green(`${sizeKiB} KiB`)}${sizeIndicator}`,
  168. `Userscript URL: ${k.blue().underline(devServerUserscriptUrl)}`,
  169. "",
  170. ].join("\n"));
  171. const curBuildStats: BuildStats = {
  172. sizeKiB,
  173. mode,
  174. timestamp: buildTs,
  175. };
  176. const newBuildStats = [
  177. curBuildStats,
  178. ...(buildStats.filter((v) => v.mode !== mode)),
  179. ];
  180. await writeFile(".build.json", JSON.stringify(newBuildStats, undefined, 2));
  181. schedExit(0);
  182. }
  183. catch(err) {
  184. console.error(k.red("Error while adding userscript header:\n"), err);
  185. schedExit(1);
  186. }
  187. })();
  188. /** Replaces tokens in the format `#{{key}}` or `/⋆#{{key}}⋆/` of the `replacements` param with their respective value */
  189. function insertValues(userscript: string, replacements: Record<string, Stringifiable>) {
  190. for(const key in replacements)
  191. userscript = userscript.replace(new RegExp(`(\\/\\*\\s*)?#{{${key}}}(\\s*\\*\\/)?`, "gm"), String(replacements[key]));
  192. return userscript;
  193. }
  194. /** Removes sourcemapping comments */
  195. function remSourcemapComments(input: string) {
  196. return input
  197. .replace(/\/\/\s?#\s?sourceMappingURL\s?=\s?.+$/gm, "");
  198. }
  199. /**
  200. * Used as a kind of "build number", though note it is always behind by at least one commit,
  201. * as the act of putting this number in the userscript and committing it changes the hash again, indefinitely
  202. */
  203. function getLastCommitSha() {
  204. return new Promise<string>((res, rej) => {
  205. exec("git rev-parse --short HEAD", (err, stdout, stderr) => {
  206. if(err) {
  207. console.error(k.red("Error while checking for last Git commit. Do you have Git installed?\n"), stderr);
  208. return rej(err);
  209. }
  210. return res(String(stdout).replace(/\r?\n/gm, "").trim());
  211. });
  212. });
  213. }
  214. async function exists(path: string) {
  215. try {
  216. await access(path, fsconst.R_OK | fsconst.W_OK);
  217. return true;
  218. }
  219. catch {
  220. return false;
  221. }
  222. }
  223. /** Resolves the value of an entry in resources.json */
  224. function resolveResourceVal(value: string, buildNbr: string) {
  225. if(!(/\$/.test(value)))
  226. return value;
  227. const replacements = [
  228. ["\\$MODE", mode],
  229. ["\\$BRANCH", branch],
  230. ["\\$HOST", host],
  231. ["\\$BUILD_NUMBER", buildNbr],
  232. ["\\$UID", buildUid],
  233. ];
  234. return replacements.reduce((acc, [key, val]) => acc.replace(new RegExp(key, "g"), val), value);
  235. };
  236. /** Returns a string of resource directives, as defined in `assets/resources.json` or undefined if the file doesn't exist or is invalid */
  237. async function getResourceDirectives(ref: string) {
  238. try {
  239. const directives: string[] = [],
  240. resourcesRaw = JSON.parse(String(await readFile(join(assetFolderPath, "resources.json")))),
  241. resources = "resources" in resourcesRaw
  242. ? resourcesRaw.resources as Record<string, string> | Record<string, { path: string; buildNbr: string }>
  243. : undefined,
  244. resourcesHashed = {} as Record<string, Record<"path" | "ref", string> & Partial<Record<"hash", string>>>;
  245. if(!resources)
  246. throw new Error("No resources found in 'assets/resources.json'");
  247. const externalAssetRegexes = resourcesJson.alwaysExternalAssetPatterns.map((p) => new RegExp(p));
  248. for(const [name, val] of Object.entries(resources)) {
  249. // skip over all external assets
  250. if(externalAssetRegexes.some((re) => re.test(name)))
  251. continue;
  252. const pathVal = typeof val === "object" ? val.path : val;
  253. const hash = (
  254. assetSource !== "local"
  255. && (typeof val === "object" && "integrity" in val ? val.integrity !== false : true)
  256. && !pathVal.match(/^https?:\/\//)
  257. )
  258. ? await getFileHashSha256(pathVal.replace(/\?.+/g, ""))
  259. : undefined;
  260. resourcesHashed[name] = typeof val === "object"
  261. ? { path: resolveResourceVal(val.path, ref), ref: resolveResourceVal(val.ref, ref), hash }
  262. : { path: getResourceUrl(resolveResourceVal(val, ref), ref), ref, hash };
  263. }
  264. const addResourceHashed = async (name: string, path: string, ref: string) => {
  265. try {
  266. if(externalAssetRegexes.some((re) => re.test(name)))
  267. return;
  268. if(assetSource === "local" || path.match(/^https?:\/\//)) {
  269. resourcesHashed[name] = { path: getResourceUrl(path, ref), ref, hash: undefined };
  270. return;
  271. }
  272. resourcesHashed[name] = { path: getResourceUrl(path, ref), ref, hash: await getFileHashSha256(path) };
  273. }
  274. catch(err) {
  275. console.warn(k.yellow(`Couldn't add hashed resource '${name}':`), err);
  276. }
  277. };
  278. await addResourceHashed("css-bundle", "/dist/BetterYTM.css", ref);
  279. for(const [locale] of Object.entries(localesJson))
  280. await addResourceHashed(`trans-${locale}`, `translations/${locale}.json`, ref);
  281. let longestName = 0;
  282. for(const name of Object.keys(resourcesHashed))
  283. longestName = Math.max(longestName, name.length);
  284. const sortedResourceEntries = Object.entries(resourcesHashed).sort(([a], [b]) => a.localeCompare(b));
  285. for(const [name, { path, ref: entryRef, hash }] of sortedResourceEntries) {
  286. const bufferSpace = " ".repeat(longestName - name.length);
  287. directives.push(`// @resource ${name}${bufferSpace} ${
  288. path.match(/^https?:\/\//)
  289. ? path
  290. : getResourceUrl(path, entryRef)
  291. }${hash ? `#sha256=${hash}` : ""}`);
  292. }
  293. return directives.join("\n");
  294. }
  295. catch(err) {
  296. console.warn("No resource directives found:", err);
  297. }
  298. }
  299. async function getRequireDirectives() {
  300. const directives: string[] = [];
  301. const requireFile = String(await readFile(join(assetFolderPath, "require.json")));
  302. const require = JSON.parse(requireFile) as RequireObj[];
  303. for(const entry of require) {
  304. if("link" in entry && typeof entry.link === "string")
  305. continue;
  306. "pkgName" in entry && directives.push(getRequireEntry(entry));
  307. "url" in entry && directives.push(`// @require ${entry.url}`);
  308. }
  309. return directives.length > 0 ? directives.join("\n") : undefined;
  310. }
  311. function getRequireEntry(entry: RequireObjPkg) {
  312. const baseUrl = entry.baseUrl ?? "https://cdn.jsdelivr.net/npm/";
  313. let version: string;
  314. const deps = {
  315. ...pkg.dependencies,
  316. ...pkg.devDependencies,
  317. };
  318. if(entry.pkgName in deps)
  319. version = deps[entry.pkgName].replace(/[^0-9.]/g, "");
  320. else
  321. throw new Error(`Library '${entry.pkgName}', referenced in 'assets/require.json' not found in dependencies or devDependencies`);
  322. return `// @require ${baseUrl}${entry.pkgName}@${version}${entry.path ? `${entry.path.startsWith("/") ? "" : "/"}${entry.path}` : ""}`;
  323. }
  324. /** Returns the @description directive block for each defined locale in `assets/locales.json` */
  325. function getLocalizedDescriptions() {
  326. try {
  327. const descriptions: string[] = [];
  328. for(const [locale, { userscriptDesc, ...rest }] of Object.entries(localesJson)) {
  329. let loc = locale;
  330. if(loc.length < 5)
  331. loc += " ".repeat(5 - loc.length);
  332. descriptions.push(`// @description:${loc} ${userscriptDesc}`);
  333. if("altLocales" in rest) {
  334. for(const altLoc of rest.altLocales) {
  335. let alt = altLoc.replace(/_/, "-");
  336. if(alt.length < 5)
  337. alt += " ".repeat(5 - alt.length);
  338. descriptions.push(`// @description:${alt} ${userscriptDesc}`);
  339. }
  340. }
  341. }
  342. return descriptions.join("\n") + "\n";
  343. }
  344. catch(err) {
  345. console.warn(k.yellow("No localized descriptions found:"), err);
  346. }
  347. }
  348. /**
  349. * Returns the full URL for a given resource path, based on the current mode and branch
  350. * @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.
  351. * @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
  352. */
  353. function getResourceUrl(path: string, ghRef?: string) {
  354. let assetPath = "/assets/";
  355. if(path.startsWith("/"))
  356. assetPath = "";
  357. assetPath += path;
  358. const finalPath = `${ghRef ?? `v${pkg.version}`}${assetPath}`;
  359. return assetSource === "local"
  360. ? `http://localhost:${devServerPort}${assetPath}?b=${buildUid}`
  361. : (
  362. assetSource === "github"
  363. ? `https://raw.githubusercontent.com/${repo}/${finalPath}`
  364. : `https://cdn.jsdelivr.net/gh/${repo}@${finalPath}`
  365. );
  366. }
  367. /**
  368. * Resolves the path to a resource.
  369. * If prefixed with a slash, the path is relative to the repository root, otherwise it is relative to the `assets` directory.
  370. */
  371. function resolveResourcePath(path: string): string {
  372. if(path.startsWith("/"))
  373. return path.slice(1);
  374. return `assets/${path}`;
  375. }
  376. /** Returns the value of a CLI argument (in the format `--arg=<value>`) or the value of `defaultVal` if it doesn't exist */
  377. function getCliArg<TReturn extends string = string>(name: string, defaultVal: TReturn | (string & {})): TReturn
  378. /** Returns the value of a CLI argument (in the format `--arg=<value>`) or undefined if it doesn't exist */
  379. function getCliArg<TReturn extends string = string>(name: string, defaultVal?: TReturn | (string & {})): TReturn | undefined
  380. /** Returns the value of a CLI argument (in the format `--arg=<value>`) or the value of `defaultVal` if it doesn't exist */
  381. function getCliArg<TReturn extends string = string>(name: string, defaultVal?: TReturn | (string & {})): TReturn | undefined {
  382. const arg = argv.find((v) => v.trim().match(new RegExp(`^(--)?${name}=.+$`, "i")));
  383. const val = arg?.split("=")?.[1];
  384. return (val && val.length > 0 ? val : defaultVal)?.trim() as TReturn | undefined;
  385. }
  386. async function getLinkedPkgs() {
  387. const requireFile = String(await readFile(join(assetFolderPath, "require.json")));
  388. const require = (JSON.parse(requireFile) as RequireObj[]);
  389. let retStr = "";
  390. for(const entry of require) {
  391. if(!("link" in entry) || typeof entry.link !== "string" || !("pkgName" in entry))
  392. continue;
  393. try {
  394. const scriptCont = String(await readFile(resolve(entry.link)));
  395. const trimmedScript = scriptCont
  396. .replace(/\n?\/\/\s*==.+==[\s\S]+\/\/\s*==\/.+==/gm, "");
  397. retStr += `\n// <link ${entry.pkgName}>\n${trimmedScript}\n// </link ${entry.pkgName}>\n\n`;
  398. }
  399. catch(err) {
  400. console.error(`Couldn't read linked package at '${entry.link}':`, err);
  401. schedExit(1);
  402. }
  403. }
  404. return retStr;
  405. }
  406. /** Schedules an exit after I/O events finish */
  407. function schedExit(code: number) {
  408. setImmediate(() => exit(code));
  409. }
  410. /** Generates a random ID of the given {@linkcode length} and {@linkcode radix} */
  411. function randomId(length = 16, radix = 16, randomCase = true) {
  412. const arr = Array.from(
  413. { length },
  414. () => Math.floor(Math.random() * radix).toString(radix)
  415. );
  416. randomCase && arr.forEach((v, i) => {
  417. arr[i] = v[Math.random() > 0.5 ? "toUpperCase" : "toLowerCase"]();
  418. });
  419. return arr.join("");
  420. }
  421. /**
  422. * Calculates the SHA-256 hash of the file at the given path.
  423. * 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.
  424. */
  425. function getFileHashSha256(path: string): Promise<string> {
  426. path = resolveResourcePath(path);
  427. return new Promise((res, rej) => {
  428. const hash = createHash("sha256");
  429. const stream = createReadStream(resolve(path));
  430. stream.on("data", data => hash.update(data));
  431. stream.on("end", () => res(hash.digest("base64")));
  432. stream.on("error", rej);
  433. });
  434. }