git repos / sago_web_git

static/app.js

raw · blame · history

// Client-side rendering: syntax highlighting for source files and markdown
// rendering for READMEs and .md files.
(function () {
  "use strict";

  // Map a filename to an explicit highlight.js language. Resolution order:
  // exact full-name match, then filename patterns, then extension. Anything
  // that resolves here overrides highlight.js auto-detection.
  var FILENAME_LANGUAGES = { "CMakeLists.txt": "cmake" };
  // Patterns matched against the whole filename, in order.
  var FILENAME_PATTERNS = [
    { test: /^\..*ignore$/i, language: "plaintext" }, // .gitignore, .dockerignore, ...
    { test: /^Dockerfile/i, language: "dockerfile" }, // Dockerfile, Dockerfile.dev, ...
  ];
  var EXTENSION_LANGUAGES = {
    cpp: "cpp",
    hpp: "cpp",
    js: "javascript",
    json: "json",
    yml: "yaml",
    txt: "plaintext",
    man: "plaintext", // no troff/man grammar in highlight.js; pin to plain text
    py: "python",
    tf: "terraform",
    sql: "sql",
    patch: "diff", // patch files are unified diffs
  };

  function languageForFilename(filename) {
    if (!filename) return null;
    if (FILENAME_LANGUAGES[filename]) return FILENAME_LANGUAGES[filename];
    for (var i = 0; i < FILENAME_PATTERNS.length; i++) {
      if (FILENAME_PATTERNS[i].test.test(filename)) return FILENAME_PATTERNS[i].language;
    }
    var dot = filename.lastIndexOf(".");
    // Extensionless files (LICENSE, README, etc.) are treated as plain text
    // rather than letting auto-detection guess a language.
    if (dot === -1) return "plaintext";
    return EXTENSION_LANGUAGES[filename.slice(dot + 1).toLowerCase()] || null;
  }

  function highlight() {
    if (typeof hljs === "undefined") return;
    document.querySelectorAll("pre.code > code.hljs").forEach(function (el) {
      var lang = languageForFilename(el.dataset.filename);
      if (lang) el.classList.add("language-" + lang);
      hljs.highlightElement(el);
      var detected = el.result && el.result.language ? el.result.language : "plaintext";
      var label = document.createElement("p");
      label.className = "lang-detected";
      label.textContent = "Detected language: " + detected;
      el.parentElement.insertAdjacentElement("afterend", label);
    });
  }

  // Each ".markdown-src" element holds raw markdown and a data-target CSS
  // selector pointing at the element to render it into.
  function renderMarkdown() {
    if (typeof marked === "undefined" || typeof DOMPurify === "undefined") return;
    document.querySelectorAll(".markdown-src").forEach(function (src) {
      var target = document.querySelector(src.dataset.target);
      // Repository markdown is untrusted: marked passes embedded HTML through
      // verbatim, so the result must be sanitized before insertion.
      if (target) target.innerHTML = DOMPurify.sanitize(marked.parse(src.textContent));
    });
  }

  // Single toggle above the log that shows or hides every commit body at once.
  function setupLogToggle() {
    var btn = document.querySelector(".log-toggle");
    if (!btn) return;
    btn.addEventListener("click", function () {
      var on = btn.getAttribute("aria-pressed") !== "true";
      btn.setAttribute("aria-pressed", on ? "true" : "false");
      btn.textContent = "expand: " + (on ? "on" : "off");
      document.querySelectorAll(".commit-body").forEach(function (el) {
        el.hidden = !on;
      });
    });
  }

  // Toggle between the file list table and a thumbnail grid. The choice is
  // remembered in localStorage so it persists while browsing into subfolders.
  function setupViewToggle() {
    var btn = document.querySelector(".view-toggle");
    if (!btn) return;
    var table = document.querySelector("table.tree");
    var grid = document.querySelector(".tree-grid");
    if (!table || !grid) return;

    function apply(grid_on) {
      btn.setAttribute("aria-pressed", grid_on ? "true" : "false");
      btn.textContent = "view: " + (grid_on ? "grid" : "list");
      table.hidden = grid_on;
      grid.hidden = !grid_on;
    }

    apply(localStorage.getItem("treeView") === "grid");
    btn.addEventListener("click", function () {
      var grid_on = btn.getAttribute("aria-pressed") !== "true";
      localStorage.setItem("treeView", grid_on ? "grid" : "list");
      apply(grid_on);
    });
  }

  // Light/dark theme toggle. The effective theme is applied to the document
  // by an inline script in <head> before first paint; here we only reflect it
  // in the button and let the user switch and persist a choice.
  function setupThemeToggle() {
    var btn = document.querySelector(".theme-toggle");
    if (!btn) return;
    var root = document.documentElement;

    function current() {
      return root.getAttribute("data-theme") === "dark" ? "dark" : "light";
    }
    function render() {
      var dark = current() === "dark";
      btn.textContent = dark ? "☀️" : "🌙"; // sun / moon
      btn.setAttribute("aria-label", "Switch to " + (dark ? "light" : "dark") + " theme");
      btn.setAttribute("aria-pressed", dark ? "true" : "false");
    }

    render();
    btn.addEventListener("click", function () {
      var next = current() === "dark" ? "light" : "dark";
      root.setAttribute("data-theme", next);
      try { localStorage.setItem("theme", next); } catch (e) { /* private mode: no persistence */ }
      render();
    });
  }

  document.addEventListener("DOMContentLoaded", function () {
    highlight();
    renderMarkdown();
    setupLogToggle();
    setupViewToggle();
    setupThemeToggle();
  });
})();