Add Server/Last-Modified headers and a {dates} dateline tag

An http middleware sets 'Server: pagerite' (dropping uvicorn's versioned
default) and content pages return Last-Modified from Node.modified. In
markdown, a {dates} line expands to the article's published/updated
dateline (updated shown only when it falls on a later day); the editor
preview resolves it for pages that exist, unsaved pages show it literal.
Demonstrated in the long-read seed page.
This commit is contained in:
2026-08-18 06:15:45 +00:00
parent 3cefdec56e
commit 87c0aac3c0
6 changed files with 76 additions and 9 deletions
+5 -2
View File
@@ -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
+8
View File
@@ -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);
+28 -3
View File
@@ -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.
+29 -3
View File
@@ -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 "<p>{dates}</p>" in html:
html = html.replace("<p>{dates}</p>", _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'<time datetime="{created.isoformat()}">{created.day} {created:%B %Y}</time>'
out = f"Published {pub}"
if modified is not None and modified.date() > created.date():
upd = f'<time datetime="{modified.isoformat()}">{modified.day} {modified:%B %Y}</time>'
out += f", updated {upd}"
return f'<p class="dateline">{out}</p>'
def has_h1(text: str) -> bool:
+2
View File
@@ -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
+4 -1
View File
@@ -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))