from __future__ import annotations import json from html import escape from pathlib import PurePosixPath from typing import Iterable, Mapping, Sequence from urllib.parse import quote from .hooks import Component from .registry import registry def _class_attr(class_name: str | None) -> str: return f' class="{escape(class_name, quote=True)}"' if class_name else "" @Component def H1(text: object, *, id: str | None = None) -> str: anchor = f' id="{escape(id, quote=True)}"' if id else "" return f"{escape(str(text))}" @Component def H2(text: object, *, id: str | None = None) -> str: anchor = f' id="{escape(id, quote=True)}"' if id else "" return f"{escape(str(text))}" @Component def H3(text: object, *, id: str | None = None) -> str: anchor = f' id="{escape(id, quote=True)}"' if id else "" return f"{escape(str(text))}" @Component def P(text: object, *, class_name: str | None = None) -> str: return f"{escape(str(text))}

" @Component def MarkdownText(text: object) -> str: """Render preformatted plain text. This component intentionally does not parse Markdown.""" return f'
{escape(str(text)).replace(chr(10), "
")}
' @Component def RawHTML(html: str) -> str: """Insert trusted HTML without escaping.""" return str(html) @Component def Image( name: str, alt: str = "", *, caption: str | None = None, width: int | str | None = None, ) -> str: normalised = PurePosixPath(str(name).replace("\\", "/")) if normalised.is_absolute() or ".." in normalised.parts: raise ValueError("Image name must be a relative path inside assets_dir") asset_name = str(normalised) width_attr = f' width="{escape(str(width), quote=True)}"' if width is not None else "" image = ( f'' ) if caption is None: return image return f"
{image}
{escape(caption)}
" @Component def Link(text: object, href: str, *, external: bool = False) -> str: extras = ' target="_blank" rel="noopener noreferrer"' if external else "" return f'{escape(str(text))}' @Component def LocalLink( text: object, target: object, *, section: str | int | float | None = None, ) -> str: """Link to a page by number, title, or the decorated page function itself.""" page = registry.resolve_page(target, section=section) # type: ignore[arg-type] href = f"#/page/{quote(page.number, safe='.')}" return ( f'' f"{escape(str(text))}" ) # Short alias matching the original API idea. LocLink = LocalLink @Component def Code(code: object, language: str = "") -> str: language_class = f' class="language-{escape(language, quote=True)}"' if language else "" return f"
{escape(str(code))}
" @Component def InlineCode(code: object) -> str: return f"{escape(str(code))}" @Component def Quote(text: object, *, source: str | None = None) -> str: footer = f"
— {escape(source)}
" if source else "" return f"

{escape(str(text))}

{footer}
" @Component def Callout(text: object, *, title: str = "Note", kind: str = "note") -> str: safe_kind = "".join(char for char in kind.lower() if char.isalnum() or char in "_-") or "note" return ( f'" ) @Component def List(items: Iterable[object], *, ordered: bool = False) -> str: tag = "ol" if ordered else "ul" values = "".join(f"
  • {escape(str(item))}
  • " for item in items) return f"<{tag}>{values}" @Component def Table(headers: Sequence[object], rows: Iterable[Sequence[object]]) -> str: head = "".join(f"{escape(str(value))}" for value in headers) body = "".join( "" + "".join(f"{escape(str(value))}" for value in row) + "" for row in rows ) return f"
    {head}{body}
    " @Component def Divider() -> str: return "
    " @Component def Badge(text: object, *, color: str = "accent") -> str: safe_color = "".join(char for char in color.lower() if char.isalnum() or char in "_-") or "accent" return f'{escape(str(text))}' @Component def Details(summary: object, content: object, *, open: bool = False) -> str: open_attr = " open" if open else "" return f"{escape(str(summary))}

    {escape(str(content))}

    " @Component def Json(data: Mapping[str, object] | Sequence[object]) -> str: return Code(json.dumps(data, ensure_ascii=False, indent=2), "json") __all__ = [ "Badge", "Callout", "Code", "Details", "Divider", "H1", "H2", "H3", "Image", "InlineCode", "Json", "Link", "List", "LocalLink", "LocLink", "MarkdownText", "P", "Quote", "RawHTML", "Table", ]