From ced6cbabdb86c968aeb8848f914318b4a413edf3 Mon Sep 17 00:00:00 2001 From: Grivn Date: Tue, 19 May 2026 16:22:09 +0000 Subject: [PATCH] feat(docs): add rendered docs navigation Add a lightweight Python renderer that serves docs/ Markdown as HTML without generating checked-in HTML. The site root now acts as a developer navigation page, while the docs tree exposes Markdown, diagrams, and the existing visual pages through docs/site/*/index.html. Update harness links to point at the new index.html visualization paths and ignore Python bytecode caches. Validation: python3 -m py_compile docs/server/render_docs.py; relative docs link check reported missing=0. --- .gitignore | 4 + docs/harness/README.md | 4 +- docs/harness/memory-loop/DESIGN.md | 2 +- docs/harness/skill-loop/DESIGN.md | 2 +- docs/server/render_docs.py | 455 ++++++++++++++++++ docs/site/index.html | 183 +++++++ .../memory-loop/{site.html => index.html} | 0 .../site/skill-loop/{site.html => index.html} | 0 docs/zh/harness/README.md | 4 +- docs/zh/harness/memory-loop/DESIGN.md | 2 +- docs/zh/harness/skill-loop/DESIGN.md | 2 +- 11 files changed, 650 insertions(+), 8 deletions(-) create mode 100644 docs/server/render_docs.py create mode 100644 docs/site/index.html rename docs/site/memory-loop/{site.html => index.html} (100%) rename docs/site/skill-loop/{site.html => index.html} (100%) diff --git a/.gitignore b/.gitignore index 3d00d81..bd232f2 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,10 @@ Thumbs.db # Go vendor/ +# Python +__pycache__/ +*.py[cod] + # Mnemon data (local dev) *.db *.db-wal diff --git a/docs/harness/README.md b/docs/harness/README.md index 57e5244..623de78 100644 --- a/docs/harness/README.md +++ b/docs/harness/README.md @@ -28,8 +28,8 @@ projection into host surfaces, and optional daemon scheduling. | Loop Module Standard | [EN](LOOP_MODULE_STANDARD.md) / [中文](../zh/harness/LOOP_MODULE_STANDARD.md) | | Host Projection | [EN](HOST_PROJECTION.md) / [中文](../zh/harness/HOST_PROJECTION.md) | | Harness Roadmap | [EN](ROADMAP.md) / [中文](../zh/harness/ROADMAP.md) | -| Memory Loop | [EN](memory-loop/DESIGN.md) / [中文](../zh/harness/memory-loop/DESIGN.md) / [site](../site/memory-loop/site.html) | -| Skill Loop | [EN](skill-loop/DESIGN.md) / [中文](../zh/harness/skill-loop/DESIGN.md) / [site](../site/skill-loop/site.html) | +| Memory Loop | [EN](memory-loop/DESIGN.md) / [中文](../zh/harness/memory-loop/DESIGN.md) / [site](../site/memory-loop/index.html) | +| Skill Loop | [EN](skill-loop/DESIGN.md) / [中文](../zh/harness/skill-loop/DESIGN.md) / [site](../site/skill-loop/index.html) | | Eval Loop | [EN](eval-loop/DESIGN.md) / [中文](../zh/harness/eval-loop/DESIGN.md) | ## Installable Assets diff --git a/docs/harness/memory-loop/DESIGN.md b/docs/harness/memory-loop/DESIGN.md index ebc64b3..8354fda 100644 --- a/docs/harness/memory-loop/DESIGN.md +++ b/docs/harness/memory-loop/DESIGN.md @@ -1,6 +1,6 @@ # Memory Loop MVP Design -Related visualization: [site.html](../../site/memory-loop/site.html) +Related visualization: [memory-loop](../../site/memory-loop/index.html) Chinese version: [DESIGN.md](../../zh/harness/memory-loop/DESIGN.md) diff --git a/docs/harness/skill-loop/DESIGN.md b/docs/harness/skill-loop/DESIGN.md index 923bde9..9bc6acd 100644 --- a/docs/harness/skill-loop/DESIGN.md +++ b/docs/harness/skill-loop/DESIGN.md @@ -1,6 +1,6 @@ # Skill Loop MVP Design -Related visualization: [site.html](../../site/skill-loop/site.html) +Related visualization: [skill-loop](../../site/skill-loop/index.html) Installable MVP assets: [harness/modules/skill-loop](../../../harness/modules/skill-loop/README.md) diff --git a/docs/server/render_docs.py b/docs/server/render_docs.py new file mode 100644 index 0000000..764e81b --- /dev/null +++ b/docs/server/render_docs.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +"""Serve docs/ as rendered Markdown without generating HTML files.""" + +from __future__ import annotations + +import html +import mimetypes +import os +import re +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import quote, unquote, urlparse + + +ROOT = Path(__file__).resolve().parents[1] +PREFIX = "/docs" +HIDDEN_TOP_LEVEL = {"server"} +PORT = int(os.environ.get("MNEMON_DOCS_RENDERER_PORT", "4180")) + + +CSS = """ +:root { + color-scheme: light; + --bg: #f6f8fb; + --ink: #151922; + --muted: #667085; + --line: #d9e0ea; + --panel: #ffffff; + --accent: #2f7d55; +} +* { box-sizing: border-box; } +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + line-height: 1.65; +} +a { color: #1d5f91; } +.topbar { + position: sticky; + top: 0; + z-index: 2; + display: flex; + justify-content: space-between; + gap: 18px; + padding: 14px 24px; + border-bottom: 1px solid var(--line); + background: rgba(246, 248, 251, 0.96); +} +.brand { + color: var(--ink); + font-weight: 800; + text-decoration: none; +} +nav { + display: flex; + flex-wrap: wrap; + gap: 14px; + font-size: 14px; +} +.page { + width: min(980px, calc(100vw - 32px)); + margin: 0 auto; + padding: 34px 0 72px; +} +h1, h2, h3, h4, h5, h6 { + line-height: 1.2; + letter-spacing: 0; +} +h1 { + margin: 0 0 20px; + font-size: clamp(32px, 5vw, 56px); +} +h2 { + margin-top: 36px; + padding-top: 10px; + border-top: 1px solid var(--line); +} +p, li { max-width: 82ch; } +pre { + overflow-x: auto; + padding: 16px; + border: 1px solid var(--line); + border-radius: 8px; + background: #111827; + color: #f8fafc; +} +code { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; +} +p code, li code, td code { + padding: 2px 5px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--panel); +} +blockquote { + margin-left: 0; + padding: 10px 16px; + border-left: 4px solid var(--accent); + color: var(--muted); + background: var(--panel); +} +table { + display: block; + width: 100%; + overflow-x: auto; + border-collapse: collapse; +} +th, td { + padding: 8px 10px; + border: 1px solid var(--line); + text-align: left; + vertical-align: top; +} +th { background: var(--panel); } +img { + max-width: 100%; + height: auto; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); +} +.listing { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + padding: 0; + list-style: none; +} +.listing a { + display: block; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + text-decoration: none; +} +@media (max-width: 720px) { + .topbar { align-items: flex-start; flex-direction: column; } +} +""".strip() + + +def is_hidden(path: Path) -> bool: + try: + rel = path.relative_to(ROOT) + except ValueError: + return True + return bool(rel.parts and rel.parts[0] in HIDDEN_TOP_LEVEL) + + +def resolve_request(path: str) -> Path | None: + if path == PREFIX: + path = PREFIX + "/" + if not path.startswith(PREFIX + "/"): + return None + rel = unquote(path[len(PREFIX) + 1 :]) + target = (ROOT / rel).resolve() + try: + target.relative_to(ROOT) + except ValueError: + return None + if not target.exists() and target.suffix == "": + markdown_target = target.with_suffix(".md") + try: + markdown_target.relative_to(ROOT) + except ValueError: + return None + if markdown_target.exists(): + target = markdown_target + if is_hidden(target): + return None + return target + + +def slugify(text: str) -> str: + slug = re.sub(r"[^\w\u4e00-\u9fff -]+", "", text.lower()).strip() + slug = re.sub(r"\s+", "-", slug) + return slug or "section" + + +def rewrite_link(href: str, source: Path) -> str: + if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", href) or href.startswith("#"): + return html.escape(href, quote=True) + target, marker, fragment = href.partition("#") + resolved = (source.parent / unquote(target)).resolve() + try: + rel = resolved.relative_to(ROOT) + except ValueError: + return "#" + + rewritten = PREFIX + "/" + quote(str(rel)) + return html.escape(rewritten + (marker + fragment if marker else ""), quote=True) + + +def inline(text: str, source: Path) -> str: + text = html.escape(text) + text = re.sub(r"`([^`]+)`", r"\1", text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"__([^_]+)__", r"\1", text) + text = re.sub(r"\*([^*]+)\*", r"\1", text) + text = re.sub(r"_([^_]+)_", r"\1", text) + + def image(match: re.Match[str]) -> str: + alt = match.group(1) + href = rewrite_link(match.group(2), source) + return f'{alt}' + + def link(match: re.Match[str]) -> str: + label = match.group(1) + href = rewrite_link(match.group(2), source) + return f'{label}' + + text = re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", image, text) + text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", link, text) + return text + + +def is_table_separator(line: str) -> bool: + cells = [cell.strip() for cell in line.strip().strip("|").split("|")] + return bool(cells) and all(re.fullmatch(r":?-{3,}:?", cell or "") for cell in cells) + + +def render_table(rows: list[str], source: Path) -> str: + parsed = [[cell.strip() for cell in row.strip().strip("|").split("|")] for row in rows] + head = parsed[0] + body = parsed[2:] if len(parsed) > 2 and is_table_separator(rows[1]) else parsed[1:] + out = ["", ""] + out.extend(f"" for cell in head) + out.append("") + if body: + out.append("") + for row in body: + out.append("") + out.extend(f"" for cell in row) + out.append("") + out.append("") + out.append("
{inline(cell, source)}
{inline(cell, source)}
") + return "\n".join(out) + + +def render_markdown(text: str, source: Path) -> tuple[str, str]: + lines = text.splitlines() + html_lines: list[str] = [] + title = source.stem + in_code = False + paragraph: list[str] = [] + list_type: str | None = None + blockquote: list[str] = [] + table_rows: list[str] = [] + + def flush_paragraph() -> None: + nonlocal paragraph + if paragraph: + html_lines.append(f"

{inline(' '.join(paragraph), source)}

") + paragraph = [] + + def flush_list() -> None: + nonlocal list_type + if list_type: + html_lines.append(f"") + list_type = None + + def flush_blockquote() -> None: + nonlocal blockquote + if blockquote: + html_lines.append(f"
{inline(' '.join(blockquote), source)}
") + blockquote = [] + + def flush_table() -> None: + nonlocal table_rows + if table_rows: + html_lines.append(render_table(table_rows, source)) + table_rows = [] + + for raw in lines: + line = raw.rstrip() + fence = re.match(r"^```(\w+)?\s*$", line) + if fence: + flush_paragraph() + flush_list() + flush_blockquote() + flush_table() + if in_code: + html_lines.append("") + in_code = False + else: + lang = fence.group(1) or "" + cls = f' class="language-{html.escape(lang)}"' if lang else "" + html_lines.append(f"
")
+                in_code = True
+            continue
+
+        if in_code:
+            html_lines.append(html.escape(raw))
+            continue
+
+        if not line.strip():
+            flush_paragraph()
+            flush_list()
+            flush_blockquote()
+            flush_table()
+            continue
+
+        if "|" in line and line.strip().startswith("|"):
+            flush_paragraph()
+            flush_list()
+            flush_blockquote()
+            table_rows.append(line)
+            continue
+        flush_table()
+
+        heading = re.match(r"^(#{1,6})\s+(.+)$", line)
+        if heading:
+            flush_paragraph()
+            flush_list()
+            flush_blockquote()
+            level = len(heading.group(1))
+            body = heading.group(2).strip()
+            if title == source.stem and level == 1:
+                title = re.sub(r"`", "", body)
+            anchor = slugify(body)
+            html_lines.append(f'{inline(body, source)}')
+            continue
+
+        quote = re.match(r"^>\s?(.*)$", line)
+        if quote:
+            flush_paragraph()
+            flush_list()
+            blockquote.append(quote.group(1))
+            continue
+
+        item = re.match(r"^[-*]\s+(.+)$", line)
+        ordered = re.match(r"^\d+\.\s+(.+)$", line)
+        if item or ordered:
+            flush_paragraph()
+            flush_blockquote()
+            wanted = "ol" if ordered else "ul"
+            if list_type != wanted:
+                flush_list()
+                html_lines.append(f"<{wanted}>")
+                list_type = wanted
+            html_lines.append(f"
  • {inline((ordered or item).group(1), source)}
  • ") + continue + + flush_list() + flush_blockquote() + paragraph.append(line.strip()) + + flush_paragraph() + flush_list() + flush_blockquote() + flush_table() + if in_code: + html_lines.append("
    ") + return title, "\n".join(html_lines) + + +def page(title: str, body: str) -> bytes: + document = f""" + + + + + {html.escape(title)} - Mnemon Docs + + + +
    + Mnemon Docs + +
    +
    +{body} +
    + + +""" + return document.encode("utf-8") + + +def render_directory(path: Path) -> bytes: + rel = path.relative_to(ROOT) + title = "Documentation Files" if rel == Path(".") else str(rel) + items = [] + if rel != Path("."): + parent = PREFIX + "/" + quote(str(rel.parent)) + "/" + items.append(f'
  • ../
  • ') + for child in sorted(path.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): + if is_hidden(child): + continue + child_rel = child.relative_to(ROOT) + suffix = "/" if child.is_dir() else "" + label = html.escape(child.name + suffix) + href = PREFIX + "/" + quote(str(child_rel)) + suffix + items.append(f'
  • {label}
  • ') + body = f"

    {html.escape(title)}

    \n" + return page(title, body) + + +class Handler(BaseHTTPRequestHandler): + server_version = "MnemonDocsRenderer/1.0" + + def do_HEAD(self) -> None: + self.handle_request(send_body=False) + + def do_GET(self) -> None: + self.handle_request(send_body=True) + + def handle_request(self, send_body: bool) -> None: + parsed = urlparse(self.path) + target = resolve_request(parsed.path) + if target is None or not target.exists(): + self.send_error(HTTPStatus.NOT_FOUND) + return + + if target.is_dir(): + body = render_directory(target) + self.write_response(body, "text/html; charset=utf-8", send_body) + return + + if target.suffix.lower() == ".md": + title, body_html = render_markdown(target.read_text(encoding="utf-8"), target) + self.write_response(page(title, body_html), "text/html; charset=utf-8", send_body) + return + + content_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" + data = target.read_bytes() + self.write_response(data, content_type, send_body) + + def write_response(self, body: bytes, content_type: str, send_body: bool) -> None: + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + if send_body: + self.wfile.write(body) + + def log_message(self, fmt: str, *args: object) -> None: + print(f"{self.address_string()} - {fmt % args}") + + +def main() -> None: + server = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) + print(f"Serving rendered docs on http://127.0.0.1:{PORT}{PREFIX}/") + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/docs/site/index.html b/docs/site/index.html new file mode 100644 index 0000000..21f07a5 --- /dev/null +++ b/docs/site/index.html @@ -0,0 +1,183 @@ + + + + + + + Mnemon Docs + + + +
    +
    +

    Mnemon Private Docs

    +

    Mnemon documentation

    +

    Rendered repository documentation for Mnemon, including Markdown docs, diagrams, and interactive visual references.

    +
    + +
    + + +

    Rendered Markdown is mounted at /docs/ and served dynamically from the repository docs directory.

    +
    +
    + + diff --git a/docs/site/memory-loop/site.html b/docs/site/memory-loop/index.html similarity index 100% rename from docs/site/memory-loop/site.html rename to docs/site/memory-loop/index.html diff --git a/docs/site/skill-loop/site.html b/docs/site/skill-loop/index.html similarity index 100% rename from docs/site/skill-loop/site.html rename to docs/site/skill-loop/index.html diff --git a/docs/zh/harness/README.md b/docs/zh/harness/README.md index 05e6b44..e32e454 100644 --- a/docs/zh/harness/README.md +++ b/docs/zh/harness/README.md @@ -23,8 +23,8 @@ host surface projection,以及可选的 daemon scheduling。 | Loop Module Standard | [中文](LOOP_MODULE_STANDARD.md) / [EN](../../harness/LOOP_MODULE_STANDARD.md) | | Host Projection | [中文](HOST_PROJECTION.md) / [EN](../../harness/HOST_PROJECTION.md) | | Harness Roadmap | [中文](ROADMAP.md) / [EN](../../harness/ROADMAP.md) | -| Memory Loop | [中文](memory-loop/DESIGN.md) / [EN](../../harness/memory-loop/DESIGN.md) / [site](../../site/memory-loop/site.html) | -| Skill Loop | [中文](skill-loop/DESIGN.md) / [EN](../../harness/skill-loop/DESIGN.md) / [site](../../site/skill-loop/site.html) | +| Memory Loop | [中文](memory-loop/DESIGN.md) / [EN](../../harness/memory-loop/DESIGN.md) / [site](../../site/memory-loop/index.html) | +| Skill Loop | [中文](skill-loop/DESIGN.md) / [EN](../../harness/skill-loop/DESIGN.md) / [site](../../site/skill-loop/index.html) | | Eval Loop | [中文](eval-loop/DESIGN.md) / [EN](../../harness/eval-loop/DESIGN.md) | ## 可安装资产 diff --git a/docs/zh/harness/memory-loop/DESIGN.md b/docs/zh/harness/memory-loop/DESIGN.md index 3bc9890..fdf3455 100644 --- a/docs/zh/harness/memory-loop/DESIGN.md +++ b/docs/zh/harness/memory-loop/DESIGN.md @@ -1,6 +1,6 @@ # Memory Loop MVP 设计 -相关可视化页面:[site.html](../../../site/memory-loop/site.html) +相关可视化页面:[memory-loop](../../../site/memory-loop/index.html) 英文版本:[DESIGN.md](../../../harness/memory-loop/DESIGN.md) diff --git a/docs/zh/harness/skill-loop/DESIGN.md b/docs/zh/harness/skill-loop/DESIGN.md index d03de59..a55a23b 100644 --- a/docs/zh/harness/skill-loop/DESIGN.md +++ b/docs/zh/harness/skill-loop/DESIGN.md @@ -1,6 +1,6 @@ # Skill Loop MVP 设计 -相关可视化页面:[site.html](../../../site/skill-loop/site.html) +相关可视化页面:[skill-loop](../../../site/skill-loop/index.html) 英文版本:[DESIGN.md](../../../harness/skill-loop/DESIGN.md)