add datastar and render shim

This commit is contained in:
Abel Luck 2026-03-30 12:27:45 +02:00
parent 9ce576e7e8
commit 2accb26546
6 changed files with 173 additions and 42 deletions

View file

@ -1 +1,4 @@
from repub.pages.dashboard import admin_page from repub.pages.dashboard import admin_component
from repub.pages.shim import shim_page
__all__ = ["admin_component", "shim_page"]

View file

@ -4,7 +4,6 @@ import htpy as h
from htpy import Node, Renderable from htpy import Node, Renderable
from repub.components import ( from repub.components import (
base_layout,
input_field, input_field,
nav_link, nav_link,
select_field, select_field,
@ -451,7 +450,7 @@ def settings_panel() -> Renderable:
] ]
def admin_page(*, stylesheet_href: str) -> Renderable: def admin_component() -> Renderable:
running_rows = ( running_rows = (
( (
h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"], h.div(class_="font-semibold text-slate-950")["Pangea mobile articles"],
@ -559,40 +558,39 @@ def admin_page(*, stylesheet_href: str) -> Renderable:
), ),
) )
return base_layout( return h.main(
page_title="Republisher Admin UI", id="morph",
stylesheet_href=stylesheet_href, class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]",
content=h.div(class_="min-h-screen lg:grid lg:grid-cols-[18rem_minmax(0,1fr)]")[ )[
sidebar(), sidebar(),
h.main(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[ h.div(class_="px-4 py-5 sm:px-6 lg:px-8 lg:py-8")[
h.div(class_="mx-auto max-w-7xl space-y-6")[ h.div(class_="mx-auto max-w-7xl space-y-6")[
page_header(), page_header(),
overview_section(), overview_section(),
h.div( h.div(
class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]" class_="grid gap-6 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.95fr)]"
)[ )[
h.div(class_="space-y-6")[ h.div(class_="space-y-6")[
source_form_section(), source_form_section(),
configured_sources_section(), configured_sources_section(),
job_table_section( job_table_section(
title="Running executions", title="Running executions",
subtitle="Operators can inspect active crawls and stop them if needed.", subtitle="Operators can inspect active crawls and stop them if needed.",
rows=running_rows, rows=running_rows,
), ),
job_table_section( job_table_section(
title="Upcoming jobs", title="Upcoming jobs",
subtitle="Schedule preview with enable, disable, run now, and delete affordances.", subtitle="Schedule preview with enable, disable, run now, and delete affordances.",
rows=upcoming_rows, rows=upcoming_rows,
), ),
job_table_section( job_table_section(
title="Completed executions", title="Completed executions",
subtitle="Recent history with direct access to text logs.", subtitle="Recent history with direct access to text logs.",
rows=completed_rows, rows=completed_rows,
), ),
],
h.div(class_="space-y-6")[log_panel(), settings_panel()],
], ],
] h.div(class_="space-y-6")[log_panel(), settings_panel()],
], ],
]
], ],
) ]

34
repub/pages/shim.py Normal file
View file

@ -0,0 +1,34 @@
from __future__ import annotations
import htpy as h
from htpy import Node, Renderable
ON_LOAD_JS = (
"@post(window.location.pathname + "
"(window.location.search + '&u=').replace(/^&/,'?'), "
"{retryMaxCount: Infinity})"
)
TAB_ID_JS = "self.crypto.randomUUID().substring(0,8)"
def shim_page(*, datastar_src: str, head: Node | None = None) -> Renderable:
return h.html(lang="en")[
h.head[
h.meta(charset="UTF-8"),
head,
h.script(id="js", defer=True, type="module", src=datastar_src),
h.meta(name="viewport", content="width=device-width, initial-scale=1.0"),
],
h.body[
h.div({"data-signals:tabid": TAB_ID_JS}),
h.div(
{
"data-init": ON_LOAD_JS,
"data-on:online__window": ON_LOAD_JS,
}
),
h.noscript["Your browser does not support JavaScript!"],
h.main(id="morph"),
],
]

File diff suppressed because one or more lines are too long

View file

@ -1,15 +1,43 @@
from __future__ import annotations from __future__ import annotations
from quart import Quart, url_for import hashlib
from repub.pages import admin_page import htpy as h
from quart import Quart, Response, request, url_for
from repub.pages import admin_component, shim_page
def _render_shim_page(*, stylesheet_href: str, datastar_src: str) -> tuple[str, str]:
head = (
h.title["Republisher Admin UI"],
h.link(rel="stylesheet", href=stylesheet_href),
)
body = str(shim_page(datastar_src=datastar_src, head=head))
etag = hashlib.sha256(body.encode("utf-8")).hexdigest()
return body, etag
def create_app() -> Quart: def create_app() -> Quart:
app = Quart(__name__) app = Quart(__name__)
@app.get("/") @app.get("/")
async def index() -> str: async def index() -> Response:
return str(admin_page(stylesheet_href=url_for("static", filename="app.css"))) body, etag = _render_shim_page(
stylesheet_href=url_for("static", filename="app.css"),
datastar_src=url_for("static", filename="datastar@1.0.0-RC.8.js"),
)
if request.if_none_match.contains(etag):
response = Response(status=304)
response.set_etag(etag)
return response
response = Response(body, mimetype="text/html")
response.set_etag(etag)
return response
@app.post("/")
async def index_patch() -> Response:
return Response(str(admin_component()), mimetype="text/html")
return app return app

59
tests/test_web.py Normal file
View file

@ -0,0 +1,59 @@
from __future__ import annotations
import asyncio
from repub.web import create_app
def test_root_get_serves_datastar_shim() -> None:
async def run() -> None:
client = create_app().test_client()
response = await client.get("/")
body = await response.get_data(as_text=True)
assert response.status_code == 200
assert response.headers["ETag"]
assert body.startswith("<!doctype html>")
assert (
'<script id="js" defer type="module" src="/static/datastar@1.0.0-RC.8.js"></script>'
in body
)
assert 'data-signals:tabid="self.crypto.randomUUID().substring(0,8)"' in body
assert 'data-init="@post(window.location.pathname +' in body
assert "retryMaxCount: Infinity" in body
assert "data-on:online__window=" in body
assert '<main id="morph"></main>' in body
asyncio.run(run())
def test_root_get_honors_if_none_match() -> None:
async def run() -> None:
client = create_app().test_client()
initial = await client.get("/")
etag = initial.headers["ETag"]
response = await client.get("/", headers={"If-None-Match": etag})
assert response.status_code == 304
assert response.headers["ETag"] == etag
asyncio.run(run())
def test_root_post_serves_morph_component() -> None:
async def run() -> None:
client = create_app().test_client()
response = await client.post("/?u=shim")
body = await response.get_data(as_text=True)
assert response.status_code == 200
assert response.content_type == "text/html; charset=utf-8"
assert body.startswith('<main id="morph"')
assert "Admin UI" in body
assert "All on one page for the v1 spike" not in body
asyncio.run(run())