[{"data":1,"prerenderedAt":628},["ShallowReactive",2],{"\u002Farticulos\u002Farquitectura-de-este-blog\u002F":3,"upcoming:\u002Farticulos\u002Farquitectura-de-este-blog\u002F":627},{"id":4,"title":5,"body":6,"date":611,"description":612,"draft":613,"extension":614,"meta":615,"navigation":616,"path":617,"seo":618,"stem":619,"tags":620,"type":625,"__hash__":626},"posts\u002Farticulos\u002Farquitectura-de-este-blog.md","Cómo construí este blog: estático, sin servidor y sin coste de hosting",{"type":7,"value":8,"toc":592},"minimark",[9,13,16,25,30,33,41,68,72,83,86,101,178,181,185,188,193,200,203,221,228,239,246,297,304,307,311,314,337,343,350,356,362,368,371,375,378,382,385,388,433,439,446,450,461,468,474,478,481,488,494,497,500,506,510,521,527,531,546,564,568,571,576,580,588],[10,11,12],"p",{},"Quería un sitio donde enseñar mis proyectos y contar cómo están hechos por dentro. No pedía mucho: que cargara rápido, que no dependiera de un servidor ni de una base de datos, y que publicar un artículo consistiera en escribir un Markdown y hacer push.",[10,14,15],{},"Aquí explico qué elegí, por qué, y lo que se rompió la primera vez que lo desplegué, que fue más de lo que esperaba.",[10,17,18],{},[19,20],"img",{"alt":21,"height":22,"src":23,"width":24},"Portada del blog",800,"\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F01-home-light.webp",1280,[26,27,29],"h2",{"id":28},"por-qué-no-wordpress","Por qué no WordPress",[10,31,32],{},"Trabajo con WordPress a menudo, así que era lo primero que me venía a la cabeza. Lo descarté porque para un blog personal se me hacía enorme: servidor PHP, base de datos, actualizaciones de seguridad y un hosting que pagar cada mes. Todo eso para servir páginas que solo cambian cuando yo publico algo, no cuando alguien entra a leerlas.",[10,34,35,36,40],{},"Un sitio estático hace lo mismo con muchas menos piezas. GitHub Pages lo aloja gratis, porque solo tiene que servir archivos. Cada página ya es HTML generado, así que no hay consultas ni renderizado en el servidor. No hay panel de administración que atacar ni plugins que actualizar un domingo por la tarde. Y los artículos son ficheros ",[37,38,39],"code",{},".md"," que viven en Git junto al código, con su historial.",[10,42,43,44,51,52,57,58,63,64,67],{},"El stack es ",[45,46,50],"a",{"href":47,"rel":48},"https:\u002F\u002Fnuxt.com\u002Fdocs",[49],"nofollow","Nuxt 4"," con ",[45,53,56],{"href":54,"rel":55},"https:\u002F\u002Fcontent.nuxt.com",[49],"Nuxt Content"," para el Markdown y ",[45,59,62],{"href":60,"rel":61},"https:\u002F\u002Ftailwindcss.com\u002Fdocs",[49],"Tailwind CSS v4"," para los estilos. ",[37,65,66],{},"npm run generate"," prerenderiza todas las rutas a HTML estático, incluidos el feed RSS y el sitemap.",[26,69,71],{"id":70},"código-privado-sitio-público","Código privado, sitio público",[10,73,74,75,78,79,82],{},"El código del blog está en un repositorio privado. Lo que se publica va a otro, ",[37,76,77],{},"blog-public",", que solo contiene el resultado de la generación. Es el mismo patrón que uso en casi todos mis proyectos: el desarrollo en un repo privado y un repo ",[37,80,81],{},"-public"," que solo sirve la versión compilada.",[10,84,85],{},"El sitio es público, pero el código no tiene por qué serlo. El repo público además queda limpio, sin historial de código, ramas ni configuración, solo con archivos listos para servir. Y si algún día quiero alojarlo en otra cuenta, puedo hacerlo sin dar acceso al código.",[10,87,88,89,92,93,96,97,100],{},"Lo que conecta los dos repos es un workflow de GitHub Actions. En cada push a ",[37,90,91],{},"main"," pasa los tests, genera el sitio y sube ",[37,94,95],{},".output\u002Fpublic"," a la rama ",[37,98,99],{},"prod"," del repo público con un token de despliegue:",[102,103,108],"pre",{"className":104,"code":105,"language":106,"meta":107,"style":107},"language-mermaid shiki shiki-themes github-light github-dark","sequenceDiagram\n  participant Dev\n  participant Priv as Repo privado\n  participant CI as GitHub Actions\n  participant Pub as blog-public\n  Dev->>Priv: push a main\n  Priv->>CI: dispara deploy.yml\n  CI->>CI: npm install + vitest\n  CI->>CI: npm run generate\n  CI->>Pub: push de .output\u002Fpublic a prod\n  Pub-->>Dev: GitHub Pages publica el sitio\n","mermaid","",[37,109,110,118,124,130,136,142,148,154,160,166,172],{"__ignoreMap":107},[111,112,115],"span",{"class":113,"line":114},"line",1,[111,116,117],{},"sequenceDiagram\n",[111,119,121],{"class":113,"line":120},2,[111,122,123],{},"  participant Dev\n",[111,125,127],{"class":113,"line":126},3,[111,128,129],{},"  participant Priv as Repo privado\n",[111,131,133],{"class":113,"line":132},4,[111,134,135],{},"  participant CI as GitHub Actions\n",[111,137,139],{"class":113,"line":138},5,[111,140,141],{},"  participant Pub as blog-public\n",[111,143,145],{"class":113,"line":144},6,[111,146,147],{},"  Dev->>Priv: push a main\n",[111,149,151],{"class":113,"line":150},7,[111,152,153],{},"  Priv->>CI: dispara deploy.yml\n",[111,155,157],{"class":113,"line":156},8,[111,158,159],{},"  CI->>CI: npm install + vitest\n",[111,161,163],{"class":113,"line":162},9,[111,164,165],{},"  CI->>CI: npm run generate\n",[111,167,169],{"class":113,"line":168},10,[111,170,171],{},"  CI->>Pub: push de .output\u002Fpublic a prod\n",[111,173,175],{"class":113,"line":174},11,[111,176,177],{},"  Pub-->>Dev: GitHub Pages publica el sitio\n",[10,179,180],{},"Si falla un test, no se publica nada.",[26,182,184],{"id":183},"lo-que-se-rompió-en-el-primer-despliegue","Lo que se rompió en el primer despliegue",[10,186,187],{},"En local funcionaba todo. En CI, la generación falló a la primera, y por dos sitios distintos.",[189,190,192],"h3",{"id":191},"las-imágenes-open-graph-devolvían-un-error-500","Las imágenes Open Graph devolvían un error 500",[10,194,195,196,199],{},"Cada artículo genera su propia imagen para cuando se comparte en redes, con el módulo ",[37,197,198],{},"nuxt-og-image",". Durante el prerender esas rutas respondían con un 500 y la generación se abortaba entera.",[10,201,202],{},"Reproduje el fallo en local con las mismas variables de entorno que el CI, y resultó que eran dos problemas, uno detrás de otro:",[204,205,206,214],"ol",{},[207,208,209,210,213],"li",{},"La plantilla de la imagen venía de un paquete de la comunidad. En desarrollo se resuelve sola, pero para producción el módulo exige tenerla copiada en el proyecto. La copié a ",[37,211,212],{},"app\u002Fcomponents\u002FOgImage\u002FBlogPost.takumi.vue",", y de paso ahora puedo adaptarla al diseño del blog.",[207,215,216,217,220],{},"Con la plantilla en su sitio, seguía fallando. Faltaba el motor que convierte la plantilla en imagen, ",[37,218,219],{},"@takumi-rs\u002Fcore",", que no se instala con el módulo. Lo añadí como dependencia y por fin generó.",[189,222,224,225],{"id":223},"las-urls-no-sabían-que-el-sitio-vivía-en-blog-public","Las URLs no sabían que el sitio vivía en ",[37,226,227],{},"\u002Fblog-public\u002F",[10,229,230,231,234,235,238],{},"Hoy el blog tiene dominio propio y se sirve desde la raíz, pero en aquel primer despliegue GitHub Pages lo servía en ",[37,232,233],{},"abdell1999.github.io\u002Fblog-public\u002F",". Nuxt lo resuelve con ",[37,236,237],{},"NUXT_APP_BASE_URL=\u002Fblog-public\u002F",", aunque no todo lo que genera URLs absolutas se entera.",[10,240,241,242,245],{},"El RSS enlazaba a ",[37,243,244],{},"abdell1999.github.io\u002Fposts\u002F...",", sin la subcarpeta, porque el endpoint construía las URLs solo con la URL del sitio. Ahora combina las dos:",[102,247,251],{"className":248,"code":249,"language":250,"meta":107,"style":107},"language-ts shiki shiki-themes github-light github-dark","const siteUrl = config.public.siteUrl\n  ? withoutTrailingSlash(joinURL(config.public.siteUrl, config.app.baseURL))\n  : ''\n","ts",[37,252,253,270,288],{"__ignoreMap":107},[111,254,255,259,263,266],{"class":113,"line":114},[111,256,258],{"class":257},"szBVR","const",[111,260,262],{"class":261},"sj4cs"," siteUrl",[111,264,265],{"class":257}," =",[111,267,269],{"class":268},"sVt8B"," config.public.siteUrl\n",[111,271,272,275,279,282,285],{"class":113,"line":120},[111,273,274],{"class":257},"  ?",[111,276,278],{"class":277},"sScJk"," withoutTrailingSlash",[111,280,281],{"class":268},"(",[111,283,284],{"class":277},"joinURL",[111,286,287],{"class":268},"(config.public.siteUrl, config.app.baseURL))\n",[111,289,290,293],{"class":113,"line":126},[111,291,292],{"class":257},"  :",[111,294,296],{"class":295},"sZZnC"," ''\n",[10,298,299,300,303],{},"El sitemap tenía su propio lío. Incluía la ruta ",[37,301,302],{},"\u002Fblog-public"," como si fuera una página más, y encima la primera configuración del CI metía la subcarpeta también en la URL del sitio, así que salía duplicada. Lo arreglé dejando la URL del sitio en la raíz del dominio y excluyendo la ruta base del sitemap.",[10,305,306],{},"Si el sitio no se va a servir desde la raíz, conviene probar la generación con esa configuración desde el primer día. Yo lo descubrí en el primer despliegue, que es el peor momento para descubrir nada.",[26,308,310],{"id":309},"algunos-detalles","Algunos detalles",[10,312,313],{},"Hay cosas pequeñas en las que sí me paré un rato.",[10,315,316,317,320,321,324,325,328,329,332,333,336],{},"El tema claro y oscuro no toca ningún componente. Los colores son variables CSS definidas dos veces, una para ",[37,318,319],{},":root"," y otra para ",[37,322,323],{},".dark",", y los componentes solo usan ",[37,326,327],{},"var(--fg)",", ",[37,330,331],{},"var(--accent)"," y compañía. Cambiar de tema es cambiar una clase en ",[37,334,335],{},"\u003Chtml>",". Por defecto sigue la preferencia del sistema.",[10,338,339],{},[19,340],{"alt":341,"height":22,"src":342,"width":24},"Portada en modo oscuro","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F02-home-dark.webp",[10,344,345,346,349],{},"El índice lateral sigue la lectura. Nuxt Content ya genera el árbol de encabezados de cada artículo; el índice lo pinta y usa un ",[37,347,348],{},"IntersectionObserver"," para marcar la sección que estás leyendo, en vez de escuchar el scroll en cada frame.",[10,351,352],{},[19,353],{"alt":354,"height":22,"src":355,"width":24},"Artículo con el índice lateral","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F03-post-toc.webp",[10,357,358,359,361],{},"Los bloques de código y los diagramas pasan por un único componente. Si el bloque es ",[37,360,106],{},", dibuja el diagrama en el navegador, como el del despliegue de más arriba. Si no, muestra el código con un botón para copiarlo.",[10,363,364],{},[19,365],{"alt":366,"height":22,"src":367,"width":24},"Diagrama renderizado dentro de un artículo","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F04-mermaid-diagram.webp",[10,369,370],{},"Los componentes con algo de lógica (el índice, los bloques de código, el selector de tema, las tarjetas y las etiquetas) tienen tests con Vitest, y el CI los ejecuta antes de cada despliegue.",[26,372,374],{"id":373},"lo-que-ha-crecido-desde-entonces","Lo que ha crecido desde entonces",[10,376,377],{},"El blog empezó con una lista de artículos, las páginas de proyecto y el RSS. Con el contenido llegaron otras necesidades, y cada una tuvo que resolverse sin añadir un servidor.",[189,379,381],{"id":380},"artículos-programados","Artículos programados",[10,383,384],{},"Escribo varios artículos seguidos, pero prefiero publicarlos de uno en uno. Quería dejarlos en el repositorio con su fecha y olvidarme. Que salieran solos ese día.",[10,386,387],{},"Un sitio estático no tiene a nadie despierto a medianoche para publicar nada, así que el trabajo se reparte entre la build y el CI:",[204,389,390,409,419],{},[207,391,392,396,397,400,401,404,405,408],{},[393,394,395],"strong",{},"La build de producción deja fuera los artículos con fecha futura."," Al arrancar, ",[37,398,399],{},"content.config.ts"," lee el frontmatter de cada Markdown y excluye de la colección ",[37,402,403],{},"posts"," los que tienen una fecha posterior a hoy en Madrid. Como todo lo demás sale de esa colección, el artículo no aparece en la portada, ni en el RSS, ni en el buscador, ni en ",[37,406,407],{},"llms.txt",".",[207,410,411,414,415,418],{},[393,412,413],{},"Un cron diario vuelve a desplegar."," El workflow tiene un ",[37,416,417],{},"schedule"," que corre justo después de medianoche, hora de Madrid. Antes de construir nada busca si algún artículo tiene la fecha de hoy. Si no hay ninguno, termina sin desplegar y no gasta minutos de CI.",[207,420,421,424,425,428,429,432],{},[393,422,423],{},"La URL del artículo ya existe antes de tiempo."," Hay una segunda colección, ",[37,426,427],{},"postSummaries",", con el título, la descripción y la fecha de todos los artículos, también los programados. Si alguien abre el enlace antes del día (porque lo compartí antes o porque lo ha adivinado), la página no da un 404: dice cuándo sale. Esa página lleva ",[37,430,431],{},"noindex"," para que Google no la guarde así.",[10,434,435],{},[19,436],{"alt":437,"height":22,"src":438,"width":24},"Página de un artículo que todavía no se ha publicado","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F05-articulo-programado.webp",[10,440,441,442,445],{},"En desarrollo, ",[37,443,444],{},"npm run dev"," enseña también los programados, para poder revisarlos como quedarán.",[189,447,449],{"id":448},"un-buscador-sin-servidor","Un buscador sin servidor",[10,451,452,453,456,457,460],{},"Buscar sin backend significa llevar el índice al navegador. Durante la generación, una ruta del servidor de Nuxt (",[37,454,455],{},"server\u002Froutes\u002Fsearch.json.ts",") recorre artículos y proyectos, junta el texto de cada sección y lo escribe en un único ",[37,458,459],{},"search.json",". Esa ruta solo se ejecuta al compilar. En producción es un fichero estático más.",[10,462,463,464,467],{},"El buscador se abre con el icono de la lupa o con ",[37,465,466],{},"Ctrl+K",". Descarga el índice la primera vez que lo abres y a partir de ahí busca en local. Ignora tildes y mayúsculas, así que «subtitulos» encuentra «subtítulos», y resalta el fragmento donde aparece el término. Los bloques de código y los diagramas quedan fuera del índice porque llenaban los resultados de ruido.",[10,469,470],{},[19,471],{"alt":472,"height":22,"src":473,"width":24},"Buscador abierto con resultados para «subtítulos»","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F06-buscador.webp",[189,475,477],{"id":476},"guías-técnicos-y-filtros","Guías, técnicos y filtros",[10,479,480],{},"Casi cada proyecto tiene dos artículos: una guía para quien quiere usarlo y otro técnico sobre cómo está hecho. Son lecturas muy distintas, así que el tipo es un campo obligatorio del frontmatter y cada artículo lo enseña antes del título. Si se me olvida ponerlo, falla un test y el artículo no se despliega.",[10,482,483,484,487],{},"Con más de veinte artículos hizo falta filtrar. La portada filtra por tipo, por proyecto y por tema, y la página de proyectos por tecnología. Los filtros viven en la URL (",[37,485,486],{},"\u002F?tipo=tecnico&tema=ia","), así que una búsqueda filtrada se puede compartir o guardar. Se calculan a partir de las etiquetas: un artículo pertenece a un proyecto si lleva la etiqueta de ese proyecto.",[10,489,490],{},[19,491],{"alt":492,"height":22,"src":493,"width":24},"La portada filtrada por artículos técnicos sobre IA","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F07-filtros.webp",[10,495,496],{},"Al principio la lista enseñaba la descripción completa y las etiquetas de cada artículo, y en pantalla cabían dos o tres. Ahora cada artículo ocupa una fila con el icono de su proyecto, el título, una línea de descripción, el tipo y la fecha. El icono sale de la ficha del proyecto, así que la guía y el artículo técnico del mismo proyecto se reconocen de un vistazo. Los artículos que no son de ningún proyecto llevan la inicial del título.",[10,498,499],{},"La página de proyectos tiene dos vistas, rejilla con capturas o lista, y recuerda cuál prefieres.",[10,501,502],{},[19,503],{"alt":504,"height":22,"src":505,"width":24},"Página de proyectos en vista de rejilla y modo oscuro","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F08-proyectos.webp",[189,507,509],{"id":508},"una-404-que-no-es-un-callejón-sin-salida","Una 404 que no es un callejón sin salida",[10,511,512,513,516,517,520],{},"La página de error por defecto de Nuxt estaba en inglés, sin la cabecera del sitio y con un único enlace. La reemplacé por una terminal que intenta hacer ",[37,514,515],{},"cd"," a la dirección que has pedido, falla y lista las secciones del blog. Debajo aparecen los tres últimos artículos, para quien llega por un enlace roto desde LinkedIn o Google. En GitHub Pages esa página se sirve como ",[37,518,519],{},"404.html"," y devuelve un 404 de verdad.",[10,522,523],{},[19,524],{"alt":525,"height":22,"src":526,"width":24},"Página 404 con la terminal y los últimos artículos","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F09-pagina-404.webp",[189,528,530],{"id":529},"dominio-propio-y-seo","Dominio propio y SEO",[10,532,533,534,537,538,541,542,545],{},"El blog dejó la subcarpeta de GitHub Pages y ahora se sirve en ",[37,535,536],{},"abdell.dev",", con un fichero ",[37,539,540],{},"CNAME"," en ",[37,543,544],{},"public\u002F",". Eso me ahorró los problemas de la ruta base del primer despliegue, aunque el código sigue soportándola.",[10,547,548,549,552,553,556,557,560,561,563],{},"Lo demás son detalles que no se ven pero cuentan. Cada página tiene su URL canónica con la barra final, que es la que GitHub Pages sirve sin redirección. Hay datos estructurados: un ",[37,550,551],{},"Person"," y un ",[37,554,555],{},"WebSite"," en todas las páginas, y un ",[37,558,559],{},"BlogPosting"," en cada artículo que apunta a esa misma persona. Cada artículo genera su imagen para redes con su título, su tipo y su fecha. Y hay un ",[37,562,407],{}," que resume quién soy, los proyectos y los artículos publicados para los asistentes de IA. Los programados no aparecen ahí hasta su día.",[26,565,567],{"id":566},"qué-haría-distinto","Qué haría distinto",[10,569,570],{},"Generaría el sitio con la configuración de producción desde el principio, subcarpeta incluida. Los dos fallos del despliegue se arreglaron rápido, pero solo aparecían en ese escenario y yo no lo había probado.",[10,572,573,574,408],{},"También metería en el CI una comprobación de enlaces sobre el HTML generado. Los tests de componentes no ven un enlace roto ni una URL a la que le falta la subcarpeta, y eso es justo lo que falló. De momento tengo un test que comprueba que cada imagen que cito en un artículo o en un proyecto existe de verdad en ",[37,575,544],{},[26,577,579],{"id":578},"lo-siguiente","Lo siguiente",[10,581,582,583,408],{},"Cada proyecto ya tiene su página, con capturas, enlaces, una guía de uso y un artículo técnico. Lo que viene es más de lo mismo: proyectos nuevos y algún artículo que no es de ninguno. Si quieres saber cuándo sale el siguiente, el blog tiene ",[45,584,587],{"href":585,"rel":586},"https:\u002F\u002Fabdell.dev\u002Frss.xml",[49],"RSS",[589,590,591],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":107,"searchDepth":120,"depth":120,"links":593},[594,595,596,601,602,609,610],{"id":28,"depth":120,"text":29},{"id":70,"depth":120,"text":71},{"id":183,"depth":120,"text":184,"children":597},[598,599],{"id":191,"depth":126,"text":192},{"id":223,"depth":126,"text":600},"Las URLs no sabían que el sitio vivía en \u002Fblog-public\u002F",{"id":309,"depth":120,"text":310},{"id":373,"depth":120,"text":374,"children":603},[604,605,606,607,608],{"id":380,"depth":126,"text":381},{"id":448,"depth":126,"text":449},{"id":476,"depth":126,"text":477},{"id":508,"depth":126,"text":509},{"id":529,"depth":126,"text":530},{"id":566,"depth":120,"text":567},{"id":578,"depth":120,"text":579},"2026-10-18","Por qué elegí Nuxt y Markdown en lugar de WordPress, cómo separo el código privado del sitio publicado, qué se rompió en el primer despliegue y cómo funcionan los artículos programados, el buscador sin servidor y los filtros.",false,"md",{},true,"\u002Farticulos\u002Farquitectura-de-este-blog",{"title":5,"description":612},"articulos\u002Farquitectura-de-este-blog",[621,622,623,624],"nuxt","vue","devops","meta","tecnico","nSKoSdiDPYBeCylHRoTs9LuyANO7uJ6ehrBEFxnStQQ",null,1793761186884]