Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions cli/engram/web/markdown.py
Original file line number Diff line number Diff line change
@@ -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"(?<![*\w])\*([^*\n]+)\*(?![*\w])")
_WIKILINK_RE = re.compile(r"\[\[([^\]|]+)(?:\|([^\]]+))?\]\]")

_SAFE_HREF_RE = re.compile(r"^(?:https?://|/|\./|\.\./|#)[^\s\"'<>]*$|^[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"<pre><code{cls}>{esc(chr(10).join(buf))}</code></pre>")
continue

if not line.strip():
i += 1
continue

if _RULE_RE.match(line):
out.append("<hr>")
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'<h{level} id="{anchor}">{_inline(raw)}</h{level}>')
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"<blockquote>{inner}</blockquote>")
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"<li>{_inline(m2.group(1))}</li>")
i += 1
tag = "ol" if ordered else "ul"
out.append(f"<{tag}>{''.join(items)}</{tag}>")
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"<p>{_inline(' '.join(buf))}</p>")
else: # a block-starter we did not consume above; emit it verbatim
out.append(f"<p>{_inline(lines[i].strip())}</p>")
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"<th>{_inline(c)}</th>" for c in header)
body = []
for r in rows:
cells = (r + [""] * width)[:width]
body.append("<tr>" + "".join(f"<td>{_inline(c)}</td>" for c in cells) + "</tr>")
return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


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"<code>{esc(m.group(1))}</code>")
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'<a href="{esc(href)}">{esc(label)}</a>'

# 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"<strong>\1</strong>", html)
html = _ITALIC_RE.sub(r"<em>\1</em>", 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}"
99 changes: 98 additions & 1 deletion cli/engram/web/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -252,7 +253,7 @@ def render_kb(root: Path) -> str:
)
rows.append(
[
esc(child.name),
f'<a href="/kb/{quote(child.name)}">{esc(child.name)}</a>',
f'<span class="pill">{esc(fm.lifecycle_state)}</span>',
esc(len(fm.chapters)),
stale_pill,
Expand All @@ -267,6 +268,102 @@ def render_kb(root: Path) -> str:
return f"<h1>Knowledge Base</h1><p class='sub'>{len(rows)} article(s)</p>{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'<li class="miss">{esc(name)} — missing</li>')
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'<li class="lvl{lv}"><a href="#{esc(a)}">{esc(t)}</a></li>'
for lv, t, a in extract_headings(body)
# h1 duplicates the chapter title rendered just above it
if 2 <= lv <= 3
)
toc.append(
f'<li><a href="#{anchor_id}"><b>{esc(title)}</b></a>'
f'<ul class="sub">{subs}</ul></li>'
)

stale = check_staleness(art).is_stale if (art / "_compile_state.toml").is_file() else None
if stale is True:
badge = '<span class="pill warn">digest stale</span>'
elif stale is False:
badge = '<span class="pill ok">digest fresh</span>'
else:
badge = '<span class="pill">not compiled</span>'

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'<div class="kv"><span class="k">{k}</span><span class="v">{v}</span></div>'
for k, v in meta_rows
)

bodies = "".join(
f'<section class="chapter" id="ch-{n}">'
f'<h2 class="chapter-title">{esc(title)}</h2>{html}</section>'
for n, (_f, title, html) in enumerate(chapters)
)
abstract = render_markdown(readme_body) if readme_body.strip() else ""

return (
f'<p class="crumb"><a href="/kb">Knowledge Base</a> › {esc(topic)}</p>'
f"<h1>{esc(fm.name)}</h1>"
f'<p class="sub">{esc(fm.description)}</p>'
f'<p class="sub">{badge} · {len(chapters)} chapter(s) · {words:,} words</p>'
f'<div class="kb-grid">'
f'<nav class="kb-toc"><div class="toc-head">Contents</div><ul>{"".join(toc)}</ul></nav>'
f'<article class="kb-body">{abstract}{bodies}</article>'
f'<aside class="kb-meta"><div class="toc-head">Metadata</div>{meta_html}</aside>'
f"</div>"
)


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
# ----------------------------------------------------------------------
Expand Down
Loading
Loading