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; }
|
* { 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>
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user