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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -20,6 +20,7 @@ classes, e.g. `{.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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
{.wide}
|
||||
|
||||
## Chapter one
|
||||
|
||||
+4
-1
@@ -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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user