Blog en Markdown

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

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.css
export 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 {
  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')),
    ),
  )

4. Une page statique par article

generateStaticParams renvoie tous les slugs au build ; data() charge l’article correspondant pour chaque page.

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,
      ),
    ),
  )
}

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