Files
MajoorWaldi-ComfyUI-Majoor-…/scripts/build_user_guide_html.py
T

792 lines
28 KiB
Python

from __future__ import annotations
import argparse
import html
import posixpath
import re
import subprocess
import sys
import urllib.parse
from dataclasses import dataclass
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DOCS_DIR = REPO_ROOT / "docs"
def _read_text(path: Path) -> str:
return path.read_text(encoding="utf-8", errors="replace")
def _tracked_doc_names() -> set[str] | None:
try:
result = subprocess.run(
["git", "ls-files", "docs/*.md"],
cwd=REPO_ROOT,
check=True,
capture_output=True,
text=True,
)
except Exception:
return None
return {Path(line.strip()).name for line in result.stdout.splitlines() if line.strip()}
def _slugify(text: str) -> str:
s = str(text or "").strip().lower()
s = re.sub(r"[^\w\s\-]+", "", s, flags=re.UNICODE)
s = re.sub(r"[\s\-]+", "-", s).strip("-")
return s or "section"
def _escape(s: str) -> str:
return html.escape(s or "", quote=False)
_RE_MD_LINK = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
_RE_MD_IMAGE = re.compile(r"!\[([^\]]*)\]\(([^)]+)\)")
def _rel_from_docs(url: str) -> str:
"""
Rewrite a relative URL that appears in docs/*.md to a URL that works from the
repo root (where user_guide.html is generated).
"""
u = (url or "").strip()
if not u:
return u
if u.startswith("#"):
return u
if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", u): # http:, https:, mailto:, etc.
return u
if u.startswith("/"):
return u
parsed = urllib.parse.urlsplit(u)
path = (parsed.path or "").replace("\\", "/")
joined = posixpath.normpath(posixpath.join("docs", path))
if joined == ".":
joined = "docs"
return urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, joined, parsed.query, parsed.fragment))
def _inline_md_to_html(text: str, *, doc_prefix: str, doc_id_by_md_name: dict[str, str]) -> str:
# Escape first, then re-inject inline constructs.
s = _escape(text)
# Inline code: `...`
# Use a conservative approach: only backticks on the same line.
def repl_code(m: re.Match) -> str:
return f'<code>{_escape(m.group(1))}</code>'
s = re.sub(r"`([^`]+)`", repl_code, s)
# Images: ![alt](url)
def repl_image(m: re.Match) -> str:
alt = (m.group(1) or "").strip()
url = (m.group(2) or "").strip()
safe_url = url
parsed = urllib.parse.urlsplit(url)
path = (parsed.path or "").replace("\\", "/")
md_name = Path(path).name if path else ""
if md_name not in doc_id_by_md_name:
safe_url = _rel_from_docs(url)
# User guide preference: do not render images. Keep a clickable link instead.
safe_attr = html.escape(safe_url, quote=True)
label = alt or "image"
return f'<a href="{safe_attr}" target="_blank" rel="noopener noreferrer">[{_escape(label)}]</a>'
s = _RE_MD_IMAGE.sub(repl_image, s)
# Links: [text](url)
def repl_link(m: re.Match) -> str:
label = m.group(1)
url = m.group(2).strip()
# Intra-doc anchors: (#heading)
if url.startswith("#"):
anchor = f"#{doc_prefix}-{_slugify(url[1:])}"
return f'<a href="{html.escape(anchor, quote=True)}">{_escape(label)}</a>'
parsed = urllib.parse.urlsplit(url)
path = (parsed.path or "").replace("\\", "/")
frag = parsed.fragment or ""
# Cross-doc md links inside docs/: map to in-page sections.
md_name = Path(path).name if path else ""
if md_name in doc_id_by_md_name:
doc_id = doc_id_by_md_name[md_name]
if frag:
anchor = f"#{doc_id}-{_slugify(frag)}"
else:
anchor = f"#{doc_id}"
return f'<a href="{html.escape(anchor, quote=True)}">{_escape(label)}</a>'
# External links: open in a new tab.
if re.match(r"^https?://", url, flags=re.IGNORECASE):
safe_url = html.escape(url, quote=True)
return f'<a href="{safe_url}" target="_blank" rel="noopener noreferrer">{_escape(label)}</a>'
# Other relative links (README, scripts, etc.): rewrite relative to docs/.
safe_url = html.escape(_rel_from_docs(url), quote=True)
return f'<a href="{safe_url}">{_escape(label)}</a>'
s = _RE_MD_LINK.sub(repl_link, s)
# Bold / italic (basic)
s = re.sub(r"\*\*([^*]+)\*\*", r"<strong>\1</strong>", s)
s = re.sub(r"\*([^*]+)\*", r"<em>\1</em>", s)
return s
def _is_hr(line: str) -> bool:
t = line.strip()
return t in {"---", "***", "___"}
def _looks_like_table_header(line: str, next_line: str) -> bool:
if "|" not in line:
return False
if "|" not in next_line:
return False
sep = next_line.strip()
# Typical md separator: | --- | ---: |
return bool(re.fullmatch(r"\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?", sep))
def _split_table_row(line: str) -> list[str]:
# Strip outer pipes if present.
t = line.strip()
if t.startswith("|"):
t = t[1:]
if t.endswith("|"):
t = t[:-1]
return [c.strip() for c in t.split("|")]
@dataclass
class Doc:
file: str
title: str
desc: str
def _docs_from_index(index_md: str) -> list[Doc]:
docs: list[Doc] = []
for line in index_md.splitlines():
m = re.match(r"^\-\s+\[([^\]]+)\]\(([^)]+\.md)\)\s+\-\s+(.*)$", line.strip())
if not m:
continue
title = m.group(1).strip()
file = m.group(2).strip()
desc = m.group(3).strip()
docs.append(Doc(file=file, title=title, desc=desc))
return docs
def _close_lists(list_stack: list[tuple[str, int]], out: list[str], to_indent: int = -1) -> None:
while list_stack and (to_indent < 0 or list_stack[-1][1] >= to_indent):
tag, _ = list_stack.pop()
out.append(f"</{tag}>")
def _flush_paragraph(
buf: list[str], out: list[str], *, doc_prefix: str, doc_id_by_md_name: dict[str, str]
) -> None:
if not buf:
return
text = " ".join(x.strip() for x in buf).strip()
if text:
out.append(f"<p>{_inline_md_to_html(text, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}</p>")
buf.clear()
def _collect_table_rows(lines: list[str], i: int) -> tuple[list[list[str]], int]:
body_rows: list[list[str]] = []
while i < len(lines) and lines[i].strip() and "|" in lines[i]:
body_rows.append(_split_table_row(lines[i]))
i += 1
return body_rows, i
def _sync_list_stack(
list_stack: list[tuple[str, int]], out: list[str], tag: str, indent: int
) -> None:
if not list_stack:
list_stack.append((tag, indent))
out.append(f"<{tag}>")
return
_cur_tag, cur_indent = list_stack[-1]
if indent > cur_indent:
list_stack.append((tag, indent))
out.append(f"<{tag}>")
return
while list_stack and indent < list_stack[-1][1]:
_close_lists(list_stack, out, to_indent=list_stack[-1][1])
if list_stack and list_stack[-1][0] != tag:
_close_lists(list_stack, out, to_indent=list_stack[-1][1])
list_stack.append((tag, indent))
out.append(f"<{tag}>")
def _md_to_html(md: str, *, doc_prefix: str, doc_id_by_md_name: dict[str, str]) -> str:
out: list[str] = []
lines = md.replace("\r\n", "\n").split("\n")
in_code = False
code_lang = ""
code_lines: list[str] = []
list_stack: list[tuple[str, int]] = [] # (ul|ol, indent)
para: list[str] = []
i = 0
while i < len(lines):
line = lines[i]
# Fenced code block
if line.strip().startswith("```"):
_flush_paragraph(para, out, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
_close_lists(list_stack, out)
if not in_code:
in_code = True
code_lang = line.strip().lstrip("`").strip()
code_lines = []
else:
in_code = False
lang_attr = f' class="language-{_escape(code_lang)}"' if code_lang else ""
code_content = _escape('\n'.join(code_lines))
out.append(f"<pre><code{lang_attr}>{code_content}</code></pre>")
code_lang = ""
code_lines = []
i += 1
continue
if in_code:
code_lines.append(line)
i += 1
continue
if not line.strip():
_flush_paragraph(para, out, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
_close_lists(list_stack, out)
i += 1
continue
if _is_hr(line):
_flush_paragraph(para, out, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
_close_lists(list_stack, out)
out.append("<hr />")
i += 1
continue
# Tables
if i + 1 < len(lines) and _looks_like_table_header(line, lines[i + 1]):
_flush_paragraph(para, out, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
_close_lists(list_stack, out)
header_cells = _split_table_row(line)
body_rows, i = _collect_table_rows(lines, i + 2)
out.append('<div class="table-wrap"><table>')
out.append(
"<thead><tr>"
+ "".join(f"<th>{_inline_md_to_html(c, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}</th>" for c in header_cells)
+ "</tr></thead>"
)
out.append("<tbody>")
for r in body_rows:
out.append(
"<tr>"
+ "".join(f"<td>{_inline_md_to_html(c, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}</td>" for c in r)
+ "</tr>"
)
out.append("</tbody></table></div>")
continue
# Headings
mh = re.match(r"^(#{1,6})\s+(.*)$", line)
if mh:
_flush_paragraph(para, out, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
_close_lists(list_stack, out)
level = len(mh.group(1))
text = mh.group(2).strip()
hid = f"{doc_prefix}-{_slugify(text)}"
out.append(
f'<h{level} id="{_escape(hid)}">'
f"{_inline_md_to_html(text, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}"
f"</h{level}>"
)
i += 1
continue
# Lists (basic nested)
ml = re.match(r"^(\s*)([-*+])\s+(.*)$", line)
mol = re.match(r"^(\s*)(\d+)\.\s+(.*)$", line)
m_list = ml or mol
if m_list:
_flush_paragraph(para, out, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
indent = len(m_list.group(1).replace("\t", " "))
tag = "ul" if ml else "ol"
item_text = m_list.group(3).strip()
_sync_list_stack(list_stack, out, tag, indent)
out.append(f"<li>{_inline_md_to_html(item_text, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}</li>")
i += 1
continue
# Blockquote (single line)
if line.lstrip().startswith(">"):
_flush_paragraph(para, out, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
_close_lists(list_stack, out)
out.append(
f'<blockquote>{_inline_md_to_html(line.lstrip()[1:].strip(), doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}</blockquote>'
)
i += 1
continue
# Default: paragraph continuation
para.append(line)
i += 1
_flush_paragraph(para, out, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
_close_lists(list_stack, out)
return "\n".join(out)
def _render_html_page(title: str, docs: list[Doc], rendered_sections: list[str]) -> str:
# "Classic" layout (top bar + sticky nav), closer to the original HTML.
css = r"""
:root{
--bg: #0f1115;
--panel: rgba(21,25,35,0.92);
--text: #e7eaf0;
--muted: rgba(231,234,240,0.68);
--border: rgba(231,234,240,0.10);
--accent: #5fb3ff;
--shadow: 0 18px 45px rgba(0,0,0,0.35);
--radius: 14px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
}
*{ box-sizing:border-box; }
html,body{ height:100%; }
body{
margin:0;
font-family: var(--sans);
color: var(--text);
background:
radial-gradient(1200px 600px at 20% -20%, rgba(95,179,255,0.18), transparent 60%),
radial-gradient(900px 550px at 100% 0%, rgba(57,217,138,0.12), transparent 55%),
var(--bg);
}
a{ color: var(--accent); text-decoration:none; }
a:hover{ text-decoration: underline; }
mark{ background: rgba(95,179,255,0.22); color: rgba(231,234,240,0.96); padding: 0 2px; border-radius: 4px; }
code{ font-family: var(--mono); font-size: 12px; padding: 2px 6px; border-radius: 8px; border: 1px solid rgba(231,234,240,0.10); background: rgba(16,20,29,0.70); color: rgba(231,234,240,0.92); }
pre{ margin: 10px 0; padding: 12px 12px; border-radius: 12px; border: 1px solid rgba(231,234,240,0.10); background: rgba(16,20,29,0.90); overflow:auto; box-shadow: inset 0 0 0 1px rgba(0,0,0,0.18); }
pre code{ padding:0; border:0; background: transparent; font-size: 12px; }
hr{ border:0; border-top: 1px solid rgba(231,234,240,0.10); margin: 18px 0; }
blockquote{ margin: 10px 0; padding: 10px 12px; border-left: 3px solid rgba(95,179,255,0.55); background: rgba(95,179,255,0.06); border-radius: 12px; color: rgba(231,234,240,0.88); }
.table-wrap{ overflow:auto; border-radius: 12px; border: 1px solid rgba(231,234,240,0.10); background: rgba(16,20,29,0.55); }
table{ width:100%; border-collapse: collapse; }
th, td{ padding: 9px 10px; vertical-align: top; border-top: 1px solid rgba(231,234,240,0.08); }
th{ text-align:left; font-size: 12px; color: var(--muted); font-weight: 700; border-top: 0; }
.wrap{ max-width: 1180px; margin:0 auto; padding: 22px 16px 70px; }
.top{
display:flex; gap:14px; align-items:flex-end; justify-content:space-between;
padding: 16px 18px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.02));
box-shadow: var(--shadow);
}
.brand{ display:flex; gap: 12px; align-items:center; }
.logo{
width: 36px; height: 36px;
border-radius: 12px;
border: 1px solid rgba(231,234,240,0.12);
background:
radial-gradient(120px 70px at 30% 20%, rgba(95,179,255,0.25), transparent 55%),
rgba(0,0,0,0.25);
display:flex; align-items:center; justify-content:center;
font-size: 18px;
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.15);
}
.logo.icon{ background-image: url('ressources/icon.png'); background-size: cover; background-position: center; font-size: 0; }
.title{ font-size: 20px; font-weight: 800; letter-spacing: 0.2px; }
.subtitle{ color: var(--muted); font-size: 12px; margin-top: 6px; line-height: 1.4; }
.pillrow{ display:flex; gap:10px; flex-wrap:wrap; justify-content:flex-end; }
.pill{
display:inline-flex; gap: 8px; align-items:center;
padding: 7px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255,255,255,0.03);
font-size: 12px;
color: var(--muted);
}
.dot{ width: 9px; height: 9px; border-radius: 999px; background: var(--accent); box-shadow: 0 0 0 3px rgba(95,179,255,0.14); }
.nav{
margin-top: 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: rgba(21,25,35,0.75);
box-shadow: var(--shadow);
padding: 10px 10px;
position: sticky;
top: 10px;
z-index: 5;
}
.navrow{ display:flex; align-items:center; justify-content:space-between; gap: 10px; flex-wrap:wrap; }
.search{ flex: 1 1 280px; min-width: 220px; padding: 9px 10px; border-radius: 999px; border: 1px solid rgba(231,234,240,0.12); background: rgba(16,20,29,0.60); color: rgba(231,234,240,0.92); outline:none; }
.search:focus{ border-color: rgba(95,179,255,0.45); box-shadow: 0 0 0 3px rgba(95,179,255,0.12); }
.navlinks{ display:flex; gap: 8px; flex-wrap:wrap; align-items:center; }
.matches{ color: var(--muted); font-size: 12px; padding: 4px 8px; border-radius: 999px; border: 1px solid rgba(231,234,240,0.10); background: rgba(255,255,255,0.02); }
.nav a{
display:inline-block;
padding: 8px 10px;
border-radius: 999px;
border: 1px solid rgba(231,234,240,0.10);
background: rgba(255,255,255,0.02);
color: rgba(231,234,240,0.86);
font-size: 12px;
white-space: nowrap;
}
.nav a:hover{
border-color: rgba(95,179,255,0.35);
background: rgba(95,179,255,0.10);
text-decoration:none;
}
.grid{ display:grid; grid-template-columns: 1fr; gap: 14px; margin-top: 14px; }
.panel{
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--panel);
box-shadow: var(--shadow);
overflow:hidden;
}
.panel-h{
padding: 12px 14px;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.01));
display:flex; align-items:center; justify-content:space-between; gap: 10px;
}
.panel-h h2{
margin:0;
font-size: 14px;
letter-spacing: 0.2px;
font-weight: 800;
color: rgba(231,234,240,0.92);
}
.panel-b{ padding: 12px 14px 14px; color: rgba(231,234,240,0.90); }
.panel-b p{ margin: 10px 0; color: rgba(231,234,240,0.86); line-height: 1.55; }
.panel-b ul, .panel-b ol{ margin: 10px 0 10px 18px; color: rgba(231,234,240,0.86); }
.panel-b li{ margin: 6px 0; }
.section{ scroll-margin-top: 12px; }
.section h1{ font-size: 20px; margin: 6px 0 10px; }
.section h2{ font-size: 17px; margin: 18px 0 10px; }
.section h3{ font-size: 14px; margin: 16px 0 8px; color: rgba(231,234,240,0.92); }
.section h4{ font-size: 13px; margin: 14px 0 6px; color: rgba(231,234,240,0.88); }
.toplink{ display:inline-block; margin-top: 10px; font-size: 12px; color: var(--muted); }
.toplink:hover{ color: rgba(231,234,240,0.90); text-decoration:none; }
"""
nav_links: list[str] = ['<a class="navlink" href="#top" data-text="top">Top</a>']
for d in docs:
doc_id = _slugify(Path(d.file).stem)
nav_links.append(
f'<a class="navlink" href="#{html.escape(doc_id)}" data-text="{html.escape((d.title + " " + d.desc).lower())}">{html.escape(d.title)}</a>'
)
js = r"""
(function(){
const q = document.getElementById('q');
const links = Array.from(document.querySelectorAll('.navlink'));
const panels = Array.from(document.querySelectorAll('section.panel.section'));
const matchesEl = document.getElementById('matches');
function escapeRegExp(s){
return (s || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function getTokens(){
const raw = (q && q.value ? q.value : '').trim();
if(!raw) return [];
return raw.split(/\s+/g).filter(Boolean);
}
function makeWordRegex(tokens){
if(!tokens.length) return null;
// Note: JS \b word-boundary is based on [A-Za-z0-9_]. Good enough for typical tags/words.
const parts = tokens.map(t => escapeRegExp(t));
return new RegExp('\\b(?:' + parts.join('|') + ')\\b', 'gi');
}
function panelMatchesAllTokens(panelText, tokens){
if(!tokens.length) return true;
const text = (panelText || '').toLowerCase();
for(const tok of tokens){
// Whole-word match using a boundary approximation.
const r = new RegExp('\\b' + escapeRegExp(tok.toLowerCase()) + '\\b', 'i');
if(!r.test(text)) return false;
}
return true;
}
function clearMarks(root){
const marks = root.querySelectorAll('mark');
for(const m of marks){
const t = document.createTextNode(m.textContent || '');
m.replaceWith(t);
}
}
function markMatches(root, tokens){
const rx = makeWordRegex(tokens);
if(!rx) return 0;
let count = 0;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node){
if(!node.nodeValue) return NodeFilter.FILTER_REJECT;
const p = node.parentElement;
if(!p) return NodeFilter.FILTER_REJECT;
if(p.closest('pre, code, script, style')) return NodeFilter.FILTER_REJECT;
if(!(p.closest('p, li, blockquote, h1, h2, h3, h4, h5, h6'))) return NodeFilter.FILTER_REJECT;
rx.lastIndex = 0;
return rx.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
}
});
const toProcess = [];
while(walker.nextNode()){
toProcess.push(walker.currentNode);
if(toProcess.length > 350) break; // safety
}
for(const node of toProcess){
const text = node.nodeValue || '';
rx.lastIndex = 0;
let m;
let last = 0;
const frag = document.createDocumentFragment();
let changed = false;
while((m = rx.exec(text)) !== null){
const start = m.index;
const end = start + (m[0] ? m[0].length : 0);
if(end <= start) break;
if(start > last) frag.appendChild(document.createTextNode(text.slice(last, start)));
const hit = document.createElement('mark');
hit.textContent = text.slice(start, end);
frag.appendChild(hit);
count += 1;
changed = true;
last = end;
if(count > 800) break;
}
if(changed){
if(last < text.length) frag.appendChild(document.createTextNode(text.slice(last)));
node.parentNode.replaceChild(frag, node);
}
if(count > 800) break;
}
return count;
}
function applyFilter(){
const tokens = getTokens();
let visiblePanels = 0;
let marked = 0;
for(const p of panels){
clearMarks(p);
const t = (p.textContent || '');
const ok = panelMatchesAllTokens(t, tokens);
p.style.display = ok ? '' : 'none';
if(ok){
visiblePanels += 1;
if(tokens.length) marked += markMatches(p, tokens);
}
}
for(const a of links){
const href = a.getAttribute('href') || '';
const id = href.startsWith('#') ? href.slice(1) : '';
if(!tokens.length || id === 'top'){
a.style.display = '';
continue;
}
const target = id ? document.getElementById(id) : null;
a.style.display = (target && target.style.display !== 'none') ? '' : 'none';
}
if(matchesEl){
matchesEl.textContent = tokens.length ? ('Matches: ' + marked + ' • Sections: ' + visiblePanels) : ('Sections: ' + panels.length);
}
}
if(q){
q.addEventListener('input', () => {
applyFilter();
});
}
for(const a of links){
a.addEventListener('click', (e) => {
const href = a.getAttribute('href') || '';
if(href.startsWith('#')){
const id = href.slice(1);
const el = document.getElementById(id);
if(el){
e.preventDefault();
el.scrollIntoView({behavior:'smooth', block:'start'});
history.replaceState(null, '', href);
}
}
});
}
// Allow ?q=... deep-linking
try{
const u = new URL(window.location.href);
const qq = (u.searchParams.get('q') || '').trim();
if(q && qq){
q.value = qq;
}
}catch(_){}
applyFilter();
})();
"""
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<title>{html.escape(title)}</title>
<style>{css}</style>
</head>
<body>
<div class="wrap">
<header class="top" id="top">
<div class="brand">
<div class="logo icon" aria-hidden="true"> </div>
<div>
<div class="title">🗂️ Majoor Assets Manager — User Guide</div>
<div class="subtitle">Generated from <code>docs/</code> for a readable single-page experience.</div>
</div>
</div>
<div class="pillrow">
<div class="pill"><span class="dot"></span> Docs: {len(docs)}</div>
<div class="pill"><span class="dot"></span> Updated: January 2026</div>
</div>
</header>
<nav class="nav">
<div class="navrow">
<input id="q" class="search" placeholder="Search (filters + highlights)..." />
<div id="matches" class="matches">Sections: {len(docs) + 1}</div>
<div class="navlinks">
{''.join(nav_links)}
</div>
</div>
</nav>
<main class="grid">
<section class="panel section" id="intro">
<div class="panel-h"><h2>Introduction</h2></div>
<div class="panel-b">
<p>Tip: edit <code>docs/DOCUMENTATION_INDEX.md</code> to change the curated order, then regenerate with <code>python scripts/build_user_guide_html.py</code>.</p>
</div>
</section>
{''.join(rendered_sections)}
<div style="text-align:center; color: var(--muted); font-size: 12px; margin-top: 10px;">
Majoor Assets Manager • Documentation • January 2026
</div>
</main>
</div>
<script>{js}</script>
</body>
</html>
"""
def build(out_path: Path) -> None:
tracked_docs = _tracked_doc_names()
index_md = _read_text(DOCS_DIR / "DOCUMENTATION_INDEX.md")
curated: list[Doc] = [
Doc(file="DOCUMENTATION_INDEX.md", title="DOCUMENTATION_INDEX.md", desc="Documentation index (curated list)"),
]
curated.extend(
d for d in _docs_from_index(index_md) if tracked_docs is None or Path(d.file).name in tracked_docs
)
# Also include non-index docs at the end (e.g. audits) for completeness.
indexed = {d.file for d in curated}
extra_md = sorted(
[
p.name
for p in DOCS_DIR.glob("*.md")
if p.name not in indexed
and p.name != "DOCUMENTATION_INDEX.md"
and (tracked_docs is None or p.name in tracked_docs)
]
)
for name in extra_md:
curated.append(Doc(file=name, title=name, desc="Additional documentation"))
doc_id_by_md_name = {Path(d.file).name: _slugify(Path(d.file).stem) for d in curated}
rendered_sections: list[str] = []
for d in curated:
md_path = DOCS_DIR / d.file
if not md_path.exists():
continue
doc_id = _slugify(Path(d.file).stem)
md = _read_text(md_path)
doc_prefix = doc_id
body = _md_to_html(md, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)
rendered_sections.append(
f"""
<section class="panel section" id="{html.escape(doc_id)}">
<div class="panel-h"><h2>{html.escape(d.title)}</h2></div>
<div class="panel-b">
<div style="color: var(--muted); font-size: 12px; margin-bottom: 10px;">{html.escape(d.desc)}</div>
<div class="section">{body}</div>
<a class="toplink" href="#top">Back to top</a>
<div style="margin-top: 10px; font-size: 12px; color: var(--muted);">
Source: <code>docs/{html.escape(d.file)}</code>
</div>
</div>
</section>
"""
)
page = _render_html_page("Majoor Assets Manager - User Guide", curated, rendered_sections)
out_path.write_text(page, encoding="utf-8", newline="\n")
def main(argv: list[str]) -> int:
p = argparse.ArgumentParser(description="Build the single-page HTML user guide from docs/*.md")
p.add_argument("--out", type=str, default=str(REPO_ROOT / "user_guide.html"), help="Output HTML path")
args = p.parse_args(argv)
out_path = Path(args.out)
out_path.parent.mkdir(parents=True, exist_ok=True)
build(out_path)
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))