Blog in Markdown
In questa pagina
Il caso d’uso per eccellenza dei siti statici: file markdown in una cartella, una pagina statica per articolo, un feed RSS e zero JavaScript lato client. Sorgente completa in examples/blog.
Cosa ottieni
- Una home page che elenca gli articoli, dal più recente
/blog/[slug]— una pagina HTML statica per ogni file markdown, tramitegenerateStaticParamsrss.xml— generato da sitelo a partire dalla configurazionersssitemap.xml— abilitata impostandosite- Zero JS pubblicato — l’analisi del markdown avviene in fase di build, in Node
Struttura del progetto
my-blog/
sitelo.config.js
content/
hello-world.md # posts: frontmatter + markdown
why-static.md
src/
lib/
posts.js # legge content/, analizza il frontmatter, renderizza il markdown
index.ht.js # / — elenco degli articoli
blog/
[slug].ht.js # /blog/:slug — una pagina per articolo
css/
styles.cssexport default {
site: 'https://example.com',
rss: {
site: 'https://example.com',
title: 'Il mio blog',
description: 'Ultimi articoli',
routePrefix: '/blog', // ogni pagina /blog/* diventa un elemento del feed
},
}1. Scrivi gli articoli in markdown
Gli articoli vivono in content/ — fuori da src/, così sitelo non li tratta mai come pagine o risorse. Il frontmatter è fatto di semplici righe chiave: valore:
---
title: Ciao, mondo
date: 2026-08-01
description: L’immancabile primo articolo.
---
Questo blog è una cartella di file markdown renderizzati in HTML statico.2. Leggili e renderizzali in Node
Un piccolo modulo destinato solo al server legge la cartella, analizza il frontmatter e renderizza il markdown con marked. Poiché nulla nell’HTML referenzia questo modulo, non arriva mai al browser.
import { readdir, readFile } from 'node:fs/promises'
import { marked } from 'marked'
// Gli articoli stanno fuori da src/, così non vengono mai trattati come pagine o risorse.
const CONTENT_DIR = new URL('../../content/', import.meta.url)
/** Minuscolo parser di frontmatter — righe `chiave: valore` fra i delimitatori ---. */
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) }
}
/** Tutti gli articoli, dal più recente. Gira in Node solo in build/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. Elenca gli articoli in home page
import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default ({ data }) => `
<html lang="it">
<head>
<title>Il mio blog</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="alternate" type="application/rss+xml" title="Il mio blog" href="/rss.xml">
</head>
<body>
<h1>Il mio 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">Feed 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: 'it' },
head(
title('Il mio blog'),
link({ rel: 'stylesheet', href: '/styles.css' }),
link({
rel: 'alternate',
type: 'application/rss+xml',
title: 'Il mio blog',
href: '/rss.xml',
}),
),
body(
h1('Il mio 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' }, 'Feed RSS')),
),
)import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default function Home({ data }) {
return (
<html lang="it">
<head>
<title>Il mio blog</title>
<link rel="stylesheet" href="/css/styles.css" />
<link
rel="alternate"
type="application/rss+xml"
title="Il mio blog"
href="/rss.xml"
/>
</head>
<body>
<h1>Il mio 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">Feed RSS</a></p>
</body>
</html>
)
}4. Una pagina statica per articolo
generateStaticParams restituisce ogni slug in fase di build; data() carica l’articolo corrispondente per ciascuna pagina.
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(`Articolo non trovato: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return `
<html lang="it">
<head>
<title>${post.title} — Il mio blog</title>
<meta name="description" content="${post.description}">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<article>
<p><a href="/">← Tutti gli articoli</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(`Articolo non trovato: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return html({ lang: 'it' },
head(
title(`${post.title} — Il mio blog`),
meta({ name: 'description', content: post.description }),
link({ rel: 'stylesheet', href: '/styles.css' }),
),
body(
article(
p(a({ href: '/' }, '← Tutti gli articoli')),
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(`Articolo non trovato: ${params.slug}`)
return { post }
}
export default function Post({ data }) {
const { post } = data
return (
<html lang="it">
<head>
<title>{post.title} — Il mio blog</title>
<meta name="description" content={post.description} />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<article>
<p><a href="/">← Tutti gli articoli</a></p>
<h1>{post.title}</h1>
<time datetime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
</body>
</html>
)
}5. RSS in regalo
Con la configurazione rss qui sopra, sitelo build produce dist/rss.xml con un elemento per ogni pagina sotto /blog — senza codice aggiuntivo.
Documentazione sul routing · Documentazione sul caricamento dati · Documentazione sulla configurazione