Skip to content

Instantly share code, notes, and snippets.

@xtrasmal
Last active January 28, 2026 12:25
Show Gist options
  • Select an option

  • Save xtrasmal/722764ef893c2304c8a1f78351037e88 to your computer and use it in GitHub Desktop.

Select an option

Save xtrasmal/722764ef893c2304c8a1f78351037e88 to your computer and use it in GitHub Desktop.
Markdown reader. Will serve the current directory or the directory you provide on localhost. Looks for INDEX.md or README.md as entrypoint. Run: mdserve <optional-directory>
#!/usr/bin/env python3
import http.server
import socketserver
import os
import argparse
import json
import socket
import webbrowser
import sys
import mimetypes
from urllib.parse import unquote, urlparse, parse_qs
# --- Embedded Frontend Application ---
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LIBRARY</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
<!-- Marked (Markdown Parser) -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Prism (Syntax Highlighting) -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<!-- Mermaid (Diagrams) -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10.9.1/dist/mermaid.min.js"></script>
<!-- KaTeX (Math Rendering) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
<script src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js"></script>
<!-- Icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
/* Anthropic Typography - Poppins for headings, Lora for body */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500;600;700&family=Lora:ital,wght@0,400;0,500;0,600;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
/* Anthropic Brand Theme CSS Variables */
:root {
--bg-primary: #141413;
--bg-secondary: #1c1c1b;
--bg-tertiary: #262624;
--bg-elevated: #2d2d2b;
--text-primary: #faf9f5;
--text-secondary: rgb(158 158 158);
--text-muted: #807e76;
--border-color: #363632;
--accent-orange: #d97757;
--accent-blue: #6a9bcc;
--accent-green: #788c5d;
--accent-color: #d97757;
--accent-hover: #e58a6a;
--accent-muted: rgba(217, 119, 87, 0.15);
--scrollbar-track: #1c1c1b;
--scrollbar-thumb: #3d3d3a;
--mermaid-bg: #1c1c1b;
--code-bg: #1a1a19;
--alert-bg: #1f1f1e;
--prose-body: rgb(158 158 158);
--tw-prose-body: var(--text-muted);
--tw-prose-invert-body:: rgb(158 158 158);
}
:root.light {
--bg-primary: #faf9f5;
--bg-secondary: #f5f4f0;
--bg-tertiary: #e8e6dc;
--bg-elevated: #ffffff;
--text-primary: #141413;
--text-secondary: #4a4946;
--text-muted: #807e76;
--border-color: #e8e6dc;
--accent-orange: #d97757;
--accent-blue: #6a9bcc;
--accent-green: #788c5d;
--accent-color: #c86a4a;
--accent-hover: #b85d40;
--accent-muted: rgba(217, 119, 87, 0.12);
--scrollbar-track: #f0eee8;
--scrollbar-thumb: #d5d3cb;
--mermaid-bg: #ffffff;
--code-bg: #f0eee8;
--alert-bg: #f5f4f0;
--tw-prose-body: var(--text-muted);
}
h1, h2, h3, h4, h5, h6, .font-heading { font-family: 'Lora', Georgia, serif;}
code, pre, .font-mono { font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace !important; }
button, nav, aside, header { font-family: 'JetBrains Mono', 'SF Mono', Consolas, monospace !important; }
/* Custom scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
/* Hide scrollbar for center content */
#main-scroll::-webkit-scrollbar { display: none; }
#main-scroll { -ms-overflow-style: none; scrollbar-width: none; }
body { background: var(--bg-primary); color: var(--text-secondary); }
.active-file { background-color: var(--bg-tertiary); color: var(--accent-color); border-right: 3px solid var(--accent-color); }
/* Mermaid adjustments */
.mermaid {
background: var(--mermaid-bg);
display: flex;
justify-content: center;
margin: 20px 0;
padding: 20px;
border-radius: 8px;
cursor: pointer;
transition: box-shadow 0.2s;
position: relative;
}
.mermaid:hover { box-shadow: 0 0 0 2px var(--accent-color); }
.mermaid::after {
content: '\\f065';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
position: absolute;
top: 8px;
right: 8px;
color: var(--text-muted);
opacity: 0;
transition: opacity 0.2s;
font-size: 14px;
}
.mermaid:hover::after { opacity: 1; }
/* Math adjustments */
.katex-display { overflow-x: auto; overflow-y: hidden; padding: 0.5em 0; }
/* TOC active state */
.toc-active { color: var(--accent-color); font-weight: 600; border-left: 2px solid var(--accent-color); padding-left: 0.5rem; }
/* Front Matter Table */
.front-matter-table {
font-size: 0.8em;
width: 100%;
border-collapse: collapse;
margin-bottom: 1.5rem;
background: var(--bg-secondary);
border-radius: 0.5rem;
overflow: hidden;
}
.front-matter-table th { text-align: left; padding: 0.5rem 1rem; color: var(--text-muted); border-bottom: 1px solid var(--border-color); width: 30%; }
.front-matter-table td { padding: 0.5rem 1rem; color: var(--text-secondary); border-bottom: 1px solid var(--border-color); font-family: monospace; font-size: 0.9em; }
.front-matter-table tr:last-child th, .front-matter-table tr:last-child td { border-bottom: none; }
/* Claude-styled Alerts (Admonitions) - using brand accent rotation */
.markdown-alert { padding: 1rem 1.25rem; margin-bottom: 1.25rem; border-left-width: 3px; border-radius: 8px; background-color: var(--alert-bg); color: var(--text-secondary); }
.markdown-alert-title { display: flex; align-items: center; font-weight: 600; margin-bottom: 0.5rem; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.05em; }
.markdown-alert-title i { margin-right: 0.5rem; font-size: 1.1em; }
.markdown-alert-note { border-left-color: var(--accent-blue); background: rgba(106, 155, 204, 0.1); }
.markdown-alert-note .markdown-alert-title { color: var(--accent-blue); }
.markdown-alert-tip { border-left-color: var(--accent-orange); background: var(--accent-muted); }
.markdown-alert-tip .markdown-alert-title { color: var(--accent-orange); }
.markdown-alert-important { border-left-color: var(--accent-orange); background: var(--accent-muted); }
.markdown-alert-important .markdown-alert-title { color: var(--accent-orange); }
.markdown-alert-warning { border-left-color: #c9a227; background: rgba(201, 162, 39, 0.1); }
.markdown-alert-warning .markdown-alert-title { color: #c9a227; }
.markdown-alert-caution { border-left-color: #c45c5c; background: rgba(196, 92, 92, 0.1); }
.markdown-alert-caution .markdown-alert-title { color: #c45c5c; }
/* Typography - Claude docs style */
.prose { font-size: 0.9375rem; line-height: 1.7; }
.prose p { margin-bottom: 1.25em; }
.prose h1 { font-size: 2em; margin-top: 0; margin-bottom: 0.75em; color: var(--text-primary); font-weight: 600; letter-spacing: -0.02em; }
.prose h2 { font-size: 1.5em; margin-top: 2em; margin-bottom: 0.75em; color: var(--text-primary); font-weight: 300; letter-spacing: -0.01em; padding-bottom: 0.5em; border-bottom: 1px solid var(--border-color); }
.prose h3 { font-size: 1.2em; margin-top: 1.75em; margin-bottom: 0.5em; color: var(--text-primary); font-weight: 600; }
.prose h4 { font-size: 1em; margin-top: 1.5em; margin-bottom: 0.5em; color: var(--text-primary); font-weight: 600; }
.prose ul, .prose ol { margin-top: 0.75em; margin-bottom: 0.75em; }
.prose li { margin-top: 0.35em; margin-bottom: 0.35em; }
.prose pre { font-size: 0.85em; border-radius: 8px; border: 1px solid var(--border-color); }
.prose code { font-size: 0.85em; padding: 0.2em 0.4em; border-radius: 4px; background: var(--code-bg); }
.prose pre code { padding: 0; background: transparent; }
.prose blockquote { margin-top: 1.25em; margin-bottom: 1.25em; border-left: 3px solid var(--accent-color); padding-left: 1em; color: var(--text-muted); font-style: italic; }
.prose table { font-size: 0.9em; border-collapse: collapse; width: 100%; }
.prose th, .prose td { padding: 0.75em 1em; border: 1px solid var(--border-color); text-align: left; }
.prose th { background: var(--bg-secondary); font-weight: 600; color: var(--text-primary); }
.prose a { color: var(--accent-color); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
.prose a:hover { border-bottom-color: var(--accent-color); }
.prose strong { color: var(--text-primary);font-family: 'Lora', Georgia, serif; font-weight: 600; }
.prose hr { border: none; border-top: 1px solid var(--border-color); margin: 2em 0; }
.prose img { border-radius: 8px; border: 1px solid var(--border-color); }
/* Override prose-invert body color */
.prose-invert { --tw-prose-body: var(--text-muted); }
/* Light mode prose adjustments */
:root.light .prose h1, :root.light .prose h2, :root.light .prose h3,
:root.light .prose h4, :root.light .prose h5, :root.light .prose h6 { color: var(--text-primary); }
:root.light .prose a { color: var(--accent-color); }
:root.light .prose strong { color: var(--text-primary); }
:root.light .prose code { background: var(--code-bg); }
:root.light .prose pre { background: var(--code-bg); }
/* Theme toggle button - pill style */
.theme-toggle {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 6px 14px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
}
.theme-toggle:hover { background: var(--bg-elevated); color: var(--text-primary); border-color: var(--accent-color); }
/* Mermaid Modal */
#mermaid-modal {
position: fixed;
inset: 0;
z-index: 100;
display: none;
}
#mermaid-modal.active { display: flex; }
#mermaid-modal .modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
}
#mermaid-modal .modal-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
#mermaid-modal .modal-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
z-index: 10;
}
#mermaid-modal .zoom-controls {
display: flex;
align-items: center;
gap: 8px;
}
#mermaid-modal .zoom-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
color: var(--text-secondary);
width: 32px;
height: 32px;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
#mermaid-modal .zoom-btn:hover { background: var(--border-color); color: var(--text-primary); }
#mermaid-modal .zoom-level { color: var(--text-muted); font-size: 13px; min-width: 50px; text-align: center; }
#mermaid-modal .close-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 24px;
cursor: pointer;
padding: 4px 8px;
}
#mermaid-modal .close-btn:hover { color: var(--text-primary); }
#mermaid-modal .modal-viewport {
flex: 1;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
}
#mermaid-modal .modal-viewport.dragging { cursor: grabbing; }
#mermaid-modal .diagram-container {
background: var(--mermaid-bg);
padding: 40px;
border-radius: 8px;
transform-origin: center center;
transition: transform 0.1s ease-out;
max-width: none;
max-height: none;
}
#mermaid-modal .diagram-container svg { max-width: none; max-height: none; }
/* Sidebar and header theme support */
.sidebar-bg { background: var(--bg-secondary); border-color: var(--border-color); }
.header-bg { background: var(--bg-primary); border-color: var(--border-color); }
.toc-bg { background: var(--bg-secondary); border-color: var(--border-color); }
.file-item { color: var(--text-muted); transition: all 0.15s; }
.file-item:hover { color: var(--text-primary); background: var(--bg-tertiary); }
.dir-item { color: var(--text-muted); transition: color 0.15s; }
.dir-item:hover { color: var(--text-primary); }
/* Active file state - orange accent */
.active-file {
background: var(--accent-muted) !important;
color: var(--accent-color) !important;
border-right: 3px solid var(--accent-color);
font-weight: 500;
}
/* Explorer section title */
.explorer-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
}
/* Main content area */
.content-area { background: var(--bg-primary); }
</style>
<script>
tailwind.config = {
darkMode: 'class',
theme: { extend: { colors: { gray: { 850: '#1f2937' } } } }
}
</script>
</head>
<body class="h-full flex flex-col overflow-hidden">
<!-- Header -->
<header class="header-bg border-b h-14 flex items-center px-6 justify-between flex-shrink-0 z-20">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg flex items-center justify-center">
<i class="fa-brands fa-accessible-icon text-lg" style="color: var(--accent-color)"></i>
</div>
<h1 class="font-semibold text-lg tracking-tight" style="color: var(--text-primary)">LIBRARY</h1>
<span class="text-xs font-medium px-2 py-0.5 rounded-full" style="background: var(--accent-muted); color: var(--accent-color)">docs</span>
<span id="current-path" class="text-sm font-mono ml-2" style="color: var(--text-muted)"></span>
</div>
<div class="flex items-center gap-4">
<div class="text-xs" style="color: var(--text-muted)">
<i class="fas fa-folder-open mr-1"></i>
<span id="root-dir">Loading...</span>
</div>
<button id="theme-toggle" class="theme-toggle" onclick="toggleTheme()">
<i class="fas fa-moon" id="theme-icon"></i>
<span id="theme-label">Dark</span>
</button>
</div>
</header>
<!-- Main Layout -->
<div class="flex flex-1 overflow-hidden">
<!-- Left: File Tree -->
<aside class="w-72 sidebar-bg border-r flex flex-col flex-shrink-0">
<div class="p-4 border-b" style="border-color: var(--border-color)">
<h2 class="explorer-title">Explorer</h2>
</div>
<nav id="file-tree" class="flex-1 overflow-y-auto p-2 text-sm space-y-0.5">
<!-- Tree injected via JS -->
</nav>
</aside>
<!-- Center: Content -->
<main class="flex-1 overflow-y-auto relative scroll-smooth content-area" id="main-scroll">
<div class="max-w-3xl mx-auto px-8 py-10 lg:px-12 lg:py-14 pb-32">
<!-- Loading State -->
<div id="loader" class="hidden flex justify-center py-20">
<i class="fas fa-circle-notch fa-spin text-4xl" style="color: var(--accent-color)"></i>
</div>
<!-- Empty State -->
<div id="empty-state" class="hidden flex flex-col items-center justify-center py-20" style="color: var(--text-muted)">
<i class="far fa-file-lines text-5xl mb-4" style="color: var(--accent-orange)"></i>
<p class="text-lg">Select a Markdown file to view</p>
</div>
<!-- Markdown Content -->
<div id="front-matter-container" class="hidden"></div>
<article id="content" class="prose prose-invert max-w-none hidden" style="color: rgb(158,158,158)">
<!-- MD Content injected here -->
</article>
</div>
</main>
<!-- Right: TOC -->
<aside class="w-64 toc-bg border-l hidden xl:flex flex-col flex-shrink-0">
<div class="p-4 border-b" style="border-color: var(--border-color)">
<h2 class="explorer-title">On this page</h2>
</div>
<nav id="toc" class="flex-1 overflow-y-auto p-4 text-sm space-y-2" style="color: var(--text-muted)">
<!-- TOC injected via JS -->
</nav>
</aside>
</div>
<!-- Mermaid Modal -->
<div id="mermaid-modal">
<div class="modal-backdrop" onclick="closeMermaidModal()"></div>
<div class="modal-container">
<div class="modal-toolbar">
<div class="zoom-controls">
<button class="zoom-btn" onclick="zoomMermaid(-0.25)" title="Zoom out"><i class="fas fa-minus"></i></button>
<span class="zoom-level" id="zoom-level">100%</span>
<button class="zoom-btn" onclick="zoomMermaid(0.25)" title="Zoom in"><i class="fas fa-plus"></i></button>
<button class="zoom-btn" onclick="resetMermaidZoom()" title="Reset"><i class="fas fa-expand"></i></button>
</div>
<button class="close-btn" onclick="closeMermaidModal()" title="Close (Esc)"><i class="fas fa-times"></i></button>
</div>
<div class="modal-viewport" id="modal-viewport">
<div class="diagram-container" id="modal-diagram"></div>
</div>
</div>
</div>
<script>
// --- Theme Management ---
let currentTheme = 'dark';
function initTheme() {
const saved = localStorage.getItem('mdserve-theme');
if (saved) {
currentTheme = saved;
} else if (window.matchMedia('(prefers-color-scheme: light)').matches) {
currentTheme = 'light';
}
applyTheme();
}
function toggleTheme() {
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
localStorage.setItem('mdserve-theme', currentTheme);
applyTheme();
reinitializeMermaid();
}
function applyTheme() {
const root = document.documentElement;
const icon = document.getElementById('theme-icon');
const label = document.getElementById('theme-label');
const content = document.getElementById('content');
if (currentTheme === 'light') {
root.classList.add('light');
icon.classList.replace('fa-moon', 'fa-sun');
label.textContent = 'Light';
content.classList.remove('prose-invert');
} else {
root.classList.remove('light');
icon.classList.replace('fa-sun', 'fa-moon');
label.textContent = 'Dark';
content.classList.add('prose-invert');
}
}
function getMermaidTheme() {
return currentTheme === 'light' ? 'default' : 'dark';
}
function reinitializeMermaid() {
mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
// Re-render any visible mermaid diagrams
const diagrams = document.querySelectorAll('.mermaid');
diagrams.forEach(async (el) => {
const code = el.getAttribute('data-mermaid-src');
if (code) {
el.innerHTML = code;
el.removeAttribute('data-processed');
}
});
if (diagrams.length > 0) {
mermaid.run({ nodes: diagrams });
}
}
// --- Mermaid Modal ---
let mermaidZoom = 1;
let mermaidPan = { x: 0, y: 0 };
let isDragging = false;
let dragStart = { x: 0, y: 0 };
function openMermaidModal(element) {
const modal = document.getElementById('mermaid-modal');
const container = document.getElementById('modal-diagram');
const svg = element.querySelector('svg');
if (svg) {
container.innerHTML = svg.outerHTML;
mermaidZoom = 1;
mermaidPan = { x: 0, y: 0 };
updateMermaidTransform();
modal.classList.add('active');
document.body.style.overflow = 'hidden';
}
}
function closeMermaidModal() {
const modal = document.getElementById('mermaid-modal');
modal.classList.remove('active');
document.body.style.overflow = '';
}
function zoomMermaid(delta) {
mermaidZoom = Math.max(0.25, Math.min(4, mermaidZoom + delta));
updateMermaidTransform();
}
function resetMermaidZoom() {
mermaidZoom = 1;
mermaidPan = { x: 0, y: 0 };
updateMermaidTransform();
}
function updateMermaidTransform() {
const container = document.getElementById('modal-diagram');
container.style.transform = `translate(${mermaidPan.x}px, ${mermaidPan.y}px) scale(${mermaidZoom})`;
document.getElementById('zoom-level').textContent = Math.round(mermaidZoom * 100) + '%';
}
// Modal pan/drag handling
document.addEventListener('DOMContentLoaded', () => {
const viewport = document.getElementById('modal-viewport');
viewport.addEventListener('mousedown', (e) => {
if (e.target.closest('.diagram-container')) {
isDragging = true;
dragStart = { x: e.clientX - mermaidPan.x, y: e.clientY - mermaidPan.y };
viewport.classList.add('dragging');
}
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
mermaidPan.x = e.clientX - dragStart.x;
mermaidPan.y = e.clientY - dragStart.y;
updateMermaidTransform();
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
viewport?.classList.remove('dragging');
});
// Mouse wheel zoom
viewport.addEventListener('wheel', (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.1 : 0.1;
zoomMermaid(delta);
}, { passive: false });
// Escape to close
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeMermaidModal();
});
});
// --- Initialization ---
initTheme();
mermaid.initialize({ startOnLoad: false, theme: getMermaidTheme() });
document.addEventListener('DOMContentLoaded', async () => {
await loadDirectoryStructure();
// Handle initial entry file logic
const params = new URLSearchParams(window.location.search);
const requestedFile = params.get('file');
if (requestedFile) {
loadFile(requestedFile);
} else {
findEntryFile();
}
// Internal link handling
document.getElementById('content').addEventListener('click', handleInternalLink);
});
// --- Internal Link Handling ---
function handleInternalLink(e) {
const link = e.target.closest('a');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
// Check if it's an internal .md link
if (href.endsWith('.md') || href.includes('.md#')) {
e.preventDefault();
// Parse the href to get path and anchor
let path = href;
let anchor = '';
if (href.includes('#')) {
const parts = href.split('#');
path = parts[0];
anchor = parts[1];
}
// Handle relative paths
if (!path.startsWith('/') && !path.startsWith('http')) {
const currentPath = document.getElementById('current-path').textContent;
const currentDir = currentPath.substring(0, currentPath.lastIndexOf('/'));
path = currentDir ? currentDir + '/' + path : path;
}
// Remove leading slash if present (our paths are relative to root)
if (path.startsWith('/')) {
path = path.substring(1);
}
// Load the file
loadFile(path).then(() => {
if (anchor) {
// Wait for DOM to update, then scroll to anchor
setTimeout(() => {
const target = document.getElementById(anchor) ||
document.querySelector(`[id$="${anchor}"]`);
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
}, 100);
}
});
}
}
// --- Logic ---
let fileStructure = [];
async function loadDirectoryStructure() {
try {
const response = await fetch('/api/structure');
const data = await response.json();
fileStructure = data.tree;
document.getElementById('root-dir').textContent = data.root_name;
const treeContainer = document.getElementById('file-tree');
treeContainer.innerHTML = buildTreeHtml(fileStructure);
} catch (err) {
console.error("Failed to load structure", err);
}
}
function buildTreeHtml(nodes, level = 0) {
let html = '';
// Sort: Directories first, then files
nodes.sort((a, b) => {
if (a.type === b.type) return a.name.localeCompare(b.name);
return a.type === 'directory' ? -1 : 1;
});
for (const node of nodes) {
const padding = level * 14;
if (node.type === 'directory') {
html += `
<div class="group">
<div class="dir-item flex items-center px-3 py-1.5 rounded cursor-pointer select-none"
style="padding-left: ${padding + 8}px"
onclick="toggleDir(this)">
<i class="fas fa-chevron-right text-[10px] w-4 transition-transform" style="color: var(--text-muted)"></i>
<i class="fas fa-folder mr-2" style="color: var(--accent-orange)"></i>
<span class="font-medium">${node.name}</span>
</div>
<div class="hidden" style="border-left: 1px solid var(--border-color); margin-left: ${padding + 18}px">${buildTreeHtml(node.children, level + 1)}</div>
</div>
`;
} else {
const isMd = node.name.toLowerCase().endsWith('.md');
const icon = isMd ? 'fa-file-lines' : 'fa-file';
const iconColor = isMd ? 'color: var(--accent-orange)' : 'color: var(--text-muted)';
const clickAction = isMd ? `onclick="loadFile('${node.path}', this)"` : '';
const cursor = isMd ? 'file-item cursor-pointer rounded' : 'cursor-default opacity-50';
html += `
<div class="flex items-center px-3 py-1.5 ${cursor}"
style="padding-left: ${padding + 24}px; color: var(--text-secondary)"
${clickAction}
data-path="${node.path}">
<i class="fas ${icon} mr-2 w-4 text-center text-sm" style="${iconColor}"></i>
<span class="truncate">${node.name}</span>
</div>
`;
}
}
return html;
}
function toggleDir(element) {
const arrow = element.querySelector('.fa-chevron-right');
const content = element.nextElementSibling;
if (content.classList.contains('hidden')) {
content.classList.remove('hidden');
arrow.classList.add('rotate-90');
element.querySelector('.fa-folder').classList.replace('fa-folder', 'fa-folder-open');
} else {
content.classList.add('hidden');
arrow.classList.remove('rotate-90');
element.querySelector('.fa-folder-open').classList.replace('fa-folder-open', 'fa-folder');
}
}
function findEntryFile() {
const rootFiles = fileStructure.filter(node => node.type === 'file');
const rootReadme = rootFiles.find(f => f.name.toUpperCase() === 'README.MD');
const rootIndex = rootFiles.find(f => f.name.toUpperCase() === 'INDEX.MD');
if (rootReadme) {
const el = document.querySelector(`[data-path="${rootReadme.path}"]`);
loadFile(rootReadme.path, el);
return;
}
if (rootIndex) {
const el = document.querySelector(`[data-path="${rootIndex.path}"]`);
loadFile(rootIndex.path, el);
return;
}
const flatten = (nodes) => {
let res = [];
nodes.forEach(n => {
res.push(n);
if(n.children) res = res.concat(flatten(n.children));
});
return res;
};
const allFiles = flatten(fileStructure);
const entry = allFiles.find(f => f.name.toUpperCase() === 'README.MD') ||
allFiles.find(f => f.name.toUpperCase() === 'INDEX.MD');
if (entry) {
const el = document.querySelector(`[data-path="${entry.path}"]`);
loadFile(entry.path, el);
} else {
document.getElementById('empty-state').classList.remove('hidden');
}
}
async function loadFile(path, element) {
const contentEl = document.getElementById('content');
const fmContainer = document.getElementById('front-matter-container');
const loader = document.getElementById('loader');
const emptyState = document.getElementById('empty-state');
emptyState.classList.add('hidden');
contentEl.classList.add('hidden');
fmContainer.classList.add('hidden');
loader.classList.remove('hidden');
document.getElementById('current-path').textContent = path;
document.querySelectorAll('.active-file').forEach(el => el.classList.remove('active-file'));
if (!element) {
element = document.querySelector(`[data-path="${path}"]`);
}
if (element) element.classList.add('active-file');
try {
const response = await fetch(`/api/file?path=${encodeURIComponent(path)}`);
if (!response.ok) throw new Error("File not found");
const text = await response.text();
renderMarkdown(text, path);
} catch (err) {
contentEl.innerHTML = `<div class="text-red-400">Error loading file: ${err.message}</div>`;
contentEl.classList.remove('hidden');
} finally {
loader.classList.add('hidden');
}
}
// --- Enhanced Markdown Rendering ---
function parseFrontMatter(text) {
// Regex double-escaped for Python string safety:
// Matches --- followed by newline, anything (including newlines), then ---
const regex = /^---\\n([\\s\\S]+?)\\n---/;
const match = text.match(regex);
if (match) {
const yaml = match[1];
const rows = yaml.trim().split('\\n').map(line => {
const parts = line.split(':');
if (parts.length < 2) return null;
const key = parts[0].trim();
const val = parts.slice(1).join(':').trim();
return { key, val };
}).filter(x => x);
return { hasFM: true, rows, content: text.replace(regex, '') };
}
return { hasFM: false, content: text };
}
function renderFrontMatter(rows) {
const container = document.getElementById('front-matter-container');
if (!rows || rows.length === 0) {
container.classList.add('hidden');
return;
}
let html = '<table class="front-matter-table"><tbody>';
rows.forEach(row => {
html += `<tr><th>${row.key}</th><td>${row.val}</td></tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
container.classList.remove('hidden');
}
function renderMarkdown(markdown, filePath) {
const contentEl = document.getElementById('content');
// 1. Extract Front Matter
const fmResult = parseFrontMatter(markdown);
renderFrontMatter(fmResult.rows);
markdown = fmResult.content;
// 2. Protect Math ($...$ and $$...$$) from Marked
const mathExpressions = [];
// Block math $$...$$ (double-escaped regex)
markdown = markdown.replace(/\\$\\$([\\s\\S]+?)\\$\\$/g, (match, expr) => {
mathExpressions.push({ type: 'display', expr });
return `%%%MATH${mathExpressions.length-1}%%%`;
});
// Inline math $...$ (double-escaped regex)
markdown = markdown.replace(/\\$([^$\\n]+?)\\$/g, (match, expr) => {
mathExpressions.push({ type: 'inline', expr });
return `%%%MATH${mathExpressions.length-1}%%%`;
});
// 3. Configure Marked
const renderer = new marked.Renderer();
const originalCode = renderer.code;
renderer.code = function(token) {
if (token.lang === 'mermaid') {
// Store source for re-rendering, make clickable
const escaped = token.text.replace(/"/g, '&quot;');
return `<div class="mermaid" data-mermaid-src="${escaped}" onclick="openMermaidModal(this)">${token.text}</div>`;
}
return originalCode.call(this, token);
};
renderer.image = function(token) {
let href = token.href;
if (!href.match(/^https?:/) && !href.startsWith('/')) {
const dir = filePath.substring(0, filePath.lastIndexOf('/'));
const sep = dir ? '/' : '';
href = dir + sep + href;
}
return `<img src="${href}" alt="${token.text}" title="${token.title || ''}" class="rounded-lg shadow-lg my-4" />`;
};
// Custom renderer for blockquotes to handle GitHub Alerts
// Syntax: > [!NOTE]
renderer.blockquote = function(token) {
const quote = token.text;
const tempDiv = document.createElement('div');
tempDiv.innerHTML = quote;
const p = tempDiv.querySelector('p');
if (p) {
const text = p.innerHTML;
// Double-escaped [ and ] for Python string safety
const match = text.match(/^\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\]/);
if (match) {
// Extract type (e.g., NOTE) from the match which will include [! and ]
// match[0] is like "[!NOTE]"
const typeTag = match[0];
// FIX: Double escaped \\w for Python string safety
const type = typeTag.replace(/[^\\w]/g, '').toLowerCase(); // clean to "note"
const iconMap = {
note: 'fa-circle-info',
tip: 'fa-lightbulb',
important: 'fa-circle-exclamation',
warning: 'fa-triangle-exclamation',
caution: 'fa-octagon-xmark'
};
let content = text.replace(match[0], '').trim();
if (content.startsWith('<br>')) content = content.substring(4).trim();
return `
<div class="markdown-alert markdown-alert-${type}">
<div class="markdown-alert-title">
<i class="fas ${iconMap[type] || 'fa-info'}"></i>
${type.charAt(0).toUpperCase() + type.slice(1)}
</div>
<div>${content}</div>
</div>
`;
}
}
return `<blockquote>${quote}</blockquote>`;
};
marked.use({ renderer });
// 4. Parse Markdown to HTML
let html = marked.parse(markdown);
// 5. Restore Math
// FIX: Double escaped \\d for Python string safety
html = html.replace(/%%%MATH(\\d+)%%%/g, (match, index) => {
const item = mathExpressions[index];
try {
return katex.renderToString(item.expr, {
displayMode: item.type === 'display',
throwOnError: false,
output: 'html'
});
} catch(e) { return `<span class="text-red-500">Math Error</span>`; }
});
contentEl.innerHTML = html;
contentEl.classList.remove('hidden');
// 6. Post-Processing
mermaid.run({ nodes: document.querySelectorAll('.mermaid') });
Prism.highlightAll();
generateTOC();
document.getElementById('main-scroll').scrollTop = 0;
}
function generateTOC() {
const tocEl = document.getElementById('toc');
const contentEl = document.getElementById('content');
const headers = contentEl.querySelectorAll('h1, h2, h3');
tocEl.innerHTML = '';
if (headers.length === 0) {
tocEl.innerHTML = '<span style="color: var(--text-muted); font-style: italic">No headers found</span>';
return;
}
headers.forEach((header, index) => {
const id = 'header-' + index;
header.id = id;
const link = document.createElement('a');
link.href = '#' + id;
link.textContent = header.textContent;
link.className = 'block py-1 transition-colors truncate';
link.style.color = 'var(--text-muted)';
link.onmouseenter = () => link.style.color = 'var(--accent-color)';
link.onmouseleave = () => { if (!link.classList.contains('toc-active')) link.style.color = 'var(--text-muted)'; };
const level = parseInt(header.tagName.charAt(1)) - 1;
if (level > 0) {
link.style.paddingLeft = (level * 1) + 'rem';
}
link.style.fontSize = level === 0 ? '1em' : '0.9em';
link.addEventListener('click', (e) => {
e.preventDefault();
header.scrollIntoView({ behavior: 'smooth' });
document.querySelectorAll('#toc a').forEach(a => a.classList.remove('toc-active'));
link.classList.add('toc-active');
});
tocEl.appendChild(link);
});
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
document.querySelectorAll('#toc a').forEach(a => {
a.classList.remove('toc-active');
if(a.getAttribute('href') === '#' + id) a.classList.add('toc-active');
});
}
});
}, { root: document.getElementById('main-scroll'), threshold: 0.1, rootMargin: "-10% 0px -80% 0px" });
headers.forEach(h => observer.observe(h));
}
</script>
</body>
</html>
"""
# --- Backend Logic ---
def get_directory_structure(rootdir):
"""
Crawls the directory and returns a JSON-serializable structure.
Ignores hidden files and git directories.
"""
def scan(path):
name = os.path.basename(path)
# Skip hidden files and common junk
if name.startswith('.') or name == '__pycache__':
return None
if os.path.isdir(path):
children = []
try:
for entry in os.scandir(path):
child = scan(entry.path)
if child:
children.append(child)
except PermissionError:
pass # Skip folders we can't read
return {
"type": "directory",
"name": name,
"path": os.path.relpath(path, rootdir).replace("\\", "/"),
"children": children
}
else:
return {
"type": "file",
"name": name,
"path": os.path.relpath(path, rootdir).replace("\\", "/")
}
root_items = []
# Handle the root directory contents
try:
for entry in os.scandir(rootdir):
item = scan(entry.path)
if item:
root_items.append(item)
except Exception as e:
print(f"Error scanning root: {e}")
return {
"root_name": os.path.basename(os.path.abspath(rootdir)),
"tree": root_items
}
class MDRequestHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, directory=None, **kwargs):
# We handle directory via the global/server context or pass it explicitly
self.served_dir = directory
super().__init__(*args, directory=directory, **kwargs)
def do_GET(self):
# Parse query params
parsed_path = urlparse(self.path)
path = parsed_path.path
query = parse_qs(parsed_path.query)
# 1. API: Get Structure
if path == '/api/structure':
self.send_response(200)
self.send_header('Content-type', 'application/json')
self.end_headers()
structure = get_directory_structure(self.served_dir)
self.wfile.write(json.dumps(structure).encode('utf-8'))
return
# 2. API: Get File Content
if path == '/api/file':
rel_path = query.get('path', [''])[0]
if not rel_path:
self.send_error(400, "Missing path parameter")
return
# Security check: prevent directory traversal outside root
full_path = os.path.abspath(os.path.join(self.served_dir, rel_path))
root_path = os.path.abspath(self.served_dir)
if not full_path.startswith(root_path):
self.send_error(403, "Access denied")
return
if os.path.exists(full_path) and os.path.isfile(full_path):
self.send_response(200)
# Determine content type (default to text/plain for MD)
self.send_header('Content-type', 'text/plain; charset=utf-8')
self.end_headers()
try:
with open(full_path, 'rb') as f:
self.wfile.write(f.read())
except Exception as e:
self.wfile.write(f"Error reading file: {e}".encode())
else:
self.send_error(404, "File not found")
return
# 3. Serve the App Shell (HTML)
if path == '/' or path == '/index.html':
self.send_response(200)
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write(HTML_TEMPLATE.encode('utf-8'))
return
# 4. Fallback: Serve Static Assets (Images, etc. referenced in Markdown)
# SimpleHTTPRequestHandler handles this automatically relative to 'directory'
return super().do_GET()
def find_free_port(start_port=4500):
port = start_port
while port < 65535:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(('localhost', port))
return port
except OSError:
port += 1
raise RuntimeError("No free ports found")
def serve(directory):
directory = os.path.abspath(directory)
if not os.path.exists(directory):
print(f"Error: Directory '{directory}' does not exist.")
sys.exit(1)
port = find_free_port()
# Create handler with the specific directory
handler = lambda *args, **kwargs: MDRequestHandler(*args, directory=directory, **kwargs)
try:
with socketserver.TCPServer(("localhost", port), handler) as httpd:
url = f"http://localhost:{port}"
print(f"\n========================================")
print(f" MDServe Running")
print(f" Serving: {directory}")
print(f" URL: {url}")
print(f"========================================\n")
print("Press Ctrl+C to stop.")
# Open browser automatically
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
httpd.serve_forever()
except KeyboardInterrupt:
print("\nStopping server...")
sys.exit(0)
import threading
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Serve a directory of Markdown files.")
parser.add_argument("directory", nargs="?", default=".", help="The directory to serve (default: current dir)")
args = parser.parse_args()
serve(args.directory)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment