SharedDocsLib/docslib/components.py
2026-08-09 17:59:49 +00:00

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",
]