[{"data":1,"prerenderedAt":481},["ShallowReactive",2],{"\u002Fposts\u002Farquitectura-de-este-blog":3},{"id":4,"title":5,"body":6,"date":466,"description":467,"draft":468,"extension":469,"meta":470,"navigation":471,"path":472,"seo":473,"stem":474,"tags":475,"__hash__":480},"posts\u002Fposts\u002Farquitectura-de-este-blog.md","Cómo construí este blog: estático, sin servidor y sin coste de hosting",{"type":7,"value":8,"toc":455},"minimark",[9,13,16,25,30,33,36,70,97,101,108,132,147,224,227,231,234,239,246,249,266,273,284,357,360,364,390,396,406,412,421,427,433,437,440,443,451],[10,11,12],"p",{},"Quería un sitio donde enseñar mis proyectos y explicar cómo los resuelvo por dentro. Las condiciones eran sencillas: que cargue rápido, que no necesite servidor ni base de datos y que publicar un artículo sea escribir un Markdown y hacer push.",[10,14,15],{},"Este artículo cuenta las decisiones que tomé, por qué, y los problemas que aparecieron al desplegarlo.",[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 a menudo con WordPress, así que era la opción obvia. La descarté porque para un blog personal es desproporcionado: necesita un servidor PHP, una base de datos, actualizaciones de seguridad y un hosting que pagar cada mes. Todo eso para servir unas páginas que cambian cuando yo publico, no cuando alguien las visita.",[10,34,35],{},"Un sitio estático resuelve lo mismo con mucho menos:",[37,38,39,47,53,59],"ul",{},[40,41,42,46],"li",{},[43,44,45],"strong",{},"Coste cero."," Se aloja en GitHub Pages, que solo sirve archivos.",[40,48,49,52],{},[43,50,51],{},"Rápido."," Cada página es HTML ya generado; no hay consultas ni renderizado en el servidor.",[40,54,55,58],{},[43,56,57],{},"Sin superficie de ataque."," No hay panel de administración, ni base de datos, ni plugins que mantener al día.",[40,60,61,64,65,69],{},[43,62,63],{},"El contenido vive en Git."," Cada artículo es un ",[66,67,68],"code",{},".md"," versionado junto al código.",[10,71,72,73,80,81,86,87,92,93,96],{},"El stack es ",[74,75,79],"a",{"href":76,"rel":77},"https:\u002F\u002Fnuxt.com\u002Fdocs",[78],"nofollow","Nuxt 4"," con ",[74,82,85],{"href":83,"rel":84},"https:\u002F\u002Fcontent.nuxt.com",[78],"Nuxt Content"," para el Markdown y ",[74,88,91],{"href":89,"rel":90},"https:\u002F\u002Ftailwindcss.com\u002Fdocs",[78],"Tailwind CSS v4"," para los estilos. ",[66,94,95],{},"npm run generate"," prerenderiza todas las rutas a HTML estático, incluido el feed RSS y el sitemap.",[26,98,100],{"id":99},"código-privado-sitio-público","Código privado, sitio público",[10,102,103,104,107],{},"El código del blog está en un repositorio privado. Lo que se publica va a otro repositorio, ",[66,105,106],{},"blog-public",", que solo contiene el resultado generado. Lo hago así por tres motivos:",[37,109,110,120,126],{},[40,111,112,115,116,119],{},[43,113,114],{},"El código fuente sigue siendo privado",", aunque el sitio sea público. Es el mismo patrón que uso en la mayoría de mis proyectos: el desarrollo en un repo privado y un repo ",[66,117,118],{},"-public"," que solo sirve la versión compilada.",[40,121,122,125],{},[43,123,124],{},"El despliegue queda separado del desarrollo."," El repo público no tiene historial de código, ramas ni configuración; solo archivos listos para servir.",[40,127,128,131],{},[43,129,130],{},"El hosting puede vivir en otra cuenta"," sin dar acceso al código.",[10,133,134,135,138,139,142,143,146],{},"Un workflow de GitHub Actions lo une todo. En cada push a ",[66,136,137],{},"main"," pasa los tests, genera el sitio y sube ",[66,140,141],{},".output\u002Fpublic"," a la rama ",[66,144,145],{},"prod"," del repo público con un token de despliegue:",[148,149,154],"pre",{"className":150,"code":151,"language":152,"meta":153,"style":153},"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","",[66,155,156,164,170,176,182,188,194,200,206,212,218],{"__ignoreMap":153},[157,158,161],"span",{"class":159,"line":160},"line",1,[157,162,163],{},"sequenceDiagram\n",[157,165,167],{"class":159,"line":166},2,[157,168,169],{},"  participant Dev\n",[157,171,173],{"class":159,"line":172},3,[157,174,175],{},"  participant Priv as Repo privado\n",[157,177,179],{"class":159,"line":178},4,[157,180,181],{},"  participant CI as GitHub Actions\n",[157,183,185],{"class":159,"line":184},5,[157,186,187],{},"  participant Pub as blog-public\n",[157,189,191],{"class":159,"line":190},6,[157,192,193],{},"  Dev->>Priv: push a main\n",[157,195,197],{"class":159,"line":196},7,[157,198,199],{},"  Priv->>CI: dispara deploy.yml\n",[157,201,203],{"class":159,"line":202},8,[157,204,205],{},"  CI->>CI: npm install + vitest\n",[157,207,209],{"class":159,"line":208},9,[157,210,211],{},"  CI->>CI: npm run generate\n",[157,213,215],{"class":159,"line":214},10,[157,216,217],{},"  CI->>Pub: push de .output\u002Fpublic a prod\n",[157,219,221],{"class":159,"line":220},11,[157,222,223],{},"  Pub-->>Dev: GitHub Pages publica el sitio\n",[10,225,226],{},"Si un test falla, no se despliega nada.",[26,228,230],{"id":229},"lo-que-se-rompió-en-el-primer-despliegue","Lo que se rompió en el primer despliegue",[10,232,233],{},"En local todo funcionaba. En CI, la generación falló. Estos fueron los problemas y cómo los resolví.",[235,236,238],"h3",{"id":237},"las-imágenes-open-graph-devolvían-un-error-500","Las imágenes Open Graph devolvían un error 500",[10,240,241,242,245],{},"Cada artículo genera su propia imagen para cuando se comparte en redes, con el módulo ",[66,243,244],{},"nuxt-og-image",". Durante el prerender, esas rutas devolvían un 500 y la generación abortaba.",[10,247,248],{},"Reproduje el fallo en local con las mismas variables de entorno que el CI y aparecieron dos causas encadenadas:",[250,251,252,259],"ol",{},[40,253,254,255,258],{},"La plantilla de la imagen venía de un paquete de plantillas de la comunidad. En desarrollo se resuelve sola, pero para producción el módulo exige copiarla al proyecto. La copié a ",[66,256,257],{},"app\u002Fcomponents\u002FOgImage\u002FBlogPost.takumi.vue",", lo que además me deja adaptarla al diseño del blog.",[40,260,261,262,265],{},"Con la plantilla ya en su sitio, seguía fallando: faltaba el motor que convierte la plantilla en imagen, ",[66,263,264],{},"@takumi-rs\u002Fcore",". No se instala automáticamente con el módulo. Añadirlo como dependencia lo resolvió.",[235,267,269,270],{"id":268},"las-urls-no-sabían-que-el-sitio-vive-en-blog-public","Las URLs no sabían que el sitio vive en ",[66,271,272],{},"\u002Fblog-public\u002F",[10,274,275,276,279,280,283],{},"GitHub Pages sirve el sitio en ",[66,277,278],{},"abdell1999.github.io\u002Fblog-public\u002F",", no en la raíz del dominio. Nuxt lo gestiona con ",[66,281,282],{},"NUXT_APP_BASE_URL=\u002Fblog-public\u002F",", pero no todo lo que genera URLs absolutas lo tiene en cuenta:",[37,285,286,347],{},[40,287,288,291,292,295,296],{},[43,289,290],{},"El RSS"," enlazaba a ",[66,293,294],{},"abdell1999.github.io\u002Fposts\u002F...",", sin la subcarpeta. El endpoint construía las URLs solo con la URL del sitio. Ahora combina las dos:",[148,297,301],{"className":298,"code":299,"language":300,"meta":153,"style":153},"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",[66,302,303,320,338],{"__ignoreMap":153},[157,304,305,309,313,316],{"class":159,"line":160},[157,306,308],{"class":307},"szBVR","const",[157,310,312],{"class":311},"sj4cs"," siteUrl",[157,314,315],{"class":307}," =",[157,317,319],{"class":318},"sVt8B"," config.public.siteUrl\n",[157,321,322,325,329,332,335],{"class":159,"line":166},[157,323,324],{"class":307},"  ?",[157,326,328],{"class":327},"sScJk"," withoutTrailingSlash",[157,330,331],{"class":318},"(",[157,333,334],{"class":327},"joinURL",[157,336,337],{"class":318},"(config.public.siteUrl, config.app.baseURL))\n",[157,339,340,343],{"class":159,"line":172},[157,341,342],{"class":307},"  :",[157,344,346],{"class":345},"sZZnC"," ''\n",[40,348,349,352,353,356],{},[43,350,351],{},"El sitemap"," incluía la ruta ",[66,354,355],{},"\u002Fblog-public"," como si fuera una página más, y la primera configuración del CI tenía la subcarpeta también en la URL del sitio, así que aparecía duplicada. La solución fue dejar la URL del sitio en la raíz del dominio y excluir la ruta base del sitemap.",[10,358,359],{},"La lección: si el sitio no se va a servir en la raíz, hay que probar la generación con esa configuración desde el principio, no descubrirlo en el primer despliegue.",[26,361,363],{"id":362},"detalles-que-cuidé","Detalles que cuidé",[10,365,366,369,370,373,374,377,378,381,382,385,386,389],{},[43,367,368],{},"Tema claro y oscuro sin tocar componentes."," Los colores son variables CSS definidas dos veces, para ",[66,371,372],{},":root"," y para ",[66,375,376],{},".dark",". Los componentes solo usan ",[66,379,380],{},"var(--fg)",", ",[66,383,384],{},"var(--accent)",", etc., así que cambiar de tema es cambiar una clase en ",[66,387,388],{},"\u003Chtml>",". Por defecto sigue la preferencia del sistema.",[10,391,392],{},[19,393],{"alt":394,"height":22,"src":395,"width":24},"Portada en modo oscuro","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F02-home-dark.webp",[10,397,398,401,402,405],{},[43,399,400],{},"Índice que sigue la lectura."," Nuxt Content genera el árbol de encabezados de cada artículo. El índice lateral lo pinta y usa un ",[66,403,404],{},"IntersectionObserver"," para marcar la sección que estás leyendo, sin escuchar el scroll en cada frame.",[10,407,408],{},[19,409],{"alt":410,"height":22,"src":411,"width":24},"Artículo con el índice lateral","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F03-post-toc.webp",[10,413,414,417,418,420],{},[43,415,416],{},"Bloques de código y diagramas."," Un único componente decide cómo pintar cada bloque: si es ",[66,419,152],{},", dibuja el diagrama en el navegador (como el del despliegue de arriba); si no, muestra el código con un botón de copiar.",[10,422,423],{},[19,424],{"alt":425,"height":22,"src":426,"width":24},"Diagrama renderizado dentro de un artículo","\u002Fimages\u002Fposts\u002Farquitectura-de-este-blog\u002F04-mermaid-diagram.webp",[10,428,429,432],{},[43,430,431],{},"Tests."," Los componentes con lógica (índice, bloques de código, selector de tema, tarjetas y etiquetas) tienen tests con Vitest, y el CI los ejecuta antes de cada despliegue.",[26,434,436],{"id":435},"qué-haría-distinto-y-qué-viene","Qué haría distinto y qué viene",[10,438,439],{},"Empezaría generando el sitio con la configuración de producción desde el primer día, con la subcarpeta incluida. Los dos problemas del despliegue eran fáciles de arreglar, pero solo aparecían en ese escenario.",[10,441,442],{},"También añadiría al CI una comprobación de enlaces sobre el HTML generado. Los tests de componentes no detectan un enlace roto ni una URL sin la subcarpeta, y eso es justo lo que falló.",[10,444,445,446,450],{},"Lo siguiente es el contenido: una página propia para cada proyecto, con capturas, enlaces y los artículos técnicos sobre cómo está hecho. El primero es ",[74,447,449],{"href":448},"\u002Fposts\u002Fabreader-tu-biblioteca-en-el-navegador","ABReader",", un lector de libros electrónicos que funciona entero en el navegador.",[452,453,454],"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":153,"searchDepth":166,"depth":166,"links":456},[457,458,459,464,465],{"id":28,"depth":166,"text":29},{"id":99,"depth":166,"text":100},{"id":229,"depth":166,"text":230,"children":460},[461,462],{"id":237,"depth":172,"text":238},{"id":268,"depth":172,"text":463},"Las URLs no sabían que el sitio vive en \u002Fblog-public\u002F",{"id":362,"depth":166,"text":363},{"id":435,"depth":166,"text":436},"2026-10-18","Por qué elegí Nuxt y Markdown en lugar de WordPress, cómo separo el código privado del sitio publicado y qué se rompió en el primer despliegue.",false,"md",{},true,"\u002Fposts\u002Farquitectura-de-este-blog",{"title":5,"description":467},"posts\u002Farquitectura-de-este-blog",[476,477,478,479],"nuxt","vue","devops","meta","ttuEtH1-ielZxx3U7cHe5MhgTvi8p5cxzNFcWMCNFlM",1792430529693]