Markdown Blog
elements man recipes/markdown-blog Read as markdownA 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
elements create migration 'add posts' -tables=posts
app/migrations/<timestamp>-add-posts.migration.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:
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:
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<Post>(
`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<Post>(
`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<Post>(
`select id, updatedAt, title, slug, body, publishedAt from posts
order by updatedAt desc`,
).all();
}
export function getById(id: string): Post {
return sql<Post>(
`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<Post>(
`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<Post>(
`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
elements create page blog
app/pages/blog/index.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:
import "./style.css";
import { Post } from "#app/shared/services/posts";
<html class="blog" (posts: Post[])>
<h1>blog</h1>
<ul class="posts">
<li e:for={p of posts}>
<a href={`/blog/${p.slug}`}>{p.title}</a>
<time>{p.publishedAt?.toLocaleDateString()}</time>
</li>
<li e:if={posts.length === 0}>no posts yet.</li>
</ul>
</html>
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
elements create page blog-post
app/pages/blog-post/index.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:
import "./style.css";
import { raw } from "@elements/app";
import { Post } from "#app/shared/services/posts";
<html class="blog-post" (post: Post, html: string)>
<article>
<header>
<h1>{post.title}</h1>
<time>{post.publishedAt?.toLocaleDateString()}</time>
</header>
<div class="body">{raw(html)}</div>
</article>
</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
elements create page admin-blog
app/pages/admin-blog/index.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:
import "./style.css";
import { Post } from "#app/shared/services/posts";
<html class="admin-blog" (posts: Post[])>
<header>
<h1>posts</h1>
<a href="/admin/blog/new">new post</a>
</header>
<table class="posts">
<thead>
<tr>
<th>title</th>
<th>slug</th>
<th>status</th>
<th>updated</th>
<th></th>
</tr>
</thead>
<tbody>
<tr e:for={p of posts}>
<td>{p.title}</td>
<td>{p.slug}</td>
<td>{p.publishedAt ? "published" : "draft"}</td>
<td>{p.updatedAt.toLocaleDateString()}</td>
<td><a href={`/admin/blog/${p.id}`}>edit</a></td>
</tr>
<tr e:if={posts.length === 0}>
<td colspan="5" class="empty">no posts yet. <a href="/admin/blog/new">create one</a>.</td>
</tr>
</tbody>
</table>
</html>
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
elements create page edit-post
app/pages/edit-post/index.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:
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;
}
<html class="edit-post"
(post: Post,
isNew: boolean,
private form: PostInput = emptyForm(post),
private error: { value: string } = { value: "" })>
<header>
<h1>{isNew ? "new post" : "edit post"}</h1>
<a href="/admin/blog">back to list</a>
</header>
<form onsubmit={() => onSave(post.id, isNew, form, error)}>
<label>title</label>
<input type="text" value={form.title} required>
<label>slug</label>
<input type="text" value={form.slug} placeholder="my-post" required>
<label>body (markdown)</label>
<textarea value={form.body} rows="20"/>
<label class="published">
<input type="checkbox"
checked={form.publishedAt !== null}
onchange={() => onTogglePublish(form)}>
published
</label>
<p e:if={error.value} class="error">{error.value}</p>
<div class="actions">
<button type="submit">save</button>
<button e:if={!isNew} type="button" class="danger" onclick={() => onDelete(post.id)}>
delete
</button>
</div>
</form>
</html>
emptyForm(post) copies the post's fields into a new PostInput so the form
binds to a separate object. The published checkbox toggles publishedAt between
null (draft) and new Date() (published now); for scheduling a future
publish, swap the checkbox for a <input type="datetime-local">.
On save, the rpc returns the saved post; the handler navigates to its
/admin/blog/:id URL. If the post was new, that URL now exists. If it was an
edit, the URL doesn't change. Either way, the navigation flushes any client-side
form state and reloads from the canonical row.
On delete, a confirm() dialog guards against accidents (browser-native, no UI
work needed). After deletion the handler navigates to the admin list.
Routes
Register all four routes in index.ts:
import blog from "#app/pages/blog";
import blogPost from "#app/pages/blog-post";
import adminBlog from "#app/pages/admin-blog";
import editPost from "#app/pages/edit-post";
// ...
app.route("/blog", blog);
app.route("/blog/:slug", blogPost);
app.route("/admin/blog", adminBlog);
app.route("/admin/blog/:id", editPost);
Notes
- One rpc for create and update.
savePost(id, input)takesid: string | null.nullinserts; a real id updates. The template branches onisNewto decide which to pass. The alternative is two rpc (createPost+updatePost) which doubles the surface area for no real gain. - Markdown rendering on the server. Rendering happens in the route, before the template runs. The first paint shows the rendered body. Move it client-side only if you need re-rendering as the user types in a live editor; the public page never needs that.
- Trust boundary at the renderer.
markedescapes HTML in the input by default. The rendered string is safe to pass toraw()because the parser has already neutralized any tag-looking characters in the source. If you configuremarkedto allow raw HTML in markdown, sanitize the output withDOMPurify(or equivalent) before passing it toraw(); otherwise a post body with a<script>tag would execute on every reader's browser. - Drafts.
publishedAt is nullis the draft state. The public read helpers skip drafts via thewhereclause; the admin list shows everything. Scheduled publish is awhere publishedAt <= now()filter; settingpublishedAtto a future timestamp keeps the post out of the public list until that time arrives. - Caching. Public read pages are good candidates for HTTP caching since their content rarely changes per-request. Elements' built-in ETag layer handles this automatically; on a published-post URL, the second request from the same browser revalidates with the existing ETag instead of re-running the route.
- Tags or categories. Add a
tags text[]column topostsand awhere ${tag} = any(tags)filter in the public list rpc. The admin editor gains a tag input that splits on commas.