GitHub 增强

为 GitHub 增加额外的功能。

目前为 2024-12-06 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name GitHub Plus
  3. // @name:zh-CN GitHub 增强
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.3.0
  6. // @description Enhance GitHub with additional features.
  7. // @description:zh-CN 为 GitHub 增加额外的功能。
  8. // @author PRO-2684
  9. // @match https://github.com/*
  10. // @match https://*.github.com/*
  11. // @run-at document-start
  12. // @icon http://github.com/favicon.ico
  13. // @license gpl-3.0
  14. // @grant GM_setValue
  15. // @grant GM_getValue
  16. // @grant GM_deleteValue
  17. // @grant GM_registerMenuCommand
  18. // @grant GM_unregisterMenuCommand
  19. // @grant GM_addValueChangeListener
  20. // @grant GM_addElement
  21. // @require https://update.greasyfork.org/scripts/470224/1460555/Tampermonkey%20Config.js
  22. // ==/UserScript==
  23.  
  24. (function() {
  25. 'use strict';
  26. const { name, version } = GM_info.script;
  27. const idPrefix = "ghp-"; // Prefix for the IDs of the elements
  28. /**
  29. * The top domain of the current page.
  30. * @type {string}
  31. */
  32. const topDomain = location.hostname.split(".").slice(-2).join(".");
  33. /**
  34. * The official domain of GitHub.
  35. * @type {string}
  36. */
  37. const officialDomain = "github.com";
  38. /**
  39. * The color used for logging. Matches the color of the GitHub.
  40. * @type {string}
  41. */
  42. const themeColor = "#f78166";
  43. /**
  44. * Regular expression to match the expanded assets URL. (https://<host>/<username>/<repo>/releases/expanded_assets/<version>)
  45. */
  46. const expandedAssetsRegex = new RegExp(`https://${topDomain.replaceAll(".", "\\.")}/([^/]+)/([^/]+)/releases/expanded_assets/([^/]+)`);
  47. /**
  48. * Data about the release. Maps `owner`, `repo` and `version` to the details of a release. Details are `Promise` objects if exist.
  49. */
  50. let releaseData = {};
  51. /**
  52. * Rate limit data for the GitHub API.
  53. * @type {Object}
  54. * @property {number} limit The maximum number of requests that the consumer is permitted to make per hour.
  55. * @property {number} remaining The number of requests remaining in the current rate limit window.
  56. * @property {number} reset The time at which the current rate limit window resets in UTC epoch seconds.
  57. */
  58. let rateLimit = {
  59. limit: -1,
  60. remaining: -1,
  61. reset: -1
  62. };
  63.  
  64. function enumType(values) {
  65. return { // The value actually stored is the index
  66. value: 0, // Default value is the first one
  67. input: (prop, orig) => {
  68. return (orig + 1) % values.length; // Cycle through the values
  69. },
  70. processor: (input) => {
  71. if (input >= values.length) throw new Error(`Invalid value: ${input}, expected to be less than ${values.length}`);
  72. return input;
  73. },
  74. formatter: (name, value) => {
  75. return `${name}: ${values[value]}`;
  76. },
  77. };
  78. }
  79.  
  80. // Configuration
  81. const configDesc = {
  82. $default: {
  83. autoClose: false
  84. },
  85. code: {
  86. name: "🔢 Code Features",
  87. type: "folder",
  88. items: {
  89. cloneFullCommand: {
  90. name: "📥 Clone Full Command",
  91. title: "Append `git clone ` before `https` and `git@` URLs under the code tab",
  92. type: "bool",
  93. value: false,
  94. },
  95. tabSize: {
  96. name: "➡️ Tab Size",
  97. title: "Set Tab indentation size",
  98. type: "int",
  99. value: 4,
  100. processor: "int_range-0-",
  101. },
  102. cursorBlink: {
  103. name: "😉 Cursor Blink",
  104. title: "Enable cursor blinking",
  105. type: "bool",
  106. value: false,
  107. },
  108. cursorAnimation: {
  109. name: "🌊 Cursor Animation",
  110. title: "Make cursor move smoothly",
  111. type: "bool",
  112. value: false,
  113. },
  114. },
  115. },
  116. appearance: {
  117. name: "🎨 Appearance",
  118. type: "folder",
  119. items: {
  120. dashboard: {
  121. name: "📰 Dashboard",
  122. title: "Configures the dashboard",
  123. ...enumType(["Default", "Hide Copilot", "Hide Feed", "Mobile-Like"]),
  124. },
  125. leftSidebar: {
  126. name: "↖️ Left Sidebar",
  127. title: "Configures the left sidebar",
  128. ...enumType(["Default", "Hidden"]),
  129. },
  130. rightSidebar: {
  131. name: "↗️ Right Sidebar",
  132. title: "Configures the right sidebar",
  133. ...enumType(["Default", "Hide 'Latest changes'", "Hide 'Explore repositories'", "Hide Completely"]),
  134. },
  135. },
  136. },
  137. release: {
  138. name: "📦 Release Features",
  139. type: "folder",
  140. items: {
  141. uploader: {
  142. name: "⬆️ Release Uploader",
  143. title: "Show uploader of release assets",
  144. type: "bool",
  145. value: true,
  146. },
  147. downloads: {
  148. name: "📥 Release Downloads",
  149. title: "Show download counts of release assets",
  150. type: "bool",
  151. value: true,
  152. },
  153. histogram: {
  154. name: "📊 Release Histogram",
  155. title: "Show a histogram of download counts for each release asset",
  156. type: "bool",
  157. },
  158. },
  159. },
  160. additional: {
  161. name: "🪄 Additional Features",
  162. type: "folder",
  163. items: {
  164. trackingPrevention: {
  165. name: "🎭 Tracking Prevention",
  166. title: () => { return `Prevent some tracking by GitHub (${name} has prevented tracking ${GM_getValue("trackingPrevented", 0)} time(s))`; },
  167. type: "bool",
  168. value: true,
  169. },
  170. },
  171. },
  172. advanced: {
  173. name: "⚙️ Advanced Settings",
  174. type: "folder",
  175. items: {
  176. token: {
  177. name: "🔑 Personal Access Token",
  178. title: "Your personal access token for GitHub API, starting with `github_pat_` (used for increasing rate limit)",
  179. type: "str",
  180. },
  181. rateLimit: {
  182. name: "📈 Rate Limit",
  183. title: "View the current rate limit status",
  184. type: "action",
  185. },
  186. debug: {
  187. name: "🐞 Debug",
  188. title: "Enable debug mode",
  189. type: "bool",
  190. },
  191. },
  192. },
  193. };
  194. const config = new GM_config(configDesc);
  195.  
  196. // Helper function for css
  197. function injectCSS(id, css) {
  198. const style = document.head.appendChild(document.createElement("style"));
  199. style.id = idPrefix + id;
  200. style.textContent = css;
  201. return style;
  202. }
  203. function cssHelper(id, enable) {
  204. const current = document.getElementById(idPrefix + id);
  205. if (current) {
  206. current.disabled = !enable;
  207. } else if (enable) {
  208. injectCSS(id, dynamicStyles[id]);
  209. }
  210. }
  211. // General functions
  212. const $ = document.querySelector.bind(document);
  213. const $$ = document.querySelectorAll.bind(document);
  214. /**
  215. * Log the given arguments if debug mode is enabled.
  216. * @param {...any} args The arguments to log.
  217. */
  218. function log(...args) {
  219. if (config.get("advanced.debug")) console.log(`%c[${name}]%c`, `color:${themeColor};`, "color: unset;", ...args);
  220. }
  221. /**
  222. * Warn the given arguments.
  223. * @param {...any} args The arguments to warn.
  224. */
  225. function warn(...args) {
  226. console.warn(`%c[${name}]%c`, `color:${themeColor};`, "color: unset;", ...args);
  227. }
  228. /**
  229. * Replace the domain of the given URL with the top domain if needed.
  230. * @param {string} url The URL to fix.
  231. * @returns {string} The fixed URL.
  232. */
  233. function fixDomain(url) {
  234. return (topDomain === officialDomain) ? url : url.replace(`https://${officialDomain}/`, `https://${topDomain}/`); // Replace top domain
  235. }
  236. /**
  237. * Fetch the given URL with the personal access token, if given. Also updates rate limit.
  238. * @param {string} url The URL to fetch.
  239. * @param {RequestInit} options The options to pass to `fetch`.
  240. * @returns {Promise<Response>} The response from the fetch.
  241. */
  242. async function fetchWithToken(url, options) {
  243. const token = config.get("advanced.token");
  244. if (token) {
  245. if (!options) options = {};
  246. if (!options.headers) options.headers = {};
  247. options.headers.accept = "application/vnd.github+json";
  248. options.headers["X-GitHub-Api-Version"] = "2022-11-28";
  249. options.headers.Authorization = `Bearer ${token}`;
  250. }
  251. const r = await fetch(url, options);
  252. function parseRateLimit(suffix, defaultValue = -1) {
  253. const parsed = parseInt(r.headers.get(`X-RateLimit-${suffix}`));
  254. return isNaN(parsed) ? defaultValue : parsed;
  255. }
  256. // Update rate limit
  257. for (const key of Object.keys(rateLimit)) {
  258. rateLimit[key] = parseRateLimit(key); // Case-insensitive
  259. }
  260. const resetDate = new Date(rateLimit.reset * 1000).toLocaleString();
  261. log(`Rate limit: remaining ${rateLimit.remaining}/${rateLimit.limit}, resets at ${resetDate}`);
  262. if (r.status === 403 || r.status === 429) { // If we get 403 or 429, we've hit the rate limit.
  263. throw new Error(`Rate limit exceeded! Will reset at ${resetDate}`);
  264. } else if (rateLimit.remaining === 0) {
  265. warn(`Rate limit has been exhausted! Will reset at ${resetDate}`);
  266. }
  267. return r;
  268. }
  269.  
  270. // CSS-related features
  271. const dynamicStyles = {
  272. "code.cursorBlink": `[data-testid="navigation-cursor"] { animation: blink 1s step-end infinite; }`,
  273. "code.cursorAnimation": `[data-testid="navigation-cursor"] { transition: top 0.1s ease-in-out, left 0.1s ease-in-out; }`
  274. };
  275. for (const prop in dynamicStyles) {
  276. cssHelper(prop, config.get(prop));
  277. }
  278.  
  279. // Code features
  280. /**
  281. * Show the full command to clone a repository.
  282. * @param {HTMLElement} [target] The target element to search for the embedded data.
  283. */
  284. function cloneFullCommand(target = document.body) {
  285. document.currentScript?.remove(); // Self-remove
  286. const embeddedData = target.querySelector('react-partial[partial-name="repos-overview"] > script[data-target="react-partial.embeddedData"]'); // The element containing the repository information
  287. if (!embeddedData) {
  288. log("Full clone command not enabled - no embedded data found");
  289. return false;
  290. }
  291. const data = JSON.parse(embeddedData?.textContent);
  292. const protocolInfo = data.props?.initialPayload?.overview?.codeButton?.local?.protocolInfo;
  293. if (!protocolInfo) {
  294. log("Full clone command not enabled - no protocol information found");
  295. return false;
  296. }
  297. function prefix(uri) {
  298. return !uri || uri.startsWith("git clone ") ? uri : "git clone " + uri;
  299. }
  300. protocolInfo.httpUrl = prefix(protocolInfo.httpUrl);
  301. protocolInfo.sshUrl = prefix(protocolInfo.sshUrl);
  302. embeddedData.textContent = JSON.stringify(data);
  303. log("Full clone command enabled");
  304. return true;
  305. }
  306. if (config.get("code.cloneFullCommand")) {
  307. // document.addEventListener("DOMContentLoaded", cloneFullCommand, { once: true }); // Doesn't work, since our script is running too late, after `embeddedData` is accessed by GitHub. Need to add the script in the head so as to defer DOM parsing.
  308. const dataPresent = $('react-partial[partial-name="repos-overview"] > script[data-target="react-partial.embeddedData"]');
  309. if (dataPresent) {
  310. cloneFullCommand();
  311. } else {
  312. // https://a.opnxng.com/exchange/stackoverflow.com/questions/41394983/how-to-defer-inline-javascript
  313. const logDef = config.get("advanced.debug") ? `const log = (...args) => console.log("%c[${name}]%c", "color:${themeColor};", "color: unset;", ...args);\n` : "const log = () => {};\n"; // Define the `log` function, respecting the debug mode
  314. const scriptText = logDef + "const target = document.body;\n" + cloneFullCommand.toString().replace(/^.*?{|}$/g, ""); // Get the function body
  315. const wrapped = `(function() {${scriptText}})();`; // Wrap the function in an IIFE so as to prevent polluting the global scope
  316. GM_addElement(document.head, "script", { textContent: wrapped, type: "module" }); // Use `GM_addElement` instead of native `appendChild` to bypass CSP
  317. // Utilize data URI and set `defer` attribute to defer the script execution (can't bypass CSP)
  318. // GM_addElement(document.head, "script", { src: `data:text/javascript,${encodeURIComponent(wrapped)}`, defer: true });
  319. }
  320. // Adapt to dynamic loading
  321. document.addEventListener("turbo:before-render", e => {
  322. cloneFullCommand(e.detail.newBody.querySelector("[data-turbo-body]") ?? e.detail.newBody);
  323. });
  324. }
  325. /**
  326. * Set the tab size for the code blocks.
  327. * @param {number} size The tab size to set.
  328. */
  329. function tabSize(size) {
  330. const id = idPrefix + "tabSize";
  331. const style = document.getElementById(id) ?? injectCSS(id, "");
  332. style.textContent = `pre, code { tab-size: ${size}; }`;
  333. }
  334.  
  335. // Appearance features
  336. /**
  337. * Dynamic styles for the enum settings.
  338. * @type {Object<string, Array<string>>}
  339. */
  340. const enumStyles = {
  341. "appearance.dashboard": [
  342. "/* Default */",
  343. "/* Hide Copilot */ #dashboard > .news > .copilotPreview__container { display: none; }",
  344. "/* Hide Feed */ #dashboard > .news > feed-container { display: none; }",
  345. `/* Mobile-Like */
  346. .application-main > div > aside[aria-label="Account context"] {
  347. display: block !important;
  348. }
  349. #dashboard > .news {
  350. > .copilotPreview__container { display: none; }
  351. > feed-container { display: none; }
  352. > .d-block.d-md-none { display: block !important; }
  353. }`,
  354. ],
  355. "appearance.leftSidebar": [
  356. "/* Default */",
  357. "/* Hidden */ .application-main .feed-background > aside.feed-left-sidebar { display: none; }",
  358. ],
  359. "appearance.rightSidebar": [
  360. "/* Default */",
  361. "/* Hide 'Latest changes' */ aside.feed-right-sidebar > .dashboard-changelog { display: none; }",
  362. "/* Hide 'Explore repositories' */ aside.feed-right-sidebar > [aria-label='Explore repositories'] { display: none; }",
  363. "/* Hide Completely */ aside.feed-right-sidebar { display: none; }",
  364. ],
  365. };
  366. /**
  367. * Helper function to configure enum styles.
  368. * @param {string} id The ID of the style.
  369. * @param {string} mode The mode to set.
  370. */
  371. function enumStyleHelper(id, mode) {
  372. const style = document.getElementById(idPrefix + id) ?? injectCSS(id, "");
  373. style.textContent = enumStyles[id][mode];
  374. }
  375. for (const prop in enumStyles) {
  376. enumStyleHelper(prop, config.get(prop));
  377. }
  378.  
  379. // Release features
  380. /**
  381. * Get the release data for the given owner, repo and version.
  382. * @param {string} owner The owner of the repository.
  383. * @param {string} repo The repository name.
  384. * @param {string} version The version tag of the release.
  385. * @returns {Promise<Object>} The release data, which resolves to an object mapping download link to details.
  386. */
  387. async function getReleaseData(owner, repo, version) {
  388. if (!releaseData[owner]) releaseData[owner] = {};
  389. if (!releaseData[owner][repo]) releaseData[owner][repo] = {};
  390. if (!releaseData[owner][repo][version]) {
  391. const url = `https://api.${topDomain}/repos/${owner}/${repo}/releases/tags/${version}`;
  392. const promise = fetchWithToken(url).then(
  393. response => response.json()
  394. ).then(data => {
  395. log(`Fetched release data for ${owner}/${repo}@${version}:`, data);
  396. const assets = {};
  397. for (const asset of data.assets) {
  398. assets[fixDomain(asset.browser_download_url)] = {
  399. downloads: asset.download_count,
  400. uploader: {
  401. name: asset.uploader.login,
  402. url: fixDomain(asset.uploader.html_url)
  403. }
  404. };
  405. }
  406. log(`Processed release data for ${owner}/${repo}@${version}:`, assets);
  407. return assets;
  408. });
  409. releaseData[owner][repo][version] = promise;
  410. }
  411. return releaseData[owner][repo][version];
  412. }
  413. /**
  414. * Create a link to the uploader's profile.
  415. * @param {Object} uploader The uploader information.
  416. * @param {string} uploader.name The name of the uploader.
  417. * @param {string} uploader.url The URL to the uploader's profile.
  418. */
  419. function createUploaderLink(uploader) {
  420. const link = document.createElement("a");
  421. link.textContent = "@" + uploader.name;
  422. link.href = uploader.url;
  423. link.title = `Uploaded by @${uploader.name}`;
  424. link.setAttribute("class", "color-fg-muted text-sm-left flex-auto ml-md-3 nowrap");
  425. return link;
  426. }
  427. /**
  428. * Create a span element with the given download count.
  429. * @param {number} downloads The download count.
  430. */
  431. function createDownloadCount(downloads) {
  432. const downloadCount = document.createElement("span");
  433. downloadCount.textContent = `${downloads} DL`;
  434. downloadCount.title = `${downloads} downloads`;
  435. downloadCount.setAttribute("class", "color-fg-muted text-sm-left flex-shrink-0 flex-grow-0 ml-md-3 nowrap");
  436. return downloadCount;
  437. }
  438. /**
  439. * Show a histogram of the download counts for the given release entry.
  440. * @param {HTMLElement} asset One of the release assets.
  441. * @param {number} value The download count of the asset.
  442. * @param {number} max The maximum download count of all assets.
  443. */
  444. function showHistogram(asset, value, max) {
  445. asset.style.setProperty("--percent", `${value / max * 100}%`);
  446. }
  447. /**
  448. * Adding additional info (download count) to the release entries under the given element.
  449. * @param {HTMLElement} el The element to search for release entries.
  450. * @param {Object} info Additional information about the release (owner, repo, version).
  451. * @param {string} info.owner The owner of the repository.
  452. * @param {string} info.repo The repository name.
  453. * @param {string} info.version The version of the release.
  454. */
  455. async function addAdditionalInfoToRelease(el, info) {
  456. const entries = el.querySelectorAll("ul > li");
  457. const assets = Array.from(entries).filter(asset => asset.querySelector("svg.octicon-package"));
  458. const releaseData = await getReleaseData(info.owner, info.repo, info.version);
  459. if (!releaseData) return;
  460. const maxDownloads = Math.max(0, ...Object.values(releaseData).map(asset => asset.downloads));
  461. assets.forEach(asset => {
  462. const downloadLink = asset.children[0].querySelector("a")?.href;
  463. const statistics = asset.children[1];
  464. const assetInfo = releaseData[downloadLink];
  465. if (!assetInfo) return;
  466. asset.classList.add("ghp-release-asset");
  467. const size = statistics.querySelector("span.flex-auto");
  468. size.classList.remove("flex-auto");
  469. size.classList.add("flex-shrink-0", "flex-grow-0");
  470. if (config.get("release.downloads")) {
  471. const downloadCount = createDownloadCount(assetInfo.downloads);
  472. statistics.prepend(downloadCount);
  473. }
  474. if (config.get("release.uploader")) {
  475. const uploaderLink = createUploaderLink(assetInfo.uploader);
  476. statistics.prepend(uploaderLink);
  477. }
  478. if (config.get("release.histogram") && maxDownloads > 0 && assets.length > 1) {
  479. showHistogram(asset, assetInfo.downloads, maxDownloads);
  480. }
  481. });
  482. }
  483. /**
  484. * Handle the `include-fragment-replace` event.
  485. * @param {CustomEvent} event The event object.
  486. */
  487. function onFragmentReplace(event) {
  488. const self = event.target;
  489. const src = self.src;
  490. const match = expandedAssetsRegex.exec(src);
  491. if (!match) return;
  492. const [_, owner, repo, version] = match;
  493. const info = { owner, repo, version };
  494. const fragment = event.detail.fragment;
  495. log("Found expanded assets:", fragment);
  496. for (const child of fragment.children) {
  497. addAdditionalInfoToRelease(child, info);
  498. }
  499. }
  500. /**
  501. * Find all release entries and setup listeners to show the download count.
  502. */
  503. function setupListeners() {
  504. log("Calling setupListeners");
  505. if (!config.get("release.downloads") && !config.get("release.uploader") && !config.get("release.histogram")) return; // No need to run
  506. // IncludeFragmentElement: https://github.com/github/include-fragment-element/blob/main/src/include-fragment-element.ts
  507. const fragments = document.querySelectorAll('[data-hpc] details[data-view-component="true"] include-fragment');
  508. fragments.forEach(fragment => {
  509. fragment.addEventListener("include-fragment-replace", onFragmentReplace, { once: true });
  510. });
  511. }
  512. if (location.hostname === topDomain) { // Only run on GitHub main site
  513. document.addEventListener("DOMContentLoaded", setupListeners, { once: true });
  514. // Examine event listeners on `document`, and you can see the event listeners for the `turbo:*` events. (Remember to check `Framework Listeners`)
  515. document.addEventListener("turbo:load", setupListeners);
  516. // Other possible approaches and reasons against them:
  517. // - Use `MutationObserver` - Not efficient
  518. // - Hook `CustomEvent` to make `include-fragment-replace` events bubble - Monkey-patching
  519. // - Patch `IncludeFragmentElement.prototype.fetch`, just like GitHub itself did at `https://github.githubassets.com/assets/app/assets/modules/github/include-fragment-element-hacks.ts`
  520. // - Monkey-patching
  521. // - If using regex to modify the response, it would be tedious to maintain
  522. // - If using `DOMParser`, the same HTML would be parsed twice
  523. injectCSS("release", `
  524. @media (min-width: 1012px) { /* Making more room for the additional info */
  525. .ghp-release-asset .col-lg-9 {
  526. width: 60%; /* Originally ~75% */
  527. }
  528. }
  529. .nowrap { /* Preventing text wrapping */
  530. overflow: hidden;
  531. text-overflow: ellipsis;
  532. white-space: nowrap;
  533. }
  534. .ghp-release-asset { /* Styling the histogram */
  535. background: linear-gradient(to right, var(--bgColor-accent-muted) var(--percent, 0%), transparent 0);
  536. }
  537. `);
  538. }
  539.  
  540. // Tracking prevention
  541. function preventTracking() {
  542. log("Calling preventTracking");
  543. const elements = [
  544. // Prevents tracking data from being sent to https://collector.github.com/github/collect
  545. // https://github.githubassets.com/assets/node_modules/@github/hydro-analytics-client/dist/meta-helpers.js
  546. // Breakpoint on function `getOptionsFromMeta` to see the argument `prefix`, which is `octolytics`
  547. // Or investigate `hydro-analytics.ts` mentioned above, you may find: `const options = getOptionsFromMeta('octolytics')`
  548. // Later, this script gathers information from `meta[name^="${prefix}-"]` elements, so we can remove them.
  549. // If `collectorUrl` is not set, the script will throw an error, thus preventing tracking.
  550. ...$$("meta[name^=octolytics-]"),
  551. // Prevents tracking data from being sent to `https://api.github.com/_private/browser/stats`
  552. // From "Network" tab, we can find that this request is sent by `https://github.githubassets.com/assets/ui/packages/stats/stats.ts` at function `safeSend`, who accepts two arguments: `url` and `data`
  553. // Search for this function in the current script, and you will find that it is only called once by function `flushStats`
  554. // `url` parameter is set in this function, by: `const url = ssrSafeDocument?.head?.querySelector<HTMLMetaElement>('meta[name="browser-stats-url"]')?.content`
  555. // After removing the meta tag, the script will return, so we can remove this meta tag to prevent tracking.
  556. $("meta[name=browser-stats-url]")
  557. ];
  558. elements.forEach(el => el?.remove());
  559. if (elements.some(el => el)) {
  560. log("Prevented tracking", elements);
  561. GM_setValue("trackingPrevented", GM_getValue("trackingPrevented", 0) + 1);
  562. }
  563. }
  564. if (config.get("additional.trackingPrevention")) {
  565. // document.addEventListener("DOMContentLoaded", preventTracking);
  566. // All we need to remove is in the `head` element, so we can run it immediately.
  567. preventTracking();
  568. document.addEventListener("turbo:before-render", preventTracking);
  569. }
  570.  
  571. // Debugging
  572. if (config.get("advanced.debug")) {
  573. const events = ["turbo:before-render", "turbo:before-morph-element", "turbo:before-frame-render", "turbo:load", "turbo:render", "turbo:morph", "turbo:morph-element", "turbo:frame-render"];
  574. events.forEach(event => {
  575. document.addEventListener(event, e => log(`Event: ${event}`, e));
  576. });
  577. }
  578.  
  579. // Callbacks
  580. const callbacks = {
  581. "code.tabSize": tabSize,
  582. };
  583. for (const [prop, callback] of Object.entries(callbacks)) {
  584. callback(config.get(prop));
  585. }
  586.  
  587. // Show rate limit
  588. config.addEventListener("get", (e) => {
  589. if (e.detail.prop === "advanced.rateLimit") {
  590. const resetDate = new Date(rateLimit.reset * 1000).toLocaleString();
  591. alert(`Rate limit: remaining ${rateLimit.remaining}/${rateLimit.limit}, resets at ${resetDate}.\nIf you see -1, it means the rate limit has not been fetched yet, or GitHub has not provided the rate limit information.`);
  592. }
  593. });
  594. config.addEventListener("set", (e) => {
  595. if (e.detail.prop in dynamicStyles) {
  596. cssHelper(e.detail.prop, e.detail.after);
  597. }
  598. if (e.detail.prop in enumStyles) {
  599. enumStyleHelper(e.detail.prop, e.detail.after);
  600. }
  601. if (e.detail.prop in callbacks) {
  602. callbacks[e.detail.prop](e.detail.after);
  603. }
  604. });
  605.  
  606. log(`${name} v${version} has been loaded 🎉`);
  607. })();