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:
+101
-2
@@ -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'<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):
|
||||
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):
|
||||
<style>{CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layout">
|
||||
{toc_html}
|
||||
<div class="wrap">
|
||||
<button id="tz-toggle" class="tz-toggle" type="button">Show local time</button>
|
||||
{body_html}
|
||||
</div>
|
||||
</div>
|
||||
<script>{SORT_JS}</script>
|
||||
<script>{TZ_JS}</script>
|
||||
<script>{TOC_JS}</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user