Manual Recipes Markdown Blog

Markdown Blog

elements man recipes/markdown-blog Read as markdown

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

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) takes id: string | null. null inserts; a real id updates. The template branches on isNew to 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. marked escapes HTML in the input by default. The rendered string is safe to pass to raw() because the parser has already neutralized any tag-looking characters in the source. If you configure marked to allow raw HTML in markdown, sanitize the output with DOMPurify (or equivalent) before passing it to raw(); otherwise a post body with a <script> tag would execute on every reader's browser.
  • Drafts. publishedAt is null is the draft state. The public read helpers skip drafts via the where clause; the admin list shows everything. Scheduled publish is a where publishedAt <= now() filter; setting publishedAt to 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 to posts and a where ${tag} = any(tags) filter in the public list rpc. The admin editor gains a tag input that splits on commas.