Markdown-Blog

Der klassische Anwendungsfall für statische Websites: Markdown-Dateien in einem Ordner, eine statische Seite pro Beitrag, ein RSS-Feed und kein clientseitiges JavaScript. Vollständiger Quelltext in examples/blog.

Was du bekommst

Projektstruktur

my-blog/
  sitelo.config.js
  content/
    hello-world.md       # Beiträge: Frontmatter + Markdown
    why-static.md
  src/
    lib/
      posts.js           # liest content/, parst Frontmatter, rendert Markdown
    index.ht.js          # / — Beitragsliste
    blog/
      [slug].ht.js       # /blog/:slug — eine Seite pro Beitrag
    css/
      styles.css
export default {
  site: 'https://example.com',
  rss: {
    site: 'https://example.com',
    title: 'Mein Blog',
    description: 'Neueste Beiträge',
    routePrefix: '/blog', // jede /blog/*-Seite wird zu einem Feed-Eintrag
  },
}

1. Beiträge als Markdown schreiben

Die Beiträge liegen in content/ — außerhalb von src/, damit sitelo sie nie als Seiten oder Assets behandelt. Das Frontmatter besteht aus schlichten key: value-Zeilen:

---
title: Hallo, Welt
date: 2026-08-01
description: Der obligatorische erste Beitrag.
---

Dieser Blog ist ein Ordner voller Markdown-Dateien, die zu statischem HTML gerendert werden.

2. Sie in Node lesen und rendern

Ein kleines, rein serverseitiges Modul liest den Ordner, parst das Frontmatter und rendert das Markdown mit marked. Da nichts im HTML dieses Modul referenziert, gelangt es nie in den Browser.

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

// Beiträge liegen außerhalb von src/, damit sie nie als Seiten oder Assets gelten.
const CONTENT_DIR = new URL('../../content/', import.meta.url)

/** Winziger Frontmatter-Parser — `key: value`-Zeilen zwischen ---Zäunen. */
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) }
}

/** Alle Beiträge, neueste zuerst. Läuft nur in Node, beim Build oder in der Entwicklung. */
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. Beiträge auf der Startseite auflisten

import {
  html, head, title, link, body, h1, ul, li, a, time, p,
} from 'javascript-to-html'
import { getPosts } from './lib/posts.js'

export async function data() {
  return { posts: await getPosts() }
}

export default ({ data }) =>
  html({ lang: 'de' },
    head(
      title('Mein Blog'),
      link({ rel: 'stylesheet', href: '/styles.css' }),
      link({
        rel: 'alternate',
        type: 'application/rss+xml',
        title: 'Mein Blog',
        href: '/rss.xml',
      }),
    ),
    body(
      h1('Mein Blog'),
      ul({ class: 'posts' },
        ...data.posts.map((post) =>
          li(
            a({ href: `/blog/${post.slug}` }, post.title),
            time({ datetime: post.date }, post.date),
            p(post.description),
          ),
        ),
      ),
      p(a({ href: '/rss.xml' }, 'RSS-Feed')),
    ),
  )

4. Eine statische Seite pro Beitrag

generateStaticParams liefert zur Build-Zeit jeden Slug; data() lädt für jede Seite den passenden Beitrag.

import {
  html, head, title, meta, link, body, article, p, a, h1, time,
} from 'javascript-to-html'
import { getPost, getPosts } from '../lib/posts.js'

export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export async function data({ params }) {
  const post = await getPost(params.slug)
  if (!post) throw new Error(`Beitrag nicht gefunden: ${params.slug}`)
  return { post }
}

export default ({ data }) => {
  const { post } = data

  return html({ lang: 'de' },
    head(
      title(`${post.title} — Mein Blog`),
      meta({ name: 'description', content: post.description }),
      link({ rel: 'stylesheet', href: '/styles.css' }),
    ),
    body(
      article(
        p(a({ href: '/' }, '← Alle Beiträge')),
        h1(post.title),
        time({ datetime: post.date }, post.date),
        post.html,
      ),
    ),
  )
}

5. RSS gratis dazu

Mit der rss-Konfiguration von oben gibt sitelo build eine dist/rss.xml aus, mit einem Eintrag für jede Seite unter /blog — ohne zusätzlichen Code.

Routing-Doku · Doku zum Daten laden · Konfigurations-Doku