Migrar un blog legacy (HTML suelto) a Content Collections
Cómo convertir cientos de posts en HTML plano a una colección Astro tipada: conversor, frontmatter Zod-válido, exclusión de stubs y taxonomías.
Ciento ochenta y cinco carpetas. Cada una con su index.html, su head repetido, su menú incrustado, su schema horneado a mano y, en el cuerpo, el artículo real ahogado entre todo lo demás. Así se veía el blog que teníamos que migrar a una colección de Astro tipada. El primer instinto —copiar y pegar— se descarta en la segunda carpeta: nadie limpia ciento ochenta y cinco cabeceras a mano sin equivocarse. El segundo instinto —un script que vuelque todo a Markdown— es el correcto, pero esconde una trampa que casi nos cuesta inflar la colección con basura: de esas ciento ochenta y cinco carpetas, veintidós no eran posts. Eran lápidas: páginas de «este artículo ya no está disponible» que un CMS viejo dejó atrás. Migramos ciento sesenta y tres reales y dejamos las veintidós fuera, y esa resta fue tan importante como la conversión.
Migrar un blog legacy no es un problema de formato, es un problema de disciplina de datos. El objetivo no es «que compile»: es que cada post entre al sistema con frontmatter que pasa el schema Zod estricto —categoría dentro de un enum cerrado, descripción en su rango, fecha real, imagen válida— y que lo muerto se quede afuera. Cuando la migración respeta el schema, el blog hereda gratis lo que la plantilla ya trae resuelto: paginación, categorías, tags y schema Article limpio. Cuando no lo respeta, heredas un índice monolítico y una tarde de errores de build. Esta guía es el proceso que separó una cosa de la otra.
Contexto: por qué una colección y no más HTML
Un blog en HTML suelto escala mal por una razón estructural: cada post repite el marco completo (head, menú, footer, schema) y nada está tipado, así que nada se valida. El índice se vuelve un solo archivo gigante que hay que editar a mano cada vez que publicas, y las taxonomías —categorías, tags— no existen o se mantienen con las uñas. Una Content Collection invierte eso: el contenido es dato tipado, el marco lo pone el layout una sola vez, y los índices, la paginación y las taxonomías se generan de la colección. La migración es el puente entre los dos mundos, y como todo puente, lo difícil es que aguante el primer paso de carga.
La trampa de los stubs: contar antes de convertir
Antes de convertir nada, separa las carpetas en dos montones: posts reales y redirect stubs. Un stub es una página que ya no tiene contenido —dice «no disponible», o solo hace un meta refresh hacia otra URL—. Si los conviertes, llenas la colección de posts vacíos con títulos genéricos que canibalizan el SEO y ensucian el índice. Detéctalos por una marca en el cuerpo: la frase de «no disponible», la etiqueta <meta http-equiv="refresh">, un cuerpo por debajo de cierto umbral de palabras. En nuestra migración, esa criba apartó veintidós de ciento ochenta y cinco. Migrar ciento sesenta y tres reales es un éxito; migrar ciento ochenta y cinco incluyendo lápidas es un retroceso disfrazado de número grande.
El conversor: del HTML al Markdown
Para extraer el contenido real de cada index.html —descartando head, menú, footer y schema— usa un conversor que parsee el HTML y serialice solo el cuerpo del artículo. La dupla de Python beautifulsoup4 + markdownify es la herramienta probada: BeautifulSoup localiza el contenedor del artículo (<article>, <main> o el selector que use el tema viejo) y markdownify lo pasa a Markdown conservando encabezados, listas y enlaces.
from bs4 import BeautifulSoup
from markdownify import markdownify as md
soup = BeautifulSoup(html, "html.parser")
cuerpo = soup.select_one("article") or soup.select_one("main")
markdown = md(str(cuerpo), heading_style="ATX")
El head viejo no es basura: es tu mejor fuente de metadatos. De ahí salen el title, la description, el datePublished del schema y el og:image. Extráelos con BeautifulSoup y vuélcalos al frontmatter en lugar de inventarlos —un dato real recuperado vale más que uno plausible inventado—.
.md es más seguro que .mdx para contenido convertido
La colección articulos está pensada en .mdx, pero el contenido convertido automáticamente debería entrar como .md, y esta es la decisión que más errores de build evita. La razón es el parser: MDX interpreta las llaves { y el signo de menor-que como sintaxis JSX, y un post viejo está lleno de ambos —fórmulas, precios entre llaves, comparaciones, fragmentos pegados del CMS— que MDX intentará compilar como expresiones y romperán el build de formas difíciles de rastrear. Markdown plano no corre ese riesgo. La solución es ampliar el loader de la colección para que acepte ambas extensiones:
// content.config.ts
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/articulos' }),
Así los artículos editoriales nuevos siguen en .mdx (con sus componentes, escritos a mano con cuidado), y los migrados entran en .md sin tocar el parser JSX. Es, literalmente, la diferencia entre un build verde a la primera y una tarde cazando errores de compilación dentro de posts que no escribiste tú.
Forzar frontmatter Zod-válido
Aquí fracasa la mayoría de las migraciones: el schema estricto rechaza lo que no encaje, y el contenido viejo casi nunca encaja a la primera. La tentación es relajar el schema para que «pase». No lo hagas —el schema estricto es el control de calidad de la migración, no el obstáculo—. En su lugar, adapta el dato al schema dentro del conversor:
title: recórtalo o complétalo al rango exigido (enarticulos, de 10 a 70 caracteres). Un título de catálogo de 90 caracteres se acorta; uno de 6 se completa con contexto.description: si la meta vieja es corta, extiéndela desde el primer párrafo del cuerpo hasta caer en el rango (70 a 160). No es capricho: es la longitud que Google muestra en la SERP.seoTitle: derívalo a 60 caracteres como máximo.category: mapéala al enum cerrado (guias,novedades,general). Nunca dejes libre la categoría del blog viejo: «Guías» y «Guias» se vuelven dos categorías distintas y fragmentan el SEO sin que nadie se dé cuenta.pubDate: tómala deldatePublisheddel schema viejo; si no existe, de la fecha del archivo. Nunca la fecha de hoy para todos, que aplana el historial y rompe el orden cronológico.heroImage: si no hay imagen real, usa un placeholder válido bajo/images/—el schema exige la ruta— y registra la deuda para sustituirla en una pasada posterior.
heroImage: "/images/blog/default.svg" # placeholder válido; sustituir por imagen real
Lo que el sistema te regala al terminar
Cuando los posts entran como colección válida, el índice deja de ser un archivo monolítico y la plantilla genera sola sus derivados. En nuestra migración, ciento sesenta y tres posts produjeron doscientas cinco páginas: los posts, más dieciocho de paginación, tres de categoría y diez de tag —todas a partir de la colección, ninguna mantenida a mano—. Esa es la línea que separa un blog que escala de uno que se vuelve un index.html de doscientos sesenta y cuatro kilobytes: la paginación y las taxonomías nacen del dato, no del esfuerzo manual.
Patrones avanzados
La categorización fina es una segunda pasada, no un bloqueo. En la primera corrida, lo honesto es mapear a general todo lo que no cae claramente en guias o novedades —en nuestro caso, ciento dieciséis de ciento sesenta y tres quedaron en general—. Intentar clasificar a la perfección en la misma pasada que conviertes mezcla dos trabajos y atrasa los dos. Migra primero con una categoría segura, refina la taxonomía después con el contenido ya tipado y consultable.
Separar perfiles de directorio de artículos editoriales. Un blog viejo suele acumular dos cosas distintas: artículos de verdad y perfiles de negocios locales (fichas que en realidad pertenecen a un directorio). Conviértelos a la colección, pero etiquétalos para poder separarlos después; mezclarlos en el mismo feed editorial diluye ambos y confunde al lector que llegó por un artículo y cae en una ficha de directorio.
Las imágenes reales son la deuda explícita. El placeholder es legítimo siempre que lo registres. Lo que no es legítimo es olvidarlo: un blog entero con la misma imagen genérica se ve abandonado. Anota la deuda en el mismo commit («heroImage placeholder, pendiente imagen real por post») para que no se vuelva permanente por silencio.
Edge cases
Las URLs viejas con tráfico. Si un stub o un post que reorganizas tenía tráfico o enlaces entrantes, no lo borres y ya: pon una redirección 301 hacia la página viva más cercana. Migrar el contenido sin migrar las redirecciones tira el SEO acumulado por la ventana.
Entidades HTML y caracteres especiales. markdownify maneja bien el grueso, pero revisa tildes, eñes y comillas tipográficas que algunos temas viejos guardaban como entidades (á, ñ). Una pasada de normalización a UTF-8 limpio evita que el frontmatter o el cuerpo lleguen con ruido.
El post que era media tabla. Algunos «artículos» viejos son en realidad una tabla de specs o un formulario. Esos no son posts: son contenido que pertenece a otra colección (productos, servicios) o que debe rehacerse. Detéctalos por su baja proporción de prosa y resérvalos para un tratamiento aparte.
Tabla: campo viejo → frontmatter nuevo
| Dato del HTML viejo | Campo de la colección | Regla de validación |
|---|---|---|
<title> / og:title | title | 10–70 caracteres |
meta description | description | 70–160 (extender si corta) |
datePublished (schema) | pubDate | fecha real, no la de hoy |
| categoría del tema viejo | category | mapear a enum cerrado |
og:image | heroImage | ruta /images/ o placeholder |
cuerpo <article> | contenido .md | Markdown, no MDX |
Checklist
- Separa posts reales de redirect stubs; cuenta ambos montones y excluye los stubs.
- Convierte HTML a Markdown con un conversor (BeautifulSoup + markdownify), no a mano.
- Amplía el
loadera**/*.{md,mdx}y migra el contenido convertido como.md. - Adapta cada campo al schema (título, descripción,
seoTitle, categoría enum,pubDate). - Usa un placeholder válido en
heroImagey registra la deuda de imágenes reales. - Pon 301 en las URLs viejas con tráfico; no borres a ciegas.
- Verifica:
astro checken cero y un build que genere posts + paginación + categorías + tags.
Preguntas frecuentes
¿Por qué no migrar a mano si tengo «pocos» posts?
Porque rara vez son pocos y porque a mano se cuela el head viejo, el schema horneado y errores de frontmatter que el schema rechazará uno por uno. Un conversor aplica las mismas reglas a todos, es reproducible, y deja registro de qué excluyó y por qué.
¿.md o .mdx para los posts migrados?
.md, sin dudarlo. El contenido convertido trae llaves y signos que MDX interpreta como JSX y rompen el build. Reserva .mdx para artículos nuevos escritos a mano que de verdad usen componentes. Ampliar el loader a {md,mdx} te deja tener ambos.
¿Qué hago con los posts que ya no existen (stubs)?
Exclúyelos de la migración. Si esas URLs tenían tráfico o enlaces, configura una redirección 301 a la página viva más cercana; pero no los conviertas en posts vacíos que ensucian el índice y compiten por términos sin contenido detrás.
¿Está mal usar un placeholder en heroImage?
No, siempre que sea una ruta válida bajo /images/ y registres la deuda. Es mejor un placeholder consistente que un build roto o una imagen 404. Lo que está mal es olvidarlo: las imágenes reales se sustituyen en una pasada posterior, planificada.
¿Cómo evito la categoría duplicada «Guías» vs «Guias»?
Mapeando al enum cerrado en el conversor. El schema usa z.enum() precisamente para que la categoría no sea texto libre; cualquier valor fuera del enum lo rechaza el build, que es justo la red que quieres.
¿Tengo que dejar la categorización perfecta en la primera pasada?
No, y intentarlo atrasa todo. Mapea a general lo dudoso, migra, y refina la taxonomía después con el contenido ya tipado. La clasificación fina es trabajo editorial; la conversión es trabajo de datos. Sepáralos.
Sigue leyendo
- Índices de sección en Astro: getCollection e ItemList — cómo el índice de blog se genera de la colección ya migrada.
- Content Layer y loaders type-safe en Astro — el
loaderque admite.mdy.mdxa la vez. - Auditar y migrar un sitio existente al estándar — la migración del blog dentro del método general página por página.
- Fuente externa: Astro Docs — Content Collections y markdownify (PyPI).