From 9ff0389640ff00c49ee22510cb7719775741a613 Mon Sep 17 00:00:00 2001 From: ergosteur Date: Fri, 21 Aug 2026 17:00:25 -0400 Subject: [PATCH] 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. --- src/md_to_html.py | 103 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/src/md_to_html.py b/src/md_to_html.py index 4c792a6..4cd10c7 100644 --- a/src/md_to_html.py +++ b/src/md_to_html.py @@ -42,6 +42,7 @@ CSS = """ } } * { box-sizing: border-box; } +html { scroll-behavior: smooth; } body { margin: 0; background: var(--bg); @@ -49,11 +50,59 @@ body { font-family: -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; 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 { - max-width: 1080px; + flex: 1 1 auto; + min-width: 0; + max-width: 960px; margin: 0 auto; 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 { font-size: 1.9rem; 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 = { "Critical": "badge-critical", "High": "badge-high", @@ -290,6 +367,23 @@ def wrap_timestamps(html_text): return RFC3339_UTC_RE.sub(r'\1', html_text) +HEADING_RE = re.compile(r'(.*?)', 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'{label}') + + if not items: + return "" + return f'' + + def find_latest_report(reports_dir): candidates = sorted(glob.glob(os.path.join(reports_dir, "ad_audit_report_*.md"))) if not candidates: @@ -301,9 +395,10 @@ def convert(md_path, out_path): with open(md_path, "r", encoding="utf-8") as f: 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 = wrap_timestamps(body_html) + toc_html = build_toc(body_html) title = "Active Directory Audit Report" first_heading = re.search(r"^#\s+(.+)$", md_text, re.MULTILINE) @@ -319,12 +414,16 @@ def convert(md_path, out_path): +
+{toc_html}
{body_html}
+
+ """