Markdown-Blog
Auf dieser Seite
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
- Eine Startseite, die die Beiträge auflistet, neueste zuerst
/blog/[slug]— eine statische HTML-Seite pro Markdown-Datei, übergenerateStaticParamsrss.xml— von sitelo aus derrss-Konfiguration erzeugtsitemap.xml— aktiviert durch Setzen vonsite- Kein ausgeliefertes JS — Markdown wird zur Build-Zeit in Node geparst
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.cssexport 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 { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default ({ data }) => `
<html lang="de">
<head>
<title>Mein Blog</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="alternate" type="application/rss+xml" title="Mein Blog" href="/rss.xml">
</head>
<body>
<h1>Mein Blog</h1>
<ul class="posts">
${data.posts
.map(
(post) => `
<li>
<a href="/blog/${post.slug}">${post.title}</a>
<time datetime="${post.date}">${post.date}</time>
<p>${post.description}</p>
</li>`,
)
.join('')}
</ul>
<p><a href="/rss.xml">RSS-Feed</a></p>
</body>
</html>
`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')),
),
)import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default function Home({ data }) {
return (
<html lang="de">
<head>
<title>Mein Blog</title>
<link rel="stylesheet" href="/css/styles.css" />
<link
rel="alternate"
type="application/rss+xml"
title="Mein Blog"
href="/rss.xml"
/>
</head>
<body>
<h1>Mein Blog</h1>
<ul className="posts">
{data.posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
<time dateTime={post.date}>{post.date}</time>
<p>{post.description}</p>
</li>
))}
</ul>
<p><a href="/rss.xml">RSS-Feed</a></p>
</body>
</html>
)
}4. Eine statische Seite pro Beitrag
generateStaticParams liefert zur Build-Zeit jeden Slug; data() lädt für jede Seite den passenden Beitrag.
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</title>
<meta name="description" content="${post.description}">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<article>
<p><a href="/">← Alle Beiträge</a></p>
<h1>${post.title}</h1>
<time datetime="${post.date}">${post.date}</time>
${post.html}
</article>
</body>
</html>
`
}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,
),
),
)
}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 function Post({ data }) {
const { post } = data
return (
<html lang="de">
<head>
<title>{post.title} — Mein Blog</title>
<meta name="description" content={post.description} />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<article>
<p><a href="/">← Alle Beiträge</a></p>
<h1>{post.title}</h1>
<time datetime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
</body>
</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