Migré 112 posts de Jekyll a Astro 6 y esto es lo que rompió
Escribo esto como ya publiqué el blog con el nuevo stack funcionando, así que no es un “creo que va a ir bien”: es un postmortem real con los 18 problemas que encontré en orden cronológico y cómo los resolví.
El punto de partida: un blog de 2016 con 112 posts publicados y 4 drafts, montado en Jekyll + tema Chirpy, desplegado en GitHub Pages. El destino: Astro 6 con Tailwind CSS 4, Content Collections, MDX y diseño propio desde cero.
Pero para entender de dónde viene la deuda técnica, hay que entender que esto no es una migración: es la tercera. WordPress.com, luego WordPress.org hospedado, luego Jekyll, ahora Astro. Y por el camino, no ha sido un solo blog: vivirtecleando.com y vivirjugando.com (de este último he borrado prácticamente todo rastro; si me conocéis de antes sabéis que hubo una época en la que, además de todo esto, me dediqué al poker profesional, aunque eso es otro tema) fueron fusionándose con este a lo largo de los años.
Cada migración añadió su capa. Cada fusión, la suya. Resultado: posts de 2016 con BOM UTF-8, archivos con CRLF de cuando Notepad era la herramienta de edición, kramdown residual de Jekyll mezclado con Liquid que ningún script automatizado capturó del todo. Deuda técnica real, no metafórica. Esta migración resolvió mucha. No toda.
La estrategia de migración
Cinco scripts en Node.js, ejecutados en orden:
- Script A: copiar posts a
.mdx, normalizar frontmatter (permalink,slug) - Script B: eliminar las líneas
que Jekyll inyectaba desde el frontmatter (el nuevo layout las renderiza solo) - Script C: convertir
{% post_url 2021/2021-02-19-slug %}a rutas directas/slug/ - Script D: eliminar atributos kramdown
{:target="_blank" rel="..."}(la intención inicial era dejar que rehype-external-links los repusiera en build, pero no soportasponsored, así que acabé con un componente<Link>propio que centraliza toda la lógica: externos, internos y afiliados) - Script E: convertir
{% include embed/youtube.html id="X" %}al componente<YouTube id="X" /> - Script F: migrar FAQs de
<details>/<summary>al componente<FAQItem>y extraer el arrayfaq:para el JSON-LD
El plan era sólido. La ejecución tuvo 18 problemas.
Los problemas, por grupos
El scaffold no funcionaba en directorio no vacío
npm create astro@latest . exige directorio vacío o lanza un prompt interactivo que se cuelga en un terminal no-TTY. El directorio temporal con espacios en la ruta también fallaba.
Solución: crear todos los archivos del proyecto a mano: package.json, astro.config.mjs, tsconfig.json, etc. Más trabajo, pero control total.
Astro 6 cambió la API de Content Collections
El plan usaba src/content/config.ts con la API de Astro ≤5. Astro 6 lanza LegacyContentConfigError al encontrar ese archivo.
La nueva API usa src/content.config.ts (sin /content/) con un glob loader explícito:
import { glob } from 'astro/loaders';
const posts = defineCollection({
loader: glob({ pattern: '**/*.mdx', base: './src/content/posts' }),
schema: z.object({ ... }) // z es Zod
});
También cambió cómo se deriva el slug. El schema se valida con Zod, y post.slug ya no existe en Astro 6. El glob loader usa post.id, que es la ruta relativa del archivo desde base. Para obtener el slug limpio hay que derivarlo de post.id o leerlo del campo slug del frontmatter.
Tailwind CSS 4 no usa integration de Astro
La documentación vieja (y la mayoría de tutoriales) usa @astrojs/tailwind. Tailwind 4 no se integra así. Se instala como plugin de Vite directamente:
// astro.config.mjs
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()]
}
});
Y en el CSS global:
@import "tailwindcss";
pnpm 11 bloqueaba esbuild y sharp
ERR_PNPM_IGNORED_BUILDS esbuild, sharp
pnpm.onlyBuiltDependencies en package.json ya no funciona con pnpm 11. La solución está en .npmrc:
onlyBuiltDependencies[]=esbuild
onlyBuiltDependencies[]=sharp
51 archivos con BOM UTF-8 → frontmatter duplicado
Este fue el más desagradable.
Los posts de 2016-2022 tenían BOM (\xef\xbb\xbf) al inicio del archivo. Mi función parseFrontmatter usaba regex /^---\r?\n/ que no matcheaba el BOM, así que no detectaba el frontmatter original y Script A envolvía el archivo en un frontmatter nuevo con solo permalink:, dejando el real como “body”:
---
permalink: /:slug/
---
import FAQItem...
--- ← BOM + frontmatter original intacto
title: "..."
slug: ...
---
body del post
Astro intentaba parsear todo eso y colapsaba.
Solución: script específico que detecta el BOM interno, extrae ambos bloques de frontmatter, hace merge de campos, y escribe el archivo limpio sin BOM.
CRLF en 88 archivos
Los posts del repo Jekyll tenían line endings CRLF. Los scripts de migración usaban indexOf('\n---\n', 4) para localizar el fin del frontmatter; con CRLF retornaba -1 y los imports de FAQItem/YouTube no se añadían.
Solución: normalizar CRLF→LF como primer paso en cada script antes de procesar. Debería haber sido el paso cero de toda la migración. Anotado.
Script E: YouTube con comillas simples
Script E matcheaba id="..." (comillas dobles). Los posts de 2017 usaban id='...' (comillas simples). Seis archivos sin convertir.
Solución: regex adicional para id='([^']+)'. Pequeño pero costó tiempo encontrarlo.
Script D: variantes de kramdown no capturadas
El regex original era \{:target="_blank"[^}]*\}. Algunos posts tenían {:rel="nofollow", target="_blank"} (orden diferente, coma), y otros tenían {:alt="..."} en imágenes.
Solución: regex más amplio \{:[^}]*\} que elimina cualquier {:...}. Los atributos los gestiona ahora el componente <Link>, que centraliza la lógica de externos (nofollow noopener), internos (sin rel) y afiliados (sponsored nofollow noopener). Un solo sitio donde tocar si cambia algo.
Los errores de parse de MDX
MDX es JSX sobre Markdown. Más estricto que HTML. Mucho más estricto que Kramdown. Esta sección es la lista de cosas que funcionaban en Jekyll y rompen en MDX.
Void elements sin self-closing
<br> ← error en MDX
<br /> ← correcto
Cuatro archivos. Principalmente contenido con embeds de Twitter y marcadores <!--more--> de Jekyll.
<del> wrapeando block content
<del>## 1.- TasteIt
Párrafo...
</del>
MDX trata <del> como inline JSX pero encuentra block content. Parse error. Un archivo afectado. La sección era contenido tachado de todas formas, la eliminé directamente.
layout: post en frontmatter
Astro interpreta el campo layout: en MDX como una importación. layout: post → intenta import post from 'post' → error de Rollup. Residuo de borradores viejos. Dos archivos.
< antes de números
**<10M tokens/mes**
MDX intenta parsear <10M como un tag JSX que empieza con dígito. Error. Solución: <10M.
Expresiones Liquid en el body
Algunos posts tenían restos de sintaxis Jekyll que los scripts no capturaron:
{{ slugHack }}: variable Liquid, JSX la interpreta como expresión{{ site.baseurl }}/path/: en anchor href<!--more-->y<!-- PENDIENTE: -->: los comentarios HTML son inválidos en MDX; requieren{/* */}
Lección: hacer un grep -r '{{' src/content/ y grep -r '<!--' src/content/ antes de intentar el primer build.
{% post_url %} con ñ en el filename
Script C usaba /\d{4}-\d{2}-\d{2}-[\w-]+/ para matchear filenames. \w en JavaScript por defecto no incluye caracteres no-ASCII. El post vaultwarden-contraseñas-seguras no se convirtió.
Solución: sustituir manualmente. Una sola ocurrencia, pero vale la pena anotar que \w no es Unicode-aware en JS por defecto.
FAQItem con comillas escapadas en el atributo
<FAQItem q="¿Qué es la \"Construcción inmediata\"?">
JSX no acepta \" dentro de atributos de string. Solución: comillas simples como delimitador:
<FAQItem q='¿Qué es la "Construcción inmediata"?'>
<section> huérfanas
Script F eliminaba <section> solo en el contexto de ## Preguntas frecuentes. Algunos posts tenían <section id="faq"> o <section> en otros contextos, y varios sin </section> de cierre. 16 archivos con MDX error “Expected a closing tag for <section>”.
Solución: script adicional que elimina todos los <section> y </section> del cuerpo y convierte <h2>...</h2> inline a ## ....
El gotcha de getStaticPaths en Astro 6
Este fue el más sorprendente.
En Astro 6 con output estático, getStaticPaths se compila como un chunk separado. Las variables definidas en el módulo fuera de la función no están en scope cuando se ejecuta.
---
const PAGE_SIZE = 10; // ← NO disponible dentro de getStaticPaths
export async function getStaticPaths() {
// ReferenceError: PAGE_SIZE is not defined
const totalPages = Math.ceil(posts.length / PAGE_SIZE);
}
---
Solución: redefinir todo lo que getStaticPaths necesite dentro de la propia función. No es un bug, es intencional por el modelo de compilación, pero la documentación no lo dice de forma explícita.
El problema de los posts futuros
Astro no tiene gate de fecha nativo. Los posts programados con fecha futura se generaban como páginas publicadas aunque no debían verse todavía.
Solución: filtro en getCollection:
const posts = await getCollection('posts', (p) => p.data.date <= new Date());
Hay que aplicarlo en todos los puntos de entrada: index, slug, paginación, categorías, tags, archivo, RSS. Olvidarlo en uno solo basta para que el post aparezca.
El bug del setext heading en CommonMark
Un bug más sutil que los anteriores, encontrado después del despliegue.
En CommonMark, un párrafo seguido inmediatamente de --- (sin blank line previa) se convierte en setext H2, no en <hr>. Si el párrafo es largo, los 3-4 párrafos de esa sección pueden fusionarse en un único heading que aparece en el TOC con todo el texto como título.
Lo importante es entender que esto funciona así
porque el estándar lo dice.
--- ← CommonMark: setext H2, no <hr>
Solución: blank line antes y después de cualquier --- en el cuerpo. Siempre.
Estadísticas finales
| Métrica | Valor |
|---|---|
| Posts migrados | 112 |
| Drafts migrados | 4 |
| Páginas generadas | 430 |
| Archivos con BOM corregidos | 51 |
| Archivos con CRLF normalizados | ±88 |
| Archivos con kramdown residual eliminado | 89 |
| Iteraciones hasta build limpio | ±15 |
Lecciones
- Normalizar CRLF→LF como paso cero, antes de cualquier script.
- Detectar y limpiar BOM antes de parsear frontmatter.
- Hacer
grepde{{,<!--,{:,<br>,<del>antes del primer build: son todos errores de parse en MDX. - En Astro 6,
getStaticPathscorre en scope aislado: definir dentro lo que necesite. layout:en frontmatter de MDX es especial en Astro: limpiar residuos Jekyll.- El filtro de fecha futura va en todos los getCollection, no en uno.
---en body necesita blank lines. Siempre.- rehype-external-links no soporta
rel="sponsored". Un componente<Link>propio con tres props (sponsored,internal, default externo) es más limpio y cubre todos los casos desde un solo sitio:
{/* Externo normal: target="_blank" rel="noopener" */}
<Link href="https://astro.build">Astro</Link>
{/* Afiliado/patrocinado: target="_blank" rel="sponsored noopener" */}
<Link href="https://amzn.to/xxx" sponsored>Producto en Amazon</Link>
{/* Dominio propio: sin target, sin rel */}
<Link href="https://marcosramirez.info" internal>Mi web</Link>
Toda la migración la hice con Claude Code. Sin vibe coding esto habría sido considerablemente más tedioso.
Si estás pensando en hacer una migración similar, los scripts están en el repo. Y si tienes dudas concretas, cuéntame en los comentarios o escríbeme directamente, lo tengo muy fresco.
Compártelo si te ha resultado útil. ¿Has migrado algún blog a Astro? ¿O tienes uno en Jekyll que estás dejando envejecer? Cuéntame.
Y… ojalá tu migración tenga menos de 18 problemas.
Artículos relacionados
FutureLink: enlazar en Astro posts que aún no has publicado
Tengo el blog lleno de posts programados que se enlazan entre sí. Y eso, con un calendario editorial de por medio, tiene una trampa: cuando un post sale el martes y enlaza a otro que sale el jueves, durante dos días ese enlace apunta al vacío. Un 404 interno, justo lo que no quieres para SEO ni para el lector. Así que me hice un componente de Astro pequeñito, FutureLink, que comprueba en tiempo de build si el post destino ya está vivo: si lo está, pinta un enlace normal; si todavía no, pinta solo el texto, sin enlace y sin romper nada. Y como el sitio se reconstruye por cron cada pocos minutos, el enlace se activa solo en cuanto el destino se publica. Te cuento el problema, el código entero y por qué lo monté así.
⚡ Por qué deberías usar pnpm en vez de npm y migrar hoy mismo
Llevo años viendo discos llenos de carpetas node_modules clonadas mil veces y esperas eternas en cada npm install. pnpm resuelve las dos cosas: guarda cada dependencia una sola vez en disco y enlaza por hard links, instala en una fracción del tiempo y te protege de las phantom dependencies. Te cuento por qué deberías cambiarte, qué tiene de truco y cómo dejar que tu memoria muscular siga escribiendo npm con un alias que funciona en todos los sistemas operativos.
Dograh, Retell o VAPI: cuál elegir para tu agente de voz en 2026
Me llega un proyecto con un requisito que cambia todo: los datos son sensibles y las conversaciones no pueden salir de la infraestructura del cliente. Así fue como acabé comparando en serio Dograh, Retell y VAPI. Precios reales, lo que te cuesta cada opción cuando el volumen crece, y por qué la privacidad es la pregunta que casi nadie hace cuando elige plataforma.