# Markdown Blog A blog with public read pages and admin-only write pages. Posts are stored as markdown in the database. The public single-post page renders the markdown to HTML inside the route handler and passes the HTML string to the template, which writes it into the article body via `oninsert`. Admin pages reuse the same data through the same shared module, gated by `isUserAdminOrThrow`. Four routes: - `/blog`: public list of published posts - `/blog/:slug`: public single post, markdown rendered - `/admin/blog`: admin list (every post including drafts) with edit links - `/admin/blog/:id`: admin editor for one post; `:id = "new"` creates one The shared module is at `app/shared/services/posts.ts` because four pages consume it. Markdown rendering is a one-file helper at `app/shared/services/markdown.ts`. Admin gating uses the helper from `elements man recipes admin-roles`. ## Migration ```bash elements create migration 'add posts' -tables=posts ``` `app/migrations/-add-posts.migration.sql`: ```sql -- add posts -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table posts ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), title text not null, slug text not null unique, body text not null, publishedAt timestamptz ); create index postsPublishedAtIdx on posts (publishedAt desc nulls last); create trigger postsTouchUpdatedAt before update on posts for each row execute function touchUpdatedAt(); ``` `publishedAt` is nullable. A null value means the post is a draft and the public pages skip it. The unique constraint on `slug` keeps URLs unambiguous. ## Markdown package Install a markdown renderer rather than rolling one inline. The recipe uses `marked`, which exposes a single `parse(text)` call that returns HTML: ```bash elements install marked ``` `elements install` adds the dependency to `package.json` and the project server rebuilds against it on the next save; no separate `npm install` step is needed. ## Posts module `app/shared/services/posts.ts`: ```ts import { sql, ValidationError } from "@elements/app"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; export interface Post { id: string; updatedAt: Date; title: string; slug: string; body: string; publishedAt: Date | null; } export interface PostInput { title: string; slug: string; body: string; publishedAt: Date | null; } export function listPublished(): Post[] { return sql( `select id, updatedAt, title, slug, body, publishedAt from posts where publishedAt is not null and publishedAt <= now() order by publishedAt desc`, ).all(); } export function getPublishedBySlug(slug: string): Post { return sql( `select id, updatedAt, title, slug, body, publishedAt from posts where slug = ${slug} and publishedAt is not null and publishedAt <= now()`, ).firstOrThrow("post not found"); } export function listAll(): Post[] { return sql( `select id, updatedAt, title, slug, body, publishedAt from posts order by updatedAt desc`, ).all(); } export function getById(id: string): Post { return sql( `select id, updatedAt, title, slug, body, publishedAt from posts where id = ${id}`, ).firstOrThrow("post not found"); } /** @rpc */ export function savePost(id: string | null, input: PostInput): Post { isUserAdminOrThrow(); if (input.title.trim().length === 0) { throw new ValidationError("title is required"); } if (!/^[a-z0-9-]+$/.test(input.slug)) { throw new ValidationError("slug must be lowercase letters, numbers, and dashes"); } if (id) { return sql( `update posts set title = ${input.title}, slug = ${input.slug}, body = ${input.body}, publishedAt = ${input.publishedAt} where id = ${id} returning id, updatedAt, title, slug, body, publishedAt`, ).firstOrThrow("post not found"); } return sql( `insert into posts (title, slug, body, publishedAt) values (${input.title}, ${input.slug}, ${input.body}, ${input.publishedAt}) returning id, updatedAt, title, slug, body, publishedAt`, ).firstOrThrow(); } /** @rpc */ export function deletePost(id: string) { isUserAdminOrThrow(); sql(`delete from posts where id = ${id}`); } ``` Read helpers (`listPublished`, `getPublishedBySlug`, `listAll`, `getById`) are regular functions called from route handlers. They don't need to be rpc because the route runs on the server. Write functions (`savePost`, `deletePost`) are `@rpc` because the admin pages call them from the browser, and they're gated with `isUserAdminOrThrow` at the top. ## Public list ```bash elements create page blog ``` `app/pages/blog/index.ts`: ```ts import blog from "./template"; import { listPublished } from "#app/shared/services/posts"; export default function route(req, res) { return new blog({ posts: listPublished() }); } ``` `app/pages/blog/template.html`: ```html import "./style.css"; import { Post } from "#app/shared/services/posts";

