Markdown blog

The canonical static-site use case: markdown files in a folder, one static page per post, an RSS feed, and zero client-side JavaScript. Full source in examples/blog.

What you get

Project layout

my-blog/
  sitelo.config.js
  content/
    hello-world.md       # posts: frontmatter + markdown
    why-static.md
  src/
    lib/
      posts.js           # read content/, parse frontmatter, render markdown
    index.ht.js          # / — post list
    blog/
      [slug].ht.js       # /blog/:slug — one page per post
    css/
      styles.css
export default {
  site: 'https://example.com',
  rss: {
    site: 'https://example.com',
    title: 'My Blog',
    description: 'Latest posts',
    routePrefix: '/blog', // every /blog/* page becomes a feed item
  },
}

1. Write posts as markdown

Posts live in content/ — outside src/, so sitelo never treats them as pages or assets. Frontmatter is plain key: value lines:

---
title: Hello, world
date: 2026-08-01
description: The obligatory first post.
---

This blog is a folder of markdown files rendered to static HTML.

2. Read and render them in Node

A small server-only module reads the folder, parses frontmatter, and renders markdown with marked. Because nothing in the HTML references this module, it never ships to the browser.

import { readdir, readFile } from 'node:fs/promises'
import { marked } from 'marked'

// Posts live outside src/ so they're never treated as pages or assets.
const CONTENT_DIR = new URL('../../content/', import.meta.url)

/** Tiny frontmatter parser — `key: value` lines between --- fences. */
function parseFrontmatter(raw) {
  const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw)
  if (!match) return { meta: {}, body: raw }

  const meta = {}
  for (const line of match[1].split('\n')) {
    const colon = line.indexOf(':')
    if (colon === -1) continue
    meta[line.slice(0, colon).trim()] = line.slice(colon + 1).trim()
  }

  return { meta, body: raw.slice(match[0].length) }
}

/** All posts, newest first. Runs in Node at build/dev time only. */
export async function getPosts() {
  const files = (await readdir(CONTENT_DIR)).filter((file) =>
    file.endsWith('.md'),
  )

  const posts = await Promise.all(
    files.map(async (file) => {
      const raw = await readFile(new URL(file, CONTENT_DIR), 'utf8')
      const { meta, body } = parseFrontmatter(raw)

      return {
        slug: file.replace(/\.md$/, ''),
        title: meta.title ?? file,
        date: meta.date ?? '1970-01-01',
        description: meta.description ?? '',
        html: marked.parse(body),
      }
    }),
  )

  return posts.sort((a, b) => b.date.localeCompare(a.date))
}

export async function getPost(slug) {
  const posts = await getPosts()
  return posts.find((post) => post.slug === slug) ?? null
}

3. List posts on the home page

4. One static page per post

generateStaticParams returns every slug at build time; data() loads the matching post for each page.

5. RSS for free

With the rss config above, sitelo build emits dist/rss.xml with an item for every page under /blog — no extra code.

Routing docs · Data loading docs · Configuration docs