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()