Add sticky sidebar TOC to the HTML report

Links to every h2/h3 section (h4 finding headers excluded -- there
can be many). Sticky on desktop, collapses to a stacked block above
the content on narrow viewports. Active section highlights via
IntersectionObserver as you scroll. Uses markdown's toc extension
for heading ids/slugs rather than hand-rolling a slugifier.
This commit is contained in:
2026-08-21 17:00:25 -04:00
parent ce4f5ec625
commit 9ff0389640
+101 -2
View File
@@ -42,6 +42,7 @@ CSS = """
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
@@ -49,11 +50,59 @@ body {
font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.55; line-height: 1.55;
} }
.layout {
display: flex;
align-items: flex-start;
max-width: 1280px;
margin: 0 auto;
}
.toc {
flex: 0 0 240px;
position: sticky;
top: 0;
align-self: flex-start;
max-height: 100vh;
overflow-y: auto;
padding: 2.5rem 1rem 2rem 1.5rem;
font-size: 0.83rem;
}
.toc-title {
color: var(--text-dim);
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.6rem;
}
.toc a {
display: block;
padding: 0.28rem 0 0.28rem 0.6rem;
color: var(--text-dim);
text-decoration: none;
border-left: 2px solid transparent;
line-height: 1.3;
}
.toc a:hover { color: var(--text); }
.toc a.active { color: var(--accent); border-left-color: var(--accent); }
.toc a.toc-h3 { padding-left: 1.5rem; font-size: 0.94em; }
.wrap { .wrap {
max-width: 1080px; flex: 1 1 auto;
min-width: 0;
max-width: 960px;
margin: 0 auto; margin: 0 auto;
padding: 2.5rem 1.5rem 5rem; padding: 2.5rem 1.5rem 5rem;
} }
@media (max-width: 900px) {
.layout { display: block; }
.toc {
position: static;
max-height: none;
width: 100%;
border-bottom: 1px solid var(--border);
padding: 1.25rem 1.5rem;
}
.wrap { max-width: 100%; }
}
h1 { h1 {
font-size: 1.9rem; font-size: 1.9rem;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
@@ -248,6 +297,34 @@ TZ_JS = """
})(); })();
""" """
TOC_JS = """
(function () {
var links = Array.prototype.slice.call(document.querySelectorAll(".toc a"));
if (!links.length) return;
var targets = links
.map(function (link) { return document.getElementById(link.getAttribute("href").slice(1)); })
.filter(Boolean);
if (!targets.length || !("IntersectionObserver" in window)) return;
function setActive(id) {
links.forEach(function (link) {
link.classList.toggle("active", link.getAttribute("href") === "#" + id);
});
}
var observer = new IntersectionObserver(function (entries) {
var visible = entries.filter(function (e) { return e.isIntersecting; });
if (visible.length) {
visible.sort(function (a, b) { return a.boundingClientRect.top - b.boundingClientRect.top; });
setActive(visible[0].target.id);
}
}, { rootMargin: "0px 0px -70% 0px", threshold: 0 });
targets.forEach(function (t) { observer.observe(t); });
})();
"""
SEVERITY_CLASS = { SEVERITY_CLASS = {
"Critical": "badge-critical", "Critical": "badge-critical",
"High": "badge-high", "High": "badge-high",
@@ -290,6 +367,23 @@ def wrap_timestamps(html_text):
return RFC3339_UTC_RE.sub(r'<span class="ts" data-utc="\1">\1</span>', html_text) return RFC3339_UTC_RE.sub(r'<span class="ts" data-utc="\1">\1</span>', html_text)
HEADING_RE = re.compile(r'<h([23]) id="([^"]+)">(.*?)</h\1>', re.S)
def build_toc(body_html):
"""Sidebar nav linking to every h2/h3 in the report (h4 finding headers
are left out -- there can be many of those and they'd swamp the nav)."""
items = []
for level, anchor_id, text in HEADING_RE.findall(body_html):
label = re.sub(r"<[^>]+>", "", text).strip()
css_class = " toc-h3" if level == "3" else ""
items.append(f'<a class="{css_class.strip()}" href="#{anchor_id}">{label}</a>')
if not items:
return ""
return f'<nav class="toc"><div class="toc-title">Contents</div>{"".join(items)}</nav>'
def find_latest_report(reports_dir): def find_latest_report(reports_dir):
candidates = sorted(glob.glob(os.path.join(reports_dir, "ad_audit_report_*.md"))) candidates = sorted(glob.glob(os.path.join(reports_dir, "ad_audit_report_*.md")))
if not candidates: if not candidates:
@@ -301,9 +395,10 @@ def convert(md_path, out_path):
with open(md_path, "r", encoding="utf-8") as f: with open(md_path, "r", encoding="utf-8") as f:
md_text = f.read() md_text = f.read()
body_html = markdown.markdown(md_text, extensions=["tables", "fenced_code"]) body_html = markdown.markdown(md_text, extensions=["tables", "fenced_code", "toc"])
body_html = badge_findings_table(body_html) body_html = badge_findings_table(body_html)
body_html = wrap_timestamps(body_html) body_html = wrap_timestamps(body_html)
toc_html = build_toc(body_html)
title = "Active Directory Audit Report" title = "Active Directory Audit Report"
first_heading = re.search(r"^#\s+(.+)$", md_text, re.MULTILINE) first_heading = re.search(r"^#\s+(.+)$", md_text, re.MULTILINE)
@@ -319,12 +414,16 @@ def convert(md_path, out_path):
<style>{CSS}</style> <style>{CSS}</style>
</head> </head>
<body> <body>
<div class="layout">
{toc_html}
<div class="wrap"> <div class="wrap">
<button id="tz-toggle" class="tz-toggle" type="button">Show local time</button> <button id="tz-toggle" class="tz-toggle" type="button">Show local time</button>
{body_html} {body_html}
</div> </div>
</div>
<script>{SORT_JS}</script> <script>{SORT_JS}</script>
<script>{TZ_JS}</script> <script>{TZ_JS}</script>
<script>{TOC_JS}</script>
</body> </body>
</html> </html>
""" """