Conversion of User Guide to the SHH stack (#2781)

This commit is contained in:
Adam Hopkins
2023-09-06 15:44:00 +03:00
committed by GitHub
parent 47215d4635
commit d255d1aae1
332 changed files with 51495 additions and 2013 deletions

View File

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from html5tagger import Builder, Document # type: ignore
class BaseRenderer:
def __init__(self, base_title: str):
self.base_title = base_title
def get_builder(self, full: bool, language: str) -> Builder:
if full:
urls = [
"/assets/code.css",
"/assets/style.css",
"/assets/docs.js",
"https://unpkg.com/htmx.org@1.9.2/dist/htmx.min.js",
"https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js",
]
builder = Document(
self.base_title, lang=language, _urls=urls, _viewport=True
)
builder.full = True
return builder
else:
builder = Builder(name="Partial")
builder.full = False
return builder

View File

@@ -0,0 +1,20 @@
from pygments.style import Style
from pygments.token import ( # Error,; Generic,; Number,; Operator,
Comment,
Keyword,
Name,
String,
Token,
)
class SanicCodeStyle(Style):
styles = {
Token: "#777",
Comment: "italic #a2a2a2",
Keyword: "#ff0d68",
Name: "#333",
Name.Class: "bold #37ae6f",
Name.Function: "#0092FF",
String: "bg:#eee #833FE3",
}

View File

View File

@@ -0,0 +1,25 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Generator
from html5tagger import Builder
from sanic import Request
class BaseLayout:
def __init__(self, builder: Builder):
self.builder = builder
@contextmanager
def __call__(
self, request: Request, full: bool = True
) -> Generator[BaseLayout, None, None]:
with self.layout(request, full=full):
yield self
@contextmanager
def layout(
self, request: Request, full: bool = True
) -> Generator[None, None, None]:
yield

View File

@@ -0,0 +1,75 @@
from datetime import datetime
from html5tagger import Builder, E # type: ignore
from sanic import Request
def do_footer(builder: Builder, request: Request) -> None:
builder.footer(
_pagination(request),
_content(),
class_="footer",
)
def _pagination(request: Request) -> Builder:
return E.div(
_pagination_left(request), _pagination_right(request), class_="level"
)
def _pagination_left(request: Request) -> Builder:
item = E.div(class_="level-item")
if not hasattr(request.ctx, "previous_page"):
return E.div(item, class_="level-left")
with item:
if p := request.ctx.previous_page:
path = p.relative_path.with_suffix(".html")
item.a(
f"{p.meta.title}",
href=f"/{path}",
hx_get=f"/{path}",
hx_target="#content",
hx_swap="innerHTML",
hx_push_url="true",
class_="button pagination",
)
return E.div(item, class_="level-left")
def _pagination_right(request: Request) -> Builder:
item = E.div(class_="level-item")
if not hasattr(request.ctx, "next_page"):
return E.div(item, class_="level-right")
with item:
if p := request.ctx.next_page:
path = p.relative_path.with_suffix(".html")
item.a(
f"{p.meta.title}",
href=f"/{path}",
hx_get=f"/{path}",
hx_target="#content",
hx_swap="innerHTML",
hx_push_url="true",
class_="button pagination",
)
return E.div(item, class_="level-right")
def _content() -> Builder:
year = datetime.now().year
inner = E.p(
E.a(
"MIT Licensed",
href="https://github.com/sanic-org/sanic/blob/master/LICENSE",
target="_blank",
rel="nofollow noopener noreferrer",
).br()(
E.small(f"Copyright © 2018-{year} Sanic Community Organization")
),
)
return E.div(
inner,
E.p("~ Made with ❤️ and ☕️ ~"),
class_="content has-text-centered",
)

View File

@@ -0,0 +1,68 @@
from webapp.display.layouts.models import MenuItem
from html5tagger import Builder, E # type: ignore
from sanic import Request
def do_navbar(builder: Builder, request: Request) -> None:
navbar_items = [
_render_navbar_item(item, request)
for item in request.app.config.NAVBAR
]
container = E.div(
_search_form(request), *navbar_items, class_="navbar-end"
)
builder.nav(
E.div(container, class_="navbar-menu"),
class_="navbar is-hidden-touch",
)
def _search_form(request: Request) -> Builder:
return E.div(
E.div(
E.input(
id_="search",
type_="text",
placeholder="Search",
class_="input",
value=request.args.get("q", ""),
hx_target="#content",
hx_swap="innerHTML",
hx_push_url="true",
hx_trigger="keyup changed delay:500ms",
hx_get=f"/{request.ctx.language}/search",
hx_params="*",
),
class_="control",
),
class_="navbar-item",
)
def _render_navbar_item(item: MenuItem, request: Request) -> Builder:
if item.items:
return E.div(
E.a(item.label, class_="navbar-link"),
E.div(
*(
_render_navbar_item(subitem, request)
for subitem in item.items
),
class_="navbar-dropdown",
),
class_="navbar-item has-dropdown is-hoverable",
)
kwargs = {
"class_": "navbar-item",
}
if item.href:
kwargs["href"] = item.href
kwargs["target"] = "_blank"
kwargs["rel"] = "nofollow noopener noreferrer"
elif item.path:
kwargs["href"] = f"/{request.ctx.language}/{item.path}"
internal = [item.label]
return E.a(*internal, **kwargs)

View File

@@ -0,0 +1,118 @@
from webapp.display.layouts.models import MenuItem
from webapp.display.text import slugify
from html5tagger import Builder, E # type: ignore
from sanic import Request
def do_sidebar(builder: Builder, request: Request) -> None:
builder.a(class_="burger")(E.span().span().span().span())
builder.aside(*_menu_items(request), class_="menu")
def _menu_items(request: Request) -> list[Builder]:
return [
_sanic_logo(request),
*_sidebar_items(request),
E.hr(),
E.p("Current with version ").strong(
request.app.config.GENERAL.current_version
),
E.hr(),
E.p("Want more? ").a(
"sanicbook.com", href="https://sanicbook.com", target="_blank"
),
]
def _sanic_logo(request: Request) -> Builder:
return E.a(
class_="navbar-item sanic-simple-logo",
href=f"https://sanic.dev/{request.ctx.language}/",
)(
E.img(
src="/assets/images/sanic-framework-logo-simple-400x97.png", # noqa: E501
alt="Sanic Framework",
)
)
def _sidebar_items(request: Request) -> list[Builder]:
return [
builder
for item in request.app.config.SIDEBAR
for builder in _render_sidebar_item(item, request, True)
]
def _render_sidebar_item(
item: MenuItem, request: Request, root: bool = False
) -> list[Builder]:
builders: list[Builder] = []
if root:
builders.append(E.p(class_="menu-label")(item.label))
else:
builders.append(_single_sidebar_item(item, request))
if item.items:
ul = E.ul(class_="menu-list")
with ul:
for subitem in item.items:
sub_builders = _render_sidebar_item(subitem, request)
ul(*sub_builders)
builders.append(ul)
return builders
def _single_sidebar_item(item: MenuItem, request: Request) -> Builder:
if item.path and item.path.startswith("/"):
path = item.path
else:
path = f"/{request.ctx.language}/{item.path}" if item.path else ""
kwargs = {}
classes: list[str] = []
li_classes = "menu-item"
_, page, _ = request.app.ctx.get_page(
request.ctx.language, item.path or ""
)
if request.path == path:
classes.append("is-active")
if item.href:
kwargs["href"] = item.href
kwargs["target"] = "_blank"
kwargs["rel"] = "nofollow noopener noreferrer"
elif not path:
li_classes += " is-group"
if _is_open_item(item, request.ctx.language, request.path):
classes.append("is-open")
else:
kwargs.update(
{
"href": path,
"hx-get": path,
"hx-target": "#content",
"hx-swap": "innerHTML",
"hx-push-url": "true",
}
)
kwargs["class_"] = " ".join(classes)
inner = E().a(item.label, **kwargs)
if page and page.anchors:
with inner.ul(class_="anchor-list"):
for anchor in page.anchors:
inner.li(
E.a(anchor.strip("`"), href=f"{path}#{slugify(anchor)}"),
class_="is-anchor",
)
return E.li(inner, class_=li_classes)
def _is_open_item(item: MenuItem, language: str, current_path: str) -> bool:
path = f"/{language}/{item.path}" if item.path else ""
if current_path == path:
return True
for subitem in item.items:
if _is_open_item(subitem, language, current_path):
return True
return False

View File

@@ -0,0 +1,50 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Generator
from html5tagger import Builder, E
from sanic import Request
from .base import BaseLayout
class HomeLayout(BaseLayout):
@contextmanager
def layout(
self, request: Request, full: bool = True
) -> Generator[None, None, None]:
self._hero(request.ctx.language)
with self.builder.div(class_="container"):
yield
def _hero(self, language: str) -> None:
with self.builder.section(class_="hero is-large has-text-centered"):
self.builder.div(
E.h1(E.span("Sanic"), class_="title"),
E.h2(class_="subtitle")("Build fast. Run fast."),
E.h3(class_="tagline")("Accelerate your web app development"),
self._do_buttons(language),
class_="hero-body",
)
def _do_buttons(self, language: str) -> Builder:
builder = E.div(class_="buttons is-centered")
with builder:
builder.a(
"Get Started",
class_="button is-primary",
href=f"/{language}/guide/getting-started.html",
)
builder.a(
"Help",
class_="button is-outlined",
href=f"/{language}/help.html",
)
builder.a(
"GitHub",
class_="button is-outlined",
href="https://github.com/sanic-org/sanic",
target="_blank",
)
return builder

View File

@@ -0,0 +1,45 @@
from contextlib import contextmanager
from typing import Generator
from webapp.display.layouts.elements.footer import do_footer
from webapp.display.layouts.elements.navbar import do_navbar
from webapp.display.layouts.elements.sidebar import do_sidebar
from sanic import Request
from .base import BaseLayout
class MainLayout(BaseLayout):
@contextmanager
def layout(
self, request: Request, full: bool = True
) -> Generator[None, None, None]:
if full:
with self.builder.div(class_="is-flex"):
self._sidebar(request)
with self.builder.main(class_="is-flex-grow-1"):
self._navbar(request)
with self.builder.div(class_="container", id="content"):
with self._content_wrapper():
yield
self._footer(request)
else:
with self._content_wrapper():
yield
self._footer(request)
@contextmanager
def _content_wrapper(self) -> Generator[None, None, None]:
with self.builder.section(class_="section"):
with self.builder.article():
yield
def _navbar(self, request: Request) -> None:
do_navbar(self.builder, request)
def _sidebar(self, request: Request) -> None:
do_sidebar(self.builder, request)
def _footer(self, request: Request) -> None:
do_footer(self.builder, request)

View File

@@ -0,0 +1,14 @@
from __future__ import annotations
from msgspec import Struct, field
class MenuItem(Struct, kw_only=False, omit_defaults=True):
label: str
path: str | None = None
href: str | None = None
items: list[MenuItem] = field(default_factory=list)
class GeneralConfig(Struct, kw_only=False):
current_version: str

View File

@@ -0,0 +1,140 @@
import re
from textwrap import dedent
from mistune import HTMLRenderer, create_markdown, escape
from mistune.directives import RSTDirective, TableOfContents
from mistune.util import safe_entity
from pygments import highlight
from pygments.formatters import html
from pygments.lexers import get_lexer_by_name
from html5tagger import HTML, Builder, E # type: ignore
from .code_style import SanicCodeStyle
from .plugins.attrs import Attributes
from .plugins.columns import Column
from .plugins.hook import Hook
from .plugins.mermaid import Mermaid
from .plugins.notification import Notification
from .plugins.span import span
from .plugins.tabs import Tabs
from .text import slugify
class DocsRenderer(HTMLRenderer):
def block_code(self, code: str, info: str | None = None):
builder = Builder("Block")
with builder.div(class_="code-block"):
if info:
lexer = get_lexer_by_name(info, stripall=False)
formatter = html.HtmlFormatter(
style=SanicCodeStyle,
wrapcode=True,
cssclass=f"highlight language-{info}",
)
builder(HTML(highlight(code, lexer, formatter)))
with builder.div(
class_="code-block__copy",
onclick="copyCode(this)",
):
builder.div(
class_="code-block__rectangle code-block__filled"
).div(class_="code-block__rectangle code-block__outlined")
else:
builder.pre(E.code(escape(code)))
return str(builder)
def heading(self, text: str, level: int, **attrs) -> str:
ident = slugify(text)
if level > 1:
text += self._make_tag(
"a", {"href": f"#{ident}", "class": "anchor"}, "#"
)
return self._make_tag(
f"h{level}", {"id": ident, "class": f"is-size-{level}"}, text
)
def link(self, text: str, url: str, title: str | None = None) -> str:
url = self.safe_url(url).removesuffix(".md")
if not url.endswith("/"):
url += ".html"
attributes: dict[str, str] = {"href": url}
if title:
attributes["title"] = safe_entity(title)
if url.startswith("http"):
attributes["target"] = "_blank"
attributes["rel"] = "nofollow noreferrer"
else:
attributes["hx-get"] = url
attributes["hx-target"] = "#content"
attributes["hx-swap"] = "innerHTML"
attributes["hx-push-url"] = "true"
return self._make_tag("a", attributes, text)
def span(self, text, classes, **attrs) -> str:
if classes:
attrs["class"] = classes
return self._make_tag("span", attrs, text)
def list(self, text: str, ordered: bool, **attrs) -> str:
tag = "ol" if ordered else "ul"
attrs["class"] = tag
return self._make_tag(tag, attrs, text)
def list_item(self, text: str, **attrs) -> str:
attrs["class"] = "li"
return self._make_tag("li", attrs, text)
def table(self, text: str, **attrs) -> str:
attrs["class"] = "table is-fullwidth is-bordered"
return self._make_tag("table", attrs, text)
def _make_tag(
self, tag: str, attributes: dict[str, str], text: str | None = None
) -> str:
attrs = " ".join(
f'{key}="{value}"' for key, value in attributes.items()
)
if text is None:
return f"<{tag} {attrs} />"
return f"<{tag} {attrs}>{text}</{tag}>"
RST_CODE_BLOCK_PATTERN = re.compile(
r"\.\.\scode-block::\s(\w+)\n\n((?:\n|(?:\s\s\s\s[^\n]*))+)"
)
_render_markdown = create_markdown(
renderer=DocsRenderer(),
plugins=[
RSTDirective(
[
# Admonition(),
Attributes(),
Notification(),
TableOfContents(),
Column(),
Mermaid(),
Tabs(),
Hook(),
]
),
"abbr",
"def_list",
"footnotes",
"mark",
"table",
span,
],
)
def render_markdown(text: str) -> str:
def replacer(match):
language = match.group(1)
code_block = dedent(match.group(2)).strip()
return f"```{language}\n{code_block}\n```\n\n"
text = RST_CODE_BLOCK_PATTERN.sub(replacer, text)
return _render_markdown(text)

View File

@@ -0,0 +1,4 @@
from .page import Page
from .renderer import PageRenderer
__all__ = ["Page", "PageRenderer"]

View File

@@ -0,0 +1,389 @@
from __future__ import annotations
import importlib
import inspect
import pkgutil
from collections import defaultdict
from dataclasses import dataclass, field
from html import escape
from docstring_parser import Docstring, DocstringParam, DocstringRaises
from docstring_parser import parse as parse_docstring
from docstring_parser.common import DocstringExample
from html5tagger import HTML, Builder, E # type: ignore
from ..markdown import render_markdown, slugify
@dataclass
class DocObject:
name: str
module_name: str
full_name: str
signature: inspect.Signature | None
docstring: Docstring
object_type: str = ""
methods: list[DocObject] = field(default_factory=list)
decorators: list[str] = field(default_factory=list)
def _extract_classes_methods(obj, full_name, docstrings):
methods = []
for method_name, method in inspect.getmembers(obj, _is_public_member):
try:
signature = _get_method_signature(method)
docstring = inspect.getdoc(method)
decorators = _detect_decorators(obj, method)
methods.append(
DocObject(
name=method_name,
module_name="",
full_name=f"{full_name}.{method_name}",
signature=signature,
docstring=parse_docstring(docstring or ""),
decorators=decorators,
object_type=_get_object_type(method),
)
)
except ValueError:
pass
docstrings[full_name].methods = methods
def _get_method_signature(method):
try:
return inspect.signature(method)
except TypeError:
signature = None
if func := getattr(method, "fget", None):
signature = inspect.signature(func)
return signature
def _is_public_member(obj: object) -> bool:
obj_name = getattr(obj, "__name__", "")
if func := getattr(obj, "fget", None):
obj_name = getattr(func, "__name__", "")
return (
not obj_name.startswith("_")
and not obj_name.isupper()
and (
inspect.ismethod(obj)
or inspect.isfunction(obj)
or isinstance(obj, property)
or isinstance(obj, property)
)
)
def _detect_decorators(cls, method):
decorators = []
method_name = getattr(method, "__name__", None)
if isinstance(cls.__dict__.get(method_name), classmethod):
decorators.append("classmethod")
if isinstance(cls.__dict__.get(method_name), staticmethod):
decorators.append("staticmethod")
if isinstance(method, property):
decorators.append("property")
return decorators
def _get_object_type(obj) -> str:
if inspect.isclass(obj):
return "class"
# If the object is a method, get the underlying function
if inspect.ismethod(obj):
obj = obj.__func__
# If the object is a coroutine or a coroutine function
if inspect.iscoroutine(obj) or inspect.iscoroutinefunction(obj):
return "async def"
return "def"
def organize_docobjects(package_name: str) -> dict[str, str]:
page_content: defaultdict[str, str] = defaultdict(str)
docobjects = _extract_docobjects(package_name)
for module, docobject in docobjects.items():
builder = Builder(name="Partial")
_docobject_to_html(docobject, builder)
ref = module.rsplit(".", module.count(".") - 1)[0]
page_content[f"/api/{ref}.md"] += str(builder)
return page_content
def _extract_docobjects(package_name: str) -> dict[str, DocObject]:
docstrings = {}
package = importlib.import_module(package_name)
for _, name, _ in pkgutil.walk_packages(
package.__path__, package_name + "."
):
module = importlib.import_module(name)
for obj_name, obj in inspect.getmembers(module):
if (
obj_name.startswith("_")
or inspect.getmodule(obj) != module
or not callable(obj)
):
continue
try:
signature = inspect.signature(obj)
except ValueError:
signature = None
docstring = inspect.getdoc(obj)
full_name = f"{name}.{obj_name}"
docstrings[full_name] = DocObject(
name=obj_name,
full_name=full_name,
module_name=name,
signature=signature,
docstring=parse_docstring(docstring or ""),
object_type=_get_object_type(obj),
)
if inspect.isclass(obj):
_extract_classes_methods(obj, full_name, docstrings)
return docstrings
def _docobject_to_html(
docobject: DocObject, builder: Builder, as_method: bool = False
) -> None:
anchor_id = slugify(docobject.full_name.replace(".", "-"))
anchor = E.a("#", class_="anchor", href=f"#{anchor_id}")
class_name, heading = _define_heading_and_class(
docobject, anchor, as_method
)
with builder.div(class_=class_name):
builder(heading)
if docobject.docstring.short_description:
builder.div(
HTML(render_markdown(docobject.docstring.short_description)),
class_="short-description mt-3 is-size-5",
)
if docobject.object_type == "class":
mro = [
item
for idx, item in enumerate(
inspect.getmro(
getattr(
importlib.import_module(docobject.module_name),
docobject.name,
)
)
)
if idx > 0 and item not in (object, type)
]
if mro:
builder.div(
E.span("Inherits from: ", class_="is-italic"),
E.span(
", ".join([cls.__name__ for cls in mro]),
class_="has-text-weight-bold",
),
class_="short-description mt-3 is-size-5",
)
builder.p(
HTML(
_signature_to_html(
docobject.name,
docobject.object_type,
docobject.signature,
docobject.decorators,
)
),
class_="signature notification is-family-monospace",
)
if docobject.docstring.long_description:
builder.div(
HTML(render_markdown(docobject.docstring.long_description)),
class_="long-description mt-3",
)
if docobject.docstring.params:
with builder.div(class_="box mt-5"):
builder.h5(
"Parameters", class_="is-size-5 has-text-weight-bold"
)
_render_params(builder, docobject.docstring.params)
if docobject.docstring.returns:
_render_returns(builder, docobject)
if docobject.docstring.raises:
_render_raises(builder, docobject.docstring.raises)
if docobject.docstring.examples:
_render_examples(builder, docobject.docstring.examples)
for method in docobject.methods:
_docobject_to_html(method, builder, as_method=True)
def _signature_to_html(
name: str,
object_type: str,
signature: inspect.Signature | None,
decorators: list[str],
) -> str:
parts = []
parts.append("<span class='function-signature'>")
for decorator in decorators:
parts.append(
f"<span class='function-decorator'>@{decorator}</span><br>"
)
parts.append(
f"<span class='is-italic'>{object_type}</span> "
f"<span class='has-text-weight-bold'>{name}</span>("
)
if not signature:
parts.append("<span class='param-name'>self</span>)")
parts.append("</span>")
return "".join(parts)
for i, param in enumerate(signature.parameters.values()):
parts.append(f"<span class='param-name'>{escape(param.name)}</span>")
annotation = ""
if param.annotation != inspect.Parameter.empty:
annotation = escape(str(param.annotation))
parts.append(
f": <span class='param-annotation'>{annotation}</span>"
)
if param.default != inspect.Parameter.empty:
default = escape(str(param.default))
if annotation == "str":
default = f'"{default}"'
parts.append(f" = <span class='param-default'>{default}</span>")
if i < len(signature.parameters) - 1:
parts.append(", ")
parts.append(")")
if signature.return_annotation != inspect.Signature.empty:
return_annotation = escape(str(signature.return_annotation))
parts.append(
f": -> <span class='return-annotation'>{return_annotation}</span>"
)
parts.append("</span>")
return "".join(parts)
def _define_heading_and_class(
docobject: DocObject, anchor: Builder, as_method: bool
) -> tuple[str, Builder]:
anchor_id = slugify(docobject.full_name.replace(".", "-"))
anchor = E.a("#", class_="anchor", href=f"#{anchor_id}")
if as_method:
class_name = "method"
heading = E.h3(
docobject.name,
anchor,
class_="is-size-4 has-text-weight-bold mt-6",
id_=anchor_id,
)
else:
class_name = "docobject"
heading = E.h2(
E.span(docobject.module_name, class_="has-text-weight-light"),
".",
E.span(docobject.name, class_="has-text-weight-bold is-size-1"),
anchor,
class_="is-size-2",
id_=anchor_id,
)
return class_name, heading
def _render_params(builder: Builder, params: list[DocstringParam]) -> None:
for param in params:
with builder.dl(class_="mt-2"):
dt_args = [param.arg_name]
if param.type_name:
parts = [
E.br(),
E.span(
param.type_name,
class_="has-text-weight-normal has-text-purple ml-2",
),
]
dt_args.extend(parts)
builder.dt(*dt_args, class_="is-family-monospace")
builder.dd(
HTML(
render_markdown(
param.description
or param.arg_name
or param.type_name
or ""
)
)
)
def _render_raises(builder: Builder, raises: list[DocstringRaises]) -> None:
with builder.div(class_="box mt-5"):
builder.h5("Raises", class_="is-size-5 has-text-weight-bold")
for raise_ in raises:
with builder.dl(class_="mt-2"):
builder.dt(raise_.type_name, class_="is-family-monospace")
builder.dd(
HTML(
render_markdown(
raise_.description or raise_.type_name or ""
)
)
)
def _render_returns(builder: Builder, docobject: DocObject) -> None:
assert docobject.docstring.returns
return_type = docobject.docstring.returns.type_name
if not return_type or return_type == "None":
return
with builder.div(class_="box mt-5"):
if not return_type and docobject.signature:
return_type = docobject.signature.return_annotation
if not return_type or return_type == inspect.Signature.empty:
return_type = "N/A"
term = (
"Return"
if not docobject.docstring.returns.is_generator
else "Yields"
)
builder.h5(term, class_="is-size-5 has-text-weight-bold")
with builder.dl(class_="mt-2"):
builder.dt(return_type, class_="is-family-monospace")
builder.dd(
HTML(
render_markdown(
docobject.docstring.returns.description
or docobject.docstring.returns.type_name
or ""
)
)
)
def _render_examples(
builder: Builder, examples: list[DocstringExample]
) -> None:
with builder.div(class_="box mt-5"):
builder.h5("Examples", class_="is-size-5 has-text-weight-bold")
for example in examples:
with builder.div(class_="mt-2"):
builder(
HTML(
render_markdown(
example.description or example.snippet or ""
)
)
)

View File

@@ -0,0 +1,164 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from typing import Type
from frontmatter import parse
from ..layouts.base import BaseLayout
from ..layouts.home import HomeLayout
from ..layouts.main import MainLayout
from ..markdown import render_markdown
from .docobject import organize_docobjects
_PAGE_CACHE: dict[
str, dict[str, tuple[Page | None, Page | None, Page | None]]
] = {}
_LAYOUTS_CACHE: dict[str, Type[BaseLayout]] = {
"home": HomeLayout,
"main": MainLayout,
}
_DEFAULT = "en"
@dataclass
class PageMeta:
language: str = _DEFAULT
title: str = ""
description: str = ""
layout: str = "main"
features: list[dict[str, str]] = field(default_factory=list)
@dataclass
class Page:
path: Path
content: str
meta: PageMeta = field(default_factory=PageMeta)
_relative_path: Path | None = None
next_page: Page | None = None
previous_page: Page | None = None
anchors: list[str] = field(default_factory=list)
DEFAULT_LANGUAGE = _DEFAULT
def get_layout(self) -> Type[BaseLayout]:
return _LAYOUTS_CACHE[self.meta.layout]
@property
def relative_path(self) -> Path:
if self._relative_path is None:
raise RuntimeError("Page not initialized")
return self._relative_path
@classmethod
def get(
cls, language: str, path: str
) -> tuple[Page | None, Page | None, Page | None]:
if path.endswith("/") or not path:
path += "index.html"
if not path.endswith(".md"):
path = path.removesuffix(".html") + ".md"
if language == "api":
path = f"/api/{path}"
return _PAGE_CACHE.get(language, {}).get(path, (None, None, None))
@classmethod
def load_pages(cls, base_path: Path, page_order: list[str]) -> list[Page]:
output: list[Page] = []
for path in base_path.glob("**/*.md"):
relative = path.relative_to(base_path)
language = relative.parts[0]
name = "/".join(relative.parts[1:])
page = cls._load_page(path)
output.append(page)
page._relative_path = relative
_PAGE_CACHE.setdefault(language, {})[name] = (
None,
page,
None,
)
_PAGE_CACHE["api"] = {}
for language, pages in _PAGE_CACHE.items():
for name, (_, current, _) in pages.items():
previous_page = None
next_page = None
try:
index = page_order.index(name)
except ValueError:
continue
try:
if index > 0:
previous_page = pages[page_order[index - 1]][1]
except KeyError:
pass
try:
if index < len(page_order) - 1:
next_page = pages[page_order[index + 1]][1]
except KeyError:
pass
pages[name] = (previous_page, current, next_page)
previous_page = None
next_page = None
api_pages = cls._load_api_pages()
filtered_order = [ref for ref in page_order if ref in api_pages]
for idx, ref in enumerate(filtered_order):
current_page = api_pages[ref]
previous_page = None
next_page = None
try:
if idx > 0:
previous_page = api_pages[filtered_order[idx - 1]]
except KeyError:
pass
try:
if idx < len(filtered_order) - 1:
next_page = api_pages[filtered_order[idx + 1]]
except KeyError:
pass
_PAGE_CACHE["api"][ref] = (previous_page, current_page, next_page)
return output
@staticmethod
def _load_page(path: Path) -> Page:
raw = path.read_text()
metadata, raw_content = parse(raw)
content = render_markdown(raw_content)
page = Page(
path=path,
content=content,
meta=PageMeta(**metadata),
)
if not page.meta.title:
page.meta.title = page.path.stem.replace("-", " ").title()
for line in raw.splitlines():
if line.startswith("##") and not line.startswith("###"):
line = line.lstrip("#").strip()
page.anchors.append(line)
return page
@staticmethod
def _load_api_pages() -> dict[str, Page]:
docstring_content = organize_docobjects("sanic")
output: dict[str, Page] = {}
for module, content in docstring_content.items():
path = Path(module)
page = Page(
path=path,
content=content,
meta=PageMeta(
title=path.stem,
description="",
layout="main",
),
)
page._relative_path = Path(f"./{module}")
output[module] = page
return output

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from contextlib import contextmanager
from typing import Type
from webapp.display.base import BaseRenderer
from html5tagger import HTML, Builder # type: ignore
from sanic import Request
from ..layouts.base import BaseLayout
from .page import Page
class PageRenderer(BaseRenderer):
def render(self, request: Request, language: str, path: str) -> Builder:
builder = self.get_builder(
full=request.headers.get("HX-Request") is None,
language=language,
)
self._body(request, builder, language, path)
return builder
def _body(
self, request: Request, builder: Builder, language: str, path: str
):
prev_page, current_page, next_page = Page.get(language, path)
request.ctx.language = (
Page.DEFAULT_LANGUAGE if language == "api" else language
)
request.ctx.current_page = current_page
request.ctx.previous_page = prev_page
request.ctx.next_page = next_page
with self._base(request, builder, current_page):
if current_page is None:
builder.h1("Not found")
return
builder(HTML(current_page.content))
@contextmanager
def _base(self, request: Request, builder: Builder, page: Page | None):
layout_type: Type[BaseLayout] = (
page.get_layout() if page else BaseLayout
)
layout = layout_type(builder)
with layout(request, builder.full):
yield

View File

View File

@@ -0,0 +1,40 @@
from re import Match
from textwrap import dedent
from typing import Any
from mistune.block_parser import BlockParser
from mistune.core import BlockState
from mistune.directives import DirectivePlugin
from html5tagger import HTML, E
class Attributes(DirectivePlugin):
def __call__(self, directive, md):
directive.register("attrs", self.parse)
if md.renderer.NAME == "html":
md.renderer.register("attrs", self._render)
def parse(
self, block: BlockParser, m: Match, state: BlockState
) -> dict[str, Any]:
info = m.groupdict()
options = dict(self.parse_options(m))
new_state = block.state_cls()
new_state.process(dedent(info["text"]))
block.parse(new_state)
options.setdefault("class_", "additional-attributes")
classes = options.pop("class", "")
if classes:
options["class_"] += f" {classes}"
return {
"type": "attrs",
"text": info["text"],
"children": new_state.tokens,
"attrs": options,
}
def _render(self, _, text: str, **attrs) -> str:
return str(E.div(HTML(text), **attrs))

View File

@@ -0,0 +1,45 @@
from re import Match
from textwrap import dedent
from typing import Any
from mistune import HTMLRenderer
from mistune.block_parser import BlockParser
from mistune.core import BlockState
from mistune.directives import DirectivePlugin, RSTDirective
from mistune.markdown import Markdown
class Column(DirectivePlugin):
def parse(
self, block: BlockParser, m: Match, state: BlockState
) -> dict[str, Any]:
info = m.groupdict()
new_state = block.state_cls()
new_state.process(dedent(info["text"]))
block.parse(new_state)
return {
"type": "column",
"text": info["text"],
"children": new_state.tokens,
"attrs": {},
}
def __call__( # type: ignore
self, directive: RSTDirective, md: Markdown
) -> None:
directive.register("column", self.parse)
if md.renderer.NAME == "html":
md.renderer.register("column", self._render_column)
def _render_column(self, renderer: HTMLRenderer, text: str, **attrs):
start = (
'<div class="columns mt-3 is-multiline">\n'
if attrs.get("first")
else ""
)
end = "</div>\n" if attrs.get("last") else ""
col = f'<div class="column is-half">{text}</div>\n'
return start + (col) + end

View File

@@ -0,0 +1,31 @@
from mistune.core import BlockState
from mistune.directives import DirectivePlugin, RSTDirective
from mistune.markdown import Markdown
class Hook(DirectivePlugin):
def __call__( # type: ignore
self, directive: RSTDirective, md: Markdown
) -> None:
if md.renderer.NAME == "html":
md.before_render_hooks.append(self._hook)
def _hook(self, md: Markdown, state: BlockState) -> None:
prev = None
for idx, token in enumerate(state.tokens):
for type_ in ("column", "tab"):
if token["type"] == type_:
maybe_next = (
state.tokens[idx + 1]
if idx + 1 < len(state.tokens)
else None
)
token.setdefault("attrs", {})
if prev and prev["type"] != type_:
token["attrs"]["first"] = True
if (
maybe_next and maybe_next["type"] != type_
) or not maybe_next:
token["attrs"]["last"] = True
prev = token

View File

@@ -0,0 +1,41 @@
from html import unescape
from re import Match
from textwrap import dedent
from typing import Any
from mistune import HTMLRenderer
from mistune.block_parser import BlockParser
from mistune.core import BlockState
from mistune.directives import DirectivePlugin, RSTDirective
from mistune.markdown import Markdown
from html5tagger import HTML, E
class Mermaid(DirectivePlugin):
def parse(
self, block: BlockParser, m: Match, state: BlockState
) -> dict[str, Any]:
info = m.groupdict()
new_state = block.state_cls()
new_state.process(dedent(info["text"]))
block.parse(new_state)
text = HTML(info["text"].strip())
return {
"type": "mermaid",
"text": text,
"children": [{"type": "text", "text": text}],
"attrs": {},
}
def __call__(self, directive: RSTDirective, md: Markdown) -> None: # type: ignore
directive.register("mermaid", self.parse)
if md.renderer.NAME == "html":
md.renderer.register("mermaid", self._render_mermaid)
def _render_mermaid(self, renderer: HTMLRenderer, text: str, **attrs):
return str(E.div(class_="mermaid")(HTML(unescape(text))))

View File

@@ -0,0 +1,47 @@
from mistune.directives import Admonition
from html5tagger import HTML, E
class Notification(Admonition):
SUPPORTED_NAMES = {
"success",
"info",
"warning",
"danger",
"tip",
"new",
"note",
}
def __call__(self, directive, md):
for name in self.SUPPORTED_NAMES:
directive.register(name, self.parse)
if md.renderer.NAME == "html":
md.renderer.register("admonition", self._render_admonition)
md.renderer.register(
"admonition_title", self._render_admonition_title
)
md.renderer.register(
"admonition_content", self._render_admonition_content
)
def _render_admonition(self, _, text, name, **attrs) -> str:
return str(
E.div(
HTML(text),
class_=f"notification is-{name}",
)
)
def _render_admonition_title(self, _, text) -> str:
return str(
E.p(
text,
class_="notification-title",
)
)
def _render_admonition_content(self, _, text) -> str:
return text

View File

@@ -0,0 +1,26 @@
import re
from mistune.markdown import Markdown
def parse_inline_span(inline, m: re.Match, state):
state.append_token(
{
"type": "span",
"attrs": {"classes": m.group("classes")},
"raw": m.group("content"),
}
)
return m.end()
SPAN_PATTERN = r"{span:(?:(?P<classes>[^\:]+?):)?(?P<content>.*?)}"
def span(md: Markdown) -> None:
md.inline.register(
"span",
SPAN_PATTERN,
parse_inline_span,
before="link",
)

View File

@@ -0,0 +1,50 @@
from re import Match
from textwrap import dedent
from typing import Any
from mistune import HTMLRenderer
from mistune.block_parser import BlockParser
from mistune.core import BlockState
from mistune.directives import DirectivePlugin, RSTDirective
from mistune.markdown import Markdown
class Tabs(DirectivePlugin):
def parse(
self, block: BlockParser, m: Match, state: BlockState
) -> dict[str, Any]:
info = m.groupdict()
new_state = block.state_cls()
new_state.process(dedent(info["text"]))
block.parse(new_state)
return {
"type": "tab",
"text": info["text"],
"children": new_state.tokens,
"attrs": {
"title": info["title"],
},
}
def __call__( # type: ignore
self,
directive: RSTDirective,
md: Markdown,
) -> None:
directive.register("tab", self.parse)
if md.renderer.NAME == "html":
md.renderer.register("tab", self._render_tab)
def _render_tab(self, renderer: HTMLRenderer, text: str, **attrs):
start = '<div class="tabs mt-6"><ul>\n' if attrs.get("first") else ""
end = (
'</ul></div><div class="tab-display"></div>\n'
if attrs.get("last")
else ""
)
content = f'<div class="tab-content">{text}</div>\n'
tab = f'<li><a>{attrs["title"]}</a>{content}</li>\n'
return start + tab + end

View File

View File

@@ -0,0 +1,68 @@
from contextlib import contextmanager
from urllib.parse import unquote
from webapp.display.search.search import Searcher
from html5tagger import Builder, E # type: ignore
from sanic import Request
from ..base import BaseRenderer
from ..layouts.main import MainLayout
class SearchRenderer(BaseRenderer):
def render(
self, request: Request, language: str, searcher: Searcher, full: bool
) -> Builder:
builder = self.get_builder(
full=request.headers.get("HX-Request") is None,
language=language,
)
self._body(request, builder, language, searcher, full)
return builder
def _body(
self,
request: Request,
builder: Builder,
language: str,
searcher: Searcher,
full: bool,
):
with self._base(request, builder, full):
builder.h1("Search")
self._results(request, builder, searcher, language)
@contextmanager
def _base(self, request: Request, builder: Builder, full: bool):
layout = MainLayout(builder)
with layout(request, full):
yield
def _results(
self,
request: Request,
builder: Builder,
searcher: Searcher,
language: str,
):
query = unquote(request.args.get("q", ""))
results = searcher.search(query, language)
if not query or not results:
builder.p("No results found")
return
with builder.div(class_="container"):
with builder.ul():
for _, doc in results:
builder.li(
E.a(
doc.title,
href=f"/{doc.page.relative_path}",
hx_get=f"/{doc.page.relative_path}",
hx_target="#content",
hx_swap="innerHTML",
hx_push_url="true",
),
f" - {doc.page.relative_path}",
)

View File

@@ -0,0 +1,175 @@
from __future__ import annotations
from collections import Counter
from pathlib import Path
from typing import ClassVar
from msgspec import Struct
from webapp.display.page import Page
class Stemmer:
STOP_WORDS: ClassVar[set[str]] = set(
"a about above after again against all am an and any are aren't as at be because been before being below between both but by can't cannot could couldn't did didn't do does doesn't doing don't down during each few for from further had hadn't has hasn't have haven't having he he'd he'll he's her here here's hers herself him himself his how how's i i'd i'll i'm i've if in into is isn't it it's its itself let's me more most mustn't my myself no nor not of off on once only or other ought our ours ourselves out over own same shan't she she'd she'll she's should shouldn't so some such than that that's the their theirs them themselves then there there's these they they'd they'll they're they've this those through to too under until up very was wasn't we we'd we'll we're we've were weren't what what's when when's where where's which while who who's whom why why's with won't would wouldn't you you'd you'll you're you've your yours yourself yourselves".split() # noqa: E501
)
PREFIXES = set("auto be fore over re un under".split())
SUFFIXES = set(
"able al ance ant ate ed en er ful hood ing ion ish ity ive ize less ly ment ness ous ship sion tion y".split() # noqa: E501
)
VOWELS = set("aeiou")
PLURALIZATION = set("s es ies".split())
def stem(self, word: str) -> str:
if word in self.STOP_WORDS:
return word
if word in self.PREFIXES:
return word
for suffix in self.SUFFIXES | self.PLURALIZATION:
if word.endswith(suffix):
return self._stem(word[: -len(suffix)])
return word
def _stem(self, word: str) -> str:
if word.endswith("e"):
return word[:-1]
if word.endswith("y") and word[-2] not in self.VOWELS:
return word[:-1]
return word
def __call__(self, word: str) -> str:
return self.stem(word)
class Document(Struct, kw_only=True):
TITLE_WEIGHT: ClassVar[int] = 3
BODY_WEIGHT: ClassVar[int] = 1
page: Page
language: str
term_frequency: dict[str, float] = {}
@property
def title(self) -> str:
return self.page.meta.title
@property
def text(self) -> str:
return self.page.content
@property
def weighted_text(self) -> str:
"""Return the text with the title repeated."""
return " ".join(
[self.title] * self.TITLE_WEIGHT + [self.text] * self.BODY_WEIGHT
)
def _term_frequency(self, stemmer: Stemmer) -> None:
"""Count the number of times each word appears in the document."""
words = [
stemmer(word)
for word in self.weighted_text.lower().split()
if word not in Stemmer.STOP_WORDS
]
num_words = len(words)
word_count = Counter(words)
self.term_frequency = {
word: count / num_words for word, count in word_count.items()
}
def process(self, stemmer: Stemmer) -> Document:
"""Process the document."""
self._term_frequency(stemmer)
return self
def _inverse_document_frequency(docs: list[Document]) -> dict[str, float]:
"""Count the number of documents each word appears in."""
num_docs = len(docs)
word_count: Counter[str] = Counter()
for doc in docs:
word_count.update(doc.term_frequency.keys())
return {word: num_docs / count for word, count in word_count.items()}
def _tf_idf_vector(
document: Document, idf: dict[str, float]
) -> dict[str, float]:
"""Calculate the TF-IDF vector for a document."""
return {
word: tf * idf[word]
for word, tf in document.term_frequency.items()
if word in idf
}
def _cosine_similarity(
vec1: dict[str, float], vec2: dict[str, float]
) -> float:
"""Calculate the cosine similarity between two vectors."""
if not vec1 or not vec2:
return 0.0
dot_product = sum(vec1.get(word, 0) * vec2.get(word, 0) for word in vec1)
magnitude1 = sum(value**2 for value in vec1.values()) ** 0.5
magnitude2 = sum(value**2 for value in vec2.values()) ** 0.5
return dot_product / (magnitude1 * magnitude2)
def _search(
query: str,
language: str,
vectors: list[dict[str, float]],
idf: dict[str, float],
documents: list[Document],
stemmer: Stemmer,
) -> list[tuple[float, Document]]:
dummy_page = Page(Path(), query)
tf_idf_query = _tf_idf_vector(
Document(page=dummy_page, language=language).process(stemmer), idf
)
similarities = [
_cosine_similarity(tf_idf_query, vector) for vector in vectors
]
return [
(similarity, document)
for similarity, document in sorted(
zip(similarities, documents),
reverse=True,
key=lambda pair: pair[0],
)[:10]
if similarity > 0
]
class Searcher:
def __init__(
self,
stemmer: Stemmer,
documents: list[Document],
):
self._documents: dict[str, list[Document]] = {}
for document in documents:
self._documents.setdefault(document.language, []).append(document)
self._idf = {
language: _inverse_document_frequency(documents)
for language, documents in self._documents.items()
}
self._vectors = {
language: [
_tf_idf_vector(document, self._idf[language])
for document in documents
]
for language, documents in self._documents.items()
}
self._stemmer = stemmer
def search(
self, query: str, language: str
) -> list[tuple[float, Document]]:
return _search(
query,
language,
self._vectors[language],
self._idf[language],
self._documents[language],
self._stemmer,
)

View File

@@ -0,0 +1,7 @@
import re
SLUGIFY_PATTERN = re.compile(r"[^a-zA-Z0-9-]")
def slugify(text: str) -> str:
return SLUGIFY_PATTERN.sub("", text.lower().replace(" ", "-"))