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
El blog tiene cara nueva: he migrado de Jekyll a Astro
Llevaba meses con la sensación de que el blog necesitaba renovarse. No porque estuviera roto, sino porque había cosas que quería hacer y no podía. Así que lo rehíce desde cero con Astro. Este post es la presentación. Los detalles técnicos de la migración vienen después.
Migrar de WordPress a Jekyll: guía completa paso a paso
Migrar de WordPress a Jekyll con GitHub Pages fue uno de los mejores cambios que hice para simplificar mi flujo de trabajo. Analizo por qué dejé WordPress, los problemas que encontré con la exportación de HTML y Markdown, y cómo configuré Jekyll para que mi blog funcionara perfectamente en GitHub Pages. Una guía completa de migración técnica.
De Cloudflare Pages a Workers con Astro: la guerra real
Cuento cómo migré marcosramirez.dev de Cloudflare Pages a Cloudflare Workers en dos días: por qué fue necesario, qué salió mal en cada intento, qué configuraciones rotaron sin funcionar y cómo quedó al final. Si estás usando Astro con el adaptador de Cloudflare y tienes rutas de API con SSR, esto te va a ahorrar tiempo.