Conversion of User Guide to the SHH stack (#2781)
This commit is contained in:
0
guide/webapp/display/__init__.py
Normal file
0
guide/webapp/display/__init__.py
Normal file
27
guide/webapp/display/base.py
Normal file
27
guide/webapp/display/base.py
Normal 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
|
||||
20
guide/webapp/display/code_style.py
Normal file
20
guide/webapp/display/code_style.py
Normal 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",
|
||||
}
|
||||
0
guide/webapp/display/layouts/__init__.py
Normal file
0
guide/webapp/display/layouts/__init__.py
Normal file
25
guide/webapp/display/layouts/base.py
Normal file
25
guide/webapp/display/layouts/base.py
Normal 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
|
||||
0
guide/webapp/display/layouts/elements/__init__.py
Normal file
0
guide/webapp/display/layouts/elements/__init__.py
Normal file
75
guide/webapp/display/layouts/elements/footer.py
Normal file
75
guide/webapp/display/layouts/elements/footer.py
Normal 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",
|
||||
)
|
||||
68
guide/webapp/display/layouts/elements/navbar.py
Normal file
68
guide/webapp/display/layouts/elements/navbar.py
Normal 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)
|
||||
118
guide/webapp/display/layouts/elements/sidebar.py
Normal file
118
guide/webapp/display/layouts/elements/sidebar.py
Normal 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
|
||||
50
guide/webapp/display/layouts/home.py
Normal file
50
guide/webapp/display/layouts/home.py
Normal 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
|
||||
45
guide/webapp/display/layouts/main.py
Normal file
45
guide/webapp/display/layouts/main.py
Normal 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)
|
||||
14
guide/webapp/display/layouts/models.py
Normal file
14
guide/webapp/display/layouts/models.py
Normal 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
|
||||
140
guide/webapp/display/markdown.py
Normal file
140
guide/webapp/display/markdown.py
Normal 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)
|
||||
4
guide/webapp/display/page/__init__.py
Normal file
4
guide/webapp/display/page/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .page import Page
|
||||
from .renderer import PageRenderer
|
||||
|
||||
__all__ = ["Page", "PageRenderer"]
|
||||
389
guide/webapp/display/page/docobject.py
Normal file
389
guide/webapp/display/page/docobject.py
Normal 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 ""
|
||||
)
|
||||
)
|
||||
)
|
||||
164
guide/webapp/display/page/page.py
Normal file
164
guide/webapp/display/page/page.py
Normal 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
|
||||
47
guide/webapp/display/page/renderer.py
Normal file
47
guide/webapp/display/page/renderer.py
Normal 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
|
||||
0
guide/webapp/display/plugins/__init__.py
Normal file
0
guide/webapp/display/plugins/__init__.py
Normal file
40
guide/webapp/display/plugins/attrs.py
Normal file
40
guide/webapp/display/plugins/attrs.py
Normal 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))
|
||||
45
guide/webapp/display/plugins/columns.py
Normal file
45
guide/webapp/display/plugins/columns.py
Normal 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
|
||||
31
guide/webapp/display/plugins/hook.py
Normal file
31
guide/webapp/display/plugins/hook.py
Normal 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
|
||||
41
guide/webapp/display/plugins/mermaid.py
Normal file
41
guide/webapp/display/plugins/mermaid.py
Normal 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))))
|
||||
47
guide/webapp/display/plugins/notification.py
Normal file
47
guide/webapp/display/plugins/notification.py
Normal 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
|
||||
26
guide/webapp/display/plugins/span.py
Normal file
26
guide/webapp/display/plugins/span.py
Normal 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",
|
||||
)
|
||||
50
guide/webapp/display/plugins/tabs.py
Normal file
50
guide/webapp/display/plugins/tabs.py
Normal 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
|
||||
0
guide/webapp/display/search/__init__.py
Normal file
0
guide/webapp/display/search/__init__.py
Normal file
68
guide/webapp/display/search/renderer.py
Normal file
68
guide/webapp/display/search/renderer.py
Normal 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}",
|
||||
)
|
||||
175
guide/webapp/display/search/search.py
Normal file
175
guide/webapp/display/search/search.py
Normal 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,
|
||||
)
|
||||
7
guide/webapp/display/text.py
Normal file
7
guide/webapp/display/text.py
Normal 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(" ", "-"))
|
||||
Reference in New Issue
Block a user