bazaar-contrib-server 0.3.0rc1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bazaar_contrib_server/__init__.py +112 -0
- bazaar_contrib_server/clock.py +40 -0
- bazaar_contrib_server/contracts.py +196 -0
- bazaar_contrib_server/i18n.py +84 -0
- bazaar_contrib_server/markdown.py +83 -0
- bazaar_contrib_server/pages.py +226 -0
- bazaar_contrib_server/py.typed +0 -0
- bazaar_contrib_server/refs.py +94 -0
- bazaar_contrib_server/resources/locales/en.toml +23 -0
- bazaar_contrib_server/resources/locales/zh-CN.toml +23 -0
- bazaar_contrib_server/resources/static/THIRD_PARTY.md +58 -0
- bazaar_contrib_server/resources/static/alpine-3.17.4.min.js +21 -0
- bazaar_contrib_server/resources/static/alpine-LICENSE.txt +21 -0
- bazaar_contrib_server/resources/static/alpine-collapse-3.17.4.min.js +1 -0
- bazaar_contrib_server/resources/static/alpine-collapse-LICENSE.txt +21 -0
- bazaar_contrib_server/resources/static/alpine-focus-3.17.4.min.js +18 -0
- bazaar_contrib_server/resources/static/alpine-focus-LICENSE.txt +21 -0
- bazaar_contrib_server/resources/static/base.css +175 -0
- bazaar_contrib_server/resources/static/embed.js +86 -0
- bazaar_contrib_server/resources/static/favicon.svg +9 -0
- bazaar_contrib_server/resources/static/fonts/fraunces-LICENSE.txt +93 -0
- bazaar_contrib_server/resources/static/fonts/fraunces-latin-full-italic.woff2 +0 -0
- bazaar_contrib_server/resources/static/fonts/fraunces-latin-full-normal.woff2 +0 -0
- bazaar_contrib_server/resources/static/fonts/inter-LICENSE.txt +93 -0
- bazaar_contrib_server/resources/static/fonts/inter-latin-wght-normal.woff2 +0 -0
- bazaar_contrib_server/resources/static/fonts/jetbrains-mono-LICENSE.txt +93 -0
- bazaar_contrib_server/resources/static/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- bazaar_contrib_server/resources/static/htmx-LICENSE.txt +13 -0
- bazaar_contrib_server/resources/static/htmx.min.js +1 -0
- bazaar_contrib_server/resources/static/hx-alpine-compat-4.0.0.js +110 -0
- bazaar_contrib_server/resources/static/shell.css +45 -0
- bazaar_contrib_server/resources/templates/server/base.html +27 -0
- bazaar_contrib_server/resources/templates/server/code.html +1 -0
- bazaar_contrib_server/resources/templates/server/error.html +8 -0
- bazaar_contrib_server/resources/templates/server/icons/chevron-left.svg +15 -0
- bazaar_contrib_server/resources/templates/server/icons/chevron-right.svg +1 -0
- bazaar_contrib_server/resources/templates/server/icons/layout-dashboard.svg +1 -0
- bazaar_contrib_server/resources/templates/server/icons/log-out.svg +1 -0
- bazaar_contrib_server/resources/templates/server/icons/settings.svg +1 -0
- bazaar_contrib_server/resources/templates/server/icons.html +3 -0
- bazaar_contrib_server/resources/templates/server/image.html +1 -0
- bazaar_contrib_server/resources/templates/server/mark.svg +9 -0
- bazaar_contrib_server/resources/templates/server/overview.html +5 -0
- bazaar_contrib_server/resources/templates/server/overview_board.html +17 -0
- bazaar_contrib_server/resources/templates/server/settings.html +16 -0
- bazaar_contrib_server/resources/templates/server/shell.html +36 -0
- bazaar_contrib_server/resources/templates/server/theme.css +9 -0
- bazaar_contrib_server/resources/themes/day.toml +41 -0
- bazaar_contrib_server/resources/themes/night.toml +37 -0
- bazaar_contrib_server/schema.py +41 -0
- bazaar_contrib_server/shell.py +505 -0
- bazaar_contrib_server/themes.py +48 -0
- bazaar_contrib_server-0.3.0rc1.dist-info/METADATA +88 -0
- bazaar_contrib_server-0.3.0rc1.dist-info/RECORD +57 -0
- bazaar_contrib_server-0.3.0rc1.dist-info/WHEEL +4 -0
- bazaar_contrib_server-0.3.0rc1.dist-info/entry_points.txt +5 -0
- bazaar_contrib_server-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The server itself: the shell modules put their pages in, its registrations,
|
|
2
|
+
the themes, and the two themes it comes with."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
lazy import asyncio
|
|
8
|
+
lazy import tomllib
|
|
9
|
+
lazy from collections.abc import AsyncIterator
|
|
10
|
+
lazy from importlib.resources import files
|
|
11
|
+
|
|
12
|
+
from bazaar_contrib_http import HTTP
|
|
13
|
+
from bazaar_contrib_server_database import SERVER_DB, SERVER_SCHEMA
|
|
14
|
+
from bazaar_contrib_server_migrations import SERVER_MIGRATIONS
|
|
15
|
+
from bazaar_core import Context, Contrib
|
|
16
|
+
lazy from starlette.exceptions import HTTPException
|
|
17
|
+
lazy from starlette.requests import HTTPConnection
|
|
18
|
+
|
|
19
|
+
from .contracts import (
|
|
20
|
+
SERVER,
|
|
21
|
+
THEME,
|
|
22
|
+
Account,
|
|
23
|
+
Login,
|
|
24
|
+
OverviewItem,
|
|
25
|
+
PermissionItem,
|
|
26
|
+
Server,
|
|
27
|
+
SettingsItem,
|
|
28
|
+
SidebarItem,
|
|
29
|
+
Theme,
|
|
30
|
+
Themes,
|
|
31
|
+
Tokens,
|
|
32
|
+
)
|
|
33
|
+
lazy from .i18n import LANGUAGES, Translator
|
|
34
|
+
lazy from .pages import Pages
|
|
35
|
+
lazy from .refs import Refs
|
|
36
|
+
lazy from .schema import MODULE
|
|
37
|
+
lazy from .shell import Shell
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"LANGUAGES",
|
|
41
|
+
"SERVER",
|
|
42
|
+
"THEME",
|
|
43
|
+
"Account",
|
|
44
|
+
"Login",
|
|
45
|
+
"OverviewItem",
|
|
46
|
+
"Pages",
|
|
47
|
+
"PermissionItem",
|
|
48
|
+
"Refs",
|
|
49
|
+
"Server",
|
|
50
|
+
"SettingsItem",
|
|
51
|
+
"SidebarItem",
|
|
52
|
+
"Theme",
|
|
53
|
+
"Themes",
|
|
54
|
+
"Tokens",
|
|
55
|
+
"Translator",
|
|
56
|
+
"account",
|
|
57
|
+
"day",
|
|
58
|
+
"night",
|
|
59
|
+
"permitted",
|
|
60
|
+
"server",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def account(connection: HTTPConnection) -> Account:
|
|
65
|
+
"""The account a request or a WebSocket behind the login is from; a
|
|
66
|
+
FastAPI dependency."""
|
|
67
|
+
return connection.state.account
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def permitted(point: str):
|
|
71
|
+
"""A FastAPI dependency refusing accounts without the permission point."""
|
|
72
|
+
|
|
73
|
+
def check(connection: HTTPConnection) -> None:
|
|
74
|
+
if not account(connection).allows(point):
|
|
75
|
+
raise HTTPException(status_code=403)
|
|
76
|
+
|
|
77
|
+
return check
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@asynccontextmanager
|
|
81
|
+
async def _server(ctx: Context) -> AsyncIterator[None]:
|
|
82
|
+
async with ctx.accept(SERVER_SCHEMA).register(MODULE):
|
|
83
|
+
shell = await Shell.open(ctx.accept(HTTP), ctx.accept(SERVER_DB))
|
|
84
|
+
async with shell.serving():
|
|
85
|
+
ctx.offer(SERVER, shell)
|
|
86
|
+
ctx.offer(THEME, shell.themes)
|
|
87
|
+
yield
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _theme(name: str) -> Contrib:
|
|
91
|
+
"""A theme plugin for the theme in resources/themes/<name>.toml."""
|
|
92
|
+
|
|
93
|
+
@asynccontextmanager
|
|
94
|
+
async def lifespan(ctx: Context) -> AsyncIterator[None]:
|
|
95
|
+
path = files(__name__) / "resources" / "themes" / f"{name}.toml"
|
|
96
|
+
theme = Theme.model_validate(
|
|
97
|
+
tomllib.loads(await asyncio.to_thread(path.read_text, encoding="utf-8"))
|
|
98
|
+
)
|
|
99
|
+
async with ctx.accept(THEME).register(theme):
|
|
100
|
+
yield
|
|
101
|
+
|
|
102
|
+
return Contrib(accepts=[THEME], lifespan=lifespan)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
server = Contrib(
|
|
106
|
+
accepts=[HTTP, SERVER_DB, SERVER_SCHEMA, SERVER_MIGRATIONS],
|
|
107
|
+
offers=[SERVER, THEME],
|
|
108
|
+
lifespan=_server,
|
|
109
|
+
)
|
|
110
|
+
# the night market and the morning market of the approved design
|
|
111
|
+
night = _theme("night")
|
|
112
|
+
day = _theme("day")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""The server's clock, and moments as the viewer reads them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
lazy from datetime import datetime, tzinfo
|
|
6
|
+
lazy from time import time_ns
|
|
7
|
+
lazy from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def now_ms() -> int:
|
|
11
|
+
return time_ns() // 1_000_000
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def zone(name: str | None) -> tzinfo | None:
|
|
15
|
+
"""The viewer's zone by IANA name; nothing when there is none, so each
|
|
16
|
+
moment is read on the server's own clock, daylight time as it was then."""
|
|
17
|
+
if name:
|
|
18
|
+
try:
|
|
19
|
+
return ZoneInfo(name)
|
|
20
|
+
except ZoneInfoNotFoundError, ValueError:
|
|
21
|
+
pass
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def clock_text(at_ms: int, tz: tzinfo | None) -> str:
|
|
26
|
+
"""A moment on the viewer's clock: the time of day, with the date before
|
|
27
|
+
it when the day is not today's."""
|
|
28
|
+
today = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
29
|
+
form = "%H:%M:%S" if at_ms >= today.timestamp() * 1000 else "%Y-%m-%d %H:%M:%S"
|
|
30
|
+
return datetime.fromtimestamp(at_ms / 1000, tz).strftime(form)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def day_text(at_ms: int, tz: tzinfo | None) -> str:
|
|
34
|
+
"""The day a moment falls on, on the viewer's calendar."""
|
|
35
|
+
return datetime.fromtimestamp(at_ms / 1000, tz).strftime("%Y-%m-%d")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def time_text(at_ms: int, tz: tzinfo | None) -> str:
|
|
39
|
+
"""The time of day of a moment, whatever the day."""
|
|
40
|
+
return datetime.fromtimestamp(at_ms / 1000, tz).strftime("%H:%M:%S")
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""What modules, login modules and theme plugins hold the server to."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal, Protocol
|
|
6
|
+
lazy from collections.abc import Sequence
|
|
7
|
+
lazy from contextlib import AbstractAsyncContextManager
|
|
8
|
+
lazy from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from bazaar_core import ServiceKey
|
|
11
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
12
|
+
lazy from starlette.requests import HTTPConnection
|
|
13
|
+
lazy from starlette.responses import Response
|
|
14
|
+
lazy from starlette.types import ASGIApp
|
|
15
|
+
|
|
16
|
+
lazy from .i18n import Translator
|
|
17
|
+
lazy from .pages import Pages
|
|
18
|
+
lazy from .refs import Refs
|
|
19
|
+
|
|
20
|
+
# one segment of an address, and a name a stylesheet can select on
|
|
21
|
+
NAME = r"^[a-z][a-z0-9-]*$"
|
|
22
|
+
# the addresses the server and the modules behind them answer themselves
|
|
23
|
+
RESERVED = frozenset({"ui", "api", "static", "login", "starlink"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class _Item(BaseModel):
|
|
27
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SidebarItem(_Item):
|
|
31
|
+
"""A link in the sidebar, for the accounts holding its permission point."""
|
|
32
|
+
|
|
33
|
+
text: str
|
|
34
|
+
# an SVG icon stroked in currentColor, as lucide's are
|
|
35
|
+
icon: str
|
|
36
|
+
path: str
|
|
37
|
+
permission: str | None = None
|
|
38
|
+
order: int = 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class OverviewItem(_Item):
|
|
42
|
+
"""Content of the overview, loaded in an iframe."""
|
|
43
|
+
|
|
44
|
+
key: str = Field(pattern=NAME)
|
|
45
|
+
title: str
|
|
46
|
+
src: str
|
|
47
|
+
permission: str | None = None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SettingsItem(_Item):
|
|
51
|
+
"""A part of the settings page, loaded in an iframe."""
|
|
52
|
+
|
|
53
|
+
key: str = Field(pattern=NAME)
|
|
54
|
+
title: str
|
|
55
|
+
src: str
|
|
56
|
+
permission: str | None = None
|
|
57
|
+
admin: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PermissionItem(_Item):
|
|
61
|
+
"""A permission point, written module.action; it is also its text key."""
|
|
62
|
+
|
|
63
|
+
point: str = Field(pattern=r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
|
|
64
|
+
description: str
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Tokens(_Item):
|
|
68
|
+
"""Every value a theme sets, named as in the approved tokens.css."""
|
|
69
|
+
|
|
70
|
+
bg: str
|
|
71
|
+
bg_glow: str
|
|
72
|
+
side_bg: str
|
|
73
|
+
surface: str
|
|
74
|
+
surface_2: str
|
|
75
|
+
raised: str
|
|
76
|
+
sunken: str
|
|
77
|
+
line: str
|
|
78
|
+
line_soft: str
|
|
79
|
+
line_strong: str
|
|
80
|
+
text: str
|
|
81
|
+
text_2: str
|
|
82
|
+
muted: str
|
|
83
|
+
faint: str
|
|
84
|
+
saffron: str
|
|
85
|
+
saffron_ink: str
|
|
86
|
+
saffron_soft: str
|
|
87
|
+
saffron_line: str
|
|
88
|
+
ember: str
|
|
89
|
+
jade: str
|
|
90
|
+
jade_soft: str
|
|
91
|
+
crimson: str
|
|
92
|
+
crimson_soft: str
|
|
93
|
+
indigo: str
|
|
94
|
+
indigo_soft: str
|
|
95
|
+
plum: str
|
|
96
|
+
plum_soft: str
|
|
97
|
+
shadow: str
|
|
98
|
+
codex: str
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Theme(_Item):
|
|
102
|
+
"""A look; its styles apply only while it is the page's theme."""
|
|
103
|
+
|
|
104
|
+
name: str = Field(pattern=NAME)
|
|
105
|
+
# what the theme is called, by language
|
|
106
|
+
labels: dict[str, str] = Field(min_length=1)
|
|
107
|
+
scheme: Literal["light", "dark"]
|
|
108
|
+
tokens: Tokens
|
|
109
|
+
styles: str = ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Account(Protocol):
|
|
113
|
+
"""Who a request is from, as the login module says."""
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def id(self) -> str: ...
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def name(self) -> str: ...
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def theme(self) -> str | None: ...
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def language(self) -> str | None: ...
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def admin(self) -> bool: ...
|
|
129
|
+
|
|
130
|
+
def allows(self, point: str) -> bool: ...
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Login(Protocol):
|
|
134
|
+
"""The module that says who a request is from; one at a time."""
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def app(self) -> ASGIApp:
|
|
138
|
+
"""Served at /login to anyone; signing out is a POST to /login/logout."""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
async def account(self, connection: HTTPConnection) -> Account | Response:
|
|
142
|
+
"""The account a request or a WebSocket is from, or the answer it
|
|
143
|
+
gets instead."""
|
|
144
|
+
...
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Themes(Protocol):
|
|
148
|
+
def register(self, theme: Theme) -> AbstractAsyncContextManager[None]:
|
|
149
|
+
"""Offer the theme while open."""
|
|
150
|
+
...
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class Server(Protocol):
|
|
154
|
+
"""Where modules put their pages, links and permission points; each
|
|
155
|
+
registration holds while its context is open."""
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def refs(self) -> Refs: ...
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def permissions(self) -> Sequence[PermissionItem]: ...
|
|
162
|
+
|
|
163
|
+
def module(
|
|
164
|
+
self,
|
|
165
|
+
name: str,
|
|
166
|
+
*,
|
|
167
|
+
ui: ASGIApp | None = None,
|
|
168
|
+
api: ASGIApp | None = None,
|
|
169
|
+
static: Path | None = None,
|
|
170
|
+
locales: Path | None = None,
|
|
171
|
+
) -> AbstractAsyncContextManager[None]:
|
|
172
|
+
"""Serve the module's interface at /ui/<name> inside the shell at
|
|
173
|
+
/<name>, its API at /api/<name> and its files at /static/<name>; its
|
|
174
|
+
locales directory holds en.toml and zh-CN.toml."""
|
|
175
|
+
...
|
|
176
|
+
|
|
177
|
+
def sidebar(self, item: SidebarItem) -> AbstractAsyncContextManager[None]: ...
|
|
178
|
+
|
|
179
|
+
def overview(self, item: OverviewItem) -> AbstractAsyncContextManager[None]: ...
|
|
180
|
+
|
|
181
|
+
def settings(self, item: SettingsItem) -> AbstractAsyncContextManager[None]: ...
|
|
182
|
+
|
|
183
|
+
def permission(self, item: PermissionItem) -> AbstractAsyncContextManager[None]: ...
|
|
184
|
+
|
|
185
|
+
def login(self, login: Login) -> AbstractAsyncContextManager[None]: ...
|
|
186
|
+
|
|
187
|
+
async def pages(self, module: str, package: str) -> Pages:
|
|
188
|
+
"""Jinja pages on the server's base template, from the package's
|
|
189
|
+
templates directory."""
|
|
190
|
+
...
|
|
191
|
+
|
|
192
|
+
def translator(self, connection: HTTPConnection) -> Translator: ...
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
SERVER = ServiceKey[Server]("server")
|
|
196
|
+
THEME = ServiceKey[Themes]("server.theme")
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Every module's words, in the language of whoever is looking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
lazy import tomllib
|
|
7
|
+
lazy from collections.abc import Mapping
|
|
8
|
+
lazy from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from jinja2 import Environment, StrictUndefined, Template
|
|
11
|
+
|
|
12
|
+
ENGLISH = "en"
|
|
13
|
+
SIMPLIFIED_CHINESE = "zh-CN"
|
|
14
|
+
LANGUAGES = (ENGLISH, SIMPLIFIED_CHINESE)
|
|
15
|
+
|
|
16
|
+
_environment = Environment(undefined=StrictUndefined, autoescape=False)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def catalogs(directory: Path) -> dict[str, dict[str, Template]]:
|
|
20
|
+
"""The languages a locales directory has, every entry a template."""
|
|
21
|
+
return {
|
|
22
|
+
language: {
|
|
23
|
+
key: _environment.from_string(source)
|
|
24
|
+
for key, source in tomllib.loads(path.read_text(encoding="utf-8")).items()
|
|
25
|
+
}
|
|
26
|
+
for language in LANGUAGES
|
|
27
|
+
if (path := directory / f"{language}.toml").is_file()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def language(chosen: str | None, accept_language: str | None) -> str:
|
|
32
|
+
"""The account's language, else the first one the browser asks for that
|
|
33
|
+
the pages speak, else English."""
|
|
34
|
+
if chosen in LANGUAGES:
|
|
35
|
+
return chosen
|
|
36
|
+
for part in (accept_language or "").split(","):
|
|
37
|
+
tag = part.split(";")[0].strip().lower()
|
|
38
|
+
if tag.startswith("zh"):
|
|
39
|
+
return SIMPLIFIED_CHINESE
|
|
40
|
+
if tag.startswith("en"):
|
|
41
|
+
return ENGLISH
|
|
42
|
+
return ENGLISH
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Translator:
|
|
47
|
+
language: str
|
|
48
|
+
_messages: Mapping[str, Template] = field(repr=False)
|
|
49
|
+
|
|
50
|
+
def text(self, key: str, arguments: Mapping[str, object] | None = None) -> str:
|
|
51
|
+
template = self._messages.get(key)
|
|
52
|
+
if template is None:
|
|
53
|
+
return key
|
|
54
|
+
return template.render(**(arguments or {}))
|
|
55
|
+
|
|
56
|
+
def has(self, key: str) -> bool:
|
|
57
|
+
return key in self._messages
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Catalogs:
|
|
61
|
+
"""The registered modules' catalogs, merged per language."""
|
|
62
|
+
|
|
63
|
+
def __init__(self) -> None:
|
|
64
|
+
self._modules: dict[str, dict[str, dict[str, Template]]] = {}
|
|
65
|
+
self._translators: dict[str, Translator] = {}
|
|
66
|
+
|
|
67
|
+
def add(self, module: str, languages: dict[str, dict[str, Template]]) -> None:
|
|
68
|
+
self._modules[module] = languages
|
|
69
|
+
self._translators.clear()
|
|
70
|
+
|
|
71
|
+
def remove(self, module: str) -> None:
|
|
72
|
+
del self._modules[module]
|
|
73
|
+
self._translators.clear()
|
|
74
|
+
|
|
75
|
+
def translator(self, language: str) -> Translator:
|
|
76
|
+
if language not in self._translators:
|
|
77
|
+
messages: dict[str, Template] = {}
|
|
78
|
+
for languages in self._modules.values():
|
|
79
|
+
# a module that speaks one language is read in it
|
|
80
|
+
messages |= languages.get(language) or next(
|
|
81
|
+
iter(languages.values()), {}
|
|
82
|
+
)
|
|
83
|
+
self._translators[language] = Translator(language, messages)
|
|
84
|
+
return self._translators[language]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""What was written, as it was meant to be read: Markdown, and code within
|
|
2
|
+
it coloured by what it is."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from markdown_it import MarkdownIt
|
|
10
|
+
from markdown_it.renderer import RendererHTML
|
|
11
|
+
from markdown_it.token import Token
|
|
12
|
+
from markdown_it.utils import EnvType, OptionsDict
|
|
13
|
+
from markupsafe import Markup
|
|
14
|
+
from pygments import highlight
|
|
15
|
+
from pygments.formatters import HtmlFormatter
|
|
16
|
+
from pygments.lexers import get_lexer_by_name
|
|
17
|
+
from pygments.lexers.special import TextLexer
|
|
18
|
+
from pygments.util import ClassNotFound
|
|
19
|
+
|
|
20
|
+
# tokens by class only, prefixed so they meet none of the page's own; the
|
|
21
|
+
# colours are the page's, in its stylesheet
|
|
22
|
+
_FORMATTER = HtmlFormatter(nowrap=True, classprefix="tok-")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fence(
|
|
26
|
+
renderer: Any,
|
|
27
|
+
tokens: Sequence[Token],
|
|
28
|
+
index: int,
|
|
29
|
+
options: Mapping[str, Any],
|
|
30
|
+
env: Mapping[str, Any],
|
|
31
|
+
) -> str:
|
|
32
|
+
del renderer, options
|
|
33
|
+
token = tokens[index]
|
|
34
|
+
language = token.info.strip().split(" ", 1)[0] if token.info else ""
|
|
35
|
+
try:
|
|
36
|
+
lexer = get_lexer_by_name(language) if language else TextLexer()
|
|
37
|
+
except ClassNotFound:
|
|
38
|
+
lexer = TextLexer()
|
|
39
|
+
return env["code"](Markup(highlight(token.content, lexer, _FORMATTER))) + "\n"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _image(
|
|
43
|
+
renderer: Any,
|
|
44
|
+
tokens: Sequence[Token],
|
|
45
|
+
index: int,
|
|
46
|
+
options: Mapping[str, Any],
|
|
47
|
+
env: Mapping[str, Any],
|
|
48
|
+
) -> str:
|
|
49
|
+
token = tokens[index]
|
|
50
|
+
return env["image"](
|
|
51
|
+
src=token.attrGet("src") or "",
|
|
52
|
+
alt=renderer.renderInlineAsText(token.children or [], options, env),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _ContentRenderer(RendererHTML):
|
|
57
|
+
def renderInline(
|
|
58
|
+
self, tokens: Sequence[Token], options: OptionsDict, env: EnvType
|
|
59
|
+
) -> str:
|
|
60
|
+
# Prose stays outside Alpine; the trusted code-block copy control is
|
|
61
|
+
# a separate component, beside its own ignored code content.
|
|
62
|
+
return (
|
|
63
|
+
"<span x-ignore>" + super().renderInline(tokens, options, env) + "</span>"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# html off: what the agent wrote is text, however it looks; a link is only a
|
|
68
|
+
# link on a scheme markdown-it lets through
|
|
69
|
+
_MARKDOWN = MarkdownIt(
|
|
70
|
+
"commonmark", {"html": False}, renderer_cls=_ContentRenderer
|
|
71
|
+
).enable(["table", "strikethrough"])
|
|
72
|
+
_MARKDOWN.add_render_rule("fence", _fence)
|
|
73
|
+
_MARKDOWN.add_render_rule("image", _image)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def render(
|
|
77
|
+
text: str, *, code: Callable[[Markup], str], image: Callable[..., str]
|
|
78
|
+
) -> Markup:
|
|
79
|
+
"""The text as HTML; each code block drawn by `code`, given its
|
|
80
|
+
highlighted content, and each image by `image`, given its `src` and
|
|
81
|
+
`alt`, so both are the page's own."""
|
|
82
|
+
|
|
83
|
+
return Markup(_MARKDOWN.render(text, {"code": code, "image": image}))
|