Blog en Markdown
En esta página
El caso canónico de los sitios estáticos: archivos markdown en una carpeta, una página estática por entrada, un feed RSS y cero JavaScript de cliente. Código completo en examples/blog.
Qué obtienes
- Una portada que lista las entradas, de más nueva a más antigua
/blog/[slug]— una página HTML estática por archivo markdown, víagenerateStaticParamsrss.xml— generado por sitelo a partir de la configuraciónrsssitemap.xml— se activa al definirsite- Cero JS publicado — el markdown se procesa en Node durante la compilación
Estructura del proyecto
my-blog/
sitelo.config.js
content/
hello-world.md # entradas: frontmatter + markdown
why-static.md
src/
lib/
posts.js # lee content/, analiza el frontmatter, renderiza markdown
index.ht.js # / — lista de entradas
blog/
[slug].ht.js # /blog/:slug — una página por entrada
css/
styles.cssexport default {
site: 'https://example.com',
rss: {
site: 'https://example.com',
title: 'Mi blog',
description: 'Últimas entradas',
routePrefix: '/blog', // cada página /blog/* se convierte en un elemento del feed
},
}1. Escribe las entradas en markdown
Las entradas viven en content/ — fuera de src/, así sitelo nunca las trata como páginas ni recursos. El frontmatter son simples líneas clave: valor:
---
title: Hola, mundo
date: 2026-08-01
description: La obligada primera entrada.
---
Este blog es una carpeta de archivos markdown renderizados a HTML estático.2. Léelas y renderízalas en Node
Un pequeño módulo exclusivo de servidor lee la carpeta, analiza el frontmatter y renderiza el markdown con marked. Como nada en el HTML referencia este módulo, nunca llega al navegador.
import { readdir, readFile } from 'node:fs/promises'
import { marked } from 'marked'
// Las entradas viven fuera de src/, así nunca se tratan como páginas ni recursos.
const CONTENT_DIR = new URL('../../content/', import.meta.url)
/** Analizador mínimo de frontmatter — líneas `clave: valor` entre vallas ---. */
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) }
}
/** Todas las entradas, de más nueva a más antigua. Solo se ejecuta en Node, en compilación o desarrollo. */
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 las entradas en la portada
import { getPosts } from './lib/posts.js'
export async function data() {
return { posts: await getPosts() }
}
export default ({ data }) => `
<html lang="es">
<head>
<title>Mi blog</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="alternate" type="application/rss+xml" title="Mi blog" href="/rss.xml">
</head>
<body>
<h1>Mi 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: 'es' },
head(
title('Mi blog'),
link({ rel: 'stylesheet', href: '/styles.css' }),
link({
rel: 'alternate',
type: 'application/rss+xml',
title: 'Mi blog',
href: '/rss.xml',
}),
),
body(
h1('Mi 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="es">
<head>
<title>Mi blog</title>
<link rel="stylesheet" href="/css/styles.css" />
<link
rel="alternate"
type="application/rss+xml"
title="Mi blog"
href="/rss.xml"
/>
</head>
<body>
<h1>Mi 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 página estática por entrada
generateStaticParams devuelve todos los slugs en tiempo de compilación; data() carga la entrada correspondiente 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(`Entrada no encontrada: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return `
<html lang="es">
<head>
<title>${post.title} — Mi blog</title>
<meta name="description" content="${post.description}">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
<article>
<p><a href="/">← Todas las entradas</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(`Entrada no encontrada: ${params.slug}`)
return { post }
}
export default ({ data }) => {
const { post } = data
return html({ lang: 'es' },
head(
title(`${post.title} — Mi blog`),
meta({ name: 'description', content: post.description }),
link({ rel: 'stylesheet', href: '/styles.css' }),
),
body(
article(
p(a({ href: '/' }, '← Todas las entradas')),
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(`Entrada no encontrada: ${params.slug}`)
return { post }
}
export default function Post({ data }) {
const { post } = data
return (
<html lang="es">
<head>
<title>{post.title} — Mi blog</title>
<meta name="description" content={post.description} />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<article>
<p><a href="/">← Todas las entradas</a></p>
<h1>{post.title}</h1>
<time datetime={post.date}>{post.date}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
</body>
</html>
)
}5. RSS gratis
Con la configuración rss de arriba, sitelo build emite dist/rss.xml con un elemento por cada página bajo /blog — sin código adicional.
Documentación de rutas · Documentación de carga de datos · Documentación de configuración