Blogue em Markdown
Nesta página
O caso canónico dos sites estáticos: ficheiros markdown numa pasta, uma página estática por artigo, um feed RSS e zero JavaScript de cliente. Código completo em examples/blog.
O que obténs
- Uma página inicial a listar os artigos, do mais recente ao mais antigo
/blog/[slug]— uma página HTML estática por ficheiro markdown, viagenerateStaticParamsrss.xml— gerado pelo sitelo a partir da configuraçãorsssitemap.xml— ativado ao definirsite- Zero JS publicado — o markdown é processado no Node, na compilação
Estrutura do projeto
my-blog/
sitelo.config.js
content/
hello-world.md # artigos: frontmatter + markdown
why-static.md
src/
lib/
posts.js # lê content/, analisa o frontmatter, renderiza markdown
index.ht.js # / — lista de artigos
blog/
[slug].ht.js # /blog/:slug — uma página por artigo
css/
styles.cssexport default {
site: 'https://example.com',
rss: {
site: 'https://example.com',
title: 'O meu blogue',
description: 'Últimos artigos',
routePrefix: '/blog', // cada página /blog/* torna-se um item do feed
},
}1. Escreve os artigos em markdown
Os artigos vivem em content/ — fora de src/, por isso o sitelo nunca os trata como páginas ou recursos. O frontmatter são simples linhas chave: valor:
---
title: Olá, mundo
date: 2026-08-01
description: O obrigatório primeiro artigo.
---
Este blogue é uma pasta de ficheiros markdown renderizados para HTML estático.2. Lê-os e renderiza-os no Node
Um pequeno módulo exclusivo do servidor lê a pasta, analisa o frontmatter e renderiza o markdown com o marked. Como nada no HTML referencia este módulo, ele nunca chega ao navegador.
import { readdir, readFile } from 'node:fs/promises'
import { marked } from 'marked'
// Os artigos vivem fora de src/, por isso nunca são tratados como páginas ou recursos.
const CONTENT_DIR = new URL('../../content/', import.meta.url)
/** Analisador mínimo de frontmatter — linhas `chave: valor` entre cercas ---. */
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) }
}
/** Todos os artigos, do mais recente ao mais antigo. Corre apenas no Node, na compilação ou em desenvolvimento. */
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. Lista os artigos na página inicial
import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default ({ data }) => `
<html lang="pt">
<head>
<title>O meu blogue</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="alternate" type="application/rss+xml" title="O meu blogue" href="/rss.xml">
</head>
<body>
<h1>O meu blogue</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: 'pt' },
head(
title('O meu blogue'),
link({ rel: 'stylesheet', href: '/styles.css' }),
link({
rel: 'alternate',
type: 'application/rss+xml',
title: 'O meu blogue',
href: '/rss.xml',
}),
),
body(
h1('O meu blogue'),
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="pt">
<head>
<title>O meu blogue</title>
<link rel="stylesheet" href="/css/styles.css" />
<link
rel="alternate"
type="application/rss+xml"
title="O meu blogue"
href="/rss.xml"
/>
</head>
<body>
<h1>O meu blogue</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. Uma página estática por artigo
generateStaticParams devolve todos os slugs na compilação; data() carrega o artigo correspondente a cada página.
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(`Artigo não encontrado: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return `
<html lang="pt">
<head>
<title>${post.title} — O meu blogue</title>
<meta name="description" content="${post.description}">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<article>
<p><a href="/">← Todos os artigos</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(`Artigo não encontrado: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return html({ lang: 'pt' },
head(
title(`${post.title} — O meu blogue`),
meta({ name: 'description', content: post.description }),
link({ rel: 'stylesheet', href: '/styles.css' }),
),
body(
article(
p(a({ href: '/' }, '← Todos os artigos')),
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(`Artigo não encontrado: ${params.slug}`)
return { post }
}
export default function Post({ data }) {
const { post } = data
return (
<html lang="pt">
<head>
<title>{post.title} — O meu blogue</title>
<meta name="description" content={post.description} />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<article>
<p><a href="/">← Todos os artigos</a></p>
<h1>{post.title}</h1>
<time datetime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
</body>
</html>
)
}5. RSS de borla
Com a configuração rss acima, o sitelo build emite dist/rss.xml com um item por cada página sob /blog — sem código extra.
Documentação de rotas · Documentação de carregamento de dados · Documentação de configuração