Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ How markdown-proxy compares to other Markdown viewing tools:
- Print: browser print with clean filename (toolbar hidden in print output)
- Source: link to original URL on remote server (remote pages only)
- Multiple CSS themes (GitHub, Simple, Dark) with switching UI
- Table of contents sidebar: toggle `TOC` in the toolbar to open a right-side panel with auto-extracted headings; visibility persists per browser (localStorage)
- Live reload for local files (auto-refreshes browser on file changes)
- Directory listing for local files
- Line anchor links: `[text](foo.md:12)` or `<a href="foo.md:12">` links navigate to specific source lines with highlighting (Markdown and text files)
Expand Down
7 changes: 7 additions & 0 deletions internal/template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ const markdownPageTplHead = `<!DOCTYPE html>
<style>` + darkCSS + `</style>
<style>` + commonCSS + `</style>
<style>` + lineAnchorCSS + `</style>
<style>` + tocCSS + `</style>
`

const markdownPageTplTail = `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
Expand All @@ -99,6 +100,7 @@ const markdownPageTplTail = `<link rel="stylesheet" href="https://cdn.jsdelivr.n
<div class="toolbar-actions">
{{if .SourceURL}}<a href="{{.SourceURL}}" target="_blank" rel="noopener" class="toolbar-link">Source</a>{{end}}
<a href="javascript:void(0)" onclick="printPage()" class="toolbar-link">Print</a>
<a href="javascript:void(0)" class="toolbar-link toc-toggle">TOC</a>
<div class="theme-switcher">
<label>Theme:</label>
<select onchange="switchTheme(this.value)">
Expand All @@ -112,6 +114,10 @@ const markdownPageTplTail = `<link rel="stylesheet" href="https://cdn.jsdelivr.n
<div class="markdown-body">
{{.Content}}
</div>
<aside id="toc-panel" class="toc-panel">
<div class="toc-header">Table of Contents</div>
<div class="toc-body"><ul class="toc-list"></ul></div>
</aside>
<script>
mermaid.initialize({startOnLoad: true, theme: document.body.className.includes('dark') ? 'dark' : 'default'});
function printPage() {
Expand Down Expand Up @@ -149,6 +155,7 @@ document.addEventListener('DOMContentLoaded', function() {
});
</script>
` + lineAnchorJS + `
` + tocJS + `
{{if .WatchPath}}
<script>
(function() {
Expand Down
258 changes: 258 additions & 0 deletions internal/template/toc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
package template

const tocCSS = `
.markdown-body h1, .markdown-body h2, .markdown-body h3,
.markdown-body h4, .markdown-body h5, .markdown-body h6 {
scroll-margin-top: 50px;
}
.toc-panel {
position: fixed;
top: 41px;
right: 0;
width: 280px;
height: calc(100vh - 41px);
overflow-y: auto;
border-left: 1px solid #e1e4e8;
background: #f6f8fa;
transform: translateX(100%);
transition: transform 0.2s ease-out;
z-index: 90;
font-size: 13px;
}
body.toc-visible .toc-panel { transform: translateX(0); }
body.toc-visible .markdown-body { margin-right: 280px; }
.toc-header {
padding: 10px 16px;
border-bottom: 1px solid #e1e4e8;
font-weight: bold;
}
.toc-body { padding: 8px 0; }
.toc-list, .toc-list ul {
list-style: none;
padding-left: 0;
margin: 0;
}
.toc-list ul { padding-left: 14px; display: none; }
.toc-list li.open > ul { display: block; }
.toc-list li { margin: 0; position: relative; }
.toc-list .toc-row {
display: flex;
align-items: flex-start;
}
.toc-list .toc-caret {
flex: 0 0 16px;
cursor: pointer;
user-select: none;
text-align: center;
line-height: 27px;
color: #6a737d;
font-size: 10px;
}
.toc-list .toc-caret::before { content: '\25B6'; }
.toc-list li.open > .toc-row > .toc-caret::before { content: '\25BC'; }
.toc-list .toc-caret.empty { visibility: hidden; }
.toc-list a {
flex: 1;
display: block;
padding: 4px 16px 4px 4px;
text-decoration: none;
color: inherit;
border-left: 2px solid transparent;
word-break: break-word;
}
.toc-list a:hover { background: rgba(0,0,0,0.05); }
.toc-list a.active {
border-left-color: #0366d6;
background: rgba(3,102,214,0.08);
font-weight: 600;
}
.toc-toggle.disabled {
opacity: 0.4;
pointer-events: none;
}
.theme-dark .toc-panel {
background: #161b22;
border-left-color: #30363d;
}
.theme-dark .toc-header { border-bottom-color: #30363d; }
.theme-dark .toc-list a:hover { background: rgba(255,255,255,0.08); }
.theme-dark .toc-list .toc-caret { color: #8b949e; }
.theme-dark .toc-list a.active {
border-left-color: #58a6ff;
background: rgba(88,166,255,0.12);
}
.theme-simple .toc-panel {
background: #f5f5f5;
border-left-color: #ddd;
}
.theme-simple .toc-header { border-bottom-color: #ddd; }
.theme-simple .toc-list a.active {
border-left-color: #07c;
background: rgba(0,119,204,0.08);
}
@media (max-width: 900px) {
body.toc-visible .markdown-body { margin-right: 0; }
.toc-panel { box-shadow: -2px 0 8px rgba(0,0,0,0.15); }
}
@media print {
.toc-panel, .toc-toggle { display: none !important; }
body.toc-visible .markdown-body { margin-right: 0 !important; }
}
`

const tocJS = `<script>
(function() {
var STORAGE_KEY = 'mdproxy_toc_visible';
var toggleBtn = document.querySelector('.toc-toggle');
var panel = document.getElementById('toc-panel');
if (!toggleBtn || !panel) return;

var headings = Array.prototype.slice.call(document.querySelectorAll(
'.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4, .markdown-body h5, .markdown-body h6'
));
if (headings.length === 0) {
toggleBtn.classList.add('disabled');
toggleBtn.setAttribute('title', 'No headings in this document');
return;
}

var listRoot = panel.querySelector('.toc-list');

// Assign IDs and determine top level (smallest tag number).
var topLevel = 6;
headings.forEach(function(h, i) {
if (!h.id) h.id = 'toc-' + i;
var level = parseInt(h.tagName.substring(1), 10);
if (level < topLevel) topLevel = level;
});
// Initially expand items whose children are at level <= topLevel + 1.
var initialOpenThreshold = topLevel + 1;

// Build nested tree. Each stack entry represents a heading whose li may host
// deeper children; the ul under it is created lazily on first child.
var linkByHeading = new Map();
var liByHeading = new Map();
var stack = [{ level: topLevel - 1, ul: listRoot, li: null }];

headings.forEach(function(h) {
var level = parseInt(h.tagName.substring(1), 10);
while (stack.length > 1 && stack[stack.length - 1].level >= level) {
stack.pop();
}
var parent = stack[stack.length - 1];
if (!parent.ul) {
parent.ul = document.createElement('ul');
parent.li.appendChild(parent.ul);
}
var li = document.createElement('li');
var row = document.createElement('div');
row.className = 'toc-row';
var caret = document.createElement('span');
caret.className = 'toc-caret empty';
var a = document.createElement('a');
a.href = '#' + h.id;
a.textContent = h.textContent.trim();
a.addEventListener('click', function(e) {
e.preventDefault();
var toolbar = document.querySelector('.toolbar');
var offset = toolbar ? toolbar.getBoundingClientRect().height : 0;
var y = h.getBoundingClientRect().top + window.pageYOffset - offset - 8;
window.scrollTo({top: y, behavior: 'smooth'});
history.replaceState(null, '', '#' + h.id);
});
row.appendChild(caret);
row.appendChild(a);
li.appendChild(row);
parent.ul.appendChild(li);
linkByHeading.set(h, a);
liByHeading.set(h, li);
stack.push({ level: level, ul: null, li: li });
});

// Wire up carets for items that ended up with child <ul>.
listRoot.querySelectorAll('li').forEach(function(li) {
var childUl = li.querySelector(':scope > ul');
if (!childUl) return;
var caret = li.querySelector(':scope > .toc-row > .toc-caret');
if (!caret) return;
caret.classList.remove('empty');
caret.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
li.classList.toggle('open');
});
});

// Initial expansion: open ancestors of items at or above initialOpenThreshold,
// so the first two heading levels are visible while deeper ones stay folded.
headings.forEach(function(h) {
var level = parseInt(h.tagName.substring(1), 10);
if (level >= initialOpenThreshold) return;
var li = liByHeading.get(h);
var node = li;
while (node && node.tagName === 'LI') {
node.classList.add('open');
var parentUl = node.parentElement;
node = parentUl ? parentUl.closest('li') : null;
}
});

// Toggle panel visibility.
function setVisible(v) {
document.body.classList.toggle('toc-visible', v);
try { localStorage.setItem(STORAGE_KEY, v ? '1' : '0'); } catch (e) {}
}
toggleBtn.addEventListener('click', function() {
setVisible(!document.body.classList.contains('toc-visible'));
});
var saved = null;
try { saved = localStorage.getItem(STORAGE_KEY); } catch (e) {}
if (saved === '1') setVisible(true);

// Scroll following: the active heading is the last one whose top is at or
// above a reference line just below the sticky toolbar. This mirrors the
// reading position regardless of scroll direction.
var active = null;
function updateActive() {
var toolbar = document.querySelector('.toolbar');
var offset = toolbar ? toolbar.getBoundingClientRect().height : 0;
var ref = offset + 10;
var candidate = null;
for (var i = 0; i < headings.length; i++) {
if (headings[i].getBoundingClientRect().top <= ref) {
candidate = headings[i];
} else {
break;
}
}
if (!candidate) candidate = headings[0];
if (candidate === active) return;
if (active) {
var prev = linkByHeading.get(active);
if (prev) prev.classList.remove('active');
}
active = candidate;
var link = linkByHeading.get(active);
if (link) {
link.classList.add('active');
var li = liByHeading.get(active);
var node = li ? li.parentElement : null;
while (node && node !== listRoot) {
if (node.tagName === 'LI') node.classList.add('open');
node = node.parentElement;
}
}
}
var ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(function() {
updateActive();
ticking = false;
});
}
window.addEventListener('scroll', onScroll, { passive: true });
updateActive();
})();
</script>`
Loading