Blog¶
Fast public pages, comments, an editor with live preview, an RSS feed and a JSON API.
blog/
├── app.py # composition root
├── settings.py # DATABASE_URL, BLOG_NAME
├── models/ # database.py, account.py, post.py, comment.py
├── services/ # blog.py (BlogService), seed.py
└── views/
├── components/ # markup.py (slugify + safe Markdown subset), shell, post_list, comments, editor
├── pages/ # public.py, admin.py, feed.py (/feed.xml, a FastAPI router)
└── styles/
1. Rich text that is safe by construction¶
def render_body(text: str) -> list:
"""A tiny Markdown subset: ``## heading``, ``### heading``, ``- list`` items and paragraphs."""
blocks = []
for block in re.split(r"\n\s*\n", text.strip()):
lines = [line.strip() for line in block.splitlines() if line.strip()]
if not lines:
continue
if lines[0].startswith("### "):
blocks.append(Heading(lines[0][4:], level=3))
elif lines[0].startswith("## "):
blocks.append(Heading(lines[0][3:], level=2))
elif all(line.startswith("- ") for line in lines):
blocks.append(h("ul", *[h("li", line[2:]) for line in lines], style=BULLETS))
else:
blocks.append(Text(" ".join(lines), style=PROSE))
return blocks
The body becomes headings, lists and paragraphs, built from nodes, not HTML strings. Every
piece of text is escaped, so a post containing <script> shows it as text.
2. Drafts that don’t exist for readers¶
@remote(method="GET")
async def get_post(self, slug: str) -> PostOut:
"""One post by its slug."""
async with self.db.session() as db:
post = await db.scalar(select(Post).where(Post.slug == slug))
if post is None or not (post.published or self.is_admin):
raise NotFound("No such post") # drafts don't exist for readers
return PostOut.model_validate(post)
Readers get the same 404 for a draft as for a missing post.
3. Zero-JavaScript pages¶
@router.page("/")
class Index(Page):
title = settings.BLOG_TITLE
description = "Notes on building web apps in Python."
posts = State(factory=list)
async def mount(self):
self.posts = await self.use(BlogService).list_posts()
def render(self):
return Shell(
Column(Heading("Notes", level=1), Text(self.description, muted=True), gap="xs"),
PostList(self.posts),
admin=self.context.user == "admin",
)
The index has no event handlers, so the browser receives HTML and CSS only. A post page loads the runtime just for its comment form:
class Comments(Component):
"""The only interactive part of a post page, so the only reason it loads the runtime."""
slug: str
comments = State(factory=list)
version = State(0)
async def mount(self):
self.comments = await self.use(BlogService).comments(self.slug)
async def add(self, form: CommentIn):
await self.use(BlogService).add_comment(self.slug, form.author, form.body)
self.comments = await self.use(BlogService).comments(self.slug)
self.version += 1 # a form with a new key is a fresh form: the fields are cleared
def render(self):
return h(
"section",
Heading(f"Comments ({len(self.comments)})", level=2),
*[
Card(
Row(Text(c.author, weight=600, tag="span"), Text(f"{c.created_at:%b %d}", muted=True, tag="span")),
Text(c.body),
key=c.id,
style=COMMENT,
)
for c in self.comments
],
Form(
Input("Name", name="author", required=True, autocomplete="name"),
Textarea("Comment", name="body", required=True, rows=3),
Button("Post comment", type="submit"),
on_submit=self.add,
key=f"comment-form-{self.version}",
),
aria_label="Comments",
style=COMMENTS,
)
4. Live preview¶
class Editor(Component):
"""The form on one side and a live preview on the other (stacked on narrow screens).
Typing in the title or body sends the debounced value to the server, which re-renders
the preview with the same ``render_body`` the public page uses.
"""
post: PostOut | None = None
body = State("")
heading = State("")
def mount(self):
if self.post is not None:
self.body, self.heading = self.post.body, self.post.title
def set_body(self, value: str):
self.body = value
def set_heading(self, value: str):
self.heading = value
async def save(self, form: PostIn):
await self.use(BlogService).save_post(form, self.post.id if self.post else None)
self.navigate("/admin")
def render(self):
p = self.post
form = Form(
Input("Title", name="title", value=p.title if p else None, required=True, on_input=self.set_heading),
Input("Address", name="slug", value=p.slug if p else None, help="Leave empty to derive it from the title."),
Input("Summary", name="summary", value=p.summary if p else None),
Input("Tags", name="tags", value=", ".join(p.tags) if p else None, help="Comma-separated."),
Textarea(
"Body",
name="body",
value=p.body if p else "",
rows=14,
on_input=self.set_body,
help="## Heading, ### Subheading, - list items; blank lines separate paragraphs.",
),
Checkbox("Published", name="published", checked=bool(p and p.published)),
Row(Button("Save", type="submit"), ButtonLink("Cancel", href="/admin", variant="ghost")),
on_submit=self.save,
style=EDITOR_HALF,
)
preview = Card(
Badge("Preview", tone="primary"),
Heading(self.heading or "Untitled", level=1),
*render_body(self.body),
style=PREVIEW,
aria_live="polite",
)
return Row(form, preview, align="start", gap="lg", collapse="narrow")
As you type, the debounced on_input handlers update body and heading, and the
preview re-renders with the same render_body the public page uses: what you see is exactly
what readers will get.
5. The escape hatch¶
views/pages/feed.py¶
"""RSS 2.0 at ``/feed.xml``, as a plain FastAPI router: the escape hatch for anything that isn't a page."""
from xml.sax.saxutils import escape
from fastapi import APIRouter, Response
from sqlalchemy import select
import settings
from models import Post, database
router = APIRouter()
@router.get("/feed.xml", include_in_schema=False)
async def feed() -> Response:
async with database.session() as db:
posts = await db.scalars(select(Post).where(Post.published.is_(True)).order_by(Post.created_at.desc()))
items = "".join(
f"<item><title>{escape(p.title)}</title><link>/posts/{escape(p.slug)}</link>"
f"<description>{escape(p.summary)}</description></item>"
for p in posts
)
xml = (
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel>'
f"<title>{escape(settings.BLOG_TITLE)}</title><link>/</link><description>Notes</description>{items}"
"</channel></rss>"
)
return Response(xml, media_type="application/rss+xml")
Anything that isn’t a page (feeds, webhooks, file downloads) is a plain FastAPI route:
app.py¶
"""The blog: the composition root.
Run: pip install "rational[db]"
python app.py --dev # hot reload, readable errors
python app.py --prod # production server
"""
from rational import App
import settings
from models import create_tables, database
from services import BlogService, seed_demo_posts
from views.pages import feed_router, router
from views.styles import THEME
app = App(
title=settings.BLOG_TITLE, description="Posts and comments.", theme=THEME, database=database, login_path="/login"
)
app.include(router)
app.service(BlogService) # JSON API under /api/blog/..., docs at /api/docs
app.fastapi.include_router(feed_router) # a plain FastAPI route: /feed.xml
app.on_startup(create_tables)
app.on_startup(seed_demo_posts)
if __name__ == "__main__":
app.run()