169 lines
5.3 KiB
Python
169 lines
5.3 KiB
Python
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"<h1{anchor}>{escape(str(text))}</h1>"
|
|
|
|
|
|
@Component
|
|
def H2(text: object, *, id: str | None = None) -> str:
|
|
anchor = f' id="{escape(id, quote=True)}"' if id else ""
|
|
return f"<h2{anchor}>{escape(str(text))}</h2>"
|
|
|
|
|
|
@Component
|
|
def H3(text: object, *, id: str | None = None) -> str:
|
|
anchor = f' id="{escape(id, quote=True)}"' if id else ""
|
|
return f"<h3{anchor}>{escape(str(text))}</h3>"
|
|
|
|
|
|
@Component
|
|
def P(text: object, *, class_name: str | None = None) -> str:
|
|
return f"<p{_class_attr(class_name)}>{escape(str(text))}</p>"
|
|
|
|
|
|
@Component
|
|
def MarkdownText(text: object) -> str:
|
|
"""Render preformatted plain text. This component intentionally does not parse Markdown."""
|
|
return f'<div class="docslib-text">{escape(str(text)).replace(chr(10), "<br>")}</div>'
|
|
|
|
|
|
@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'<img data-docslib-asset="{escape(asset_name, quote=True)}" '
|
|
f'alt="{escape(alt, quote=True)}"{width_attr} loading="lazy">'
|
|
)
|
|
if caption is None:
|
|
return image
|
|
return f"<figure>{image}<figcaption>{escape(caption)}</figcaption></figure>"
|
|
|
|
|
|
@Component
|
|
def Link(text: object, href: str, *, external: bool = False) -> str:
|
|
extras = ' target="_blank" rel="noopener noreferrer"' if external else ""
|
|
return f'<a href="{escape(href, quote=True)}"{extras}>{escape(str(text))}</a>'
|
|
|
|
|
|
@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'<a href="{href}" data-docslib-local-link="{escape(page.number, quote=True)}">'
|
|
f"{escape(str(text))}</a>"
|
|
)
|
|
|
|
|
|
# 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"<pre><code{language_class}>{escape(str(code))}</code></pre>"
|
|
|
|
|
|
@Component
|
|
def InlineCode(code: object) -> str:
|
|
return f"<code>{escape(str(code))}</code>"
|
|
|
|
|
|
@Component
|
|
def Quote(text: object, *, source: str | None = None) -> str:
|
|
footer = f"<footer>— {escape(source)}</footer>" if source else ""
|
|
return f"<blockquote><p>{escape(str(text))}</p>{footer}</blockquote>"
|
|
|
|
|
|
@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'<aside class="callout callout-{safe_kind}"><strong>{escape(title)}</strong>'
|
|
f"<div>{escape(str(text))}</div></aside>"
|
|
)
|
|
|
|
|
|
@Component
|
|
def List(items: Iterable[object], *, ordered: bool = False) -> str:
|
|
tag = "ol" if ordered else "ul"
|
|
values = "".join(f"<li>{escape(str(item))}</li>" for item in items)
|
|
return f"<{tag}>{values}</{tag}>"
|
|
|
|
|
|
@Component
|
|
def Table(headers: Sequence[object], rows: Iterable[Sequence[object]]) -> str:
|
|
head = "".join(f"<th>{escape(str(value))}</th>" for value in headers)
|
|
body = "".join(
|
|
"<tr>" + "".join(f"<td>{escape(str(value))}</td>" for value in row) + "</tr>"
|
|
for row in rows
|
|
)
|
|
return f"<div class=\"table-wrap\"><table><thead><tr>{head}</tr></thead><tbody>{body}</tbody></table></div>"
|
|
|
|
|
|
@Component
|
|
def Divider() -> str:
|
|
return "<hr>"
|
|
|
|
|
|
@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'<span class="badge badge-{safe_color}">{escape(str(text))}</span>'
|
|
|
|
|
|
@Component
|
|
def Details(summary: object, content: object, *, open: bool = False) -> str:
|
|
open_attr = " open" if open else ""
|
|
return f"<details{open_attr}><summary>{escape(str(summary))}</summary><p>{escape(str(content))}</p></details>"
|
|
|
|
|
|
@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",
|
|
]
|