Mi primer blog

Planteamiento del problema:
La plataforma carece de un canal de content marketing. Un blog permite que cada tenant publique artículos multilingües optimizados para SEO, redactados en Strapi CMS y renderizados en la aplicación SvelteKit, lo que impulsa el tráfico orgánico, consolida autoridad y aprovecha la canalización i18n existente de 13 configuraciones regionales.
Criterios de éxito
- Las entradas de blog creadas en Strapi se renderizan en el frontend con la identidad de marca del tenant
- Las entradas se traducen automáticamente a los 13 idiomas a través del pipeline de webhooks existente (sin cambios en la infraestructura de traducción)
- Las páginas de blog posicionan en buscadores (datos estructurados, sitemap, hreflang, og:article)
- Los tenants sin el feature flag
blogno tienen rutas de blog (404)
Historias de usuario
- Como autor de contenido, quiero escribir entradas de blog en Strapi con markdown enriquecido, imágenes y vídeos incrustados para poder publicar contenido sin tocar código.
- Como visitante del sitio, quiero explorar las entradas del blog filtradas por categoría para poder encontrar contenido relevante.
- Como visitante del sitio, quiero una tabla de contenidos en las entradas largas para poder navegar rápidamente.
- Como visitante del sitio, quiero leer las entradas en mi idioma, con traducción automática desde el inglés.
- Como visitante del sitio, quiero compartir las entradas en redes sociales.
- Como visitante del sitio, quiero descubrir entradas relacionadas y conocer al autor.
- Como responsable de SEO, quiero que cada entrada tenga metatags correctas, datos estructurados y entradas en el sitemap.
Requisitos funcionales
Imprescindible (MVP)
- Tipos de contenido en Strapi:
blog-posts,blog-authors,blog-categories— todos con campos de tenant, locale e inherit - Página de listado del blog (
/blog) — grid paginado de posts, filtro por categoría mediante query param - Página de detalle del post (
/blog/[slug]) — renderizado completo de markdown con ToC, tarjeta de autor, posts relacionados y opción de compartir en redes sociales - Página de categoría (
/blog/category/[slug]) — listado filtrado reutilizando los mismos componentes de listado - Página de autor (
/blog/author/[slug]) — biografía del autor + sus posts - Tabla de contenidos — generada automáticamente a partir de los encabezados h2/h3, sidebar fija en escritorio, colapsable en móvil, resaltado del encabezado activo mediante Intersection Observer
- Embeds de YouTube/Vimeo — las URLs simples en markdown se convierten automáticamente en iframes responsive (youtube-nocookie.com por privacidad)
- Tiempo de lectura — calculado en el servidor a partir del recuento de palabras (Math.ceil(words / 200))
- Botones para compartir en redes — X, Facebook, LinkedIn, email, copiar enlace (Web Share API cuando esté disponible)
- Posts relacionados — misma categoría, excluir el actual, límite 3, ordenados por publishedAt desc
- Feed RSS (
/blog/rss.xml) — RSS 2.0, locale mediante query param - SEO — SeoComponent por post, etiquetas og:article, esquema JSON-LD BlogPosting, URLs canónicas
- Sitemap — URLs dinámicas de posts de blog/categorías/autores añadidas al sitemap existente con hreflang
- Feature flag —
blog: booleanen TenantConfig.features, el layout guard devuelve 404 cuando está deshabilitado - i18n — claves de mensajes Paraglide para todos los textos de la interfaz (navegación, etiquetas, paginación), contenido de Strapi traducido automáticamente mediante el webhook existente
- Paginación — basada en páginas usando la paginación nativa de Strapi (9 posts por página)
- Post destacado — flag booleano
featureden blog-posts; el post destacado se muestra como tarjeta hero en la parte superior de la página de listado/blog(tarjeta más grande, a ancho completo, por encima del grid)
Deseable (Post-MVP)
- Búsqueda de texto completo en todos los posts del blog
- Series de posts / artículos en varias partes
- Barra de progreso de lectura
- Comentarios (probablemente mediante un embed de terceros)
- CTA de suscripción a newsletter dentro de los posts
- Contador de visitas / integración de analítica
Requisitos técnicos
Modelo de datos (Tipos de contenido de Strapi)
blog-posts (api::blog-post.blog-post):
| Campo | Tipo | Localizado | Notas |
|---|---|---|---|
| title | Texto (obligatorio) | Sí | Vinculado por la configuración de traducción |
| slug | UID (desde title) | No | Excluido de la traducción |
| excerpt | Texto | Sí | Para tarjetas + meta descripción |
| body | Rich Text (Markdown) | Sí | Renderizado vía marked |
| featuredImage | Media (único) | No | URL + alternativeText |
| category | Relación (manyToOne -> blog-category) | No | |
| author | Relación (manyToOne -> blog-author) | No | |
| featured | Boolean (por defecto false) | No | Publicación destacada mostrada como tarjeta hero en el listado |
| seo | Componente (shared.seo) | Sí | Reutiliza el SeoComponent existente |
| tenant | Texto | No | Filtro tenant estándar |
| inherit | Boolean (por defecto true) | No | Indicador de auto-traducción |
blog-authors (api::blog-author.blog-author):
| Campo | Tipo | Localizado | Notas |
|---|---|---|---|
| name | Texto (obligatorio) | Sí | |
| slug | UID (desde name) | No | |
| bio | Rich Text (Markdown) | Sí | |
| avatar | Media (único) | No | |
| socialLinks | JSON | No | { twitter?, linkedin?, website? } |
| tenant | Texto | No | |
| inherit | Boolean (por defecto true) | No |
blog-categories (api::blog-category.blog-category):
| Campo | Tipo | Localizado | Notas |
|---|---|---|---|
| name | Texto (obligatorio) | Sí | |
| slug | UID (desde name) | No | |
| description | Texto | Sí | |
| color | Texto | No | Color hex para insignias de la UI |
| tenant | Texto | No | |
| inherit | Boolean (por defecto true) | No |
Interfaces TypeScript
Ver src/lib/services/strapi-types.ts para BlogPost, BlogAuthor, BlogCategory.
Rutas
Todas bajo src/routes/(marketing)/blog/:
| Ruta | Server Load | Propósito |
|---|---|---|
blog/+layout.server.ts |
Feature guard + carga de categorías | Puerta de acceso + datos de navegación |
blog/+page.server.ts |
Publicaciones paginadas, filtro ?category= opcional |
Listado |
blog/[slug]/+page.server.ts |
Publicación por slug + publicaciones relacionadas + cálculo de tiempo de lectura | Detalle |
blog/category/[slug]/+page.server.ts |
Publicaciones filtradas por categoría | Listado por categoría |
blog/author/[slug]/+page.server.ts |
Autor + sus publicaciones | Página de autor |
blog/rss.xml/+server.ts |
RSS 2.0 XML, parámetro ?locale= |
Feed |
Componentes de UI
Todos en src/lib/components/blog/:
| Componente | Props | Notas |
|---|---|---|
BlogPostCard.svelte |
post: BlogPost |
Tarjeta con imagen, distintivo de categoría, título, extracto, autor, fecha, tiempo de lectura |
BlogPostContent.svelte |
body: string |
Markdown a HTML vía marked con extensión para incrustar vídeo + IDs de encabezado |
TableOfContents.svelte |
entries: TocEntry[] |
Barra lateral fija (sticky), Intersection Observer para el estado activo, colapsable en móvil |
AuthorCard.svelte |
author: BlogAuthor |
Avatar, nombre, biografía, enlaces sociales |
SocialShare.svelte |
url: string, title: string |
Botones para compartir con fallback de Web Share API |
RelatedPosts.svelte |
posts: BlogPost[] |
Grid de BlogPostCard |
BlogPagination.svelte |
pagination: StrapiPagination, basePath: string |
Anterior/siguiente + números de página |
Seguridad y autenticación
- El blog es público (no se requiere autenticación): contenido de marketing
- Acceso de lectura a la API de Strapi mediante el token existente
STRAPI_JWT - Las incrustaciones de vídeo usan
youtube-nocookie.compara el cumplimiento del RGPD
Casos límite y manejo de errores
- Publicación no encontrada: 404 a través de SvelteKit
error() - Sin publicaciones en la categoría: estado vacío con mensaje
blog_no_posts - Imagen destacada ausente: la tarjeta se muestra sin imagen
- Traducción no disponible:
loadContentWithFallback()hace fallback al inglés - Idiomas RTL: el diseño respeta
dir="rtl"(ar, he, ur)
Plan de pruebas E2E
Los archivos de prueba se encuentran en tests/blog/. Sigue los patrones existentes: parametrización en distintos tamaños de pantalla, Axe para a11y, networkidle espera al contenido de Strapi.
Pruebas de ruta crítica
- Carga del listado de blog —
/blogrenderiza una cuadrícula de tarjetas de post con título, extracto, distintivo de categoría, autor, fecha y tiempo de lectura - La paginación funciona —
/blog?page=2muestra la segunda página de resultados; los botones anterior/siguiente navegan correctamente; la página 1 no tiene botón de «Anterior» - Filtro por categoría — al hacer clic en una chip de categoría se filtran los posts; la URL se actualiza a
?category=<slug>; la chip «All» limpia el filtro - Detalle de post se renderiza —
/blog/[slug]muestra título, imagen destacada, cuerpo formateado, tarjeta de autor, tiempo de lectura y fecha de publicación - Tabla de contenidos — la página de detalle del post genera un ToC a partir de los encabezados h2/h3; al hacer clic en una entrada del ToC se hace scroll hasta el encabezado
- Posts relacionados — el detalle del post muestra hasta 3 posts relacionados de la misma categoría (excluyendo el actual)
- Página de categoría —
/blog/category/[slug]muestra nombre de la categoría, descripción y solo posts de esa categoría - Página de autor —
/blog/author/[slug]muestra biografía del autor, avatar, enlaces sociales y sus posts - Botones para compartir en redes — los botones de compartir son visibles en el detalle del post; «Copiar enlace» copia la URL y muestra un texto de confirmación
- RSS feed —
GET /blog/rss.xmldevuelve XML válido conContent-Type: application/rss+xmly contiene entradas de post - Navegación — el enlace al blog en el menú del sitio navega a
/blog; las migas de pan muestran la jerarquía correcta - Post destacado —
/blogmuestra el post destacado como una tarjeta hero más grande por encima de la cuadrícula normal
Pruebas de casos límite y errores
- Post no encontrado —
/blog/nonexistent-slugdevuelve una página 404 - Categoría vacía — una categoría sin posts muestra el mensaje de estado vacío
blog_no_posts - Imagen destacada ausente — la tarjeta de post se renderiza correctamente sin imagen (sin
<img>roto) - Autor/categoría ausentes — un post sin autor o categoría se renderiza sin esos elementos (sin fallo)
- Feature flag desactivada — cuando
blog: false,/blogdevuelve 404
Pruebas transversales
- i18n: cambio de locale — navega a
/fr/blog, verifica que las cadenas de la interfaz (título de la página, «Leer más», etiquetas de paginación) estén en francés; el contenido del post vuelve al inglés si no está traducido - i18n: diseño RTL — navega a
/ar/blogy/ar/blog/[slug], verifica quedir="rtl"esté configurado y que el diseño se renderice correctamente (ToC en el lado correcto) - SEO: meta tags — la página de detalle del post tiene
og:type=article,og:title,og:description,article:published_timey enlace canónico - SEO: JSON-LD — la página de detalle del post contiene un
<script type="application/ld+json">con@type: BlogPosting, headline, author, datePublished - SEO: sitemap —
/sitemap.xmlincluye URLs de posts del blog con alternancias hreflang - Accesibilidad — ejecuta auditorías Axe en
/blog,/blog/[slug],/blog/author/[slug]tanto en móviles como en escritorio (siguiendo el patrón de pruebas de a11y existente) - Navegación por teclado — las entradas del ToC y los botones de paginación se pueden enfocar y activar mediante teclado; los indicadores de foco son visibles
Fuera de alcance
- Comentarios en las entradas del blog
- Búsqueda de texto completo
- Integración de newsletter dentro de las entradas
- Analíticas / recuento de visualizaciones
- Plantillas de entradas personalizadas por tenant
Ludo es educador y emprendedor en el campo de la tecnología, guiado por una creencia sencilla: la educación de calidad debe ser accesible para todos.