Blog en Markdown
Sur cette page
Le cas d’usage canonique du site statique : des fichiers markdown dans un dossier, une page statique par article, un flux RSS et zéro JavaScript côté client. Source complète dans examples/blog.
Ce que vous obtenez
- Une page d’accueil listant les articles, du plus récent au plus ancien
/blog/[slug]— une page HTML statique par fichier markdown, viagenerateStaticParamsrss.xml— généré par sitelo à partir de la configurationrsssitemap.xml— activé en définissantsite- Zéro JS livré — le markdown est analysé au build, dans Node
Structure du projet
my-blog/
sitelo.config.js
content/
hello-world.md # articles : frontmatter + markdown
why-static.md
src/
lib/
posts.js # lit content/, analyse le frontmatter, rend le markdown
index.ht.js # / — liste des articles
blog/
[slug].ht.js # /blog/:slug — une page par article
css/
styles.cssexport default {
site: 'https://example.com',
rss: {
site: 'https://example.com',
title: 'Mon blog',
description: 'Derniers articles',
routePrefix: '/blog', // chaque page /blog/* devient une entrée du flux
},
}1. Écrire les articles en markdown
Les articles vivent dans content/ — hors de src/, si bien que sitelo ne les traite jamais comme des pages ou des ressources. Le frontmatter est une simple suite de lignes clé: valeur :
---
title: Bonjour, monde
date: 2026-08-01
description: L’inévitable premier article.
---
Ce blog est un dossier de fichiers markdown rendus en HTML statique.2. Les lire et les rendre dans Node
Un petit module côté serveur lit le dossier, analyse le frontmatter et rend le markdown avec marked. Comme rien dans le HTML ne référence ce module, il n’atteint jamais le navigateur.
import { readdir, readFile } from 'node:fs/promises'
import { marked } from 'marked'
// Les articles vivent hors de src/, ils ne sont donc jamais traités comme des pages ou des ressources.
const CONTENT_DIR = new URL('../../content/', import.meta.url)
/** Petit analyseur de frontmatter — des lignes `clé: valeur` entre les barrières ---. */
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) }
}
/** Tous les articles, du plus récent au plus ancien. S’exécute uniquement dans Node, au build ou en dev. */
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. Lister les articles sur l’accueil
import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default ({ data }) => `
<html lang="fr">
<head>
<title>Mon blog</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="alternate" type="application/rss+xml" title="Mon blog" href="/rss.xml">
</head>
<body>
<h1>Mon 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">Flux RSS</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: 'fr' },
head(
title('Mon blog'),
link({ rel: 'stylesheet', href: '/styles.css' }),
link({
rel: 'alternate',
type: 'application/rss+xml',
title: 'Mon blog',
href: '/rss.xml',
}),
),
body(
h1('Mon 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' }, 'Flux RSS')),
),
)import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default function Home({ data }) {
return (
<html lang="fr">
<head>
<title>Mon blog</title>
<link rel="stylesheet" href="/css/styles.css" />
<link
rel="alternate"
type="application/rss+xml"
title="Mon blog"
href="/rss.xml"
/>
</head>
<body>
<h1>Mon 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">Flux RSS</a></p>
</body>
</html>
)
}4. Une page statique par article
generateStaticParams renvoie tous les slugs au build ; data() charge l’article correspondant pour chaque page.
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(`Article introuvable: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return `
<html lang="fr">
<head>
<title>${post.title} — Mon blog</title>
<meta name="description" content="${post.description}">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<article>
<p><a href="/">← Tous les articles</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(`Article introuvable: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return html({ lang: 'fr' },
head(
title(`${post.title} — Mon blog`),
meta({ name: 'description', content: post.description }),
link({ rel: 'stylesheet', href: '/styles.css' }),
),
body(
article(
p(a({ href: '/' }, '← Tous les articles')),
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(`Article introuvable: ${params.slug}`)
return { post }
}
export default function Post({ data }) {
const { post } = data
return (
<html lang="fr">
<head>
<title>{post.title} — Mon blog</title>
<meta name="description" content={post.description} />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<article>
<p><a href="/">← Tous les articles</a></p>
<h1>{post.title}</h1>
<time datetime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
</body>
</html>
)
}5. Le RSS offert
Avec la configuration rss ci-dessus, sitelo build produit dist/rss.xml avec une entrée pour chaque page sous /blog — sans code supplémentaire.
Docs du routage · Docs du chargement de données · Docs de configuration