Serve each article's Markdown at .md URLs (and via Accept: text/markdown)

/pages.md (front page /.md) serves the page's source Markdown — per-language
hybrid assembly, title injected as '# title' when the content has no h1 —
with the same ETag/Last-Modified/no-cache scheme as the HTML, which now
carries vary: accept. Pages advertise the counterpart via
<link rel=alternate type=text/markdown> and llms.txt links point at the
.md URLs.
This commit is contained in:
LeoVasanko committed 2026-09-25 18:04:47 +00:00
1 parent 7780d190d0
commit 31b56c3e5e
5 files changed
+65 -9

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@ Pagerite is a CMS. See `docs` for the full design and implementation details. Ke
- `files.py` — `FileStore` (content-addressed, RAM-cached), image derivative helpers (`store_image`), file routes (`/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, favicon settings).
- `api.py` — editor REST + WS: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, `/_translate/{key}`.
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetch, `/_ws`, `/_api/ws/analytics`, the `/_a` page (docs/analytics.md).
- `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all.
- `feeds.py` — machine-readable exports: `/llms.txt`, `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); all published articles, full content, linked from every page `<head>` and the sitemap, recorded in analytics.
- `pages.py` — public content pages: `/`, `/sitemap.xml`, `/robots.txt`, the `/{path:path}` catch-all. Articles serve their Markdown (title injected as `# title`, per-language hybrid) at a `.md`-suffixed URL, or at the plain URL when Accept explicitly names text/markdown; pages link it via `<link rel="alternate" type="text/markdown">`, and llms.txt links point at the `.md` URLs.
- `feeds.py` — machine-readable exports: `/llms.txt` (compact index linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1), `/feed.xml` (RSS 2.0 + atom:link); the feeds carry all published articles with full content, linked from every page `<head>` and the sitemap, recorded in analytics.
- `data.py` — msgspec Structs for the kanta database.
- `chunks.py` — block-level Markdown chunking and content-hash keys for the chunk stores (docs/migrate.md).
- `i18n.py` — language selection, translation assembly (chunks + overrides) and translated-edit recording (per-chunk user overrides in `Data.overrides`, per-language title overrides, refresh).
+2 -2
View File
@@ -10,8 +10,8 @@ Thin FastAPI assembly: lifespan (open the kanta database, load the file store, t
- `files.py` — the `FileStore` and image derivative helpers, and the file routes: `/_api/files`, `/_f/`, `/_themes/`, `/_fonts/`, the favicon settings endpoints.
- `api.py` — the editor REST API and WebSockets: `/_api/pages`, `/_api/structure`, `/_api/settings`, `/_api/toggle-task`, `/_api/translations`, `/_api/ws/editor`, and the translator channel `/_translate/{clientkey}`.
- `tracking.py` — visit analytics: GeoIP, client enrichment, favicon fetching, debounced broadcasts, the `/_ws` activity socket, the admin stream `/_api/ws/analytics`, and the `/_a` viewer page.
- `feeds.py` — machine-readable site exports: `/llms.txt` (Markdown site map for LLM agents), `/feed.json` (JSON Feed 1.1) and `/feed.xml` (RSS 2.0 + atom:link), all carrying every published article with full content (relative URLs absolutized), linked from every page's `<head>` and from the sitemap, and recorded in analytics like page GETs. Bodies are RAM-cached (keyed by base URL, cleared by `_invalidate_pages` like the page render cache) and served with a content-hash ETag (304 revalidation).
- `pages.py` — the public content pages: `/`, `/sitemap.xml`, `/robots.txt` and the `/{path:path}` catch-all.
- `feeds.py` — machine-readable site exports: `/llms.txt` (Markdown site map for LLM agents, linking each article's `.md` Markdown URL), `/feed.json` (JSON Feed 1.1) and `/feed.xml` (RSS 2.0 + atom:link), the feeds carrying every published article with full content (relative URLs absolutized), linked from every page's `<head>` and from the sitemap, and recorded in analytics like page GETs. Bodies are RAM-cached (keyed by base URL, cleared by `_invalidate_pages` like the page render cache) and served with a content-hash ETag (304 revalidation).
- `pages.py` — the public content pages: `/`, `/sitemap.xml`, `/robots.txt` and the `/{path:path}` catch-all. Every article also has a Markdown counterpart: a `.md` suffix on the page URL (or an Accept header explicitly naming `text/markdown` on the plain URL — the HTML response carries `vary: accept`) serves the page's source Markdown (per-language hybrid assembly via `?lang=`/Accept-Language, page title injected as `# title` when the content has no h1, same ETag/Last-Modified scheme as the HTML). Pages advertise it with `<link rel="alternate" type="text/markdown">`.
Route ordering is load-bearing and lives in `app.py`: the api/tracking/files routers are included BEFORE `frontend.route(app, "/")` is called — fastapi-vue inserts its file routes at the position where `route()` was called (during `load()` in the lifespan), so anything registered earlier wins. The content catch-all `/{path:path}` is included AFTER `frontend.route()` so that built frontend assets still take priority over content slugs. The `Frontend` is constructed with `spa=False` explicitly: it only serves the built files without a catch-all.
+3 -2
View File
@@ -2,7 +2,8 @@
- ``/llms.txt`` (llmstxt.org convention): a Markdown map of the site for
LLM agents — the brand as title, then every published article as a link
with a short excerpt.
to its ``.md`` Markdown counterpart (pages.show_page) with a short
excerpt.
- ``/feed.json``: JSON Feed 1.1 of all published articles, full content.
- ``/feed.xml``: the same as RSS 2.0 (with an atom:link self reference)
for older feed readers.
@@ -78,7 +79,7 @@ def _iso(node: Node) -> str:
def _render_llms(base: str) -> str:
lines = [f"# {data.brand}", "", "## Pages", ""]
for path, node in _articles():
url = f"{base}/{path}" if path else base
url = f"{base}/{path}.md" if path else f"{base}/.md"
excerpt = _description(_body_html(node, base), 120)
suffix = f": {excerpt}" if excerpt else ""
lines.append(f"- [{node.title or path}]({url}){suffix}")
+52 -3
View File
@@ -19,7 +19,9 @@ from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse, Response
from pagerite import i18n, state
from pagerite.data import Node, resolve, sorted_nodes
from pagerite.chunks import chunk_key
from pagerite.data import Node, node_markdown, resolve, sorted_nodes
from pagerite.markdown import has_h1
from pagerite.state import (
SITE_URL,
_html_response,
@@ -47,6 +49,40 @@ def _is_trackable_path(path: str) -> bool:
return not _is_reserved(path)
def _markdown_response(
request: Request, path: str, node: Node, lang: str, original: str
) -> Response:
"""The page as Markdown (text/markdown): the same source the HTML page
renders (per-language hybrid assembly), with the page title injected as
``# title`` when the content has no h1 of its own — mirroring
markdown.render's title injection, so the .md reads like the page.
Served at the page's ``.md`` URL, and also at the plain URL when the
request's Accept header explicitly names text/markdown (Vary: accept).
Language selection is the page's own (?lang= or Accept-Language), so
``/page.md?lang=fi`` is the Finnish markdown."""
content = node_markdown(data, node) or ""
title = node.title
if lang != original and lang in node.langs:
content = i18n.hybrid_markdown(data, node, path, lang)
if t := data.trans.get(chunk_key(node.title), {}).get(lang):
title = t
if title and not has_h1(content):
content = f"# {title}\n\n{content}"
etag = f'"{path}@{node.modified.timestamp()}g{state._render_gen}l{lang}md"'
headers = {
"etag": etag,
"last-modified": _http_date(node.modified),
"cache-control": "no-cache",
"vary": "accept",
}
if request.headers.get("if-none-match") == etag:
return Response(status_code=304, headers=headers)
if _is_trackable_path(path):
_record_get(request, lang=lang)
return Response(content, media_type="text/markdown", headers=headers)
@router.get("/")
async def front_page(request: Request) -> Response:
"""Render the front page (slug path "")."""
@@ -164,6 +200,14 @@ async def show_page(request: Request, path: str) -> Response:
"""
path = path.strip("/")
accept_language = request.headers.get("accept-language", "")
# A .md suffix (or an Accept header explicitly naming text/markdown)
# asks for the page's Markdown instead of HTML. Slugs never contain a
# dot, so "page.md" unambiguously means the markdown of "page"; the
# front page's markdown URL is "/.md".
markdown = False
if path.endswith(".md"):
path = path[:-3]
markdown = True
if path and _is_reserved(path):
# Invalid slug shape: not a content URL, let FastAPI return its
# built-in 404 instead of rendering an editable article page.
@@ -179,18 +223,20 @@ async def show_page(request: Request, path: str) -> Response:
# Accept-Language header regardless of the selection, and record
# the resolved language as the GET's rendered language.
query_lang = request.query_params.get("lang")
original = i18n.primary_lang(data.menu, path)
lang = i18n.select_language(
query_lang,
accept_language,
lambda tag: tag in node.langs,
original=i18n.primary_lang(data.menu, path),
original=original,
)
# A ?lang= override is replicated onto the page's navigation links
# (link_lang), so clicks and prefetches stay in the chosen language.
# Query and header-selected renders of the same language differ in
# their links, so link_lang is part of the ETag and body cache key.
link_lang = i18n.base_tag(query_lang or "")
# no-cache forbids serving a stored page without revalidation
if markdown or "text/markdown" in request.headers.get("accept", ""):
return _markdown_response(request, path, node, lang, original)
# (browsers would otherwise cache heuristically and serve stale
# pages, e.g. after a theme change). In-session speed instead comes
# from pagerite.js's in-memory page cache (preload everything, never
@@ -209,6 +255,9 @@ async def show_page(request: Request, path: str) -> Response:
"etag": etag,
"last-modified": _http_date(node.modified),
"cache-control": "no-cache",
# The same URL serves text/markdown when the Accept header
# explicitly asks for it (_markdown_response).
"vary": "accept",
},
lang=lang,
link_lang=link_lang,
+6
View File
@@ -336,6 +336,7 @@ def _layout(
lang: str = i18n.ORIGINAL_LANGUAGE,
canonical: str = "",
alternates: list[tuple[str, str]] = (),
markdown_url: str = "",
) -> Template:
"""Page layout template with standard assets and ES-module scripts.
@@ -391,6 +392,9 @@ def _layout(
rel="alternate", type="application/rss+xml", title="RSS feed", href="/feed.xml"
)
doc.link(rel="llms-txt", type="text/markdown", title="llms.txt", href="/llms.txt")
# The page's own Markdown counterpart (pages.show_page's .md mode).
if markdown_url:
doc.link(rel="alternate", type="text/markdown", href=markdown_url)
for key, value in (social or {}).items():
if value:
if key.startswith(("og:", "article:")):
@@ -1397,6 +1401,8 @@ def render_page(
lang,
canonical,
alternates,
# The page's Markdown counterpart (pages.show_page's .md mode).
f"/{path}.md",
)(
Title=f"{title} – {brand}" if brand else title,
Brand=_brand_link(brand, brand_html, link_lang),