diff --git a/AGENTS.md b/AGENTS.md index 33d5a11..600e813 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,8 +89,11 @@ not for the public pages. See `docs/design-principles.md` for the design. on every page, otherwise browsers fall back to the build's `/favicon.ico` by convention. - `markdown.py` — markdown-it-py renderer (html passthrough + attrs, - footnote, deflist, tasklists plugins). Custom image rule: relative srcs - resolve against the page path, titled images become figures. + footnote, deflist, tasklists plugins; typographer + breaks on). Custom + image rule: relative srcs resolve against the page path, titled images + become figures. A `{dates}` line expands to the article's + published/updated dateline (`p.dateline`, from `Node.created`/ + `modified`; left literal in previews of unsaved pages). - `views.py` — the shared page layout as an html5tagger `Template` with placeholders (`Title`, `Brand`, `Banner`, `Nav`, `Sidebar`, `Main`), nav rendering straight from the `Data.menu` tree (siblings sorted by diff --git a/frontend/src/assets/pagerite.css b/frontend/src/assets/pagerite.css index 8416f4f..26f00bf 100644 --- a/frontend/src/assets/pagerite.css +++ b/frontend/src/assets/pagerite.css @@ -777,6 +777,14 @@ body.editing:has(.multicol) img.wide:not(figure img) { list-style: none; } +/* Published/updated line, expanded from the {dates} tag in the markdown + (typically placed right after the article's h1). */ +.dateline { + color: var(--muted); + font-size: 0.85rem; + text-align: left; +} + .footnote { font-size: 0.85rem; color: var(--muted); diff --git a/pagerite/app.py b/pagerite/app.py index 64e80a9..e67e821 100644 --- a/pagerite/app.py +++ b/pagerite/app.py @@ -18,6 +18,7 @@ import re from collections.abc import AsyncIterator from contextlib import asynccontextmanager from datetime import UTC, datetime +from email.utils import format_datetime from pathlib import Path import blake3 @@ -154,6 +155,14 @@ app = FastAPI( ) +@app.middleware("http") +async def _headers(request: Request, call_next) -> Response: + """Replace uvicorn's default Server header with ours (no version).""" + response = await call_next(request) + response.headers["server"] = "pagerite" + return response + + class PageIn(BaseModel): """Payload for creating or replacing a page.""" @@ -471,6 +480,11 @@ async def delete_page(path: str) -> None: _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$") +def _http_date(dt: datetime) -> str: + """RFC 7231 date for the Last-Modified header.""" + return format_datetime(dt.astimezone(UTC), usegmt=True) + + def _is_reserved(path: str) -> bool: """Slug shape that content may never use: each segment must be lower-case ASCII letters, digits, hyphens and underscores (underscores may not be @@ -542,10 +556,17 @@ async def editor_ws(ws: WebSocket) -> None: }) case "render": markdown = msg.get("markdown", "") + chain = resolve(data.menu, path) + node = chain[-1] if chain else None await ws.send_json({ "type": "html", "path": path, - "html": render(markdown, path), + "html": render( + markdown, + path, + node.created if node else None, + node.modified if node else None, + ), "has_h1": has_h1(markdown), }) case "save": @@ -653,12 +674,16 @@ async def show_page(request: Request, path: str) -> HTMLResponse | Response: return Response(status_code=304) return HTMLResponse( views.render_page(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), - headers={"etag": etag}, + headers={"etag": etag, "last-modified": _http_date(node.modified)}, ) if node is not None and node.published and node.content is None: # Category label without a landing page: placeholder with the pen # to create it (404 — no page here, but the node is real). - return HTMLResponse(views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), 404) + return HTMLResponse( + views.render_category(data.menu, path, data.brand, data.custom_css, data.theme, data.favicon), + 404, + headers={"last-modified": _http_date(node.modified)}, + ) if node is None and not path: # No front page (no top-level node with slug ""): "/" opens the # first item of the navigation instead. diff --git a/pagerite/markdown.py b/pagerite/markdown.py index e8d41cd..e96828f 100644 --- a/pagerite/markdown.py +++ b/pagerite/markdown.py @@ -20,6 +20,7 @@ classes, e.g. `![alt](photo.avif "Caption"){.right}`. """ import re +from datetime import datetime from markdown_it import MarkdownIt from markdown_it.common.utils import escapeHtml @@ -135,9 +136,34 @@ md.core.ruler.push("unwrap_lone_figures", _unwrap_lone_figures) md.core.ruler.push("tag_task_checkboxes", _tag_task_checkboxes) -def render(text: str, page_path: str = "") -> str: - """Render Markdown text to an HTML string.""" - return md.render(text, {"page_path": page_path}) +def render( + text: str, + page_path: str = "", + created: datetime | None = None, + modified: datetime | None = None, +) -> str: + """Render Markdown text to an HTML string. + + A ``{dates}`` line expands to the article's published/updated dateline + (needs ``created``/``modified``; left as-is in contexts without them, + e.g. the editor preview). Position is the author's choice — typically + right after the article's h1. + """ + html = md.render(text, {"page_path": page_path}) + if created is not None and "

{dates}

" in html: + html = html.replace("

{dates}

", _dateline(created, modified)) + return html + + +def _dateline(created: datetime, modified: datetime | None) -> str: + """Published/updated line for the ``{dates}`` tag. The updated date is + shown only when it falls on a later day than the publication.""" + pub = f'' + out = f"Published {pub}" + if modified is not None and modified.date() > created.date(): + upd = f'' + out += f", updated {upd}" + return f'

{out}

' def has_h1(text: str) -> bool: diff --git a/pagerite/seed.py b/pagerite/seed.py index 138ba17..745917c 100644 --- a/pagerite/seed.py +++ b/pagerite/seed.py @@ -77,6 +77,8 @@ def render(text: str, page_path: str) -> str: LONG_READ = """\ *An essay long enough to scroll, to demonstrate the gentle reveal of headings, figures and code blocks as they enter the viewport.* +{dates} + ![Layered dunes](dunes.svg "Full-width artwork between sections"){.wide} ## Chapter one diff --git a/pagerite/views.py b/pagerite/views.py index a156a04..1bd0ea4 100644 --- a/pagerite/views.py +++ b/pagerite/views.py @@ -366,7 +366,10 @@ def page_content(menu: dict[str, Node], path: str) -> HTML: # only rendered as h1 when the markdown has none of its own. if not has_h1(node.content or ""): doc.h1(node.title) - doc.div(HTML(render(node.content or "", path)), class_="body") + doc.div( + HTML(render(node.content or "", path, node.created, node.modified)), + class_="body", + ) return HTML(str(doc))