Skip to content
Merged
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
50 changes: 46 additions & 4 deletions claude_code_log/html/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,12 +266,54 @@ class HtmlRenderer(Renderer):

# Consulted by Renderer._dispatch_format Strategy 2: plugin-defined
# content classes contributing a ``format_html`` method get picked up
# here. See work/tool-renderer-plugins.md §`_dispatch_format`
# resolution order. Class-side ``format_html`` may return None to
# opt for mistune-derived HTML; that fallback is handled by callers
# of format_content, not by the dispatcher itself.
# here. See dev-docs/plugins.md §5 for the resolution order.
#
# v1 contract: ``format_html`` MUST return a real string. The
# absence of the method on a plugin class drives the fallback —
# ``_dispatch_format`` (overridden below) synthesizes HTML from
# the class-side ``format_markdown`` via mistune when only the
# Markdown side is implemented. There is no None-as-sentinel.
_class_dispatch_format: str = "html"

def _dispatch_format(self, obj: Any, message: "TemplateMessage") -> str:
"""HtmlRenderer-specific dispatch with Markdown→HTML synthesis.

Resolution order on the actual class (`type(obj)`):

1. Class defines ``format_html`` in its ``__dict__`` → use it
verbatim. The return MUST be a real string (no None sentinel).
2. Class defines ``format_markdown`` (but not ``format_html``)
in its ``__dict__`` → synthesize HTML by rendering the
Markdown via mistune, wrapped in ``<div class="markdown">``
so theme rules scoped under ``.markdown`` fire. By
definition the synthesized output is Markdown-derived, so
the wrap is automatic — plugin authors don't need
``has_markdown = True`` for this path.
3. Neither on the actual class → defer to the base MRO walk
(which finds renderer-side ``format_<ClassName>`` methods
for built-in content classes, or class-side methods on
ancestors).

Step 2 deliberately wins over an ancestor's renderer-side
``format_<ClassName>``: a plugin author who defined
``format_markdown`` on their subclass meant for their Markdown
to drive the rendering, not for the parent class's built-in
renderer behaviour to take over.
"""
from .utils import render_markdown

# ``obj`` is intentionally untyped (``Any``); the class-side
# methods we look up on its ``__dict__`` are plugin-defined.
obj_cls = cast("type[object]", type(obj))
html_method = obj_cls.__dict__.get("format_html")
if html_method is not None:
return cast(str, html_method(obj, self, message))
md_method = obj_cls.__dict__.get("format_markdown")
if md_method is not None:
md_source = cast(str, md_method(obj, self, message))
return f'<div class="markdown">{render_markdown(md_source)}</div>'
return super()._dispatch_format(obj, message)

def __init__(self, image_export_mode: str = "embedded"):
"""Initialize the HTML renderer.

Expand Down
49 changes: 49 additions & 0 deletions claude_code_log/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import logging
from importlib.metadata import EntryPoint, entry_points
from typing import (
TYPE_CHECKING,
Any,
ClassVar,
Optional,
Expand All @@ -34,6 +35,12 @@
runtime_checkable,
)

if TYPE_CHECKING:
# Visible to static type-checkers (pyright/mypy) and to
# ``__all__`` validation; resolved at runtime via the
# PEP-562 ``__getattr__`` further down.
from .html.utils import render_markdown, render_markdown_collapsible

from .models import MessageContent, MessageMeta

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -313,10 +320,52 @@ def apply_transformers(
return candidate


# ----------------------------------------------------------------------
# Public re-exports for plugin authors
# ----------------------------------------------------------------------
# Helpers that live in ``claude_code_log/html/utils.py`` and are useful
# in plugin ``format_html`` methods. Re-exported under
# ``claude_code_log.plugins`` so plugin code imports from a stable
# namespace; the internal ``html/utils.py`` can churn (rename, split,
# restructure) without breaking plugin authors as long as the names
# below keep working.
#
# Resolved lazily via PEP-562 ``__getattr__`` because ``html`` itself
# imports from this module's siblings (``factories``, ``utils``), and
# an eager top-level import would close a circular loop during package
# init. Plugin authors are unaffected — ``from claude_code_log.plugins
# import render_markdown_collapsible`` Just Works because Python calls
# ``__getattr__`` when the name isn't already in the module dict.
#
# Add to ``_PUBLIC_HELPERS`` only on concrete plugin-author demand —
# a wider public surface is a wider commitment. See
# ``dev-docs/plugins.md`` §4.1 "Plugin-facing helpers" for the
# documented signatures.

_PUBLIC_HELPERS: frozenset[str] = frozenset(
{"render_markdown", "render_markdown_collapsible"}
)


def __getattr__(name: str) -> Any: # PEP 562
if name in _PUBLIC_HELPERS:
from .html.utils import (
render_markdown as _rm,
render_markdown_collapsible as _rmc,
)

globals()["render_markdown"] = _rm
globals()["render_markdown_collapsible"] = _rmc
return globals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")


__all__ = [
"ENTRY_POINT_GROUP",
"MessageTransformer",
"apply_transformers",
"load_transformers",
"render_markdown",
"render_markdown_collapsible",
"reset_cache",
]
Loading
Loading