GitZip Lite

Download selected files and folders from GitHub repositories.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         GitZip Lite
// @icon         https://www.google.com/s2/favicons?sz=64&domain=github.com
// @namespace    https://github.com/tizee-tampermonkey-scripts/tampermonkey-gitzip-lite
// @version      1.6.3
// @description  Download selected files and folders from GitHub repositories.
// @author       tizee
// @match        https://github.com/*/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @require      https://unpkg.com/[email protected]/dist/powerglitch.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @connect      api.github.com
// @connect      raw.githubusercontent.com
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const itemCollectSelector =
    "div.js-navigation-item, table tbody tr.react-directory-row > td[class$='cell-large-screen']";
  const tokenKey = "githubApiToken";

  const { parseRepoURL, getGitURL, getInfoURL } = {
    parseRepoURL: (repoUrl) => {
      const repoExp = new RegExp(
        "^https://github.com/([^/]+)/([^/]+)(/(tree|blob)/([^/]+)(/(.*))?)?"
      );
      const matches = repoUrl.match(repoExp);

      if (!matches || matches.length === 0) return null;

      const author = matches[1];
      const project = matches[2];
      const branch = matches[5];
      const type = matches[4];
      const path = matches[7] || "";

      const rootUrl = branch
        ? `https://github.com/${author}/${project}/tree/${branch}`
        : `https://github.com/${author}/${project}`;

      if (!type && repoUrl.length - rootUrl.length > 1) {
        return null;
      }

      return {
        author,
        project,
        branch,
        type,
        path,
        inputUrl: repoUrl,
        rootUrl,
      };
    },
    getGitURL: (author, project, type, sha) => {
      if (type === "blob" || type === "tree") {
        const pluralType = type + "s";
        return `https://api.github.com/repos/${author}/${project}/git/${pluralType}/${sha}`;
      }
      return null;
    },
    getInfoURL: (author, project, path, branch) => {
      let url = `https://api.github.com/repos/${author}/${project}/contents/${path}`;
      if (branch) {
        url += `?ref=${branch}`;
      }
      return url;
    },
  };

  // --- GitZip Functions ---

  function base64toBlob(base64Data, contentType) {
    contentType = contentType || "";
    const sliceSize = 1024;
    const byteCharacters = atob(base64Data);
    const bytesLength = byteCharacters.length;
    const slicesCount = Math.ceil(bytesLength / sliceSize);
    const byteArrays = new Array(slicesCount);

    for (let sliceIndex = 0; sliceIndex < slicesCount; ++sliceIndex) {
      const begin = sliceIndex * sliceSize;
      const end = Math.min(begin + sliceSize, bytesLength);

      const bytes = new Array(end - begin);
      for (let offset = begin, i = 0; offset < end; ++i, ++offset) {
        bytes[i] = byteCharacters[offset].charCodeAt(0);
      }
      byteArrays[sliceIndex] = new Uint8Array(bytes);
    }
    return new Blob(byteArrays, { type: contentType });
  }

  function callAjax(url, token) {
    return new Promise(function (resolve, reject) {
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        headers: {
          Authorization: token ? "token " + token : undefined,
          Accept: "application/json",
        },
        onload: function (response) {
          if (response.status >= 200 && response.status < 300) {
            try {
              const jsonResponse = JSON.parse(response.responseText);
              resolve({ response: jsonResponse });
            } catch (e) {
              console.debug("Error parsing JSON:", e);
              reject(e);
            }
          } else {
            console.debug("Request failed with status:", response.status);
            logMessage("ERROR", `Request failed with status: ${response.status}`);
            reject(response);
          }
        },
        onerror: function (error) {
          logMessage("ERROR", error);
          reject(error);
        },
      });
    });
  }

  // New dedicated function for binary downloads
  function downloadFile(url, token) {
    return new Promise(function (resolve, reject) {
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        responseType: "arraybuffer",
        headers: {
          Authorization: token ? "token " + token : undefined,
          Accept: "application/octet-stream",
        },
        onload: function (response) {
          if (response.status >= 200 && response.status < 300) {
            resolve(new Uint8Array(response.response));
          } else {
            reject(new Error(`Download failed: ${response.status}`));
          }
        },
        onerror: reject,
      });
    });
  }

  // --- End GitZip Functions ---

  function addCheckboxes() {
    const fileRows = document.querySelectorAll(itemCollectSelector);
    fileRows.forEach((row) => {
      if (row.querySelector(".gitziplite-check-wrap")) return;

      // Ensure the row is relatively positioned
      row.style.position = "relative";

      const checkboxContainer = document.createElement("div");
      checkboxContainer.classList.add("gitziplite-check-wrap");
      checkboxContainer.style.position = "absolute";
      checkboxContainer.style.left = "4px";
      checkboxContainer.style.top = "50%";
      checkboxContainer.style.transform = "translateY(-50%)";
      checkboxContainer.style.display = "flex";
      checkboxContainer.style.alignItems = "center";
      checkboxContainer.style.height = "100%";
      checkboxContainer.style.display = "none";

      const checkbox = document.createElement("input");
      checkbox.type = "checkbox";
      checkbox.classList.add("gitziplite-checkbox");

      checkboxContainer.appendChild(checkbox);

      // Find the first element to insert before.  Handles both file and directory rows.
      const insertBeforeElement = row.firstChild;
      if (insertBeforeElement) {
        row.insertBefore(checkboxContainer, insertBeforeElement);
      } else {
        row.appendChild(checkboxContainer); // Fallback if no children exist
      }

      // Add event listeners for hover
      row.addEventListener("mouseenter", () => {
        checkboxContainer.style.display = "flex";
      });

      row.addEventListener("mouseleave", () => {
        if (!checkbox.checked) {
          checkboxContainer.style.display = "none";
        }
      });

      row.addEventListener("dblclick", () => {
        console.debug("double click", row, checkbox);
        if (checkbox.checked) {
          checkboxContainer.style.display = "none";
        } else {
          checkboxContainer.style.display = "flex";
        }
        checkbox.checked = !checkbox.checked;
        checkbox.dispatchEvent(new Event("change"));
      });

      // Add event listener for checkbox change
      checkbox.addEventListener("change", () => {
        let link;
        if (row.tagName === "TD") {
          link = row.querySelector("a[href]");
        } else {
          link = row.querySelector("a[href]");
        }

        if (link) {
          const title = link.textContent.trim();
          const command = checkbox.checked ? "SELECT" : "UNSELECT";
          logMessage(command, title);
        }
      });
    });
  }

  let logWindow;
  let logToggleButton;
  let downloadButton;
  let mainContainer;
  let stickerButton;

  // Add global styles
  GM_addStyle(`
    /* Container Styles */
    .gitziplite-container {
        position: fixed;
        bottom: 1rem;
        right: 1rem;
        z-index: 1000;
        width: 480px;
        background-color: rgba(28, 28, 30, 0.95);
        border-radius: 16px;
        box-shadow: 0 4px 24px rgba(0, 0, 0, 0.2);
        padding: 1.25rem;
        backdrop-filter: blur(20px);
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
        border: 1px solid rgba(255, 255, 255, 0.08);
        display: none; /* Hide window by default */
    }

    /* Sidebar sticker button */
    .gitziplite-sticker-button {
        position: fixed;
        right: 0;
        top: 30%;
        background-color: rgba(28, 28, 30, 0.95);
        color: white;
        border-radius: 8px 0 0 8px;
        padding: 10px;
        cursor: pointer;
        z-index: 999;
        box-shadow: -2px 0 8px rgba(0, 0, 0, 0.2);
        transition: all 0.2s ease;
        border: 1px solid rgba(255, 255, 255, 0.08);
        border-right: none;
    }

    .gitziplite-sticker-button:hover {
        background-color: rgba(40, 40, 45, 0.95);
        transform: translateX(-2px);
    }

    /* Hide button for the container */
    .gitziplite-hide-button {
        position: absolute;
        top: -14px;
        right: -14px;
        width: 28px;
        height: 28px;
        border-radius: 14px;
        background-color: rgba(28, 28, 30, 0.95);
        border: 1px solid rgba(255, 255, 255, 0.08);
        color: white;
        font-size: 16px;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        transition: background-color 0.2s ease;
        z-index: 1001;
        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
    }

    .gitziplite-hide-button:hover {
        background-color: rgba(40, 40, 45, 0.95);
    }

    /* Log Window Styles */
    .gitziplite-log {
        width: 100%;
        height: 16rem;
        margin-bottom: 0.75rem;
        overflow-y: auto;
        border-radius: 12px;
        background-color: rgba(0, 0, 0, 0.25);
        color: #E4E4E4;
        font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, monospace;
        font-size: 12px;
        line-height: 1.5;
        padding: 0.75rem;
        scrollbar-width: thin;
        scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
        border: 1px solid rgba(255, 255, 255, 0.06);
    }

    /* Scrollbar Styles */
    .gitziplite-log::-webkit-scrollbar {
        width: 6px;
        height: 6px;
    }

    .gitziplite-log::-webkit-scrollbar-track {
        background: transparent;
    }

    .gitziplite-log::-webkit-scrollbar-thumb {
        background: rgba(255, 255, 255, 0.2);
        border-radius: 3px;
    }

    .gitziplite-log::-webkit-scrollbar-thumb:hover {
        background: rgba(255, 255, 255, 0.3);
    }

    /* Log Entry Styles */
    .gitziplite-log-entry {
        padding: 0.25rem 0;
        display: flex;
        align-items: center;
        gap: 0.5rem;
        opacity: 0;
        transform: translateY(10px);
        animation: gitziplite-fadeIn 0.2s ease-out forwards;
    }

    .gitziplite-log-timestamp {
        color: #8E8E93;
        min-width: 5.5rem;
        font-feature-settings: "tnum";
        font-variant-numeric: tabular-nums;
    }

    .gitziplite-log-command {
        min-width: 5rem;
        padding: 0.125rem 0.5rem;
        border-radius: 6px;
        font-weight: 500;
        text-align: center;
        backdrop-filter: blur(8px);
    }

    .gitziplite-log-content {
        color: #E4E4E4;
        flex: 1;
    }

    /* Button Container */
    .gitziplite-buttons {
        display: flex;
        gap: 0.75rem;
        justify-content: space-between;
        align-items: center;
    }

    /* Button Styles */
    .gitziplite-button {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
        font-size: 13px;
        font-weight: 510;
        padding: 0.625rem 1rem;
        border-radius: 8px;
        cursor: pointer;
        transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
        border: none;
        outline: none;
        white-space: nowrap;
        user-select: none;
        position: relative;
        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
    }

    .gitziplite-button-primary {
        background-color: #0A84FF;
        color: white;
    }

    .gitziplite-button-primary:hover {
        background-color: #007AFF;
        transform: translateY(-1px);
        box-shadow: 0 4px 12px rgba(10, 132, 255, 0.3);
    }

    .gitziplite-button-primary:active {
        transform: translateY(0);
        background-color: #0062CC;
        box-shadow: 0 1px 2px rgba(10, 132, 255, 0.2);
    }

    .gitziplite-button-secondary {
        background-color: rgba(255, 255, 255, 0.1);
        color: #FFFFFF;
        border: 1px solid rgba(255, 255, 255, 0.1);
    }

    .gitziplite-button-secondary:hover {
        background-color: rgba(255, 255, 255, 0.15);
        transform: translateY(-1px);
        box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    }

    .gitziplite-button-secondary:active {
        transform: translateY(0);
        background-color: rgba(255, 255, 255, 0.05);
        box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
    }

    /* Animation */
    @keyframes gitziplite-fadeIn {
        to {
            opacity: 1;
            transform: translateY(0);
        }
    }
  `);

  function createDownloadButton() {
    // Create sticker button for the sidebar
    stickerButton = document.createElement("div");
    stickerButton.className = "gitziplite-sticker-button";
    stickerButton.innerHTML = `
        <div style="display: flex; flex-direction: column; align-items: center;">
            <svg width="16" height="16" viewBox="0 0 16 16" style="margin-bottom: 8px;">
                <path fill="currentColor" d="M8 12l-4.5-4.5 1.5-1.5L7 8.25V2h2v6.25L11 6l1.5 1.5L8 12zm-6 2v-2h12v2H2z"></path>
            </svg>
            <div style="writing-mode: vertical-lr; transform: rotate(180deg); font-size: 12px; letter-spacing: 1px; margin-top: 5px;">GitZip</div>
        </div>
    `;
    stickerButton.setAttribute("title", "Show GitZip Download Window");
    stickerButton.addEventListener("click", () => {
      mainContainer.style.display = "block";
      stickerButton.style.display = "none";
    });
    document.body.appendChild(stickerButton);

    // Main container
    mainContainer = document.createElement("div");
    mainContainer.className = "gitziplite-container";

    // Hide button
    const hideButton = document.createElement("button");
    hideButton.className = "gitziplite-hide-button";
    hideButton.innerHTML = "✕";
    hideButton.setAttribute("title", "Hide Download Window");
    hideButton.addEventListener("click", () => {
      mainContainer.style.display = "none";
      stickerButton.style.display = "block";
    });
    mainContainer.appendChild(hideButton);

    // Log Window Container
    logWindow = document.createElement("div");
    logWindow.setAttribute("aria-label", "Log Window");
    logWindow.className = "gitziplite-log";
    logWindow.style.display = "none";

    // Button Container
    const buttonContainer = document.createElement("div");
    buttonContainer.className = "gitziplite-buttons";

    // Log Toggle Button
    logToggleButton = document.createElement("button");
    logToggleButton.textContent = "Show Log";
    logToggleButton.className = "gitziplite-button gitziplite-button-secondary";
    logToggleButton.addEventListener("click", () => {
      logWindow.style.display =
        logWindow.style.display === "none" ? "block" : "none";
      logToggleButton.textContent =
        logWindow.style.display === "none" ? "Show Log" : "Hide Log";
    });

    // Download Button
    downloadButton = document.createElement("button");
    downloadButton.textContent = "Download Selected";
    downloadButton.className = "gitziplite-button gitziplite-button-primary";
    downloadButton.addEventListener("click", downloadSelected);

    // Assemble the UI
    buttonContainer.appendChild(logToggleButton);
    buttonContainer.appendChild(downloadButton);
    mainContainer.appendChild(logWindow);
    mainContainer.appendChild(buttonContainer);
    document.body.appendChild(mainContainer);

    // Hide the window by default
    mainContainer.style.display = "none";
    stickerButton.style.display = "block";
  }

  function logMessage(command, content) {
    const now = new Date();
    const timestamp = `${String(now.getHours()).padStart(2, "0")}:${String(
      now.getMinutes()
    ).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`;

    const commandColors = {
      ERROR: { bg: "#FF453A20", color: "#FF453A" },
      SUCCESS: { bg: "#32D74B20", color: "#32D74B" },
      PROCESS: { bg: "#0A84FF20", color: "#0A84FF" },
      SELECT: { bg: "#FFD60A20", color: "#FFD60A" },
      UNSELECT: { bg: "#FFD60A20", color: "#FFD60A" },
      INFO: { bg: "#64D2FF20", color: "#64D2FF" },
    };

    const colorScheme =
      commandColors[command.toUpperCase()] || commandColors.INFO;

    const logEntry = document.createElement("div");
    logEntry.className = "gitziplite-log-entry";
    logEntry.innerHTML = `
        <span class="gitziplite-log-timestamp">${timestamp}</span>
        <span class="gitziplite-log-command" style="background: ${colorScheme.bg}; color: ${colorScheme.color}">
            ${command}
        </span>
        <span class="gitziplite-log-content">${content}</span>
    `;

    logWindow.appendChild(logEntry);
    logWindow.scrollTop = logWindow.scrollHeight;
  }

  /**
   * Collects selected files and folders from the DOM.
   * @returns {{files: [], folders: []}} - An object containing arrays of selected files and folders.
   */
  function collectSelectedItems() {
    const selectedFiles = [];
    const selectedFolders = [];
    const checkboxes = document.querySelectorAll(
      ".gitziplite-checkbox:checked"
    );

    checkboxes.forEach((checkbox) => {
      const row = checkbox.parentNode.parentNode; // Direct parent access
      if (!row) {
        console.warn("Could not find a parent row for a selected checkbox.");
        return; // Skip to the next checkbox
      }
      console.debug(row);
      let link;

      if (row.tagName === "TD") {
        link = row.querySelector("a[href]");
      } else {
        link = row.querySelector("a[href]");
      }

      if (link) {
        const href = link.href;
        const title = link.textContent.trim();
        const resolved = parseRepoURL(href);
        if (resolved && resolved.type === "blob") {
          selectedFiles.push({ href: href, title: title });
        } else if (resolved && resolved.type === "tree") {
          selectedFolders.push({ href: href, title: title });
        }
      }
    });

    return { files: selectedFiles, folders: selectedFolders };
  }

  /**
   * Zips the given contents and triggers a download.
   * @param {Array<{path: string, content: string}>} allContents - Array of file contents to zip.
   * @param {object} resolvedUrl - Parsed URL information of the repository.
   */
  function zipAndDownload(allContents, resolvedUrl) {
    if (allContents.length === 1) {
      // Handle single file download
      const singleItem = allContents[0];
      console.debug(singleItem);
      if (singleItem.isBinary) {
        // Create Blob directly from Uint8Array
        const blob = new Blob([singleItem.content], {
          type: "application/octet-stream",
        });
        saveAs(blob, singleItem.path);
      } else {
        // Handle base64 encoded text files
        const blob = base64toBlob(singleItem.content, "");
        saveAs(blob, singleItem.path);
      }
    } else {
      // Handle zip archive creation
      try {
        const currDate = new Date();
        const dateWithOffset = new Date(
          currDate.getTime() - currDate.getTimezoneOffset() * 60000
        );
        window.JSZip.defaults.date = dateWithOffset;

        const zip = new window.JSZip();
        allContents.forEach((item) => {
          if (item.isBinary) {
            // Add binary file as Uint8Array
            zip.file(item.path, item.content, {
              createFolders: true,
              binary: true,
              date: dateWithOffset,
            });
          } else {
            // Add base64 encoded file
            zip.file(item.path, item.content, {
              createFolders: true,
              base64: true,
              date: dateWithOffset,
            });
          }
        });

        zip.generateAsync({ type: "blob" }).then((content) => {
          saveAs(
            content,
            [resolvedUrl.project]
              .concat(resolvedUrl.path.split("/"))
              .join("-") + ".zip"
          );
        });
      } catch (error) {
        console.debug("Error zipping files:", error);
        logMessage("ERROR", "zipping files.");
      }
    }
  }

  async function downloadSelected() {
    const { files: selectedFiles, folders: selectedFolders } =
      collectSelectedItems();

    if (selectedFiles.length === 0 && selectedFolders.length === 0) {
      logMessage("ERROR", "No files or folders selected.");
      return;
    }

    const resolvedUrl = parseRepoURL(window.location.href);
    if (!resolvedUrl) {
      logMessage("ERROR", "Could not resolve repository URL.");
      return;
    }

    const githubToken = GM_getValue(tokenKey);

    const allContents = [];

    async function processFolder(folder, pathPrefix = "") {
      logMessage("PROCESS", `${folder.title}`);
      const folderResolvedUrl = parseRepoURL(folder.href);
      const apiUrl = getInfoURL(
        folderResolvedUrl.author,
        folderResolvedUrl.project,
        folderResolvedUrl.path,
        folderResolvedUrl.branch
      );

      try {
        const xmlResponse = await callAjax(apiUrl, githubToken);
        const folderContents = xmlResponse.response;

        for (const item of folderContents) {
          const itemPath = pathPrefix + "/" + item.name;
          if (item.type === "file") {
            logMessage("PROCESS", `${itemPath}`);
            const fileInfoUrl = getInfoURL(
              folderResolvedUrl.author,
              folderResolvedUrl.project,
              folderResolvedUrl.path + "/" + item.name,
              folderResolvedUrl.branch
            );
            const fileXmlResponse = await callAjax(fileInfoUrl, githubToken);
            const fileContent = fileXmlResponse.response;
            allContents.push({
              path: itemPath,
              content: fileContent.content,
            });
          } else if (item.type === "dir") {
            await processFolder(
              { href: folder.href + "/" + item.name, title: item.name },
              itemPath
            );
          }
        }
      } catch (error) {
        console.debug("Error fetching folder:", folder.title, error);
        logMessage("ERROR", `Error fetching folder: ${folder.title}`);
      }
    }

    for (const folder of selectedFolders) {
      await processFolder(folder, folder.title);
    }

    for (const file of selectedFiles) {
      logMessage("PROCESS", `${file.title}`);
      const fileResolvedUrl = parseRepoURL(file.href);
      const infoUrl = getInfoURL(
        fileResolvedUrl.author,
        fileResolvedUrl.project,
        fileResolvedUrl.path,
        fileResolvedUrl.branch
      );
      logMessage("PROCESS", `${infoUrl}`);
      console.debug(`file info url: ${infoUrl}`);
      try {
        const xmlResponse = await callAjax(infoUrl, githubToken);
        const fileContent = xmlResponse.response;

        if (fileContent.encoding === "base64" && fileContent.content) {
          allContents.push({
            path: file.title,
            content: fileContent.content,
            isBinary: false,
          });
        } else if (fileContent.download_url) {
          // Handle binary file with dedicated download function
          const binaryData = await downloadFile(
            fileContent.download_url,
            githubToken
          );
          allContents.push({
            path: file.title,
            content: binaryData,
            isBinary: true,
          });
        }
      } catch (error) {
        console.debug("Error fetching file:", file.title, error);
        logMessage("ERROR", `fetching file: ${file.title}`);
        return;
      }
    }

    zipAndDownload(allContents, resolvedUrl);
    logMessage("SUCCESS", "Download complete.");
  }

  // Register menu command for setting token
  GM_registerMenuCommand("Set GitHub API Token", () => {
    const token = prompt("Enter your GitHub API token:");
    if (token) {
      GM_setValue(tokenKey, token);
      alert("Token saved successfully!");
    }
  });

  function onDomLoaded() {
    addCheckboxes();
    createDownloadButton();
  }

  function onUrlChange() {
    addCheckboxes();
  }

  // Initialize
  onDomLoaded();
  // Glitch Animation
  PowerGlitch.glitch(logToggleButton, {
    playMode: "click",
    timing: {
      duration: 400,
      easing: "ease-in-out",
    },
    shake: {
      velocity: 20,
      amplitudeX: 0,
      amplitudeY: 0.1,
    },
  });
  PowerGlitch.glitch(downloadButton, {
    playMode: "click",
    timing: {
      duration: 400,
      easing: "ease-in-out",
    },
  });

  // Observe GitHub repository page URL changes (e.g., navigating into a new directory)
  const observer = new MutationObserver(onUrlChange);
  observer.observe(document.body, { childList: true, subtree: true });
})();