diff --git a/cli/engram/web/markdown.py b/cli/engram/web/markdown.py new file mode 100644 index 0000000..2cb8c1c --- /dev/null +++ b/cli/engram/web/markdown.py @@ -0,0 +1,245 @@ +"""A small Markdown-to-HTML renderer for KB chapters (SPEC §6.3). + +Deliberately not a full CommonMark implementation. It covers exactly the +constructs the KB chapter guide asks authors to use — headings, paragraphs, +fenced code, tables, lists, block quotes, rules, and the inline set — and +renders everything else as plain text rather than guessing. + +Why hand-rolled: the project keeps its core dependency-free, and a +server-rendered read-only view does not justify pulling a parser in. The +trade-off is accepted scope, not accepted sloppiness — every branch escapes +its text before emitting, so a chapter can never inject markup. +""" + +from __future__ import annotations + +import re + +from engram.web.render import esc + +__all__ = ["render_markdown", "extract_headings", "strip_frontmatter"] + +_FENCE_RE = re.compile(r"^(?:```|~~~)\s*([A-Za-z0-9_+-]*)\s*$") +_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") +_ULIST_RE = re.compile(r"^[-*+]\s+(.*)$") +_OLIST_RE = re.compile(r"^\d+[.)]\s+(.*)$") +_RULE_RE = re.compile(r"^(?:-{3,}|\*{3,}|_{3,})\s*$") +_TABLE_SEP_RE = re.compile(r"^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$") + +# Inline: code first so its content is never re-scanned for emphasis. +_CODE_RE = re.compile(r"`([^`]+)`") +_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)") +_BOLD_RE = re.compile(r"\*\*([^*]+)\*\*") +_ITALIC_RE = re.compile(r"(?]*$|^[A-Za-z0-9._/-]+$") + + +def strip_frontmatter(text: str) -> tuple[dict[str, str], str]: + """Split a leading ``---`` YAML block off. Values are kept as raw strings — + this view only displays them, so a full YAML parse would buy nothing.""" + if not text.startswith("---"): + return {}, text + end = text.find("\n---", 3) + if end == -1: + return {}, text + head = text[3:end] + body = text[end + 4 :].lstrip("\n") + meta: dict[str, str] = {} + for line in head.splitlines(): + if ":" in line and not line.startswith((" ", "\t", "-")): + k, _, v = line.partition(":") + meta[k.strip()] = v.strip().strip("\"'") + return meta, body + + +def extract_headings(text: str) -> list[tuple[int, str, str]]: + """Return ``(level, text, anchor)`` for each ATX heading outside code fences.""" + out: list[tuple[int, str, str]] = [] + in_fence = False + seen: dict[str, int] = {} + for line in text.splitlines(): + if _FENCE_RE.match(line): + in_fence = not in_fence + continue + if in_fence: + continue + m = _HEADING_RE.match(line) + if not m: + continue + level, raw = len(m.group(1)), m.group(2).strip() + plain = _strip_inline_markup(raw) + anchor = _slug(plain, seen) + out.append((level, plain, anchor)) + return out + + +def render_markdown(text: str) -> str: + """Render ``text`` to an HTML fragment. Never raises on malformed input.""" + lines = text.splitlines() + out: list[str] = [] + i = 0 + seen: dict[str, int] = {} + n = len(lines) + + while i < n: + line = lines[i] + + fence = _FENCE_RE.match(line) + if fence: + lang = fence.group(1) + i += 1 + buf: list[str] = [] + while i < n and not _FENCE_RE.match(lines[i]): + buf.append(lines[i]) + i += 1 + i += 1 # closing fence (or EOF — unterminated fences still render) + cls = f' class="lang-{esc(lang)}"' if lang else "" + out.append(f"
{esc(chr(10).join(buf))}
") + continue + + if not line.strip(): + i += 1 + continue + + if _RULE_RE.match(line): + out.append("
") + i += 1 + continue + + h = _HEADING_RE.match(line) + if h: + level, raw = len(h.group(1)), h.group(2).strip() + anchor = _slug(_strip_inline_markup(raw), seen) + out.append(f'{_inline(raw)}') + i += 1 + continue + + # Table: a header row followed by a delimiter row. + if line.lstrip().startswith("|") and i + 1 < n and _TABLE_SEP_RE.match(lines[i + 1].strip()): + header = _split_row(line) + i += 2 + rows: list[list[str]] = [] + while i < n and lines[i].lstrip().startswith("|"): + rows.append(_split_row(lines[i])) + i += 1 + out.append(_table(header, rows)) + continue + + if line.lstrip().startswith(">"): + buf = [] + while i < n and lines[i].lstrip().startswith(">"): + buf.append(lines[i].lstrip()[1:].lstrip()) + i += 1 + inner = render_markdown("\n".join(buf)) + out.append(f"
{inner}
") + continue + + lm = _ULIST_RE.match(line.lstrip()) or _OLIST_RE.match(line.lstrip()) + if lm: + ordered = _OLIST_RE.match(line.lstrip()) is not None + items: list[str] = [] + while i < n: + stripped = lines[i].lstrip() + m2 = _OLIST_RE.match(stripped) if ordered else _ULIST_RE.match(stripped) + if not m2: + break + items.append(f"
  • {_inline(m2.group(1))}
  • ") + i += 1 + tag = "ol" if ordered else "ul" + out.append(f"<{tag}>{''.join(items)}") + continue + + # Paragraph: consume until a blank line or the start of another block. + buf = [] + while i < n and lines[i].strip() and not _starts_block(lines[i]): + buf.append(lines[i].strip()) + i += 1 + if buf: + out.append(f"

    {_inline(' '.join(buf))}

    ") + else: # a block-starter we did not consume above; emit it verbatim + out.append(f"

    {_inline(lines[i].strip())}

    ") + i += 1 + + return "\n".join(out) + + +def _starts_block(line: str) -> bool: + s = line.lstrip() + return bool( + _FENCE_RE.match(line) + or _HEADING_RE.match(line) + or _RULE_RE.match(line) + or s.startswith(">") + or s.startswith("|") + or _ULIST_RE.match(s) + or _OLIST_RE.match(s) + ) + + +def _split_row(line: str) -> list[str]: + s = line.strip() + if s.startswith("|"): + s = s[1:] + if s.endswith("|"): + s = s[:-1] + return [c.strip() for c in s.split("|")] + + +def _table(header: list[str], rows: list[list[str]]) -> str: + width = len(header) + head = "".join(f"{_inline(c)}" for c in header) + body = [] + for r in rows: + cells = (r + [""] * width)[:width] + body.append("" + "".join(f"{_inline(c)}" for c in cells) + "") + return f"{head}{''.join(body)}
    " + + +def _inline(text: str) -> str: + """Escape, then re-introduce the inline constructs. Code spans are pulled + out first so their contents never pick up emphasis or link syntax.""" + spans: list[str] = [] + + def _stash(m: re.Match[str]) -> str: + spans.append(f"{esc(m.group(1))}") + return f"\x00{len(spans) - 1}\x00" + + staged = _CODE_RE.sub(_stash, text) + html = esc(staged) + + def _link(m: re.Match[str]) -> str: + label, href = m.group(1), m.group(2) + if not _SAFE_HREF_RE.match(href): + return esc(label) + return f'{esc(label)}' + + # esc() has already run, so match against the escaped forms. + html = re.sub(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]", lambda m: esc(m.group(2) or m.group(1)), html) + html = re.sub(r"\[([^\]]+)\]\(([^)\s]+)\)", _link, html) + html = _BOLD_RE.sub(r"\1", html) + html = _ITALIC_RE.sub(r"\1", html) + + for idx, span in enumerate(spans): + html = html.replace(f"\x00{idx}\x00", span) + return html + + +def _strip_inline_markup(text: str) -> str: + text = _CODE_RE.sub(r"\1", text) + text = _LINK_RE.sub(r"\1", text) + text = _WIKILINK_RE.sub(lambda m: m.group(2) or m.group(1), text) + text = _BOLD_RE.sub(r"\1", text) + text = _ITALIC_RE.sub(r"\1", text) + return text.strip() + + +def _slug(text: str, seen: dict[str, int]) -> str: + """Stable, collision-free anchor. Non-ASCII is kept — the KB is bilingual and + browsers handle percent-encoded fragments fine.""" + base = re.sub(r"[^\w一-鿿-]+", "-", text.strip().lower()).strip("-") + base = base or "section" + count = seen.get(base, 0) + seen[base] = count + 1 + return base if count == 0 else f"{base}-{count}" diff --git a/cli/engram/web/pages.py b/cli/engram/web/pages.py index 2964d73..3b30291 100644 --- a/cli/engram/web/pages.py +++ b/cli/engram/web/pages.py @@ -15,6 +15,7 @@ from contextlib import contextmanager from datetime import date from pathlib import Path +from urllib.parse import quote from typing import Any from engram.web.render import card, esc, table @@ -252,7 +253,7 @@ def render_kb(root: Path) -> str: ) rows.append( [ - esc(child.name), + f'{esc(child.name)}', f'{esc(fm.lifecycle_state)}', esc(len(fm.chapters)), stale_pill, @@ -267,6 +268,102 @@ def render_kb(root: Path) -> str: return f"

    Knowledge Base

    {len(rows)} article(s)

    {body}" +def render_kb_detail(root: Path, topic: str) -> str | None: + """One KB article: chapter body, table of contents, and metadata. + + Returns ``None`` when the topic does not resolve to a readable article so + the caller can answer 404 — an unreadable article and a missing one are the + same thing to a reader, and distinguishing them would leak directory shape. + """ + from engram.kb import check_staleness, kb_root, parse_readme + from engram.kb.format import KbFormatError + + from engram.web.markdown import extract_headings, render_markdown, strip_frontmatter + + if not _safe_topic(topic): + return None + art = kb_root(root) / topic + readme = art / "README.md" + if art.is_symlink() or not readme.is_file(): + return None + try: + fm, readme_body = parse_readme(readme) + except KbFormatError: + return None + + chapters: list[tuple[str, str, str]] = [] # (file, title, html) + toc: list[str] = [] + words = 0 + for name in fm.chapters: + # `chapters:` is author-controlled; keep it inside the article dir. + if "/" in name or "\\" in name or name.startswith("."): + continue + f = art / name + if not f.is_file(): + toc.append(f'
  • {esc(name)} — missing
  • ') + continue + raw = f.read_text(encoding="utf-8", errors="replace") + meta, body = strip_frontmatter(raw) + words += len(body.split()) + title = meta.get("title") or name + anchor_id = f"ch-{len(chapters)}" + chapters.append((name, title, render_markdown(body))) + subs = "".join( + f'
  • {esc(t)}
  • ' + for lv, t, a in extract_headings(body) + # h1 duplicates the chapter title rendered just above it + if 2 <= lv <= 3 + ) + toc.append( + f'
  • {esc(title)}' + f'
  • ' + ) + + stale = check_staleness(art).is_stale if (art / "_compile_state.toml").is_file() else None + if stale is True: + badge = 'digest stale' + elif stale is False: + badge = 'digest fresh' + else: + badge = 'not compiled' + + meta_rows = [ + ("Lifecycle", esc(fm.lifecycle_state)), + ("Scope", esc(getattr(fm, "scope", "") or "—")), + ("Author", esc(getattr(fm, "primary_author", "") or "—")), + ("Chapters", str(len(fm.chapters))), + ("Words", f"{words:,}"), + ] + meta_html = "".join( + f'
    {k}{v}
    ' + for k, v in meta_rows + ) + + bodies = "".join( + f'
    ' + f'

    {esc(title)}

    {html}
    ' + for n, (_f, title, html) in enumerate(chapters) + ) + abstract = render_markdown(readme_body) if readme_body.strip() else "" + + return ( + f'

    Knowledge Base › {esc(topic)}

    ' + f"

    {esc(fm.name)}

    " + f'

    {esc(fm.description)}

    ' + f'

    {badge} · {len(chapters)} chapter(s) · {words:,} words

    ' + f'
    ' + f'' + f'
    {abstract}{bodies}
    ' + f'' + f"
    " + ) + + +def _safe_topic(topic: str) -> bool: + """Reject anything that is not a plain directory name.""" + return bool(topic) and "/" not in topic and "\\" not in topic and not topic.startswith(".") + + # ---------------------------------------------------------------------- # Inbox # ---------------------------------------------------------------------- diff --git a/cli/engram/web/render.py b/cli/engram/web/render.py index 989790e..03ddeaa 100644 --- a/cli/engram/web/render.py +++ b/cli/engram/web/render.py @@ -9,6 +9,8 @@ from __future__ import annotations +import re + from html import escape __all__ = ["card", "esc", "layout", "table"] @@ -58,6 +60,49 @@ th{background:var(--bg-subtle);color:var(--muted);font-weight:600; text-transform:uppercase;font-size:11px;letter-spacing:.5px} tr:last-child td{border-bottom:none} +.crumb{color:var(--muted);margin:0 0 6px;font-size:13px} +.kb-grid{display:grid;grid-template-columns:220px minmax(0,1fr) 200px;gap:22px; + align-items:start;margin-top:20px} +/* An article needs more line length than the dashboard tables do. */ +main:has(.kb-grid){max-width:1460px} +.kb-toc,.kb-meta{background:var(--card);border-radius:var(--radius);padding:14px 16px; + box-shadow:var(--shadow);position:sticky;top:18px;max-height:calc(100vh - 40px);overflow:auto} +.toc-head{font-weight:700;font-size:12px;letter-spacing:.06em;text-transform:uppercase; + color:var(--muted);margin-bottom:8px} +.kb-toc ul{list-style:none;margin:0;padding:0;font-size:13px} +.kb-toc ul.sub{margin:2px 0 8px 10px;border-left:2px solid #eee7d9;padding-left:8px} +.kb-toc li{margin:3px 0;line-height:1.35} +.kb-toc li.miss{color:var(--danger)} +.kb-toc .lvl3{margin-left:9px;font-size:12px;color:var(--muted)} +.kb-body{min-width:0} +.kb-body .chapter{margin:0 0 34px} +.kb-body .chapter-title{font-size:21px;margin:26px 0 10px;padding-bottom:6px; + border-bottom:2px solid var(--orange)} +.kb-body h1{font-size:22px;margin:22px 0 8px} +.kb-body h2{font-size:18px;margin:20px 0 8px} +.kb-body h3{font-size:15px;margin:16px 0 6px;color:var(--muted)} +.kb-body p{margin:9px 0} +.kb-body table{width:100%;border-collapse:collapse;margin:12px 0;font-size:14px;display:block; + overflow-x:auto} +.kb-body th,.kb-body td{border:1px solid #e6e2d6;padding:6px 9px;text-align:left;vertical-align:top} +.kb-body th{background:var(--bg-subtle);font-weight:600} +.kb-body pre{background:#f7f5ee;border:1px solid #e6e2d6;border-radius:8px;padding:11px 13px; + overflow-x:auto;margin:11px 0} +.kb-body pre code{font-family:var(--mono);font-size:12.5px;line-height:1.5;background:none;padding:0} +.kb-body code{font-family:var(--mono);font-size:12.5px;background:var(--bg-subtle); + padding:1px 4px;border-radius:4px} +.kb-body blockquote{margin:11px 0;padding:2px 14px;border-left:3px solid var(--orange); + background:#fdf8f4;color:#4a4842} +.kb-body hr{border:0;border-top:1px solid #e6e2d6;margin:20px 0} +.kb-body ul,.kb-body ol{margin:9px 0;padding-left:22px} +.kb-body li{margin:3px 0} +.kb-meta .kv{display:flex;justify-content:space-between;gap:8px;font-size:12.5px; + padding:5px 0;border-bottom:1px solid #f0ede3} +.kb-meta .kv:last-child{border-bottom:0} +.kb-meta .k{color:var(--muted)} +.kb-meta .v{font-weight:600;text-align:right;word-break:break-word} +@media(max-width:1100px){.kb-grid{grid-template-columns:1fr} + .kb-toc,.kb-meta{position:static;max-height:none}} .pill{display:inline-block;padding:2px 9px;border-radius:9999px;font-size:12px; background:var(--bg-subtle);color:var(--muted)} .pill.mandatory{background:#f6dfd6;color:var(--danger)} @@ -84,8 +129,27 @@ def esc(value: object) -> str: return escape(str(value), quote=True) -def layout(title: str, body_html: str, *, active: str = "/") -> str: - """Wrap page body in the full HTML shell + sidebar nav.""" +def _nonce_attr(nonce: str | None) -> str: + """Render ``nonce="..."`` or nothing. Rejects anything not base64url-safe + so a caller cannot break out of the attribute.""" + if not nonce: + return "" + if not re.fullmatch(r"[A-Za-z0-9_-]{8,128}", nonce): + raise ValueError("style_nonce must be 8-128 chars of [A-Za-z0-9_-]") + return f' nonce="{nonce}"' + + +def layout( + title: str, body_html: str, *, active: str = "/", style_nonce: str | None = None +) -> str: + """Wrap page body in the full HTML shell + sidebar nav. + + ``style_nonce`` is stamped on the inline ``" + f"{esc(title)} · engram" + f"{_CSS}" '
    ' f'' f"
    {body_html}
    " diff --git a/cli/engram/web/server.py b/cli/engram/web/server.py index c3a071b..17e98d2 100644 --- a/cli/engram/web/server.py +++ b/cli/engram/web/server.py @@ -27,6 +27,7 @@ DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8787 _LOOPBACK = {"127.0.0.1", "::1", "localhost"} +_CSP_BASE = "default-src 'self'" @dataclass(frozen=True, slots=True) @@ -34,10 +35,24 @@ class Response: status: int body: str content_type: str = "text/html; charset=utf-8" + csp: str = _CSP_BASE def _page(title: str, body: str, active: str, status: int = 200) -> Response: - return Response(status=status, body=layout(title, body, active=active)) + """Render a page and pair it with a CSP that admits exactly its own style block. + + All styling is a single inline ``