blog

  • {p.title}
  • no posts yet.
``` The public list shows only published posts (the read helper filters by `publishedAt is not null and publishedAt <= now()`). No auth needed; everyone can see this page. ## Public single post ```bash elements create page blog-post ``` `app/pages/blog-post/index.ts`: ```ts import { marked } from "marked"; import blogPost from "./template"; import { getPublishedBySlug } from "#app/shared/services/posts"; export default function route(req, res) { let post = getPublishedBySlug(req.params.slug); let html = marked.parse(post.body) as string; return new blogPost({ post, html }); } ``` The route loads the post by slug, runs `marked.parse(post.body)` to get an HTML string, and passes both to the template. Rendering on the server puts the public HTML in the first response, which is good for search engines and for users with JavaScript disabled. `getPublishedBySlug` throws `NotFoundError` (a safe 404) when no row matches, so the route never has to check for `undefined`. `marked.parse` is sync by default and returns `string`; the cast pins the return type for TypeScript since the declared signature allows for async modes that this recipe doesn't use. `app/pages/blog-post/template.html`: ```html import "./style.css"; import { raw } from "@elements/app"; import { Post } from "#app/shared/services/posts";

{post.title}

{raw(html)}
``` `{html}` on its own would HTML-escape the rendered markdown, defeating the parser. `{raw(html)}` opts out of escaping for this one binding so the markup renders as HTML. The trust boundary is `marked` itself: as long as the parser is trusted to escape its input (the default), its output is safe to pass to `raw()`. ## Admin list ```bash elements create page admin-blog ``` `app/pages/admin-blog/index.ts`: ```ts import adminBlog from "./template"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; import { listAll } from "#app/shared/services/posts"; export default function route(req, res) { isUserAdminOrThrow(); return new adminBlog({ posts: listAll() }); } ``` `app/pages/admin-blog/template.html`: ```html import "./style.css"; import { Post } from "#app/shared/services/posts";

posts

new post
title slug status updated
{p.title} {p.slug} {p.publishedAt ? "published" : "draft"} {p.updatedAt.toLocaleDateString()} edit
no posts yet. create one.
``` The admin list shows every post regardless of `publishedAt`, with a status column distinguishing drafts from published posts. The "edit" link routes to `/admin/blog/:id`; the "new post" link routes to `/admin/blog/new`, which is the same edit route with `:id === "new"`. ## Admin editor ```bash elements create page edit-post ``` `app/pages/edit-post/index.ts`: ```ts import editPost from "./template"; import { isUserAdminOrThrow } from "#app/shared/services/admin"; import { getById, Post } from "#app/shared/services/posts"; export default function route(req, res) { isUserAdminOrThrow(); let isNew = req.params.id === "new"; if (isNew) { let blank: Post = { id: "", updatedAt: new Date(), title: "", slug: "", body: "", publishedAt: null, }; return new editPost({ post: blank, isNew: true }); } let post = getById(req.params.id); return new editPost({ post, isNew: false }); } ``` `app/pages/edit-post/template.html`: ```html import "./style.css"; import { ValidationError, redirect } from "@elements/app"; import { Post, PostInput, savePost, deletePost, } from "#app/shared/services/posts"; function emptyForm(post: Post): PostInput { return { title: post.title, slug: post.slug, body: post.body, publishedAt: post.publishedAt, }; } function onSave( postId: string, isNew: boolean, form: PostInput, error: { value: string }, ) { try { let saved = savePost(isNew ? null : postId, form); redirect(`/admin/blog/${saved.id}`); } catch (err: any) { if (err instanceof ValidationError) { error.value = err.message; return; } throw err; } } function onDelete(postId: string) { if (!confirm("delete this post?")) { return; } deletePost(postId); redirect("/admin/blog"); } function onTogglePublish(form: PostInput) { form.publishedAt = form.publishedAt === null ? new Date() : null; }

{isNew ? "new post" : "edit post"}

back to list
onSave(post.id, isNew, form, error)}>