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'{_escape(m.group(1))}' 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'[{_escape(label)}]' 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'{_escape(label)}' 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'{_escape(label)}' # 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'{_escape(label)}' # Other relative links (README, scripts, etc.): rewrite relative to docs/. safe_url = html.escape(_rel_from_docs(url), quote=True) return f'{_escape(label)}' s = _RE_MD_LINK.sub(repl_link, s) # Bold / italic (basic) s = re.sub(r"\*\*([^*]+)\*\*", r"\1", s) s = re.sub(r"\*([^*]+)\*", r"\1", 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"") 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"

{_inline_md_to_html(text, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}

") 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"
{code_content}
") 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("
") 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('
') out.append( "" + "".join(f"" for c in header_cells) + "" ) out.append("") for r in body_rows: out.append( "" + "".join(f"" for c in r) + "" ) out.append("
{_inline_md_to_html(c, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}
{_inline_md_to_html(c, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}
") 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'' f"{_inline_md_to_html(text, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}" f"" ) 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"
  • {_inline_md_to_html(item_text, doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}
  • ") 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'
    {_inline_md_to_html(line.lstrip()[1:].strip(), doc_prefix=doc_prefix, doc_id_by_md_name=doc_id_by_md_name)}
    ' ) 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] = ['Top'] for d in docs: doc_id = _slugify(Path(d.file).stem) nav_links.append( f'{html.escape(d.title)}' ) 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""" {html.escape(title)}
    🗂️ Majoor Assets Manager — User Guide
    Generated from docs/ for a readable single-page experience.
    Docs: {len(docs)}
    Updated: January 2026

    Introduction

    Tip: edit docs/DOCUMENTATION_INDEX.md to change the curated order, then regenerate with python scripts/build_user_guide_html.py.

    {''.join(rendered_sections)}
    Majoor Assets Manager • Documentation • January 2026
    """ 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"""

    {html.escape(d.title)}

    {html.escape(d.desc)}
    {body}
    Back to top
    Source: docs/{html.escape(d.file)}
    """ ) 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:]))