birta 0.2.0

Preview markdown files in the browser with GitHub-style rendering
Documentation
<!DOCTYPE html>
<html lang="en" {{THEME_ATTR}}>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{FILENAME}} — birta</title>
  <link rel="icon" type="image/png" href="/favicon.png">
  <style>{{GITHUB_CSS}}</style>
  <style>{{THEME_OVERRIDES}}</style>
  <style>{{PAGE_CSS}}</style>
  <style>{{SYNTAX_CSS}}</style>
  <style>{{ALERTS_CSS}}</style>
  <style id="theme-vars">{{THEME_VARS_CSS}}</style>
  <style>{{FONT_CSS}}</style>
  {{CUSTOM_CSS}}
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.css" crossorigin="anonymous">
  <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.21/dist/katex.min.js" crossorigin="anonymous"></script>
  <script defer src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
</head>
<body class="{{BODY_CLASS}}">
  <header class="header{{HEADER_CLASS}}">
    <div class="header-left">
      <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
        <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/>
      </svg>
      <span class="filename" id="filename">{{FILENAME}}</span>
    </div>
    <div class="header-right">
      <div class="theme-controls">
        <select class="theme-select" id="theme-select" title="Switch theme">
          {{THEME_OPTIONS}}
        </select>
        <button class="theme-toggle" id="theme-toggle" title="Toggle light/dark">
          <svg id="icon-sun" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
            <path d="M8 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm0-1.5a2.5 2.5 0 1 1 0-5 2.5 2.5 0 0 1 0 5ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0Zm0 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.75.75 0 0 1-1.06 1.06l-1.06-1.06a.75.75 0 0 1 0-1.06Zm8.193 8.192a.75.75 0 0 1 1.06 0l1.061 1.061a.75.75 0 0 1-1.06 1.061l-1.06-1.061a.75.75 0 0 1 0-1.06ZM0 8a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5H.75A.75.75 0 0 1 0 8Zm13 0a.75.75 0 0 1 .75-.75h1.5a.75.75 0 0 1 0 1.5h-1.5A.75.75 0 0 1 13 8ZM2.343 13.657a.75.75 0 0 1 0-1.06l1.06-1.061a.75.75 0 0 1 1.061 1.06l-1.06 1.061a.75.75 0 0 1-1.061 0Zm8.193-8.192a.75.75 0 0 1 0-1.061l1.061-1.06a.75.75 0 1 1 1.06 1.06l-1.06 1.06a.75.75 0 0 1-1.06 0Z"/>
          </svg>
          <svg id="icon-moon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
            <path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/>
          </svg>
        </button>
      </div>
      <button class="reading-toggle" id="reading-toggle" title="Reading mode (r)">
        <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
          <path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/>
        </svg>
      </button>
    </div>
  </header>

  <main class="container">
    <div class="file-header">
      <svg class="file-header-icon" width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
        <path d="M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v12.5A1.75 1.75 0 0 1 14.25 16H1.75A1.75 1.75 0 0 1 0 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25V1.75a.25.25 0 0 0-.25-.25Zm7.47 3.97a.75.75 0 0 1 1.06 0l2 2a.75.75 0 0 1 0 1.06l-2 2a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L10.69 8 9.22 6.53a.75.75 0 0 1 0-1.06ZM6.78 6.53 5.31 8l1.47 1.47a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215l-2-2a.75.75 0 0 1 0-1.06l2-2a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z"/>
      </svg>
      <span class="file-header-name">{{FILENAME}}</span>
    </div>
    <article class="markdown-body" id="content">{{CONTENT}}</article>
  </main>

  <div class="reading-progress" id="reading-progress"></div>
  <div class="reading-exit-zone" id="reading-exit-zone"></div>
  <div class="reading-exit-bar" id="reading-exit-bar">
    <button class="reading-exit-btn" id="reading-exit-btn" title="Exit reading mode (Esc)">
      <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
        <path d="M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"/>
      </svg>
    </button>
  </div>

  <div class="status" id="status"></div>

  <script>
    // --- State ---
    var STATIC_MODE = {{STATIC_MODE}};
    var THEME_MODE = '{{THEME_MODE}}';
    var activeVariant = '{{ACTIVE_VARIANT}}';
    var variants = {{VARIANTS_JSON}};

    var html = document.documentElement;
    var mdBody = document.getElementById('content');
    var toggleBtn = document.getElementById('theme-toggle');
    var iconSun = document.getElementById('icon-sun');
    var iconMoon = document.getElementById('icon-moon');
    var themeSelect = document.getElementById('theme-select');
    var themeVarsEl = document.getElementById('theme-vars');
    var currentWs = null;

    // --- Reading Mode ---
    var readingToggle = document.getElementById('reading-toggle');
    var readingProgress = document.getElementById('reading-progress');
    var readingExitZone = document.getElementById('reading-exit-zone');
    var readingExitBar = document.getElementById('reading-exit-bar');
    var readingExitBtn = document.getElementById('reading-exit-btn');
    var isReadingMode = document.body.classList.contains('reading-mode');

    function toggleReadingMode(force) {
      isReadingMode = typeof force === 'boolean' ? force : !isReadingMode;
      document.body.classList.toggle('reading-mode', isReadingMode);
      readingProgress.style.display = isReadingMode ? 'block' : 'none';
      readingExitZone.style.display = isReadingMode ? 'block' : 'none';
      if (isReadingMode) updateProgress();
    }

    function updateProgress() {
      var scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
      var scrollHeight = document.documentElement.scrollHeight - document.documentElement.clientHeight;
      var pct = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
      readingProgress.style.width = pct + '%';
    }

    window.addEventListener('scroll', function() {
      if (isReadingMode) updateProgress();
    }, { passive: true });

    readingToggle.addEventListener('click', function() { toggleReadingMode(); });
    readingExitBtn.addEventListener('click', function() { toggleReadingMode(false); });

    // Initialize reading mode UI if started via --reading-mode
    if (isReadingMode) {
      readingProgress.style.display = 'block';
      readingExitZone.style.display = 'block';
      updateProgress();
    }

    document.addEventListener('keydown', function(e) {
      if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.tagName === 'TEXTAREA') return;
      if (e.key === 'r' && !e.ctrlKey && !e.metaKey && !e.altKey) {
        e.preventDefault();
        toggleReadingMode();
      } else if (e.key === 'Escape' && isReadingMode) {
        e.preventDefault();
        toggleReadingMode(false);
      }
    });

    // --- Theme UI ---
    function applyVariant(variant) {
      activeVariant = variant;
      html.setAttribute('data-theme', variant);
      mdBody.setAttribute('data-theme', variant);
      updateToggleIcon();
    }

    function updateToggleIcon() {
      var isDark = activeVariant === 'dark';
      iconSun.style.display = isDark ? 'block' : 'none';
      iconMoon.style.display = isDark ? 'none' : 'block';
    }

    function updateToggleVisibility() {
      var canToggle = variants.length === 2 && THEME_MODE === 'toggle';
      toggleBtn.classList.toggle('disabled', !canToggle);
    }

    function updateThemeSelect(themeName) {
      themeSelect.value = themeName;
    }

    // Initial setup
    (function() {
      // If only one option in select, hide it
      if (STATIC_MODE || themeSelect.options.length <= 1) {
        themeSelect.style.display = 'none';
      }

      // Respect OS light/dark preference for dual-variant themes
      if (THEME_MODE === 'toggle' && window.matchMedia) {
        var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
        activeVariant = prefersDark ? 'dark' : 'light';
      }

      applyVariant(activeVariant);
      updateToggleVisibility();

      // Toggle sends variant change to server
      toggleBtn.addEventListener('click', function() {
        if (toggleBtn.classList.contains('disabled')) return;
        var next = activeVariant === 'dark' ? 'light' : 'dark';
        if (STATIC_MODE) {
          applyVariant(next);
          return;
        }
        if (!currentWs || currentWs.readyState !== WebSocket.OPEN) return;
        currentWs.send(JSON.stringify({ type: 'variant_change', variant: next }));
      });

      // Dropdown sends theme change to server
      if (!STATIC_MODE) {
        themeSelect.addEventListener('change', function() {
          if (!currentWs || currentWs.readyState !== WebSocket.OPEN) return;
          currentWs.send(JSON.stringify({ type: 'theme_change', theme: themeSelect.value }));
        });
      }
    })();

    // --- Math rendering via KaTeX ---
    function renderMath() {
      if (typeof katex === 'undefined') return;
      document.querySelectorAll('[data-math-style]').forEach(function(el) {
        if (el.hasAttribute('data-math-rendered')) return;
        var displayMode = el.getAttribute('data-math-style') === 'display';
        var tex = el.textContent;
        try {
          katex.render(tex, el, { displayMode: displayMode, throwOnError: false });
          el.setAttribute('data-math-rendered', '');
        } catch (e) { /* leave raw text on error */ }
      });
    }

    // --- Mermaid diagram rendering ---
    var mermaidReady = false;
    function initMermaid() {
      if (typeof mermaid === 'undefined') return;
      if (mermaidReady) return;
      var theme = activeVariant === 'dark' ? 'dark' : 'default';
      mermaid.initialize({ startOnLoad: false, theme: theme });
      mermaidReady = true;
    }

    function renderMermaid() {
      if (typeof mermaid === 'undefined') return;
      initMermaid();
      mermaid.run({ querySelector: 'pre.mermaid' });
    }

    // Render on initial load
    function renderAll() { renderMath(); renderMermaid(); }
    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', renderAll);
    } else {
      setTimeout(renderAll, 0);
    }

    // --- Scroll synchronization ---
    function scrollToLine(targetLine) {
      var els = mdBody.querySelectorAll('[data-sourcepos]');
      if (!els.length) return;
      var best = null;
      var lo = 0, hi = els.length - 1;
      while (lo <= hi) {
        var mid = (lo + hi) >> 1;
        var line = parseInt(els[mid].getAttribute('data-sourcepos'), 10);
        if (line <= targetLine) {
          best = els[mid];
          lo = mid + 1;
        } else {
          hi = mid - 1;
        }
      }
      if (best) {
        best.scrollIntoView({ behavior: 'smooth', block: 'center' });
      }
    }

    // --- Checkbox write-back ---
    function enableCheckboxes() {
      mdBody.querySelectorAll('input[type="checkbox"][disabled]').forEach(function(cb) {
        cb.disabled = false;
        cb.addEventListener('change', function(e) {
          var li = e.target.closest('li[data-sourcepos]');
          if (!li || !currentWs || currentWs.readyState !== WebSocket.OPEN) return;
          var line = parseInt(li.getAttribute('data-sourcepos'), 10);
          currentWs.send(JSON.stringify({
            type: 'checkbox', line: line, checked: e.target.checked
          }));
        });
      });
    }

    // --- WebSocket ---
    if (!STATIC_MODE) (function() {
      var status = document.getElementById('status');
      var reconnectDelay = 1000;
      var maxDelay = 10000;

      function showStatus(msg) {
        status.textContent = msg;
        status.classList.add('visible');
      }

      function hideStatus() {
        status.classList.remove('visible');
      }

      function connect() {
        var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
        var ws = new WebSocket(proto + '//' + location.host + '/ws');
        currentWs = ws;

        ws.onopen = function() {
          reconnectDelay = 1000;
          hideStatus();
        };

        ws.onmessage = function(event) {
          try {
            var msg = JSON.parse(event.data);
          } catch (e) {
            return;
          }

          switch (msg.type) {
            case 'content':
              mdBody.innerHTML = msg.html;
              renderMath();
              renderMermaid();
              enableCheckboxes();
              break;

            case 'theme_update':
              // Update CSS variables
              themeVarsEl.textContent = msg.css_vars;
              // Update theme attribute
              if (msg.theme_attr) {
                html.setAttribute('data-birta-theme', msg.theme_attr);
              } else {
                html.removeAttribute('data-birta-theme');
              }
              // Update variant state
              variants = msg.variants;
              activeVariant = msg.active_variant;
              THEME_MODE = msg.has_toggle ? 'toggle' : 'fixed-' + msg.active_variant;
              applyVariant(activeVariant);
              updateToggleVisibility();
              updateThemeSelect(msg.theme_name);
              // Update content HTML (re-rendered with new syntax theme)
              mdBody.innerHTML = msg.html;
              renderMath();
              // Re-init mermaid with correct theme
              mermaidReady = false;
              renderMermaid();
              enableCheckboxes();
              break;

            case 'scroll':
              scrollToLine(msg.line);
              break;
          }
        };

        ws.onclose = function() {
          currentWs = null;
          showStatus('Disconnected \u2014 reconnecting\u2026');
          setTimeout(function() {
            reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
            connect();
          }, reconnectDelay);
        };
      }

      connect();
    })();

    // Enable checkboxes on initial page load (not in static mode)
    if (!STATIC_MODE) enableCheckboxes();
  </script>
</body>
</html>