blocks.
"""
# Normalize CRLF to LF to prevent double line breaks in HTML
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
return html.escape(normalized)
def _render_code_with_pygments(renderer: Any, node: Any, context: Any) -> str:
"""wenmode ``code`` handler: Pygments when the fence names a language.
Falls back to wenmode's own ```` rendering when there is
no language hint, so plain fences stay as they were.
"""
if not node.lang:
return str(_default_render_code(renderer, node, context))
from pygments import highlight
from pygments.lexer import Lexer
from pygments.lexers import get_lexer_by_name, TextLexer
from pygments.formatter import Formatter
from pygments.formatters import HtmlFormatter
from pygments.util import ClassNotFound
lang = node.lang.split()[0]
lexer: Lexer = TextLexer()
try:
lexer = get_lexer_by_name(lang, stripall=False)
except ClassNotFound:
pass # Already have default
formatter: Formatter = HtmlFormatter(
linenos=False, # No line numbers in markdown code blocks
cssclass="highlight",
wrapcode=True,
)
with timing_stat("_pygments_timings"):
return str(highlight(node.value, lexer, formatter))
def _render_html_escaped(renderer: Any, node: Any, context: Any) -> str:
"""wenmode ``html`` handler: raw HTML is shown as text, never live.
Transcript content is untrusted from every source (#245 XSS), so
every raw-HTML node is entity-escaped. A block-level one (marked by
``markdown_plugins.BlockHtmlMarker``) is wrapped in a paragraph so
it lays out like the text it now is.
"""
if node.data and node.data.get("block"):
return "" + str(renderer.escape_html(node.value.strip())) + "
\n"
return str(renderer.escape_html(node.value))
# Schemes a link or image in a transcript must never carry: mistune's
# list, plus OS protocol handlers that a click would hand to the desktop
# (``search-ms:`` and ``ms-appinstaller:`` have real abuse history) and
# the browser-internal ones. Anything else — ``cci:`` editor links,
# ``vercel.json:20`` file references, relative paths — stays a working
# link: transcripts are full of targets no allowlist would anticipate,
# and none of those can execute script.
_HARMFUL_URL_SCHEMES = frozenset(
{
"javascript",
"vbscript",
"file",
"data",
"feed",
"jar",
"livescript",
"mocha",
"ms-its",
"mk",
"res",
"view-source",
# beyond mistune's list
"search-ms",
"ms-appinstaller",
"ms-msdt",
"intent",
"blob",
"filesystem",
"about",
}
)
_GOOD_DATA_PREFIXES = (
"data:image/gif;",
"data:image/png;",
"data:image/jpeg;",
"data:image/webp;",
)
class _TranscriptHTMLRenderer(HTMLRenderer):
"""wenmode's HTML renderer with a denylist URL policy.
wenmode's default allows only ``http``, ``https``, ``irc``, ``ircs``,
``mailto`` and ``tel``; every other scheme loses its ``href``. The
XSS contract (#245) only needs the dangerous schemes gone, so this
keeps mistune's denylist (``file:`` included) and lets the rest
through unchanged. Attribute sanitising and raw-HTML escaping are
wenmode's defaults, untouched.
"""
def sanitize_url(self, value: str) -> Optional[str]:
try:
normalized = _normalize_url_for_scheme_check(value)
scheme = urlsplit(normalized).scheme.lower()
except ValueError:
return None
if scheme in _HARMFUL_URL_SCHEMES and not normalized.lower().startswith(
_GOOD_DATA_PREFIXES
):
return None
return value
class _TranscriptHtmlHandlers:
"""wenmode plugin installing the transcript HTML handlers above."""
def setup(self, wen: Wenmode, /) -> None:
wen.register_renderer_handlers(
{
"html": {
"code": _render_code_with_pygments,
"html": _render_html_escaped,
}
}
)
def _build_transcript_markdown(document: bool = False) -> Wenmode:
"""The wenmode pipeline both content renderers use.
``escape=True`` renders raw HTML embedded in the source text
(```` into
the field" — so rendering it unescaped lets that payload execute when
the transcript HTML is opened. The Markdown output path neutralises
raw HTML the same way (see ``markdown/renderer.py::_protect_html_tags``).
Escaping does not affect Markdown formatting, Pygments output, SHA
links or code fences — only raw HTML in the body.
The SHA → commit-URL linkifier (issue #156) reads the per-render cwd
from a ContextVar, so the cached singleton keeps working unchanged
across transcripts from different repos.
``document`` adds wenmode's ``frontmatter`` plugin, for a whole body
(a message, a Markdown file, a plan) that may open with YAML front
matter; see ``render_markdown_document``.
"""
from ..markdown_plugins import transcript_plugins, transcript_rules
from ..git_remote import resolve_sha_for_current_render
plugins: list[Any] = [*transcript_plugins(), _TranscriptHtmlHandlers()]
if document:
from wenmode.plugins import frontmatter
from ..frontmatter import load_frontmatter
plugins.append(frontmatter.configure(load=load_frontmatter))
return Wenmode(
transcript_rules(resolve_sha_for_current_render),
# escape: untrusted content (XSS, #245). soft_break: a newline in
# prose is a line break (checklists in assistant messages carry no
# two-trailing-spaces markup) — mistune's hard_wrap.
renderer=_TranscriptHTMLRenderer(escape=True, soft_break="br"),
plugins=plugins,
)
@functools.lru_cache(maxsize=1)
def _get_markdown_renderer() -> Wenmode:
"""Cached renderer for assistant/tool/web-authored content.
Assistant prose, Task/WebSearch/WebFetch results, plans, system
messages, teammate bodies. See ``_build_transcript_markdown`` for
the escaping contract.
"""
return _build_transcript_markdown()
@functools.lru_cache(maxsize=1)
def _get_document_markdown_renderer() -> Wenmode:
"""Cached renderer for whole bodies that open with YAML front matter.
The transcript pipeline plus wenmode's ``frontmatter`` plugin. Only
``render_markdown_document`` uses it: inline renders, previews and the
Markdown inside a params-table value never parse front matter.
"""
return _build_transcript_markdown(document=True)
def _frontmatter_html(value: Any) -> str:
"""HTML for loaded front matter: a params table, or a YAML code block."""
from ..frontmatter import RawFrontmatter, yaml_fence
from .tool_formatters import render_params_table
if isinstance(value, RawFrontmatter):
body = render_markdown(yaml_fence(value.source))
else:
body = render_params_table(value)
return f"{body}"
def render_markdown_document(text: str, user: bool = False) -> str:
"""Render a whole Markdown body, showing YAML front matter as data.
A body that opens with front matter (``frontmatter.is_frontmatter_start``)
is parsed by the document renderer, whose ``frontmatter`` plugin takes
the block out of the body; it renders as a params table above the rest
(a YAML code block when it is not a mapping). Any other body renders
exactly as ``render_markdown`` (``render_user_markdown`` when ``user``).
``user`` does not apply to the front-matter path: the document renderer
is the same escaping pipeline as both, so one memo entry serves either.
"""
from ..frontmatter import is_frontmatter_start
from ..git_remote import current_render_repo_cwd
if not is_frontmatter_start(text):
return _render_markdown_memoized(text, escaping_user_renderer=user)
memo_key = ("document", current_render_repo_cwd(), text)
cached = markdown_cache.get(memo_key)
if cached is not None:
return cached
with timing_stat("_markdown_timings"):
renderer = _get_document_markdown_renderer()
root = renderer.parse(text)
body = renderer.render_node(root)
data = root.data or {}
if "frontmatter" in data:
body = _frontmatter_html(data["frontmatter"]) + body
markdown_cache.put(memo_key, body)
return body
def _render_markdown_memoized(text: str, escaping_user_renderer: bool) -> str:
"""Render ``text`` through one of the two wenmode singletons, memoized.
Markdown output is a pure function of the text *and* the active render
repo cwd: the SHA-linkifier plugin resolves commit hashes against
``git_remote._render_repo_cwd``, so the same ``5baac35`` links to
different repositories in different transcripts. The cwd is therefore
part of the key — without it a long-lived host (``serve``, the TUI)
would serve one project's commit links inside another's page.
``escaping_user_renderer`` selects between the two renderer singletons
(they differ only in call site convention today, but they are distinct
objects and must not share cache entries).
"""
from ..git_remote import current_render_repo_cwd
memo_key = (escaping_user_renderer, current_render_repo_cwd(), text)
cached = markdown_cache.get(memo_key)
if cached is not None:
return cached
# Track markdown rendering time if enabled
with timing_stat("_markdown_timings"):
renderer = (
_get_user_markdown_renderer()
if escaping_user_renderer
else _get_markdown_renderer()
)
rendered = renderer.render(text)
markdown_cache.put(memo_key, rendered)
return rendered
def render_markdown(text: str) -> str:
"""Convert markdown text to HTML using wenmode with Pygments syntax highlighting."""
return _render_markdown_memoized(text, escaping_user_renderer=False)
_INLINE_PARA_BOUNDARY_RE = re.compile(r"\s*")
def render_markdown_inline(text: str) -> str:
"""Render markdown collapsed to inline-safe HTML.
Question/option text from AskUserQuestion is LLM-authored, so it carries
real Markdown (inline code, emphasis, links). Callers embed the result
inside inline wrappers (````, ````), so this unwraps the
enclosing ```` and folds any paragraph boundaries into ``
`` —
a multi-paragraph free-form reply must not emit block ``
`` inside an
inline element.
Uses the ``escape=True`` renderer so raw HTML in the text (``