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("{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(' '.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"{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"{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'Knowledge Base › {esc(topic)}
' + f"{esc(fm.description)}
' + f'{badge} · {len(chapters)} chapter(s) · {words:,} words
' + f'