<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:webfeeds="http://webfeeds.org/rss/1.0" version="2.0">
  <channel>
    <title>Paigar</title>
    <link>https://media.paigar.eu/archivo/v3/</link>
    <atom:link href="https://media.paigar.eu/archivo/v3/feed/feed.xml" rel="self" type="application/rss+xml"/>
    <description>Web personal de Juanjo Marcos — desarrollo web, código y reflexiones.</description>
    <lastBuildDate>Mon, 25 May 2026 12:36:01 GMT</lastBuildDate>
    <language>es</language>
    <item>
      <title>LQIP en Lume: placeholders inline generados en build</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/</guid>
      <description>
        Cómo genero placeholders de baja calidad para imágenes durante la compilación con un script Deno, los incrusto en base64 y dejo que el navegador haga el cross-fade sin JavaScript.
      </description>
      <content:encoded>
        <![CDATA[<p>Cuando una imagen tarda en descargarse del CDN, el navegador deja un hueco. La página da un saltito cuando la imagen finalmente entra. Si he reservado el espacio con <code>width</code> y <code>height</code> no hay layout shift, pero el hueco vacío sigue ahí. Y si la conexión es lenta, el hueco dura más de lo razonable.</p>
<p>LQIP — <em>Low Quality Image Placeholder</em> — es la técnica que llena ese hueco: durante la espera muestro una versión diminuta y borrosa de la imagen, y cuando la real termina de descargar, sustituyo una por la otra con un cross-fade. Es lo que hace Medium desde hace años, y antes lo hizo Pinterest.</p>
<p>La técnica en sí está documentada en mil sitios. Lo que cuento aquí es cómo la implementé en <a href="https://idenautas.com/">Idenautas</a>, que corre sobre Lume: el script Deno que genera los placeholders en build, cómo los incrusto en el HTML, y cómo encajo todo en <code>_config.ts</code> sin meter JavaScript de cliente más allá del <code>onload</code> del propio <code>&lt;img&gt;</code>.</p>
<h2 id="la-idea" tabindex="-1">La idea <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#la-idea">#</a></h2>
<p>Tres decisiones que conviene fijar antes de escribir nada:</p>
<ol>
<li><strong>El placeholder se genera en build, no en runtime.</strong> El servidor (o el CDN, en mi caso Bunny) no tiene que hacer nada en cada visita. La consecuencia es que el placeholder viaja <strong>inline</strong> en el HTML como <code>data:image/jpeg;base64,...</code> y aparece sin una sola petición HTTP adicional.</li>
<li><strong>El placeholder es una versión de 16 píxeles de ancho de la propia imagen, en JPG.</strong> A esa resolución el peso ronda los 300-500 bytes. Codificado en base64 son unos ~600 bytes por imagen — irrelevante en el HTML.</li>
<li><strong>El cross-fade lo hace el navegador.</strong> El <code>&lt;img&gt;</code> lleva un <code>onload</code> que añade la clase <code>.loaded</code> a su contenedor, y el CSS hace el resto con <code>opacity</code> y <code>transition</code>. Cero JavaScript propio, cero IntersectionObserver, cero librerías.</li>
</ol>
<p>El truco está en que las tres decisiones son interdependientes. Si genero el placeholder en runtime, no puedo incrustarlo. Si no es minúsculo, no puedo permitirme incrustarlo en cada <code>&lt;img&gt;</code>. Si no lo incrusto, necesito una segunda petición HTTP solo para el placeholder, y eso elimina la mitad de la ventaja.</p>
<h2 id="el-script%3A-scripts%2Flqip.ts" tabindex="-1">El script: <code>scripts/lqip.ts</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#el-script%3A-scripts%2Flqip.ts">#</a></h2>
<p>El script tiene tres responsabilidades: encontrar las imágenes que se usan en el sitio, descargar su versión de 16 píxeles del CDN, y guardar el resultado en un JSON cacheable.</p>
<h3 id="descubrir-las-im%C3%A1genes-referenciadas" tabindex="-1">Descubrir las imágenes referenciadas <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#descubrir-las-im%C3%A1genes-referenciadas">#</a></h3>
<p>No quiero mantener una lista de imágenes a mano. El script camina <code>src/</code> con <code>@std/fs/walk</code> y busca dos patrones en los archivos <code>.md</code>, <code>.vto</code>, <code>.njk</code> y <code>.ts</code>:</p>
<ul>
<li>Llamadas a los shortcodes <code>{{ img(&quot;ruta&quot;) }}</code> y <code>{{ cardPicture(&quot;ruta&quot;) }}</code>.</li>
<li>La clave <code>heroImage:</code> en el frontmatter.</li>
</ul>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">const</span> <span class="token constant">SHORTCODE_RE</span> <span class="token operator">=</span>
  <span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">(?:\{%[-\s]*|\{\{[-\s]*(?:await\s+)?)(?:img|cardPicture)\s*\(?\s*["']([^"']+)["']</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">;</span>
<span class="token keyword">const</span> <span class="token constant">HERO_RE</span> <span class="token operator">=</span> <span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">^heroImage:\s*(.+)$</span><span class="token regex-delimiter">/</span><span class="token regex-flags">m</span></span><span class="token punctuation">;</span>

<span class="token keyword">async</span> <span class="token keyword">function</span> <span class="token function">findImagePaths</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">Promise</span><span class="token operator">&lt;</span><span class="token builtin">string</span><span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token operator">></span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> paths <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name">Set<span class="token operator">&lt;</span><span class="token builtin">string</span><span class="token operator">></span></span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">for</span> <span class="token keyword">await</span> <span class="token punctuation">(</span>
    <span class="token keyword">const</span> entry <span class="token keyword">of</span> <span class="token function">walk</span><span class="token punctuation">(</span><span class="token constant">SRC_DIR</span><span class="token punctuation">,</span> <span class="token punctuation">{</span>
      exts<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">".md"</span><span class="token punctuation">,</span> <span class="token string">".vto"</span><span class="token punctuation">,</span> <span class="token string">".njk"</span><span class="token punctuation">,</span> <span class="token string">".ts"</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
      skip<span class="token operator">:</span> <span class="token punctuation">[</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">node_modules</span><span class="token regex-delimiter">/</span></span><span class="token punctuation">,</span> <span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">_data\/lqip\.json$</span><span class="token regex-delimiter">/</span></span><span class="token punctuation">]</span><span class="token punctuation">,</span>
    <span class="token punctuation">}</span><span class="token punctuation">)</span>
  <span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>entry<span class="token punctuation">.</span>isFile<span class="token punctuation">)</span> <span class="token keyword">continue</span><span class="token punctuation">;</span>
    <span class="token keyword">const</span> content <span class="token operator">=</span> <span class="token keyword">await</span> Deno<span class="token punctuation">.</span><span class="token function">readTextFile</span><span class="token punctuation">(</span>entry<span class="token punctuation">.</span>path<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword">let</span> m<span class="token operator">:</span> RegExpExecArray <span class="token operator">|</span> <span class="token keyword">null</span><span class="token punctuation">;</span>
    <span class="token constant">SHORTCODE_RE</span><span class="token punctuation">.</span>lastIndex <span class="token operator">=</span> <span class="token number">0</span><span class="token punctuation">;</span>
    <span class="token keyword">while</span> <span class="token punctuation">(</span><span class="token punctuation">(</span>m <span class="token operator">=</span> <span class="token constant">SHORTCODE_RE</span><span class="token punctuation">.</span><span class="token function">exec</span><span class="token punctuation">(</span>content<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token operator">!==</span> <span class="token keyword">null</span><span class="token punctuation">)</span> paths<span class="token punctuation">.</span><span class="token function">add</span><span class="token punctuation">(</span>m<span class="token punctuation">[</span><span class="token number">1</span><span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword">const</span> hero <span class="token operator">=</span> content<span class="token punctuation">.</span><span class="token function">match</span><span class="token punctuation">(</span><span class="token constant">HERO_RE</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span>hero<span class="token punctuation">)</span> paths<span class="token punctuation">.</span><span class="token function">add</span><span class="token punctuation">(</span>hero<span class="token punctuation">[</span><span class="token number">1</span><span class="token punctuation">]</span><span class="token punctuation">.</span><span class="token function">trim</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">^["']|["']$</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">""</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
  <span class="token keyword">return</span> <span class="token punctuation">[</span><span class="token operator">...</span>paths<span class="token punctuation">]</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>El regex de los shortcodes acepta tanto la sintaxis Nunjucks heredada (<code>{% img &quot;...&quot; %}</code>) como la nueva de Vento (<code>{{ img(&quot;...&quot;) }}</code>). Migrar de una a otra es trabajo que hago a fuego lento, así que el script tiene que entender ambas durante el periodo de transición.</p>
<p>Es una solución imperfecta — un parser real entendería el código sin riesgo de falsos positivos —, pero a la práctica el regex acierta en el 100% de los casos del sitio. Si una imagen se referencia desde un layout o un sitio menos estándar, basta con añadir su patrón al regex.</p>
<h3 id="descargar-los-placeholders-del-cdn" tabindex="-1">Descargar los placeholders del CDN <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#descargar-los-placeholders-del-cdn">#</a></h3>
<p>Las imágenes de Idenautas viven en Bunny Storage y el build no las regenera: las versiones a <code>480px</code>, <code>800px</code>, <code>1200px</code>, <code>1920px</code> y <code>16px</code> (esta última, mi placeholder) ya están subidas con sufijo en el nombre. La ruta de cada placeholder es predecible:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">function</span> <span class="token function">imgBase</span><span class="token punctuation">(</span>imgPath<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> i <span class="token operator">=</span> imgPath<span class="token punctuation">.</span><span class="token function">lastIndexOf</span><span class="token punctuation">(</span><span class="token string">"."</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">return</span> i <span class="token operator">>=</span> <span class="token number">0</span> <span class="token operator">?</span> imgPath<span class="token punctuation">.</span><span class="token function">slice</span><span class="token punctuation">(</span><span class="token number">0</span><span class="token punctuation">,</span> i<span class="token punctuation">)</span> <span class="token operator">:</span> imgPath<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token comment">// imgBase("portada.jpg") + "-16.jpg" → "portada-16.jpg"</span>
</code></pre>
<p>Para cada imagen, descargo <code>${CDN}${base}-16.jpg</code> y la convierto a data URI:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">async</span> <span class="token keyword">function</span> <span class="token function">fetchBase64</span><span class="token punctuation">(</span>url<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">Promise</span><span class="token operator">&lt;</span><span class="token builtin">string</span><span class="token operator">></span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> res <span class="token operator">=</span> <span class="token keyword">await</span> <span class="token function">fetch</span><span class="token punctuation">(</span>url<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>res<span class="token punctuation">.</span>ok<span class="token punctuation">)</span> <span class="token keyword">throw</span> <span class="token keyword">new</span> <span class="token class-name">Error</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">HTTP </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>res<span class="token punctuation">.</span>status<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">const</span> type <span class="token operator">=</span> res<span class="token punctuation">.</span>headers<span class="token punctuation">.</span><span class="token function">get</span><span class="token punctuation">(</span><span class="token string">"content-type"</span><span class="token punctuation">)</span> <span class="token operator">??</span> <span class="token string">"image/jpeg"</span><span class="token punctuation">;</span>
  <span class="token keyword">const</span> bytes <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name">Uint8Array</span><span class="token punctuation">(</span><span class="token keyword">await</span> res<span class="token punctuation">.</span><span class="token function">arrayBuffer</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">return</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">data:</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token keyword">type</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">;base64,</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token function">encodeBase64</span><span class="token punctuation">(</span>bytes<span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p><code>encodeBase64</code> viene de <code>jsr:@std/encoding/base64</code>. Es una primitiva de la librería estándar de Deno; no añado dependencias.</p>
<h3 id="el-cache%3A-src%2F_data%2Flqip.json" tabindex="-1">El cache: <code>src/_data/lqip.json</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#el-cache%3A-src%2F_data%2Flqip.json">#</a></h3>
<p>El detalle que marca la diferencia entre un script aceptable y uno usable a diario es el cache. Sin cache, cada <code>npm run publicar</code> haría tantas peticiones HTTP como imágenes hay en el sitio. Con cache, solo se descargan las nuevas:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">let</span> existing<span class="token operator">:</span> Record<span class="token operator">&lt;</span><span class="token builtin">string</span><span class="token punctuation">,</span> <span class="token builtin">string</span><span class="token operator">></span> <span class="token operator">=</span> <span class="token punctuation">{</span><span class="token punctuation">}</span><span class="token punctuation">;</span>
<span class="token keyword">try</span> <span class="token punctuation">{</span>
  existing <span class="token operator">=</span> <span class="token constant">JSON</span><span class="token punctuation">.</span><span class="token function">parse</span><span class="token punctuation">(</span><span class="token keyword">await</span> Deno<span class="token punctuation">.</span><span class="token function">readTextFile</span><span class="token punctuation">(</span><span class="token constant">OUTPUT</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span> <span class="token keyword">catch</span> <span class="token punctuation">{</span>
  <span class="token comment">// primera ejecución, no hay cache</span>
<span class="token punctuation">}</span>

<span class="token keyword">const</span> lqip<span class="token operator">:</span> Record<span class="token operator">&lt;</span><span class="token builtin">string</span><span class="token punctuation">,</span> <span class="token builtin">string</span><span class="token operator">></span> <span class="token operator">=</span> <span class="token punctuation">{</span><span class="token punctuation">}</span><span class="token punctuation">;</span>
<span class="token keyword">let</span> downloaded <span class="token operator">=</span> <span class="token number">0</span><span class="token punctuation">;</span>
<span class="token keyword">for</span> <span class="token punctuation">(</span><span class="token keyword">const</span> img <span class="token keyword">of</span> images<span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">if</span> <span class="token punctuation">(</span>existing<span class="token punctuation">[</span>img<span class="token punctuation">]</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    lqip<span class="token punctuation">[</span>img<span class="token punctuation">]</span> <span class="token operator">=</span> existing<span class="token punctuation">[</span>img<span class="token punctuation">]</span><span class="token punctuation">;</span>
    <span class="token keyword">continue</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
  <span class="token keyword">const</span> url <span class="token operator">=</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token constant">CDN</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token function">imgBase</span><span class="token punctuation">(</span>img<span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">-16.jpg</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>
  <span class="token keyword">try</span> <span class="token punctuation">{</span>
    lqip<span class="token punctuation">[</span>img<span class="token punctuation">]</span> <span class="token operator">=</span> <span class="token keyword">await</span> <span class="token function">fetchBase64</span><span class="token punctuation">(</span>url<span class="token punctuation">)</span><span class="token punctuation">;</span>
    downloaded<span class="token operator">++</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span> <span class="token keyword">catch</span> <span class="token punctuation">(</span>err<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token builtin">console</span><span class="token punctuation">.</span><span class="token function">error</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">  [lqip] ✗ </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>img<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">: </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token punctuation">(</span>err <span class="token keyword">as</span> Error<span class="token punctuation">)</span><span class="token punctuation">.</span>message<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>El JSON resultante es un mapa <code>ruta-original → data URI</code>. El script lo guarda en <code>src/_data/lqip.json</code> solo si el contenido ha cambiado — escribir el archivo en cada build invalidaría el watcher de Lume sin necesidad y dispararía recargas en desarrollo:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">const</span> prevJson <span class="token operator">=</span> <span class="token constant">JSON</span><span class="token punctuation">.</span><span class="token function">stringify</span><span class="token punctuation">(</span>existing<span class="token punctuation">,</span> <span class="token keyword">null</span><span class="token punctuation">,</span> <span class="token number">2</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> nextJson <span class="token operator">=</span> <span class="token constant">JSON</span><span class="token punctuation">.</span><span class="token function">stringify</span><span class="token punctuation">(</span>lqip<span class="token punctuation">,</span> <span class="token keyword">null</span><span class="token punctuation">,</span> <span class="token number">2</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span>prevJson <span class="token operator">!==</span> nextJson<span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">await</span> Deno<span class="token punctuation">.</span><span class="token function">writeTextFile</span><span class="token punctuation">(</span><span class="token constant">OUTPUT</span><span class="token punctuation">,</span> nextJson<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Otra ventaja del JSON cacheado: las imágenes que ya no se referencian desde ningún lado <strong>se eliminan del mapa</strong> automáticamente, porque el script reconstruye el objeto desde cero a partir del escaneo. No necesita una lógica de <em>garbage collection</em> aparte.</p>
<h2 id="integraci%C3%B3n-con-lume" tabindex="-1">Integración con Lume <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#integraci%C3%B3n-con-lume">#</a></h2>
<p>El script expone una función <code>generateLQIP()</code> para poder llamarse desde <code>_config.ts</code>. La conexión es mínima:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">import</span> <span class="token punctuation">{</span> generateLQIP <span class="token punctuation">}</span> <span class="token keyword">from</span> <span class="token string">"./scripts/lqip.ts"</span><span class="token punctuation">;</span>

<span class="token keyword">let</span> lqipData<span class="token operator">:</span> Record<span class="token operator">&lt;</span><span class="token builtin">string</span><span class="token punctuation">,</span> <span class="token builtin">string</span><span class="token operator">></span> <span class="token operator">=</span> <span class="token punctuation">{</span><span class="token punctuation">}</span><span class="token punctuation">;</span>
<span class="token keyword">try</span> <span class="token punctuation">{</span>
  lqipData <span class="token operator">=</span> <span class="token constant">JSON</span><span class="token punctuation">.</span><span class="token function">parse</span><span class="token punctuation">(</span><span class="token keyword">await</span> Deno<span class="token punctuation">.</span><span class="token function">readTextFile</span><span class="token punctuation">(</span><span class="token string">"./src/_data/lqip.json"</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span> <span class="token keyword">catch</span> <span class="token punctuation">{</span>
  <span class="token comment">// primer build, todavía no hay cache</span>
<span class="token punctuation">}</span>

site<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"beforeBuild"</span><span class="token punctuation">,</span> <span class="token keyword">async</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
  lqipData <span class="token operator">=</span> <span class="token keyword">await</span> <span class="token function">generateLQIP</span><span class="token punctuation">(</span><span class="token punctuation">{</span> quiet<span class="token operator">:</span> <span class="token boolean">false</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>Dos detalles aquí:</p>
<ul>
<li><strong>Carga del cache al arrancar.</strong> El <code>JSON.parse</code> inicial existe para que los servidores de desarrollo en frío arranquen con el mapa ya rellenado, sin esperar a la primera regeneración.</li>
<li><strong><code>beforeBuild</code> y no <code>beforeUpdate</code>.</strong> En desarrollo, mientras edito un post, no quiero que cada cambio dispare una conexión al CDN. La regeneración solo ocurre en builds completos.</li>
</ul>
<p>Con <code>lqipData</code> en memoria, los shortcodes que generan el HTML pueden consultarlo:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript">site<span class="token punctuation">.</span><span class="token function">data</span><span class="token punctuation">(</span><span class="token string">"img"</span><span class="token punctuation">,</span> <span class="token keyword">function</span> <span class="token punctuation">(</span>imgPath<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">,</span> alt<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">,</span> <span class="token operator">...</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> lqip <span class="token operator">=</span> lqipData<span class="token punctuation">[</span>imgPath<span class="token punctuation">]</span> <span class="token operator">||</span> <span class="token function">imgUrl</span><span class="token punctuation">(</span>imgPath<span class="token punctuation">,</span> <span class="token number">16</span><span class="token punctuation">,</span> <span class="token string">"jpg"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">return</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">&lt;div class="lqip-wrap" style="background-image:url('</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>lqip<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">')">
    &lt;picture>...&lt;/picture>
  &lt;/div></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>El fallback <code>|| imgUrl(imgPath, 16, &quot;jpg&quot;)</code> cubre el caso en el que añado una imagen al post pero todavía no he regenerado el cache. En vez de quedarme sin placeholder, sirvo la URL del placeholder directamente desde el CDN — funciona, solo es marginalmente menos eficiente porque el navegador hace una petición HTTP extra mientras llega la imagen real.</p>
<h2 id="el-html-resultante" tabindex="-1">El HTML resultante <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#el-html-resultante">#</a></h2>
<p>Para cada imagen, el shortcode produce este HTML:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>lqip-wrap<span class="token punctuation">"</span></span>
     <span class="token attr-name">style</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>background-image:url('data:image/jpeg;base64,/9j/4AAQ...')<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>picture</span><span class="token punctuation">></span></span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>source</span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>image/avif<span class="token punctuation">"</span></span> <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-480.avif 480w, ...<span class="token punctuation">"</span></span> <span class="token attr-name">sizes</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>...<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>source</span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>image/webp<span class="token punctuation">"</span></span> <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-480.webp 480w, ...<span class="token punctuation">"</span></span> <span class="token attr-name">sizes</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>...<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-1200.jpg<span class="token punctuation">"</span></span> <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-480.jpg 480w, ...<span class="token punctuation">"</span></span>
         <span class="token attr-name">sizes</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>...<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>...<span class="token punctuation">"</span></span> <span class="token attr-name">loading</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>lazy<span class="token punctuation">"</span></span>
         <span class="token attr-name">width</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>1200<span class="token punctuation">"</span></span> <span class="token attr-name">height</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>800<span class="token punctuation">"</span></span>
         <span class="token attr-name">onload</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>this.parentNode.classList.add('loaded')<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>picture</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">></span></span>
</code></pre>
<p>Tres piezas:</p>
<ul>
<li>El <strong>wrapper</strong> lleva el placeholder como <code>background-image</code>. Aparece instantáneo: ya viaja en el HTML.</li>
<li>El <strong><code>&lt;picture&gt;</code></strong> sirve la imagen definitiva con <code>srcset</code> para densidades y formatos modernos. Eso es ortogonal al LQIP — es la técnica de <a href="https://media.paigar.eu/bitacora/imagenes-responsive-picture/">imágenes responsive</a>, aplicada a la imagen real.</li>
<li>El <strong><code>onload</code></strong> añade <code>.loaded</code> al wrapper cuando el <code>&lt;img&gt;</code> termina de descargar, lo que dispara el cross-fade.</li>
</ul>
<h2 id="el-css%3A-cross-fade-sin-javascript" tabindex="-1">El CSS: cross-fade sin JavaScript <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#el-css%3A-cross-fade-sin-javascript">#</a></h2>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.lqip-wrap</span> <span class="token punctuation">{</span>
  <span class="token property">position</span><span class="token punctuation">:</span> relative<span class="token punctuation">;</span>
  <span class="token property">background-size</span><span class="token punctuation">:</span> cover<span class="token punctuation">;</span>
  <span class="token property">background-position</span><span class="token punctuation">:</span> center<span class="token punctuation">;</span>
  <span class="token property">background-repeat</span><span class="token punctuation">:</span> no-repeat<span class="token punctuation">;</span>
  <span class="token property">overflow</span><span class="token punctuation">:</span> hidden<span class="token punctuation">;</span>
  <span class="token property">width</span><span class="token punctuation">:</span> 100%<span class="token punctuation">;</span>
  <span class="token property">height</span><span class="token punctuation">:</span> 100%<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token selector">.lqip-wrap > img</span> <span class="token punctuation">{</span>
  <span class="token property">display</span><span class="token punctuation">:</span> block<span class="token punctuation">;</span>
  <span class="token property">width</span><span class="token punctuation">:</span> 100%<span class="token punctuation">;</span>
  <span class="token property">height</span><span class="token punctuation">:</span> 100%<span class="token punctuation">;</span>
  <span class="token property">object-fit</span><span class="token punctuation">:</span> cover<span class="token punctuation">;</span>
  <span class="token property">opacity</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
  <span class="token property">transition</span><span class="token punctuation">:</span> opacity 0.4s ease<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token selector">.lqip-wrap.loaded > img</span> <span class="token punctuation">{</span>
  <span class="token property">opacity</span><span class="token punctuation">:</span> 1<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>El placeholder es el fondo del wrapper. El <code>&lt;img&gt;</code> empieza con <code>opacity: 0</code>, ocupando el mismo espacio. Cuando dispara su <code>onload</code>, el wrapper recibe <code>.loaded</code>, el <code>&lt;img&gt;</code> pasa a <code>opacity: 1</code>, y la transición de 0.4s hace el cross-fade.</p>
<p><code>object-fit: cover</code> se asegura de que la imagen real cubra el wrapper sin deformarse, lo que importa porque el <code>width</code> y <code>height</code> del <code>&lt;img&gt;</code> definen la proporción pero el contenedor real lo controla CSS.</p>
<h2 id="por-qu%C3%A9-16-p%C3%ADxeles-y-por-qu%C3%A9-jpg" tabindex="-1">Por qué 16 píxeles y por qué JPG <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#por-qu%C3%A9-16-p%C3%ADxeles-y-por-qu%C3%A9-jpg">#</a></h2>
<p>Probé valores entre 8 y 32 píxeles. Por debajo de 16 el placeholder se nota pixelado en la transición; por encima, el peso crece más rápido que la mejora visual. El JPG a 16px y calidad por defecto pesa unos 350 bytes — aceptable.</p>
<p>Sobre el formato: aquí JPG gana a WebP y AVIF. A 16 píxeles las cabeceras de WebP/AVIF representan un porcentaje ridículamente alto del archivo, y la ganancia de compresión sobre JPG es marginal. Además, los placeholders viajan en el HTML, donde el ahorro de bytes brutos sí cuenta — y JPG genera buffers pequeños y predecibles. He medido los tres formatos: a 16px, JPG es el más ligero en mi caso.</p>
<h2 id="por-qu%C3%A9-no-blurhash%2C-plaiceholder-o-transform_images" tabindex="-1">Por qué no BlurHash, Plaiceholder o <code>transform_images</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#por-qu%C3%A9-no-blurhash%2C-plaiceholder-o-transform_images">#</a></h2>
<p>Existen alternativas conocidas:</p>
<ul>
<li><strong><a href="https://blurha.sh/">BlurHash</a></strong> codifica el placeholder como un string ASCII de unos 30 caracteres y lo reconstruye con JavaScript en el cliente. El string es más compacto que una data URI base64, sí — pero requiere ~3 KB de JavaScript en cada página y un canvas para reconstruir el placeholder. Para una web sin frameworks no compensa.</li>
<li><strong><a href="https://plaiceholder.co/">Plaiceholder</a></strong> es una librería de Node.js que genera LQIPs (entre otros formatos: blurhash, color dominante, SVG). En Idenautas no me hace falta el paso de generar la imagen pequeña — Bunny ya tiene la versión de 16px subida —, y prefiero un script de 100 líneas que entiendo entero a una dependencia más.</li>
<li><strong>El plugin oficial <a href="https://lume.land/plugins/transform_images/"><code>transform_images</code></a></strong> procesa imágenes con Sharp dentro del propio build de Lume. No genera placeholders como tales, pero sí puede producir la variante de 16 px y leerla luego para incrustarla en base64 — todo en una sola pasada de Lume, sin un script aparte como el mío. Si dejas que Lume gestione también tus variantes responsive con <code>transform_images</code> o el plugin <code>picture</code>, esa ruta es más coherente. En Idenautas las variantes están subidas a Bunny Storage por un pipeline anterior a Lume, así que el script de LQIP solo se ocupa del placeholder; si arrancase el proyecto desde cero hoy, probablemente movería todo el flujo de imágenes a <code>transform_images</code> y haría el LQIP ahí mismo.</li>
</ul>
<p>Si fuera un proyecto donde las imágenes solo viven a tamaño completo y necesito regenerarlas, <code>transform_images</code> (o <code>sharp</code> directamente) sería la opción razonable. Mi pipeline ya produce las variantes responsive con un script aparte, así que añadir <code>-16.jpg</code> a esa lista era trivial.</p>
<h2 id="lo-que-cuesta-y-lo-que-aporta" tabindex="-1">Lo que cuesta y lo que aporta <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/lqip-placeholders-lume/#lo-que-cuesta-y-lo-que-aporta">#</a></h2>
<p>El coste, en bytes incrustados, es de unos 600 bytes por imagen en el HTML. En una página con cinco imágenes son 3 KB extra antes de cualquier compresión gzip — y gzip los reduce todavía más, porque las cabeceras JPG son repetitivas entre placeholders. Es un coste muy bajo para evitar huecos vacíos en la primera pintada.</p>
<p>Lo que aporta es perceptual: la página se siente más rápida sin serlo necesariamente. Las imágenes ya están en su sitio cuando entras, solo enfocan. El usuario rara vez identifica conscientemente la técnica, pero nota la diferencia cuando la quitas.</p>
<p>Es una de esas inversiones de unas pocas horas que se quedan trabajando en silencio durante años. Y en Lume, con un script Deno y un evento <code>beforeBuild</code>, encaja sin necesidad de plugins ni configuración adicional.</p>
]]>
      </content:encoded>
      <pubDate>Sun, 26 Apr 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Migrar de Eleventy a Lume: lo que nadie te cuenta</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/</guid>
      <description>
        Tres problemas reales que encontré al migrar este sitio de Eleventy a Lume, y cómo los resolví. Includes rotos, tags que desaparecen y un sistema de bundling que no existe.
      </description>
      <content:encoded>
        <![CDATA[<p>Migrar un sitio estático de un generador a otro parece sencillo sobre el papel. Ambos procesan plantillas, ambos generan HTML, la estructura es parecida. Y en gran parte es así: el contenido Markdown no cambia, el CSS sigue siendo CSS, y el JavaScript vanilla funciona igual.</p>
<p>Pero hay trampas. No errores evidentes que te bloquean con un mensaje rojo — sino comportamientos sutiles que hacen que las páginas se generen vacías, que los posts pierdan sus categorías, o que el CSS deje de llegar donde tiene que llegar. Todo compila, todo parece funcionar, pero el resultado no es el que esperas.</p>
<p>Estos son los tres problemas reales que encontré migrando paigar.es de Eleventy a Lume.</p>
<h2 id="1.-los-includes-dentro-de-bucles-no-reciben-variables" tabindex="-1">1. Los includes dentro de bucles no reciben variables <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#1.-los-includes-dentro-de-bucles-no-reciben-variables">#</a></h2>
<p>En Eleventy con Nunjucks, esto funciona perfectamente:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{% for post in collections.bitacora %}
  {% include "partials/postcard.njk" %}
{% endfor %}
</code></pre>
<p>El partial <code>postcard.njk</code> accede a la variable <code>post</code> del bucle padre sin problemas. Es el comportamiento esperado de Nunjucks: los includes heredan el scope del template que los llama.</p>
<p>En Lume, el mismo patrón genera HTML vacío. Sin errores, sin warnings — simplemente no renderiza nada dentro del bucle. La página se construye, el div contenedor aparece, pero los postcards no están.</p>
<p>Lo desconcertante es que <code>search.pages()</code> sí devuelve resultados. Puedes verificarlo con un debug inline:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{% for post in search.pages("bitacora", "date=desc") %}
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>p</span><span class="token punctuation">></span></span>{{ post.title }}<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>p</span><span class="token punctuation">></span></span>  {# Esto SÍ funciona #}
  {% include "partials/postcard.njk" %}  {# Esto NO #}
{% endfor %}
</code></pre>
<p>El título se muestra, pero el include produce una cadena vacía. El partial no puede ver <code>post</code>.</p>
<h3 id="por-qu%C3%A9-pasa-esto" tabindex="-1">Por qué pasa esto <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#por-qu%C3%A9-pasa-esto">#</a></h3>
<p>Óscar Otero, el creador de Lume, me explicó la causa técnica. Nunjucks es por defecto un motor síncrono, pero Lume es asíncrono por naturaleza, así que utiliza Nunjucks en modo asíncrono. En ese modo, algunas etiquetas no funcionan cuando contienen código asíncrono (como un <code>include</code>) y hay que usar otras: <code>asyncEach</code> en vez de <code>for</code>, e <code>ifAsync</code> en vez de <code>if</code>. Esta es precisamente una de las razones por las que Óscar decidió crear Vento.</p>
<h3 id="la-soluci%C3%B3n-en-nunjucks%3A-macros" tabindex="-1">La solución en Nunjucks: macros <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#la-soluci%C3%B3n-en-nunjucks%3A-macros">#</a></h3>
<p>La alternativa que funciona en Lume con Nunjucks son los macros. En lugar de un partial que depende del scope del padre, defines una función que recibe los datos como argumentos:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{# partials/postcard.njk #}
{% macro renderPostcard(post, postHeading) %}
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>article</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>postcard<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>h3</span><span class="token punctuation">></span></span>{{ post.title }}<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>h3</span><span class="token punctuation">></span></span>
  ...
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>article</span><span class="token punctuation">></span></span>
{% endmacro %}
</code></pre>
<p>Y en el template que lo usa:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{% from "partials/postcard.njk" import renderPostcard %}

{% for post in search.pages("bitacora", "date=desc") %}
  {{ renderPostcard(post, "h2") }}
{% endfor %}
</code></pre>
<h3 id="la-soluci%C3%B3n-definitiva%3A-vento" tabindex="-1">La solución definitiva: Vento <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#la-soluci%C3%B3n-definitiva%3A-vento">#</a></h3>
<p>Al migrar de Nunjucks a Vento (el motor nativo de Lume), el problema desaparece porque Vento permite pasar datos explícitamente al include:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{{ for post of search.pages("bitacora", "date=desc") }}
  {{ include "partials/postcard.vto" { post, postHeading: "h2" } }}
{{ /for }}
</code></pre>
<p>Esa sintaxis <code>{ post, postHeading: &quot;h2&quot; }</code> es un objeto de datos que el include recibe. No hay ambigüedad sobre qué variables están disponibles. Es más explícito y más fiable que depender de la herencia de scope.</p>
<h2 id="2.-la-cascada-de-datos-no-fusiona-arrays" tabindex="-1">2. La cascada de datos no fusiona arrays <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#2.-la-cascada-de-datos-no-fusiona-arrays">#</a></h2>
<p>En Eleventy, puedes definir valores por defecto para todos los archivos de un directorio con un fichero <code>posts.11tydata.js</code>:</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript"><span class="token comment">// bitacora/posts/posts.11tydata.js</span>
module<span class="token punctuation">.</span>exports <span class="token operator">=</span> <span class="token punctuation">{</span>
  <span class="token literal-property property">tags</span><span class="token operator">:</span> <span class="token punctuation">[</span><span class="token string">"bitacora"</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
  <span class="token literal-property property">layout</span><span class="token operator">:</span> <span class="token string">"layouts/post.njk"</span><span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<p>Si un post tiene sus propios tags en el frontmatter (<code>tags: [css, técnicas]</code>), Eleventy los <strong>fusiona</strong> con los del directorio. El post acaba con <code>[&quot;bitacora&quot;, &quot;css&quot;, &quot;técnicas&quot;]</code>. Esto es fundamental para que <code>collections.bitacora</code> incluya todos los posts de la sección.</p>
<p>En Lume, el equivalente es un archivo <code>_data.yml</code>:</p>
<pre class="language-yaml" tabindex="0"><code class="language-yaml"><span class="token comment"># bitacora/_data.yml</span>
<span class="token key atrule">tags</span><span class="token punctuation">:</span>
  <span class="token punctuation">-</span> bitacora
<span class="token key atrule">layout</span><span class="token punctuation">:</span> layouts/post.vto
</code></pre>
<p>Pero Lume <strong>reemplaza</strong> los arrays en lugar de fusionarlos. Un post con <code>tags: [css, técnicas]</code> pierde el tag <code>bitacora</code> del directorio. El resultado: <code>search.pages(&quot;bitacora&quot;)</code> devuelve cero resultados.</p>
<h3 id="el-primer-intento%3A-mergedkeys" tabindex="-1">El primer intento: mergedKeys <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#el-primer-intento%3A-mergedkeys">#</a></h3>
<p>Lume tiene una opción <code>mergedKeys</code> que permite fusionar arrays:</p>
<pre class="language-yaml" tabindex="0"><code class="language-yaml"><span class="token key atrule">mergedKeys</span><span class="token punctuation">:</span>
  <span class="token key atrule">tags</span><span class="token punctuation">:</span> array
<span class="token key atrule">tags</span><span class="token punctuation">:</span>
  <span class="token punctuation">-</span> bitacora
<span class="token key atrule">layout</span><span class="token punctuation">:</span> layouts/post.vto
</code></pre>
<p>Funciona — pero con un efecto secundario. El <code>_data.yml</code> aplica a <strong>todos</strong> los archivos del directorio, incluyendo <code>index.vto</code>. El índice de la sección acaba con el tag <code>bitacora</code>, apareciendo en los resultados de búsqueda como si fuera un post más. El feed RSS incluye la página de índice. Las postcards muestran la sección como si fuera un artículo.</p>
<h3 id="la-primera-soluci%C3%B3n%3A-un-preprocessor" tabindex="-1">La primera solución: un preprocessor <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#la-primera-soluci%C3%B3n%3A-un-preprocessor">#</a></h3>
<p>Mi primera solución fue un preprocessor en <code>_config.ts</code> que solo inyecta el tag de sección en archivos Markdown, ignorando las plantillas <code>.vto</code>:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript">site<span class="token punctuation">.</span><span class="token function">preprocess</span><span class="token punctuation">(</span><span class="token punctuation">[</span><span class="token string">".md"</span><span class="token punctuation">]</span><span class="token punctuation">,</span> <span class="token punctuation">(</span>pages<span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
  <span class="token keyword">for</span> <span class="token punctuation">(</span><span class="token keyword">const</span> page <span class="token keyword">of</span> pages<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> path <span class="token operator">=</span> page<span class="token punctuation">.</span>src<span class="token punctuation">.</span>path<span class="token punctuation">;</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span>path<span class="token punctuation">.</span><span class="token function">startsWith</span><span class="token punctuation">(</span><span class="token string">"/bitacora/"</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
      page<span class="token punctuation">.</span>data<span class="token punctuation">.</span>tags <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token operator">...</span><span class="token punctuation">(</span>page<span class="token punctuation">.</span>data<span class="token punctuation">.</span>tags <span class="token operator">||</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token string">"bitacora"</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token keyword">if</span> <span class="token punctuation">(</span>path<span class="token punctuation">.</span><span class="token function">startsWith</span><span class="token punctuation">(</span><span class="token string">"/reflexiones/"</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
      page<span class="token punctuation">.</span>data<span class="token punctuation">.</span>tags <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token operator">...</span><span class="token punctuation">(</span>page<span class="token punctuation">.</span>data<span class="token punctuation">.</span>tags <span class="token operator">||</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token string">"reflexiones"</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>El filtro <code>[&quot;.md&quot;]</code> es la clave. Solo procesa archivos Markdown, así que los <code>index.vto</code> de cada sección no reciben el tag. Es más explícito que la cascada de datos y no tiene efectos colaterales. Funcionaba bien, pero seguía usando <code>tags</code> para algo que no es realmente una etiqueta.</p>
<h3 id="la-soluci%C3%B3n-definitiva%3A-separar-secci%C3%B3n-de-tags" tabindex="-1">La solución definitiva: separar sección de tags <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#la-soluci%C3%B3n-definitiva%3A-separar-secci%C3%B3n-de-tags">#</a></h3>
<p>Óscar Otero, el creador de Lume, me comentó que <code>tags</code> debería tener <code>mergedKeys</code> activado por defecto, y que el comportamiento que encontré podría ser un bug de Lume o un error en mi implementación — era mi primera vez con Lume, así que no descarto haberme dejado algo por el camino. Pero lo más interesante fue su propuesta: en vez de usar <code>tags</code> para agrupar por sección, usar una variable propia como <code>type</code>.</p>
<p>La idea es sencilla. En el <code>_data.yml</code> de cada sección defines el tipo:</p>
<pre class="language-yaml" tabindex="0"><code class="language-yaml"><span class="token comment"># bitacora/_data.yml</span>
<span class="token key atrule">layout</span><span class="token punctuation">:</span> layouts/post.vto
<span class="token key atrule">type</span><span class="token punctuation">:</span> bitacora
</code></pre>
<p>Y en el <code>index.vto</code> de la sección lo anulas para que el índice no aparezca en las búsquedas:</p>
<pre class="language-yaml" tabindex="0"><code class="language-yaml"><span class="token punctuation">---</span>
<span class="token key atrule">type</span><span class="token punctuation">:</span>
<span class="token punctuation">---</span>
</code></pre>
<p>Las búsquedas pasan de <code>search.pages(&quot;bitacora&quot;)</code> a <code>search.pages(&quot;type=bitacora&quot;)</code>. Los <code>tags</code> quedan libres para lo que realmente son — etiquetas de contenido como <code>css</code>, <code>técnicas</code> o <code>eleventy</code> — sin mezclarse con la agrupación por sección.</p>
<p>Este enfoque es más limpio que el preprocessor: no necesita código en <code>_config.ts</code>, la intención queda clara en los datos, y <code>tags</code> deja de hacer doble función. Es el que uso actualmente.</p>
<h2 id="3.-el-bundling-de-css-y-js-no-existe" tabindex="-1">3. El bundling de CSS y JS no existe <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#3.-el-bundling-de-css-y-js-no-existe">#</a></h2>
<p>Eleventy tiene un plugin de bundling que permite incluir CSS y JavaScript inline directamente desde las plantillas:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{# base.njk — recoge CSS de todos los templates #}
{% css %}{% include "public/css/reset.css" %}{% endcss %}
{% css %}{% include "public/css/tokens.css" %}{% endcss %}
{% css %}{% include "public/css/components.css" %}{% endcss %}

<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>style</span><span class="token punctuation">></span></span>
  {% getBundle "css" %}
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>style</span><span class="token punctuation">></span></span>
</code></pre>
<p>El mecanismo es potente: cada plantilla puede añadir su propio CSS con <code>{% css %}...{% endcss %}</code>, y todo se concatena en un solo <code>&lt;style&gt;</code> en el <code>&lt;head&gt;</code>. Los posts añaden estilos de navegación, la home añade estilos del hero, la 404 añade estilos del terminal — y el usuario solo descarga el CSS que la página necesita.</p>
<p>Lume no tiene nada parecido. No hay <code>{% css %}</code>, no hay <code>{% getBundle %}</code>, no hay concatenación de CSS desde plantillas.</p>
<h3 id="la-soluci%C3%B3n%3A-postcss-%2B-inline-%2B-pagecss" tabindex="-1">La solución: postcss + inline + pageCss <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#la-soluci%C3%B3n%3A-postcss-%2B-inline-%2B-pagecss">#</a></h3>
<p>La migración requiere tres pasos:</p>
<p><strong>Primero</strong>, extraer todos los bloques <code>{% css %}...{% endcss %}</code> de las plantillas a archivos CSS individuales: <code>page-hero.css</code>, <code>page-post.css</code>, <code>page-404.css</code>, <code>page-sobre.css</code>, etc. Cada archivo contiene exactamente el CSS que antes vivía dentro de su plantilla.</p>
<p><strong>Segundo</strong>, crear un <code>main.css</code> que importe solo el CSS base — el que todas las páginas necesitan:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* main.css */</span>
<span class="token atrule"><span class="token rule">@import</span> <span class="token string">"./public/css/reset.css"</span><span class="token punctuation">;</span></span>
<span class="token atrule"><span class="token rule">@import</span> <span class="token string">"./public/css/tokens.css"</span><span class="token punctuation">;</span></span>
<span class="token atrule"><span class="token rule">@import</span> <span class="token string">"./public/css/theme.css"</span><span class="token punctuation">;</span></span>
<span class="token atrule"><span class="token rule">@import</span> <span class="token string">"./public/fonts/fonts.css"</span><span class="token punctuation">;</span></span>
<span class="token atrule"><span class="token rule">@import</span> <span class="token string">"./public/css/layout.css"</span><span class="token punctuation">;</span></span>
<span class="token atrule"><span class="token rule">@import</span> <span class="token string">"./public/css/components.css"</span><span class="token punctuation">;</span></span>
<span class="token atrule"><span class="token rule">@import</span> <span class="token string">"./public/css/code.css"</span><span class="token punctuation">;</span></span>
</code></pre>
<p>El plugin <code>postcss</code> resuelve los <code>@import</code> y concatena todo. El plugin <code>inline</code> sustituye el <code>&lt;link&gt;</code> por un <code>&lt;style&gt;</code> con el contenido incrustado:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>link</span> <span class="token attr-name">rel</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>stylesheet<span class="token punctuation">"</span></span> <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>/css_main.css<span class="token punctuation">"</span></span> <span class="token attr-name">inline</span><span class="token punctuation">></span></span>
</code></pre>
<p><strong>Tercero</strong>, cada plantilla declara en su frontmatter qué CSS extra necesita con un array <code>pageCss</code>:</p>
<pre class="language-yaml" tabindex="0"><code class="language-yaml"><span class="token punctuation">---</span>
<span class="token key atrule">layout</span><span class="token punctuation">:</span> layouts/base.vto
<span class="token key atrule">title</span><span class="token punctuation">:</span> <span class="token string">"Paigar — Juanjo Marcos"</span>
<span class="token key atrule">pageCss</span><span class="token punctuation">:</span>
  <span class="token punctuation">-</span> /css/page<span class="token punctuation">-</span>hero.css
<span class="token punctuation">---</span>
</code></pre>
<p>El layout base recorre ese array y añade cada archivo como un <code>&lt;link inline&gt;</code>:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{{ for css of pageCss }}
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>link</span> <span class="token attr-name">rel</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>stylesheet<span class="token punctuation">"</span></span> <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>{{ css }}<span class="token punctuation">"</span></span> <span class="token attr-name">inline</span><span class="token punctuation">></span></span>
{{ /for }}
</code></pre>
<p>El resultado es que la homepage solo carga el CSS del hero, un post solo carga el CSS de posts, y la 404 solo carga el suyo. Exactamente el mismo comportamiento que el <code>getBundle</code> de Eleventy — cada página recibe solo el CSS que necesita, todo inline, cero peticiones HTTP.</p>
<p>Para JavaScript, la misma idea. El atributo <code>inline</code> funciona también con <code>&lt;script&gt;</code>:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>script</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>/js/theme-toggle.js<span class="token punctuation">"</span></span> <span class="token attr-name">inline</span><span class="token punctuation">></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>script</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>script</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>/js/externallinks.js<span class="token punctuation">"</span></span> <span class="token attr-name">inline</span><span class="token punctuation">></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>script</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>script</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>/js/reveal.js<span class="token punctuation">"</span></span> <span class="token attr-name">inline</span><span class="token punctuation">></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>script</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>script</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>/js/nav-mobile.js<span class="token punctuation">"</span></span> <span class="token attr-name">inline</span><span class="token punctuation">></span></span><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>script</span><span class="token punctuation">></span></span>
</code></pre>
<p>Lume lee cada archivo y lo incrusta directamente en el HTML. Cero peticiones externas para JS.</p>
<h2 id="%C2%BFmereci%C3%B3-la-pena%3F" tabindex="-1">¿Mereció la pena? <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrar-eleventy-a-lume/#%C2%BFmereci%C3%B3-la-pena%3F">#</a></h2>
<p>Sí. Los tres problemas se resolvieron en el mismo día, y el resultado es un proyecto más limpio:</p>
<ul>
<li><strong>Deno en lugar de Node</strong> — sin <code>node_modules</code> de 200 MB, sin <code>package.json</code>, sin <code>package-lock.json</code></li>
<li><strong>Vento en lugar de Nunjucks</strong> — sintaxis más clara, datos explícitos en includes, expresiones JavaScript reales</li>
<li><strong>TypeScript nativo</strong> — la configuración, los generadores y el script de publicación son <code>.ts</code>, con tipado sin transpilación</li>
<li><strong>Build en ~2 segundos</strong> — 159 archivos generados, incluyendo conversión SVG→PNG de las imágenes Open Graph</li>
</ul>
<p>El código fuente pasó de depender de 10 paquetes npm a cero dependencias de Node.js. Todo corre sobre Deno y las importaciones son URLs directas o paquetes de JSR. Para un sitio que predica la simplicidad, es coherente.</p>
]]>
      </content:encoded>
      <pubDate>Sat, 21 Mar 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Imágenes Open Graph automáticas con Lume</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/</guid>
      <description>
        Cómo generar automáticamente imágenes de vista previa para redes sociales en cada post, usando un generador TypeScript, SVG y resvg-wasm.
      </description>
      <content:encoded>
        <![CDATA[<p>Cuando compartes un enlace en redes sociales, lo primero que ves es una imagen. Si no la tienes, tu enlace aparece como un rectángulo gris con texto plano. No es el fin del mundo, pero es una oportunidad perdida.</p>
<p>Crear esas imágenes a mano para cada post es tedioso. Y conectar un servicio externo para algo tan simple es sobredimensionar el problema. La solución está en el propio build: generar las imágenes durante la compilación, sin servicios externos.</p>
<p>La idea original la encontré en el artículo de <a href="https://bnijenhuis.nl/notes/automatically-generate-open-graph-images-in-eleventy/">Bernard Nijenhuis</a> para Eleventy, y la he adaptado a Lume con las herramientas que Deno ofrece.</p>
<h2 id="la-estrategia" tabindex="-1">La estrategia <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#la-estrategia">#</a></h2>
<p>El truco es usar SVG como plantilla intermedia. SVG es código, así que puedes generarlo programáticamente. Después, una librería WASM convierte ese SVG a PNG durante el build.</p>
<p>El flujo completo:</p>
<ol>
<li>Un generador TypeScript (<code>og-images.page.ts</code>) produce un archivo SVG por cada post</li>
<li>El SVG contiene el título del post, la sección y el branding del sitio</li>
<li>Después del build, un evento <code>afterBuild</code> en <code>_config.ts</code> convierte todos los SVG a PNG con resvg</li>
<li>Las meta tags <code>og:image</code> apuntan a las imágenes PNG generadas</li>
</ol>
<p>Todo ocurre en el build. No hay servicios externos, no hay APIs, no hay imágenes que mantener a mano.</p>
<h2 id="el-generador%3A-og-images.page.ts" tabindex="-1">El generador: og-images.page.ts <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#el-generador%3A-og-images.page.ts">#</a></h2>
<p>En Lume, los archivos <code>.page.ts</code> son generadores: exportan una función que puede producir múltiples páginas. Cada <code>yield</code> genera un archivo. Es el equivalente a la paginación de otros SSG, pero con TypeScript puro.</p>
<p>El generador empieza recopilando todos los posts de ambas secciones con <code>search.pages()</code>:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">export</span> <span class="token keyword">default</span> <span class="token keyword">function</span><span class="token operator">*</span> <span class="token punctuation">(</span><span class="token punctuation">{</span> search <span class="token punctuation">}</span><span class="token operator">:</span> Lume<span class="token punctuation">.</span>Data<span class="token punctuation">)</span> <span class="token punctuation">{</span>
	<span class="token keyword">const</span> posts <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token operator">...</span>search<span class="token punctuation">.</span><span class="token function">pages</span><span class="token punctuation">(</span><span class="token string">"bitacora"</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token operator">...</span>search<span class="token punctuation">.</span><span class="token function">pages</span><span class="token punctuation">(</span><span class="token string">"reflexiones"</span><span class="token punctuation">)</span><span class="token punctuation">]</span><span class="token punctuation">;</span>

	<span class="token keyword">for</span> <span class="token punctuation">(</span><span class="token keyword">const</span> post <span class="token keyword">of</span> posts<span class="token punctuation">)</span> <span class="token punctuation">{</span>
		<span class="token keyword">const</span> title <span class="token operator">=</span> post<span class="token punctuation">.</span>title <span class="token keyword">as</span> <span class="token builtin">string</span><span class="token punctuation">;</span>
		<span class="token keyword">const</span> tags <span class="token operator">=</span> <span class="token punctuation">(</span>post<span class="token punctuation">.</span>tags <span class="token operator">||</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">)</span> <span class="token keyword">as</span> <span class="token builtin">string</span><span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
		<span class="token comment">// ...</span>
	<span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Para cada post hay que resolver tres cosas: partir el título en líneas, determinar la sección, y extraer el slug para el nombre de archivo.</p>
<h3 id="partir-el-t%C3%ADtulo-en-l%C3%ADneas" tabindex="-1">Partir el título en líneas <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#partir-el-t%C3%ADtulo-en-l%C3%ADneas">#</a></h3>
<p>SVG no sabe partir texto automáticamente. Si el título tiene 80 caracteres, se sale del canvas. La solución es dividir el texto en líneas de máximo 36 caracteres, cortando siempre por espacios:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">const</span> parts <span class="token operator">=</span> title<span class="token punctuation">.</span><span class="token function">split</span><span class="token punctuation">(</span><span class="token string">" "</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> titleLines<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">[</span><span class="token punctuation">]</span> <span class="token operator">=</span> parts<span class="token punctuation">.</span><span class="token function">reduce</span><span class="token punctuation">(</span><span class="token punctuation">(</span>prev<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">,</span> current<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
	<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>prev<span class="token punctuation">.</span>length<span class="token punctuation">)</span> <span class="token keyword">return</span> <span class="token punctuation">[</span>current<span class="token punctuation">]</span><span class="token punctuation">;</span>
	<span class="token keyword">const</span> lastLine <span class="token operator">=</span> prev<span class="token punctuation">[</span>prev<span class="token punctuation">.</span>length <span class="token operator">-</span> <span class="token number">1</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
	<span class="token keyword">if</span> <span class="token punctuation">(</span>lastLine<span class="token punctuation">.</span>length <span class="token operator">+</span> <span class="token number">1</span> <span class="token operator">+</span> current<span class="token punctuation">.</span>length <span class="token operator">></span> <span class="token number">36</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
		<span class="token keyword">return</span> <span class="token punctuation">[</span><span class="token operator">...</span>prev<span class="token punctuation">,</span> current<span class="token punctuation">]</span><span class="token punctuation">;</span>
	<span class="token punctuation">}</span>
	prev<span class="token punctuation">[</span>prev<span class="token punctuation">.</span>length <span class="token operator">-</span> <span class="token number">1</span><span class="token punctuation">]</span> <span class="token operator">=</span> lastLine <span class="token operator">+</span> <span class="token string">" "</span> <span class="token operator">+</span> current<span class="token punctuation">;</span>
	<span class="token keyword">return</span> prev<span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>El 36 depende del tamaño de fuente y del ancho del canvas. Con <code>font-size=&quot;48&quot;</code> y un canvas de 1200px, 36 caracteres encajan bien.</p>
<h3 id="posici%C3%B3n-vertical-del-t%C3%ADtulo" tabindex="-1">Posición vertical del título <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#posici%C3%B3n-vertical-del-t%C3%ADtulo">#</a></h3>
<p>La posición Y del título se ajusta según el número de líneas, para que quede centrado visualmente en la imagen:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">const</span> lineCount <span class="token operator">=</span> titleLines<span class="token punctuation">.</span>length<span class="token punctuation">;</span>
<span class="token keyword">let</span> titleY<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">;</span>
<span class="token keyword">if</span> <span class="token punctuation">(</span>lineCount <span class="token operator">===</span> <span class="token number">1</span><span class="token punctuation">)</span> titleY <span class="token operator">=</span> <span class="token number">310</span><span class="token punctuation">;</span>
<span class="token keyword">else</span> <span class="token keyword">if</span> <span class="token punctuation">(</span>lineCount <span class="token operator">===</span> <span class="token number">2</span><span class="token punctuation">)</span> titleY <span class="token operator">=</span> <span class="token number">280</span><span class="token punctuation">;</span>
<span class="token keyword">else</span> <span class="token keyword">if</span> <span class="token punctuation">(</span>lineCount <span class="token operator">===</span> <span class="token number">3</span><span class="token punctuation">)</span> titleY <span class="token operator">=</span> <span class="token number">240</span><span class="token punctuation">;</span>
<span class="token keyword">else</span> titleY <span class="token operator">=</span> <span class="token number">200</span><span class="token punctuation">;</span>
</code></pre>
<h3 id="secci%C3%B3n-y-slug" tabindex="-1">Sección y slug <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#secci%C3%B3n-y-slug">#</a></h3>
<p>La sección se determina a partir de los tags del post. El slug se extrae de la URL — es el último segmento:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">const</span> seccion <span class="token operator">=</span> tags<span class="token punctuation">.</span><span class="token function">includes</span><span class="token punctuation">(</span><span class="token string">"bitacora"</span><span class="token punctuation">)</span> <span class="token operator">?</span> <span class="token string">"BITACORA"</span> <span class="token operator">:</span> <span class="token string">"REFLEXIONES"</span><span class="token punctuation">;</span>

<span class="token keyword">const</span> urlParts <span class="token operator">=</span> <span class="token punctuation">(</span>post<span class="token punctuation">.</span>url <span class="token keyword">as</span> <span class="token builtin">string</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">split</span><span class="token punctuation">(</span><span class="token string">"/"</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">filter</span><span class="token punctuation">(</span>Boolean<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token keyword">const</span> slug <span class="token operator">=</span> urlParts<span class="token punctuation">[</span>urlParts<span class="token punctuation">.</span>length <span class="token operator">-</span> <span class="token number">1</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
</code></pre>
<h3 id="el-svg" tabindex="-1">El SVG <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#el-svg">#</a></h3>
<p>Con todos los datos preparados, se construye el SVG como un template literal. Las líneas del título se generan como <code>&lt;tspan&gt;</code> con la coordenada Y incrementada en 62px por línea. El texto se escapa con una función auxiliar <code>escapeXml</code> para evitar que caracteres como <code>&amp;</code> o <code>&lt;</code> rompan el XML:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">const</span> tspans <span class="token operator">=</span> titleLines
	<span class="token punctuation">.</span><span class="token function">map</span><span class="token punctuation">(</span>
		<span class="token punctuation">(</span>line<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">,</span> i<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">)</span> <span class="token operator">=></span>
			<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">    &lt;tspan x="80" y="</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>titleY <span class="token operator">+</span> i <span class="token operator">*</span> <span class="token number">62</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">"></span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token function">escapeXml</span><span class="token punctuation">(</span>line<span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">&lt;/tspan></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
	<span class="token punctuation">)</span>
	<span class="token punctuation">.</span><span class="token function">join</span><span class="token punctuation">(</span><span class="token string">"\n"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>El diseño es intencionalmente sencillo: fondo oscuro (#111118), una barra naranja lateral (#f86624) como marca visual, el nombre de la sección en naranja, el título en claro, y el branding del sitio abajo. Todo con <code>&lt;rect&gt;</code>, <code>&lt;text&gt;</code>, <code>&lt;line&gt;</code> y <code>&lt;circle&gt;</code>.</p>
<p>Finalmente, el generador produce el archivo:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">yield</span> <span class="token punctuation">{</span>
  url<span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">/og-images/</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>slug<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">.svg</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
  content<span class="token operator">:</span> svg<span class="token punctuation">,</span>
<span class="token punctuation">}</span><span class="token punctuation">;</span>
</code></pre>
<h2 id="por-qu%C3%A9-png-y-no-jpeg-o-webp" tabindex="-1">Por qué PNG y no JPEG o WebP <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#por-qu%C3%A9-png-y-no-jpeg-o-webp">#</a></h2>
<p>La elección del formato no es casual. Estas imágenes son texto sobre fondos planos, sin fotografías ni degradados complejos. PNG comprime ese tipo de contenido muy bien y mantiene los bordes del texto nítidos. JPEG introduciría artefactos de compresión visibles en las letras y líneas rectas — necesitarías calidad alta para disimularlos, y el archivo acabaría pesando lo mismo o más.</p>
<p>WebP sería ideal por tamaño, pero los crawlers de redes sociales (Facebook, LinkedIn, WhatsApp) históricamente han tenido problemas con WebP en <code>og:image</code>. Facebook recomienda oficialmente PNG o JPEG.</p>
<p>En la práctica, las imágenes generadas pesan entre 22 y 38 KB. No merece la pena buscar más optimización.</p>
<h2 id="la-conversi%C3%B3n%3A-svg-a-png-con-resvg-wasm" tabindex="-1">La conversión: SVG a PNG con resvg-wasm <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#la-conversi%C3%B3n%3A-svg-a-png-con-resvg-wasm">#</a></h2>
<p>Los SVG no sirven directamente como imágenes Open Graph — los crawlers de redes sociales esperan formatos rasterizados. Aquí es donde la migración a Lume trajo un reto interesante.</p>
<p>En Eleventy, la conversión era trivial: <code>@11ty/eleventy-img</code> usa Sharp, que es una librería nativa de Node.js con bindings precompilados. En Deno, Sharp no funciona directamente. Y la mayoría de paquetes npm de conversión SVG→PNG están o deprecados, o usan binarios nativos incompatibles con Deno, o tienen APIs inestables.</p>
<p>La solución fue <a href="https://deno.land/x/resvg_wasm">resvg-wasm</a>, una versión compilada a WebAssembly del renderizador SVG de Mozilla. Funciona en cualquier plataforma sin binarios nativos.</p>
<p>La conversión se ejecuta en un evento <code>afterBuild</code> de Lume, cuando los SVG ya están generados en <code>_site/og-images/</code>:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">import</span> <span class="token punctuation">{</span> render <span class="token keyword">as</span> renderSvgToPng <span class="token punctuation">}</span> <span class="token keyword">from</span> <span class="token string">"https://deno.land/x/resvg_wasm@0.2.0/mod.ts"</span><span class="token punctuation">;</span>

site<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"afterBuild"</span><span class="token punctuation">,</span> <span class="token keyword">async</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
	<span class="token keyword">const</span> ogDir <span class="token operator">=</span> site<span class="token punctuation">.</span><span class="token function">dest</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">+</span> <span class="token string">"/og-images"</span><span class="token punctuation">;</span>

	<span class="token keyword">try</span> <span class="token punctuation">{</span>
		<span class="token keyword">const</span> entries <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token operator">...</span>Deno<span class="token punctuation">.</span><span class="token function">readDirSync</span><span class="token punctuation">(</span>ogDir<span class="token punctuation">)</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
		<span class="token keyword">const</span> svgFiles <span class="token operator">=</span> entries<span class="token punctuation">.</span><span class="token function">filter</span><span class="token punctuation">(</span><span class="token punctuation">(</span>e<span class="token punctuation">)</span> <span class="token operator">=></span> e<span class="token punctuation">.</span>name<span class="token punctuation">.</span><span class="token function">endsWith</span><span class="token punctuation">(</span><span class="token string">".svg"</span><span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

		<span class="token keyword">if</span> <span class="token punctuation">(</span>svgFiles<span class="token punctuation">.</span>length <span class="token operator">===</span> <span class="token number">0</span><span class="token punctuation">)</span> <span class="token keyword">return</span><span class="token punctuation">;</span>

		<span class="token keyword">let</span> converted <span class="token operator">=</span> <span class="token number">0</span><span class="token punctuation">;</span>
		<span class="token keyword">for</span> <span class="token punctuation">(</span><span class="token keyword">const</span> entry <span class="token keyword">of</span> svgFiles<span class="token punctuation">)</span> <span class="token punctuation">{</span>
			<span class="token keyword">const</span> svgPath <span class="token operator">=</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>ogDir<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">/</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>entry<span class="token punctuation">.</span>name<span class="token interpolation-punctuation punctuation">}</span></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>
			<span class="token keyword">const</span> pngPath <span class="token operator">=</span> svgPath<span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token string">".svg"</span><span class="token punctuation">,</span> <span class="token string">".png"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
			<span class="token keyword">const</span> svgContent <span class="token operator">=</span> <span class="token keyword">await</span> Deno<span class="token punctuation">.</span><span class="token function">readTextFile</span><span class="token punctuation">(</span>svgPath<span class="token punctuation">)</span><span class="token punctuation">;</span>

			<span class="token keyword">const</span> pngBuffer <span class="token operator">=</span> <span class="token keyword">await</span> <span class="token function">renderSvgToPng</span><span class="token punctuation">(</span>svgContent<span class="token punctuation">)</span><span class="token punctuation">;</span>
			<span class="token keyword">await</span> Deno<span class="token punctuation">.</span><span class="token function">writeFile</span><span class="token punctuation">(</span>pngPath<span class="token punctuation">,</span> pngBuffer<span class="token punctuation">)</span><span class="token punctuation">;</span>
			<span class="token keyword">await</span> Deno<span class="token punctuation">.</span><span class="token function">remove</span><span class="token punctuation">(</span>svgPath<span class="token punctuation">)</span><span class="token punctuation">;</span>
			converted<span class="token operator">++</span><span class="token punctuation">;</span>
		<span class="token punctuation">}</span>

		<span class="token builtin">console</span><span class="token punctuation">.</span><span class="token function">log</span><span class="token punctuation">(</span><span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">[og-images] </span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>converted<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string"> SVG convertidos a PNG</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">)</span><span class="token punctuation">;</span>
	<span class="token punctuation">}</span> <span class="token keyword">catch</span> <span class="token punctuation">(</span>err<span class="token punctuation">)</span> <span class="token punctuation">{</span>
		<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span><span class="token punctuation">(</span>err <span class="token keyword">instanceof</span> <span class="token class-name">Deno</span><span class="token punctuation">.</span>errors<span class="token punctuation">.</span>NotFound<span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
			<span class="token builtin">console</span><span class="token punctuation">.</span><span class="token function">error</span><span class="token punctuation">(</span><span class="token string">"[og-images] Error:"</span><span class="token punctuation">,</span> err<span class="token punctuation">)</span><span class="token punctuation">;</span>
		<span class="token punctuation">}</span>
	<span class="token punctuation">}</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>La API es mínima — una sola función <code>render()</code> que recibe SVG como string y devuelve PNG como <code>Uint8Array</code>. Por cada SVG, genera el PNG y elimina el original.</p>
<h2 id="las-meta-tags" tabindex="-1">Las meta tags <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#las-meta-tags">#</a></h2>
<p>Solo queda apuntar las meta tags a las imágenes generadas. En el layout base:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{{ if tags &amp;&amp; (tags.includes("bitacora") || tags.includes("reflexiones")) }}
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span>
	<span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:image<span class="token punctuation">"</span></span>
	<span class="token attr-name">content</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>{{ metadata.url }}/og-images/{{ page.src.slug }}.png<span class="token punctuation">"</span></span> <span class="token punctuation">/></span></span>
{{ else }}
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:image<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>{{ metadata.url }}/og-images/default.png<span class="token punctuation">"</span></span> <span class="token punctuation">/></span></span>
{{ /if }}
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:image:width<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>1200<span class="token punctuation">"</span></span> <span class="token punctuation">/></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">property</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>og:image:height<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>630<span class="token punctuation">"</span></span> <span class="token punctuation">/></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>meta</span> <span class="token attr-name">name</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>twitter:card<span class="token punctuation">"</span></span> <span class="token attr-name">content</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>summary_large_image<span class="token punctuation">"</span></span> <span class="token punctuation">/></span></span>
</code></pre>
<p>Los posts obtienen su imagen específica. El resto de páginas usan una imagen genérica con el nombre y la descripción del sitio. El valor <code>summary_large_image</code> en <code>twitter:card</code> hace que la imagen se muestre en grande al compartir en X.</p>
<h2 id="sobre-las-fuentes" tabindex="-1">Sobre las fuentes <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#sobre-las-fuentes">#</a></h2>
<p>Un detalle importante: el renderizador SVG usa las fuentes del sistema donde se ejecuta el build. Si usas una tipografía personalizada que no está instalada en la máquina, el resultado será diferente. En mi caso uso Arial como fuente para las imágenes OG, que está disponible en prácticamente cualquier sistema.</p>
<h2 id="el-archivo-completo" tabindex="-1">El archivo completo <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#el-archivo-completo">#</a></h2>
<p>Para referencia, este es el <code>og-images.page.ts</code> completo tal como funciona en producción:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript"><span class="token keyword">export</span> <span class="token keyword">default</span> <span class="token keyword">function</span><span class="token operator">*</span> <span class="token punctuation">(</span><span class="token punctuation">{</span> search <span class="token punctuation">}</span><span class="token operator">:</span> Lume<span class="token punctuation">.</span>Data<span class="token punctuation">)</span> <span class="token punctuation">{</span>
	<span class="token keyword">const</span> posts <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token operator">...</span>search<span class="token punctuation">.</span><span class="token function">pages</span><span class="token punctuation">(</span><span class="token string">"bitacora"</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token operator">...</span>search<span class="token punctuation">.</span><span class="token function">pages</span><span class="token punctuation">(</span><span class="token string">"reflexiones"</span><span class="token punctuation">)</span><span class="token punctuation">]</span><span class="token punctuation">;</span>

	<span class="token keyword">for</span> <span class="token punctuation">(</span><span class="token keyword">const</span> post <span class="token keyword">of</span> posts<span class="token punctuation">)</span> <span class="token punctuation">{</span>
		<span class="token keyword">const</span> title <span class="token operator">=</span> post<span class="token punctuation">.</span>title <span class="token keyword">as</span> <span class="token builtin">string</span><span class="token punctuation">;</span>
		<span class="token keyword">const</span> tags <span class="token operator">=</span> <span class="token punctuation">(</span>post<span class="token punctuation">.</span>tags <span class="token operator">||</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">)</span> <span class="token keyword">as</span> <span class="token builtin">string</span><span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">;</span>

		<span class="token comment">// Partir el título en líneas de máximo 36 caracteres</span>
		<span class="token keyword">const</span> parts <span class="token operator">=</span> title<span class="token punctuation">.</span><span class="token function">split</span><span class="token punctuation">(</span><span class="token string">" "</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
		<span class="token keyword">const</span> titleLines<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">[</span><span class="token punctuation">]</span> <span class="token operator">=</span> parts<span class="token punctuation">.</span><span class="token function">reduce</span><span class="token punctuation">(</span>
			<span class="token punctuation">(</span>prev<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">,</span> current<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
				<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>prev<span class="token punctuation">.</span>length<span class="token punctuation">)</span> <span class="token keyword">return</span> <span class="token punctuation">[</span>current<span class="token punctuation">]</span><span class="token punctuation">;</span>
				<span class="token keyword">const</span> lastLine <span class="token operator">=</span> prev<span class="token punctuation">[</span>prev<span class="token punctuation">.</span>length <span class="token operator">-</span> <span class="token number">1</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
				<span class="token keyword">if</span> <span class="token punctuation">(</span>lastLine<span class="token punctuation">.</span>length <span class="token operator">+</span> <span class="token number">1</span> <span class="token operator">+</span> current<span class="token punctuation">.</span>length <span class="token operator">></span> <span class="token number">36</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
					<span class="token keyword">return</span> <span class="token punctuation">[</span><span class="token operator">...</span>prev<span class="token punctuation">,</span> current<span class="token punctuation">]</span><span class="token punctuation">;</span>
				<span class="token punctuation">}</span>
				prev<span class="token punctuation">[</span>prev<span class="token punctuation">.</span>length <span class="token operator">-</span> <span class="token number">1</span><span class="token punctuation">]</span> <span class="token operator">=</span> lastLine <span class="token operator">+</span> <span class="token string">" "</span> <span class="token operator">+</span> current<span class="token punctuation">;</span>
				<span class="token keyword">return</span> prev<span class="token punctuation">;</span>
			<span class="token punctuation">}</span><span class="token punctuation">,</span>
			<span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">,</span>
		<span class="token punctuation">)</span><span class="token punctuation">;</span>

		<span class="token keyword">const</span> lineCount <span class="token operator">=</span> titleLines<span class="token punctuation">.</span>length<span class="token punctuation">;</span>
		<span class="token keyword">let</span> titleY<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">;</span>
		<span class="token keyword">if</span> <span class="token punctuation">(</span>lineCount <span class="token operator">===</span> <span class="token number">1</span><span class="token punctuation">)</span> titleY <span class="token operator">=</span> <span class="token number">310</span><span class="token punctuation">;</span>
		<span class="token keyword">else</span> <span class="token keyword">if</span> <span class="token punctuation">(</span>lineCount <span class="token operator">===</span> <span class="token number">2</span><span class="token punctuation">)</span> titleY <span class="token operator">=</span> <span class="token number">280</span><span class="token punctuation">;</span>
		<span class="token keyword">else</span> <span class="token keyword">if</span> <span class="token punctuation">(</span>lineCount <span class="token operator">===</span> <span class="token number">3</span><span class="token punctuation">)</span> titleY <span class="token operator">=</span> <span class="token number">240</span><span class="token punctuation">;</span>
		<span class="token keyword">else</span> titleY <span class="token operator">=</span> <span class="token number">200</span><span class="token punctuation">;</span>

		<span class="token keyword">const</span> seccion <span class="token operator">=</span> tags<span class="token punctuation">.</span><span class="token function">includes</span><span class="token punctuation">(</span><span class="token string">"bitacora"</span><span class="token punctuation">)</span> <span class="token operator">?</span> <span class="token string">"BITACORA"</span> <span class="token operator">:</span> <span class="token string">"REFLEXIONES"</span><span class="token punctuation">;</span>

		<span class="token comment">// Extraer el slug de la URL del post</span>
		<span class="token keyword">const</span> urlParts <span class="token operator">=</span> <span class="token punctuation">(</span>post<span class="token punctuation">.</span>url <span class="token keyword">as</span> <span class="token builtin">string</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">split</span><span class="token punctuation">(</span><span class="token string">"/"</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">filter</span><span class="token punctuation">(</span>Boolean<span class="token punctuation">)</span><span class="token punctuation">;</span>
		<span class="token keyword">const</span> slug <span class="token operator">=</span> urlParts<span class="token punctuation">[</span>urlParts<span class="token punctuation">.</span>length <span class="token operator">-</span> <span class="token number">1</span><span class="token punctuation">]</span><span class="token punctuation">;</span>

		<span class="token keyword">const</span> tspans <span class="token operator">=</span> titleLines
			<span class="token punctuation">.</span><span class="token function">map</span><span class="token punctuation">(</span>
				<span class="token punctuation">(</span>line<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">,</span> i<span class="token operator">:</span> <span class="token builtin">number</span><span class="token punctuation">)</span> <span class="token operator">=></span>
					<span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">    &lt;tspan x="80" y="</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>titleY <span class="token operator">+</span> i <span class="token operator">*</span> <span class="token number">62</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">"></span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span><span class="token function">escapeXml</span><span class="token punctuation">(</span>line<span class="token punctuation">)</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">&lt;/tspan></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
			<span class="token punctuation">)</span>
			<span class="token punctuation">.</span><span class="token function">join</span><span class="token punctuation">(</span><span class="token string">"\n"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

		<span class="token keyword">const</span> svg <span class="token operator">=</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">&lt;?xml version="1.0" encoding="UTF-8" standalone="no"?>
&lt;svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">

  &lt;!-- Fondo -->
  &lt;rect width="1200" height="630" fill="#111118"/>

  &lt;!-- Barra naranja lateral -->
  &lt;rect x="0" y="0" width="8" height="630" fill="#f86624"/>

  &lt;!-- Seccion -->
  &lt;text x="80" y="</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>titleY <span class="token operator">-</span> <span class="token number">60</span><span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">" font-family="Arial, Helvetica, sans-serif" font-size="22" fill="#f86624" letter-spacing="3"></span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>seccion<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">&lt;/text>

  &lt;!-- Titulo -->
  &lt;text font-family="Arial, Helvetica, sans-serif" font-size="48" font-weight="bold" fill="#dcdcd4">
</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>tspans<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">
  &lt;/text>

  &lt;!-- Linea separadora -->
  &lt;line x1="80" y1="530" x2="1120" y2="530" stroke="#2a2a3a" stroke-width="1"/>

  &lt;!-- Branding -->
  &lt;text x="80" y="575" font-family="Arial, Helvetica, sans-serif" font-size="24" fill="#8e8e86">paigar.es&lt;/text>

  &lt;!-- Punto naranja -->
  &lt;circle cx="1120" cy="568" r="6" fill="#f86624"/>

&lt;/svg></span><span class="token template-punctuation string">`</span></span><span class="token punctuation">;</span>

		<span class="token keyword">yield</span> <span class="token punctuation">{</span>
			url<span class="token operator">:</span> <span class="token template-string"><span class="token template-punctuation string">`</span><span class="token string">/og-images/</span><span class="token interpolation"><span class="token interpolation-punctuation punctuation">${</span>slug<span class="token interpolation-punctuation punctuation">}</span></span><span class="token string">.svg</span><span class="token template-punctuation string">`</span></span><span class="token punctuation">,</span>
			content<span class="token operator">:</span> svg<span class="token punctuation">,</span>
		<span class="token punctuation">}</span><span class="token punctuation">;</span>
	<span class="token punctuation">}</span>
<span class="token punctuation">}</span>

<span class="token keyword">function</span> <span class="token function">escapeXml</span><span class="token punctuation">(</span>str<span class="token operator">:</span> <span class="token builtin">string</span><span class="token punctuation">)</span><span class="token operator">:</span> <span class="token builtin">string</span> <span class="token punctuation">{</span>
	<span class="token keyword">return</span> str
		<span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">&amp;</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">"&amp;amp;"</span><span class="token punctuation">)</span>
		<span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">&lt;</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">"&amp;lt;"</span><span class="token punctuation">)</span>
		<span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">></span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">"&amp;gt;"</span><span class="token punctuation">)</span>
		<span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">"</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">"&amp;quot;"</span><span class="token punctuation">)</span>
		<span class="token punctuation">.</span><span class="token function">replace</span><span class="token punctuation">(</span><span class="token regex"><span class="token regex-delimiter">/</span><span class="token regex-source language-regex">'</span><span class="token regex-delimiter">/</span><span class="token regex-flags">g</span></span><span class="token punctuation">,</span> <span class="token string">"&amp;apos;"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<h2 id="la-alternativa-oficial%3A-el-plugin-og_images" tabindex="-1">La alternativa oficial: el plugin og_images <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#la-alternativa-oficial%3A-el-plugin-og_images">#</a></h2>
<p>Lume tiene un <a href="https://lume.land/plugins/og_images/">plugin oficial de imágenes Open Graph</a> que resuelve el mismo problema. Usa Satori (de Vercel) para convertir componentes JSX en SVG, y Sharp para rasterizar a PNG. Los layouts se definen como funciones JSX con estilos inline, y se asignan desde el frontmatter con <code>openGraphLayout</code>.</p>
<p>Es una opción válida si prefieres un enfoque más integrado con el ecosistema de Lume. En mi caso elegí la implementación manual por varias razones:</p>
<ul>
<li><strong>Control total del SVG</strong> — puedo usar cualquier elemento SVG (<code>&lt;line&gt;</code>, <code>&lt;circle&gt;</code>, <code>&lt;tspan&gt;</code>) sin las limitaciones de Satori, que solo soporta un subconjunto de CSS basado en flexbox.</li>
<li><strong>Sin Sharp</strong> — Sharp es una librería nativa de Node.js que no funciona directamente en Deno. Con resvg-wasm no hay binarios nativos ni dependencias de plataforma.</li>
<li><strong>Menos dependencias</strong> — el generador es un único archivo TypeScript de 89 líneas, sin configuración JSX ni paquetes adicionales.</li>
</ul>
<p>El plugin oficial es más cómodo si no necesitas un diseño muy específico o si ya usas JSX en tu proyecto. Pero para un sitio que busca minimizar dependencias, la solución manual encaja mejor.</p>
<h2 id="el-resultado" tabindex="-1">El resultado <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-og-automaticas-lume/#el-resultado">#</a></h2>
<p>Con esta solución, cada vez que hago build se generan automáticamente las imágenes de vista previa para todos los posts. Sin intervención manual, sin servicios externos, sin imágenes que versionar en el repositorio. Solo código que genera código que genera imágenes.</p>
<p>La técnica original es para Eleventy con Sharp. Mi adaptación a Lume usa generadores <code>.page.ts</code> para la creación de SVGs y resvg-wasm para la conversión a PNG, eliminando la dependencia de Node.js.</p>
]]>
      </content:encoded>
      <pubDate>Tue, 17 Mar 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Migrando paigar.es de Eleventy a Lume</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/</guid>
      <description>Por qué decidí migrar este sitio de Eleventy a Lume, qué cambió en la arquitectura y qué se ganó en el proceso.</description>
      <content:encoded>
        <![CDATA[<p>Hace unos días escribí sobre <a href="https://media.paigar.eu/reflexiones/alternativas-a-eleventy/">lo que supone el cambio de Eleventy a Build Awesome</a> y sobre por qué Lume me parecía la alternativa más coherente. Al final del artículo decía que tenía la tentación de probarlo con mi propio sitio. Pues lo he hecho. Este es el resultado.</p>
<p>Esta versión 3.x de paigar.es <a href="https://media.paigar.eu/bitacora/construyendo-paigar-con-eleventy/">nació con Eleventy</a> y funcionaba bien. No había una razón urgente para cambiar. Pero las razones de fondo — independencia del proyecto, afinidad con Deno, curiosidad técnica — pesaban lo suficiente como para justificar el experimento. Y siendo un sitio personal, me podía permitir romper cosas.</p>
<h2 id="qu%C3%A9-se-gana-con-lume" tabindex="-1">Qué se gana con Lume <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#qu%C3%A9-se-gana-con-lume">#</a></h2>
<p><strong>Deno en lugar de Node.</strong> Adiós a <code>package.json</code>, <code>package-lock.json</code> y la carpeta <code>node_modules</code> de 200 MB. Las dependencias se resuelven por URL o desde JSR. El proyecto entero se gestiona con <code>deno.json</code> y un solo <code>_config.ts</code>.</p>
<p><strong>TypeScript nativo.</strong> La configuración, los generadores de páginas y el script de publicación son <code>.ts</code>. Sin transpilación, sin configuración extra, con tipado real.</p>
<p><strong>Vento en lugar de Nunjucks.</strong> El motor de plantillas nativo de Lume es más limpio: usa <code>{{ }}</code> para todo — variables, condicionales, bucles — con expresiones JavaScript reales en lugar de un lenguaje propio. Y los includes pasan datos de forma explícita, lo que evita problemas de scope que tenía con Nunjucks.</p>
<p><strong>Plugins integrados.</strong> Feed RSS, sitemap, minificación HTML, PostCSS, inlining de assets — todo con una línea en la configuración. En Eleventy necesitaba paquetes npm separados para cada cosa.</p>
<h2 id="lo-que-cambi%C3%B3" tabindex="-1">Lo que cambió <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#lo-que-cambi%C3%B3">#</a></h2>
<h3 id="de-nunjucks-a-vento" tabindex="-1">De Nunjucks a Vento <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#de-nunjucks-a-vento">#</a></h3>
<p>El cambio más visible. Donde antes escribía:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{% for post in collections.bitacora %} {% include "partials/postcard.njk" %} {%
endfor %}
</code></pre>
<p>Ahora escribo:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{{ for post of search.pages("bitacora", "date=desc") }} {{ include
"partials/postcard.vto" { post } }} {{ /for }}
</code></pre>
<p><code>search.pages()</code> sustituye a las colecciones de Eleventy. Es más potente — puedes filtrar por tags, ordenar, paginar — y la sintaxis es JavaScript estándar.</p>
<h3 id="de-colecciones-a-search" tabindex="-1">De colecciones a search <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#de-colecciones-a-search">#</a></h3>
<p>En Eleventy, las colecciones se alimentaban de los tags del frontmatter y de la cascada de datos (<code>posts.11tydata.js</code>). En Lume, uso un preprocessor que inyecta el tag de sección solo a los archivos Markdown:</p>
<pre class="language-typescript" tabindex="0"><code class="language-typescript">site<span class="token punctuation">.</span><span class="token function">preprocess</span><span class="token punctuation">(</span><span class="token punctuation">[</span><span class="token string">".md"</span><span class="token punctuation">]</span><span class="token punctuation">,</span> <span class="token punctuation">(</span>pages<span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
	<span class="token keyword">for</span> <span class="token punctuation">(</span><span class="token keyword">const</span> page <span class="token keyword">of</span> pages<span class="token punctuation">)</span> <span class="token punctuation">{</span>
		<span class="token keyword">if</span> <span class="token punctuation">(</span>page<span class="token punctuation">.</span>src<span class="token punctuation">.</span>path<span class="token punctuation">.</span><span class="token function">startsWith</span><span class="token punctuation">(</span><span class="token string">"/bitacora/"</span><span class="token punctuation">)</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
			page<span class="token punctuation">.</span>data<span class="token punctuation">.</span>tags <span class="token operator">=</span> <span class="token punctuation">[</span><span class="token operator">...</span><span class="token punctuation">(</span>page<span class="token punctuation">.</span>data<span class="token punctuation">.</span>tags <span class="token operator">||</span> <span class="token punctuation">[</span><span class="token punctuation">]</span><span class="token punctuation">)</span><span class="token punctuation">,</span> <span class="token string">"bitacora"</span><span class="token punctuation">]</span><span class="token punctuation">;</span>
		<span class="token punctuation">}</span>
	<span class="token punctuation">}</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>Más explícito, sin efectos colaterales, y sin depender de una cascada de datos que puede sorprenderte.</p>
<h3 id="de-bundling-a-postcss-%2B-inline" tabindex="-1">De bundling a postcss + inline <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#de-bundling-a-postcss-%2B-inline">#</a></h3>
<p>Eleventy tiene un plugin de bundling que concatena CSS y JS desde las plantillas e inyecta todo inline. Lume no tiene equivalente directo, pero la combinación de los plugins <code>postcss</code> e <code>inline</code> consigue el mismo resultado:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>link</span> <span class="token attr-name">rel</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>stylesheet<span class="token punctuation">"</span></span> <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>/css_main.css<span class="token punctuation">"</span></span> <span class="token attr-name">inline</span> <span class="token punctuation">/></span></span>
</code></pre>
<p>PostCSS resuelve los <code>@import</code> y concatena. El plugin <code>inline</code> sustituye el <code>&lt;link&gt;</code> por un <code>&lt;style&gt;</code> con el contenido. Cada página puede declarar CSS adicional en su frontmatter con <code>pageCss</code>, así que solo carga lo que necesita — igual que antes.</p>
<h3 id="de-sharp-a-resvg-wasm" tabindex="-1">De Sharp a resvg-wasm <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#de-sharp-a-resvg-wasm">#</a></h3>
<p>Las imágenes Open Graph se generaban con <code>@11ty/eleventy-img</code> (que usa Sharp internamente). Sharp no funciona en Deno, así que la conversión SVG→PNG ahora usa <code>resvg-wasm</code>, una versión WebAssembly del renderizador SVG de Mozilla. Funciona en cualquier plataforma sin binarios nativos.</p>
<h3 id="de-node-a-deno-para-publicar" tabindex="-1">De Node a Deno para publicar <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#de-node-a-deno-para-publicar">#</a></h3>
<p>El script de publicación (<code>publicar.mjs</code>) era Node.js con <code>dotenv</code>. Ahora es <code>publicar.ts</code> con APIs nativas de Deno: <code>Deno.env</code>, <code>Deno.readFile</code>, <code>Deno.Command</code>. Cero dependencias npm.</p>
<h2 id="lo-que-no-cambi%C3%B3" tabindex="-1">Lo que no cambió <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#lo-que-no-cambi%C3%B3">#</a></h2>
<p>Todo lo que importa se mantuvo intacto:</p>
<ul>
<li><strong>El diseño</strong> — el mismo CSS, los mismos tokens, el mismo sistema de layout &quot;límites&quot;</li>
<li><strong>El contenido</strong> — los archivos Markdown no se tocaron (salvo el frontmatter de <code>permalink</code> a <code>url</code>)</li>
<li><strong>El tema claro/oscuro</strong> — misma implementación con <code>data-theme</code> y localStorage</li>
<li><strong>El rendimiento</strong> — CSS y JS inline, HTML minificado, cero frameworks en el cliente</li>
<li><strong>El despliegue</strong> — Git → Build → Bunny CDN → Purga de caché</li>
</ul>
<p>El sitio se ve exactamente igual. Si no lees el footer donde ahora dice &quot;Hecho con Lume&quot;, no sabrías que cambió nada.</p>
<h2 id="el-resultado" tabindex="-1">El resultado <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/migrando-paigar-de-eleventy-a-lume/#el-resultado">#</a></h2>
<p>El build genera 159 archivos en unos 2 segundos. Las 42 imágenes Open Graph se convierten de SVG a PNG solo cuando su contenido cambia — en builds sucesivos se sirven desde una caché local. El proyecto no tiene ninguna dependencia de Node.js. Todo corre sobre Deno.</p>
<p>Después de la migración aproveché para limpiar: eliminé filtros que Vento hacía innecesarios (de 12 a 5), moví la versión anterior del sitio a un subdominio propio para no arrastrar 17 MB en cada deploy, y reorganicé los assets para que solo los archivos realmente externos (cookieconsent) se copien al output — el CSS y JS funcional se incrusta inline directamente desde las plantillas.</p>
<p>Para un sitio que predica la simplicidad y el código limpio, la arquitectura ahora es coherente con el mensaje. Y si te interesan los detalles técnicos de la migración, los he documentado en <a href="https://media.paigar.eu/bitacora/migrar-eleventy-a-lume/">un artículo aparte</a> con los tres problemas que más tiempo me costaron resolver.</p>
]]>
      </content:encoded>
      <pubDate>Thu, 12 Mar 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Un sistema de layout con CSS Grid y columnas nombradas</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/css-grid-sistema-limites/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/css-grid-sistema-limites/</guid>
      <description>
        Cómo construir un sistema de anchos de contenido flexible usando CSS Grid con líneas nombradas. El patrón que uso en todos mis proyectos.
      </description>
      <content:encoded>
        <![CDATA[<p>Uno de los patrones que más uso en mis webs es un sistema de grid con columnas nombradas. Lo llamo &quot;límites&quot; y permite que cualquier elemento hijo defina su propio ancho sin necesidad de clases wrapper adicionales. Lo uso en este sitio, en Bilbonauta, y en casi todos los proyectos de Idenautas.</p>
<h2 id="el-problema" tabindex="-1">El problema <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/css-grid-sistema-limites/#el-problema">#</a></h2>
<p>En la mayoría de webs, el contenido tiene diferentes anchos. El texto principal suele estar a 65-70 caracteres para una lectura cómoda, pero a veces quieres que una imagen ocupe todo el viewport, que una cita sea más estrecha, o que una sección de tarjetas sea más ancha que el texto.</p>
<p>La solución habitual es anidar divs con <code>max-width</code> y <code>margin: 0 auto</code>:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token comment">&lt;!-- El markup típico --></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>wrapper<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>narrow-wrapper<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
    <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>p</span><span class="token punctuation">></span></span>Texto estrecho...<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>p</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>full-width<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>hero.jpg<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span><span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>wrapper<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>p</span><span class="token punctuation">></span></span>Texto normal...<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>p</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">></span></span>
</code></pre>
<p>Esto funciona, pero ensucia el HTML con contenedores que no aportan semántica. Y empeora cuando tienes cinco o seis anchos diferentes.</p>
<h2 id="la-soluci%C3%B3n%3A-columnas-nombradas" tabindex="-1">La solución: columnas nombradas <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/css-grid-sistema-limites/#la-soluci%C3%B3n%3A-columnas-nombradas">#</a></h2>
<p>La idea es definir un único grid con columnas nombradas que representan los diferentes anchos:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.limites</span> <span class="token punctuation">{</span>
  <span class="token property">display</span><span class="token punctuation">:</span> grid<span class="token punctuation">;</span>
  <span class="token property">grid-template-columns</span><span class="token punctuation">:</span>
    [total-start] <span class="token function">minmax</span><span class="token punctuation">(</span>1rem<span class="token punctuation">,</span> 1fr<span class="token punctuation">)</span>
    [ancho-start] <span class="token function">minmax</span><span class="token punctuation">(</span>0<span class="token punctuation">,</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--anchura-ancho<span class="token punctuation">)</span> - <span class="token function">var</span><span class="token punctuation">(</span>--anchura-normal<span class="token punctuation">)</span><span class="token punctuation">)</span> / 2<span class="token punctuation">)</span><span class="token punctuation">)</span>
    [normal-start] <span class="token function">minmax</span><span class="token punctuation">(</span>0<span class="token punctuation">,</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--anchura-normal<span class="token punctuation">)</span> - <span class="token function">var</span><span class="token punctuation">(</span>--anchura-estrecho<span class="token punctuation">)</span><span class="token punctuation">)</span> / 2<span class="token punctuation">)</span><span class="token punctuation">)</span>
    [estrecho-start] <span class="token function">min</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--anchura-estrecho<span class="token punctuation">)</span><span class="token punctuation">,</span> 100% - 2rem<span class="token punctuation">)</span>
    [estrecho-end] <span class="token function">minmax</span><span class="token punctuation">(</span>0<span class="token punctuation">,</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--anchura-normal<span class="token punctuation">)</span> - <span class="token function">var</span><span class="token punctuation">(</span>--anchura-estrecho<span class="token punctuation">)</span><span class="token punctuation">)</span> / 2<span class="token punctuation">)</span><span class="token punctuation">)</span>
    [normal-end] <span class="token function">minmax</span><span class="token punctuation">(</span>0<span class="token punctuation">,</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--anchura-ancho<span class="token punctuation">)</span> - <span class="token function">var</span><span class="token punctuation">(</span>--anchura-normal<span class="token punctuation">)</span><span class="token punctuation">)</span> / 2<span class="token punctuation">)</span><span class="token punctuation">)</span>
    [ancho-end] <span class="token function">minmax</span><span class="token punctuation">(</span>1rem<span class="token punctuation">,</span> 1fr<span class="token punctuation">)</span>
    [total-end]<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Parece intimidante al principio, pero la lógica es sencilla: estás definiendo columnas simétricas que actúan como márgenes entre cada nivel de ancho. La columna central (<code>estrecho</code>) tiene el contenido más estrecho, y las columnas a los lados se expanden progresivamente hasta <code>total</code>, que ocupa todo el viewport.</p>
<h2 id="c%C3%B3mo-se-usa" tabindex="-1">Cómo se usa <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/css-grid-sistema-limites/#c%C3%B3mo-se-usa">#</a></h2>
<p>Cada hijo del grid elige su ancho con una simple clase:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.limites > *</span> <span class="token punctuation">{</span> <span class="token property">grid-column</span><span class="token punctuation">:</span> normal<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token selector">.limites > .ancho</span> <span class="token punctuation">{</span> <span class="token property">grid-column</span><span class="token punctuation">:</span> ancho<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token selector">.limites > .estrecho</span> <span class="token punctuation">{</span> <span class="token property">grid-column</span><span class="token punctuation">:</span> estrecho<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token selector">.limites > .total</span> <span class="token punctuation">{</span> <span class="token property">grid-column</span><span class="token punctuation">:</span> total<span class="token punctuation">;</span> <span class="token punctuation">}</span>
</code></pre>
<p>Y en el HTML:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>main</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>limites<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>p</span><span class="token punctuation">></span></span>Este párrafo ocupa el ancho normal (65ch).<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>p</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>section</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>ancho<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>Esto es más ancho (55rem).<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>section</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>blockquote</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>estrecho<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>Esto es más estrecho.<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>blockquote</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>total<span class="token punctuation">"</span></span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>panoramica.jpg<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span><span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>main</span><span class="token punctuation">></span></span>
</code></pre>
<p>Sin wrappers, sin media queries para los anchos, sin <code>max-width</code> repetidos. El grid se encarga de todo.</p>
<h2 id="por-qu%C3%A9-funciona" tabindex="-1">Por qué funciona <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/css-grid-sistema-limites/#por-qu%C3%A9-funciona">#</a></h2>
<p>El truco está en las <strong>líneas nombradas</strong> de CSS Grid. Cuando defines <code>[nombre-start]</code> y <code>[nombre-end]</code>, puedes usar <code>grid-column: nombre</code> como shorthand. El navegador entiende que <code>nombre</code> se refiere al rango entre <code>nombre-start</code> y <code>nombre-end</code>.</p>
<p>Los <code>minmax()</code> hacen que sea responsive por naturaleza. Cuando el viewport se estrecha, las columnas exteriores colapsan a su mínimo (<code>1rem</code> de padding lateral) y las columnas intermedias llegan a <code>0</code>. El resultado: en móvil, <code>normal</code>, <code>ancho</code> y <code>estrecho</code> convergen al mismo ancho, que es el viewport menos <code>2rem</code> de margen.</p>
<h2 id="las-custom-properties" tabindex="-1">Las custom properties <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/css-grid-sistema-limites/#las-custom-properties">#</a></h2>
<p>Los anchos están definidos como variables CSS en los tokens del sitio:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:root</span> <span class="token punctuation">{</span>
  <span class="token property">--anchura-ancho</span><span class="token punctuation">:</span> 55rem<span class="token punctuation">;</span>
  <span class="token property">--anchura-normal</span><span class="token punctuation">:</span> 65ch<span class="token punctuation">;</span>
  <span class="token property">--anchura-estrecho</span><span class="token punctuation">:</span> 50ch<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Esto permite ajustar los anchos en un solo lugar. Y como son custom properties, podrías cambiarlos por sección si alguna página necesita un layout diferente.</p>
<p>Un detalle importante: <code>--anchura-normal</code> usa <code>ch</code> (el ancho del carácter &quot;0&quot;) en lugar de <code>rem</code> o <code>px</code>. Es deliberado — quiero que la anchura del texto dependa del tamaño de la fuente, no de un número arbitrario de píxeles. Si cambio la fuente o el tamaño, la anchura óptima de lectura se ajusta sola.</p>
<h2 id="lo-que-no-encontrar%C3%A1s-en-un-framework" tabindex="-1">Lo que no encontrarás en un framework <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/css-grid-sistema-limites/#lo-que-no-encontrar%C3%A1s-en-un-framework">#</a></h2>
<p>Este patrón no viene en Bootstrap, no está en Tailwind, no lo genera ningún plugin. Es el tipo de solución que aparece después de años trabajando con CSS, entendiendo las especificaciones y pensando en el problema real en vez de buscar la clase preconstruida.</p>
<p>No digo que los frameworks no tengan su lugar. Pero para un sistema de layout, cuatro líneas de CSS Grid y tres custom properties hacen más que cientos de clases de utilidad.</p>
]]>
      </content:encoded>
      <pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Anatomía de .visually-hidden — cada propiedad tiene su razón</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/</guid>
      <description>
        Desglose línea por línea del patrón CSS visually-hidden: por qué cada propiedad existe, qué problema resuelve, y por qué no puedes simplemente usar display:none.
      </description>
      <content:encoded>
        <![CDATA[<p>Si has trabajado con accesibilidad web, conoces <code>.visually-hidden</code> (o <code>.sr-only</code>, su nombre en Bootstrap). Es una clase CSS que oculta contenido visualmente pero lo mantiene accesible para lectores de pantalla. La usas para skip links, etiquetas adicionales en formularios, texto descriptivo que complementa iconos sin etiqueta.</p>
<p>Lo que probablemente no conoces es <em>por qué</em> cada propiedad está ahí. Y no, no es casual. Cada línea resuelve un problema específico, y si quitas una, algo se rompe en algún lugar.</p>
<h2 id="el-patr%C3%B3n-completo" tabindex="-1">El patrón completo <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#el-patr%C3%B3n-completo">#</a></h2>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.visually-hidden:not(:focus):not(:active)</span> <span class="token punctuation">{</span>
  <span class="token property">clip-path</span><span class="token punctuation">:</span> <span class="token function">inset</span><span class="token punctuation">(</span>50%<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">height</span><span class="token punctuation">:</span> 1px<span class="token punctuation">;</span>
  <span class="token property">overflow</span><span class="token punctuation">:</span> hidden<span class="token punctuation">;</span>
  <span class="token property">position</span><span class="token punctuation">:</span> absolute<span class="token punctuation">;</span>
  <span class="token property">white-space</span><span class="token punctuation">:</span> nowrap<span class="token punctuation">;</span>
  <span class="token property">width</span><span class="token punctuation">:</span> 1px<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Seis propiedades y dos pseudo-clases negadas. Vamos línea por línea.</p>
<h2 id="por-qu%C3%A9-no-display%3A-none" tabindex="-1">Por qué no <code>display: none</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#por-qu%C3%A9-no-display%3A-none">#</a></h2>
<p>Empecemos por lo que <strong>no</strong> se usa. <code>display: none</code> elimina el elemento del árbol de accesibilidad. Los lectores de pantalla no lo leen. <code>visibility: hidden</code> hace lo mismo. Ambos son invisibles para todos, no solo visualmente.</p>
<p>Lo que necesitamos es algo más sutil: un elemento que <strong>existe</strong> en el árbol de accesibilidad, que el lector de pantalla puede leer, pero que no ocupa espacio visual en la pantalla. Es como susurrar algo que solo ciertos oyentes pueden escuchar.</p>
<h2 id="position%3A-absolute" tabindex="-1"><code>position: absolute</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#position%3A-absolute">#</a></h2>
<p>Saca el elemento del flujo del documento. Hasta aquí, nada sorprendente.</p>
<p>Pero el detalle está en lo que <strong>no</strong> hace: no usa coordenadas negativas como <code>left: -9999px</code>. Esa fue la técnica clásica durante años, y tiene problemas reales:</p>
<ul>
<li>En páginas con dirección RTL (árabe, hebreo), el contenido desplazado a la izquierda puede generar barras de scroll horizontales.</li>
<li>Algunos lectores de pantalla intentan desplazar la vista hasta donde está el contenido, provocando saltos de scroll desconcertantes.</li>
<li>JAWS tiene una función llamada Visual Tracking que muestra un borde rojo alrededor del elemento que está leyendo. Con <code>left: -9999px</code>, ese borde se dibuja fuera de la pantalla y el indicador visual se pierde.</li>
</ul>
<p><code>position: absolute</code> sin coordenadas simplemente coloca el elemento donde estaría en el flujo, pero sin afectar a los demás. Sin saltos, sin scrollbars fantasma.</p>
<h2 id="width%3A-1px-y-height%3A-1px" tabindex="-1"><code>width: 1px</code> y <code>height: 1px</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#width%3A-1px-y-height%3A-1px">#</a></h2>
<p>¿Por qué no <code>0</code>? Porque dimensiones cero pueden sacar el elemento del árbol de accesibilidad en algunos navegadores. En Safari, un elemento con ancho o alto cero no puede recibir foco — lo que rompe los skip links para usuarios de teclado.</p>
<p>Un píxel es el mínimo necesario para que el navegador considere que el elemento &quot;existe&quot; lo suficiente como para mantenerlo accesible. No lo verás, pero el navegador sabe que está ahí.</p>
<h2 id="overflow%3A-hidden" tabindex="-1"><code>overflow: hidden</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#overflow%3A-hidden">#</a></h2>
<p>Con un contenedor de 1×1 píxeles, el texto del interior se desbordaría y sería visible. <code>overflow: hidden</code> corta cualquier contenido que se escape de esa caja mínima. Combinado con las dimensiones, el resultado visual es nada. Cero píxeles visibles.</p>
<h2 id="clip-path%3A-inset(50%25)" tabindex="-1"><code>clip-path: inset(50%)</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#clip-path%3A-inset(50%25)">#</a></h2>
<p>Esta es la propiedad que hace el trabajo pesado de ocultar. <code>inset(50%)</code> recorta el elemento desde los cuatro lados hacia dentro un 50%, lo que resulta en un área de recorte de 0×0. Visualmente desaparece.</p>
<p>Lo importante es que <code>clip-path</code> solo afecta la representación visual. El contenido sigue existiendo en el árbol de accesibilidad. Es como poner una máscara sobre algo — no lo destruyes, solo lo tapas.</p>
<p>Quizá hayas visto versiones antiguas del patrón que usan la propiedad <code>clip</code> (sin el <code>-path</code>). Eso era necesario para Internet Explorer. <code>clip</code> está deprecated desde hace años, así que si no necesitas soportar IE, puedes quedarte solo con <code>clip-path</code>.</p>
<h2 id="white-space%3A-nowrap" tabindex="-1"><code>white-space: nowrap</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#white-space%3A-nowrap">#</a></h2>
<p>Esta es la propiedad que más me sorprendió cuando entendí su razón de ser.</p>
<p>Sin <code>nowrap</code>, el texto dentro de la caja de 1×1 píxeles se envuelve. Cada palabra acaba en su propia &quot;línea&quot; dentro de esa caja mínima. Y aquí vienen dos problemas reales:</p>
<p><strong>NVDA interpreta los saltos como líneas separadas.</strong> Si tu texto es &quot;Ir al contenido principal&quot;, sin <code>nowrap</code> NVDA puede leerlo como cinco anuncios separados: &quot;Ir&quot;, &quot;al&quot;, &quot;contenido&quot;, &quot;principal&quot;. Una experiencia confusa para el usuario.</p>
<p><strong>JAWS Visual Tracking se descuadra.</strong> El borde rojo que JAWS dibuja alrededor del elemento leído se extiende verticalmente para acomodar todas esas &quot;líneas&quot; de una palabra, creando un rectángulo alto y estrecho que se superpone con otros elementos de la página.</p>
<p><code>white-space: nowrap</code> fuerza todo el texto en una sola línea lógica. El desbordamiento ya lo gestiona <code>overflow: hidden</code> y <code>clip-path</code>.</p>
<h2 id="%3Anot(%3Afocus)%3Anot(%3Aactive)" tabindex="-1"><code>:not(:focus):not(:active)</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#%3Anot(%3Afocus)%3Anot(%3Aactive)">#</a></h2>
<p>El selector completo incluye dos negaciones que anulan todo el ocultamiento cuando el elemento recibe foco.</p>
<p>¿Por qué? Piensa en un skip link: &quot;Ir al contenido principal&quot;. Ese enlace debe ser invisible por defecto, pero cuando un usuario de teclado presiona Tab y el enlace recibe foco, <strong>tiene que hacerse visible</strong>. Un usuario con visión que navega con teclado necesita ver dónde está el foco.</p>
<p>Si el skip link es invisible incluso con foco, el usuario presiona Tab, no ve nada, presiona Enter sin saber qué va a pasar, y la experiencia se rompe. Es peor que no tener skip link.</p>
<p>La adición de <code>:not(:active)</code> es un matiz para Safari. En Safari, al hacer clic en un elemento, este pierde momentáneamente el estado <code>:focus</code> durante el estado <code>:active</code>. Sin esta negación, el elemento parpadearía — visible durante el foco, invisible durante el click, visible de nuevo. El <code>:not(:active)</code> lo mantiene visible durante toda la interacción.</p>
<h2 id="el-caso-estrella%3A-%22ir-al-contenido-principal%22" tabindex="-1">El caso estrella: &quot;Ir al contenido principal&quot; <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#el-caso-estrella%3A-%22ir-al-contenido-principal%22">#</a></h2>
<p>He mencionado los skip links varias veces, pero merece la pena detenerse en por qué existen.</p>
<p>Cuando un usuario de lector de pantalla o de teclado llega a una página, lo primero que encuentra es la navegación: logo, menú principal, enlaces secundarios, quizá un buscador. En un sitio con una cabecera compleja, pueden ser 20 o 30 tabulaciones antes de llegar al contenido real. En cada página. Imagina tener que cruzar el mismo pasillo de 30 puertas cada vez que entras en una habitación.</p>
<p>Un skip link es un atajo: el primer elemento focusable de la página, antes incluso de la navegación, que enlaza directamente al contenido principal:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>body</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>a</span> <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>#contenido<span class="token punctuation">"</span></span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>visually-hidden<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
    Ir al contenido principal
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>a</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>header</span><span class="token punctuation">></span></span>...<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>header</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>main</span> <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>contenido<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
    ...
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>main</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>body</span><span class="token punctuation">></span></span>
</code></pre>
<p>Para un usuario con visión que usa ratón, este enlace no existe — está oculto con <code>.visually-hidden</code>. Pero para un usuario de teclado, aparece en la primera pulsación de Tab. Y para un usuario de lector de pantalla, es lo primero que escucha.</p>
<h3 id="darle-estilo-al-foco" tabindex="-1">Darle estilo al foco <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#darle-estilo-al-foco">#</a></h3>
<p>Que el enlace se haga visible con <code>:not(:focus)</code> es solo la mitad del trabajo. Si al recibir foco simplemente &quot;aparece&quot; con los estilos por defecto del navegador, el resultado es un texto sin contexto flotando en algún rincón de la página. Necesita estilos propios que lo hagan reconocible:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.visually-hidden:focus</span> <span class="token punctuation">{</span>
  <span class="token property">position</span><span class="token punctuation">:</span> fixed<span class="token punctuation">;</span>
  <span class="token property">top</span><span class="token punctuation">:</span> 1rem<span class="token punctuation">;</span>
  <span class="token property">left</span><span class="token punctuation">:</span> 1rem<span class="token punctuation">;</span>
  <span class="token property">z-index</span><span class="token punctuation">:</span> 9999<span class="token punctuation">;</span>
  <span class="token property">padding</span><span class="token punctuation">:</span> 0.75rem 1.5rem<span class="token punctuation">;</span>
  <span class="token property">background</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-fondo<span class="token punctuation">,</span> #1a1a1a<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">color</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-texto<span class="token punctuation">,</span> #ffffff<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">font-size</span><span class="token punctuation">:</span> 1rem<span class="token punctuation">;</span>
  <span class="token property">font-weight</span><span class="token punctuation">:</span> 600<span class="token punctuation">;</span>
  <span class="token property">text-decoration</span><span class="token punctuation">:</span> none<span class="token punctuation">;</span>
  <span class="token property">border-radius</span><span class="token punctuation">:</span> 0.25rem<span class="token punctuation">;</span>
  <span class="token property">box-shadow</span><span class="token punctuation">:</span> 0 2px 8px <span class="token function">rgba</span><span class="token punctuation">(</span>0<span class="token punctuation">,</span> 0<span class="token punctuation">,</span> 0<span class="token punctuation">,</span> 0.3<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">outline</span><span class="token punctuation">:</span> 2px solid currentColor<span class="token punctuation">;</span>
  <span class="token property">outline-offset</span><span class="token punctuation">:</span> 2px<span class="token punctuation">;</span>
  <span class="token property">width</span><span class="token punctuation">:</span> auto<span class="token punctuation">;</span>
  <span class="token property">height</span><span class="token punctuation">:</span> auto<span class="token punctuation">;</span>
  <span class="token property">overflow</span><span class="token punctuation">:</span> visible<span class="token punctuation">;</span>
  <span class="token property">clip-path</span><span class="token punctuation">:</span> none<span class="token punctuation">;</span>
  <span class="token property">white-space</span><span class="token punctuation">:</span> normal<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Hay varios detalles que importan:</p>
<ul>
<li><strong><code>position: fixed</code> con coordenadas explícitas</strong> — lo coloca en una posición predecible, siempre visible, independientemente del scroll.</li>
<li><strong>Contraste alto</strong> — fondo oscuro con texto claro (o al revés). El usuario necesita verlo inmediatamente.</li>
<li><strong><code>outline</code> visible</strong> — refuerza que el elemento tiene foco. Nunca quites el outline sin dar una alternativa.</li>
<li><strong>Resetear las propiedades de ocultamiento</strong> — <code>width: auto</code>, <code>height: auto</code>, <code>clip-path: none</code>, <code>overflow: visible</code> y <code>white-space: normal</code> deshacen explícitamente cada propiedad del patrón <code>.visually-hidden</code>. Si no las reseteas, el elemento sigue recortado o limitado a 1×1 píxeles aunque tenga foco.</li>
</ul>
<p>El skip link es probablemente la mejora de accesibilidad con mejor relación esfuerzo-impacto que puedes implementar. Unas pocas líneas de HTML y CSS, y la experiencia de navegación con teclado mejora drásticamente.</p>
<h2 id="un-hack-robusto-sigue-siendo-un-hack" tabindex="-1">Un hack robusto sigue siendo un hack <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#un-hack-robusto-sigue-siendo-un-hack">#</a></h2>
<p>James Edwards, el autor del <a href="https://vispero.com/resources/the-anatomy-of-visually-hidden/">análisis original en Vispero</a>, lo describe perfectamente: es &quot;un hack robusto y probado&quot;. Y tiene razón. Cada una de estas propiedades existe para parchear un caso límite de un navegador o un lector de pantalla específico.</p>
<p>No hay una forma nativa de decirle al navegador &quot;este contenido es solo para lectores de pantalla&quot;. Tenemos <code>aria-label</code> y <code>aria-labelledby</code> para algunos casos, pero para bloques de texto más largos o skip links, seguimos necesitando este truco CSS.</p>
<p>Lo ideal sería algo como un valor nativo — un hipotético <code>content-visibility: assistive-only</code> o similar — que declare la intención directamente en vez de simular el efecto con seis propiedades. Pero mientras ese día llega, <code>.visually-hidden</code> cumple su función.</p>
<h2 id="ll%C3%A9vate-esto" tabindex="-1">Llévate esto <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/anatomia-visually-hidden-css/#ll%C3%A9vate-esto">#</a></h2>
<p>Si mantienes un archivo de utilidades CSS, asegúrate de que tu <code>.visually-hidden</code> incluye las seis propiedades. No recortes. No &quot;simplifiques&quot;. Cada línea está ahí por una razón, probada en campo durante años por la comunidad de accesibilidad.</p>
<p>Y la próxima vez que alguien te diga que la accesibilidad web es fácil, enséñale que necesitamos seis propiedades CSS y dos pseudo-clases negadas solo para ocultar un texto de forma inclusiva.</p>
]]>
      </content:encoded>
      <pubDate>Thu, 12 Feb 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>prefers-reduced-motion: respetar al usuario que no quiere animaciones</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/prefers-reduced-motion/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/prefers-reduced-motion/</guid>
      <description>
        Cómo usar la media query prefers-reduced-motion para desactivar animaciones cuando el usuario lo ha pedido. Con ejemplos prácticos y la filosofía detrás.
      </description>
      <content:encoded>
        <![CDATA[<p>Hay usuarios que no quieren animaciones en la web. No es una preferencia estética — es una necesidad. Las animaciones pueden causar mareos, náuseas y desorientación en personas con trastornos vestibulares. Otros simplemente las encuentran distractivas. Ambos grupos merecen poder navegar cómodamente.</p>
<p>Los sistemas operativos modernos permiten activar una opción de &quot;reducir movimiento&quot; (macOS, iOS, Windows, Android). Y CSS tiene una media query que detecta esa preferencia: <code>prefers-reduced-motion</code>.</p>
<h2 id="la-implementaci%C3%B3n-b%C3%A1sica" tabindex="-1">La implementación básica <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/prefers-reduced-motion/#la-implementaci%C3%B3n-b%C3%A1sica">#</a></h2>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@media</span> <span class="token punctuation">(</span><span class="token property">prefers-reduced-motion</span><span class="token punctuation">:</span> reduce<span class="token punctuation">)</span></span> <span class="token punctuation">{</span>
  <span class="token selector">*,
  *::before,
  *::after</span> <span class="token punctuation">{</span>
    <span class="token property">animation-duration</span><span class="token punctuation">:</span> 0.01ms <span class="token important">!important</span><span class="token punctuation">;</span>
    <span class="token property">animation-iteration-count</span><span class="token punctuation">:</span> 1 <span class="token important">!important</span><span class="token punctuation">;</span>
    <span class="token property">transition-duration</span><span class="token punctuation">:</span> 0.01ms <span class="token important">!important</span><span class="token punctuation">;</span>
    <span class="token property">scroll-behavior</span><span class="token punctuation">:</span> auto <span class="token important">!important</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Este bloque nuclear desactiva todas las animaciones y transiciones de un plumazo. Es efectivo pero tosco — también elimina transiciones sutiles que no causan problemas, como un cambio de color en hover.</p>
<h2 id="un-enfoque-m%C3%A1s-matizado" tabindex="-1">Un enfoque más matizado <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/prefers-reduced-motion/#un-enfoque-m%C3%A1s-matizado">#</a></h2>
<p>En vez de matar todas las animaciones, prefiero desactivar selectivamente las que implican movimiento real (traslaciones, rotaciones, escalados) y mantener las que son puramente visuales (cambios de color, opacidad):</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@media</span> <span class="token punctuation">(</span><span class="token property">prefers-reduced-motion</span><span class="token punctuation">:</span> reduce<span class="token punctuation">)</span></span> <span class="token punctuation">{</span>
  <span class="token selector">.reveal</span> <span class="token punctuation">{</span>
    <span class="token property">opacity</span><span class="token punctuation">:</span> 1 <span class="token important">!important</span><span class="token punctuation">;</span>
    <span class="token property">transform</span><span class="token punctuation">:</span> none <span class="token important">!important</span><span class="token punctuation">;</span>
    <span class="token property">transition</span><span class="token punctuation">:</span> none <span class="token important">!important</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>

  <span class="token selector">.animado</span> <span class="token punctuation">{</span>
    <span class="token property">animation</span><span class="token punctuation">:</span> none <span class="token important">!important</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Así mantengo los cambios de color en hover (que son informativos, no decorativos) pero elimino los elementos que entran deslizándose, las rotaciones del scroll y las transiciones de posición.</p>
<h2 id="la-filosof%C3%ADa%3A-motion-como-mejora-progresiva" tabindex="-1">La filosofía: motion como mejora progresiva <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/prefers-reduced-motion/#la-filosof%C3%ADa%3A-motion-como-mejora-progresiva">#</a></h2>
<p>Hay un enfoque alternativo que me gusta más. En vez de definir animaciones y luego desactivarlas, puedes hacer lo contrario: definir la versión estática por defecto y añadir animaciones solo cuando el usuario no ha pedido reducirlas:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* Por defecto: sin animación */</span>
<span class="token selector">.reveal</span> <span class="token punctuation">{</span>
  <span class="token property">opacity</span><span class="token punctuation">:</span> 1<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token comment">/* Solo animar si el usuario no ha pedido reducir movimiento */</span>
<span class="token atrule"><span class="token rule">@media</span> <span class="token punctuation">(</span><span class="token property">prefers-reduced-motion</span><span class="token punctuation">:</span> no-preference<span class="token punctuation">)</span></span> <span class="token punctuation">{</span>
  <span class="token selector">.reveal</span> <span class="token punctuation">{</span>
    <span class="token property">opacity</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
    <span class="token property">transform</span><span class="token punctuation">:</span> <span class="token function">translateY</span><span class="token punctuation">(</span>20px<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token property">transition</span><span class="token punctuation">:</span> opacity 0.6s ease<span class="token punctuation">,</span> transform 0.6s ease<span class="token punctuation">;</span>
  <span class="token punctuation">}</span>

  <span class="token selector">.reveal.visible</span> <span class="token punctuation">{</span>
    <span class="token property">opacity</span><span class="token punctuation">:</span> 1<span class="token punctuation">;</span>
    <span class="token property">transform</span><span class="token punctuation">:</span> none<span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Este enfoque trata la animación como una <strong>mejora progresiva</strong>: la funcionalidad base es estática y funcional, y la animación se añade solo cuando es bienvenida. Es más robusto y más respetuoso.</p>
<h2 id="javascript-tambi%C3%A9n" tabindex="-1">JavaScript también <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/prefers-reduced-motion/#javascript-tambi%C3%A9n">#</a></h2>
<p>Si tu JavaScript controla animaciones, también debería comprobar la preferencia:</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript"><span class="token keyword">const</span> prefersReducedMotion <span class="token operator">=</span> window<span class="token punctuation">.</span><span class="token function">matchMedia</span><span class="token punctuation">(</span>
  <span class="token string">"(prefers-reduced-motion: reduce)"</span>
<span class="token punctuation">)</span><span class="token punctuation">.</span>matches<span class="token punctuation">;</span>

<span class="token keyword">if</span> <span class="token punctuation">(</span><span class="token operator">!</span>prefersReducedMotion<span class="token punctuation">)</span> <span class="token punctuation">{</span>
  <span class="token comment">// Inicializar IntersectionObserver para reveals</span>
  <span class="token comment">// Activar animaciones de scroll</span>
  <span class="token comment">// etc.</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Y si la preferencia puede cambiar durante la sesión (el usuario activa/desactiva la opción mientras navega):</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript"><span class="token keyword">const</span> motionQuery <span class="token operator">=</span> window<span class="token punctuation">.</span><span class="token function">matchMedia</span><span class="token punctuation">(</span><span class="token string">"(prefers-reduced-motion: reduce)"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

motionQuery<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"change"</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
  <span class="token keyword">if</span> <span class="token punctuation">(</span>motionQuery<span class="token punctuation">.</span>matches<span class="token punctuation">)</span> <span class="token punctuation">{</span>
    <span class="token function">desactivarAnimaciones</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span> <span class="token keyword">else</span> <span class="token punctuation">{</span>
    <span class="token function">activarAnimaciones</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<h2 id="en-este-sitio" tabindex="-1">En este sitio <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/prefers-reduced-motion/#en-este-sitio">#</a></h2>
<p>Las animaciones de &quot;reveal&quot; (elementos que aparecen al hacer scroll) respetan <code>prefers-reduced-motion</code>. Si tienes la opción activada en tu sistema operativo, los elementos simplemente están ahí — sin deslizamiento, sin fundido. El contenido es el mismo; la decoración es la que cambia.</p>
<p>Es un esfuerzo mínimo para el desarrollador y una diferencia enorme para el usuario que lo necesita. No hay excusa para ignorarlo.</p>
]]>
      </content:encoded>
      <pubDate>Fri, 30 Jan 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>El selector :has() — el selector padre que CSS siempre necesitó</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/</guid>
      <description>
        Casos prácticos del selector :has() de CSS: formularios reactivos, navegación contextual y layouts que responden al contenido. Sin JavaScript y con progressive enhancement.
      </description>
      <content:encoded>
        <![CDATA[<p>Durante años, la pregunta más repetida en cualquier foro de CSS era: &quot;¿puedo seleccionar un elemento padre en función de sus hijos?&quot;. La respuesta siempre fue no. Podías ir hacia abajo (descendientes), hacia los lados (hermanos con <code>~</code> y <code>+</code>), pero nunca hacia arriba. El selector padre era el unicornio de CSS.</p>
<p><code>:has()</code> cambia eso. Y no solo resuelve el caso del selector padre — es mucho más potente de lo que parece a primera vista.</p>
<h2 id="qu%C3%A9-es-%3Ahas()" tabindex="-1">Qué es <code>:has()</code> <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#qu%C3%A9-es-%3Ahas()">#</a></h2>
<p>En su forma más simple, <code>:has()</code> selecciona un elemento que <strong>contiene</strong> algo:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* Selecciona los artículos que tienen una imagen */</span>
<span class="token selector">article:has(img)</span> <span class="token punctuation">{</span>
  <span class="token property">grid-template-columns</span><span class="token punctuation">:</span> 1fr 1fr<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token comment">/* Selecciona los artículos que NO tienen imagen */</span>
<span class="token selector">article:not(:has(img))</span> <span class="token punctuation">{</span>
  <span class="token property">grid-template-columns</span><span class="token punctuation">:</span> 1fr<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Eso es el selector padre. Un <code>article</code> que contiene un <code>img</code> recibe un layout diferente. Antes necesitabas una clase extra en el HTML o JavaScript para conseguir esto.</p>
<p>Pero <code>:has()</code> acepta cualquier selector como argumento, incluyendo pseudo-clases, combinadores y selectores compuestos. Ahí es donde se pone interesante.</p>
<h2 id="formularios-reactivos-sin-javascript" tabindex="-1">Formularios reactivos sin JavaScript <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#formularios-reactivos-sin-javascript">#</a></h2>
<p>El caso de uso que más me convence es hacer que los formularios respondan a su propio estado sin una sola línea de JavaScript.</p>
<h3 id="mostrar-ayuda-cuando-un-campo-tiene-foco" tabindex="-1">Mostrar ayuda cuando un campo tiene foco <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#mostrar-ayuda-cuando-un-campo-tiene-foco">#</a></h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.campo:has(input:focus) .ayuda</span> <span class="token punctuation">{</span>
  <span class="token property">display</span><span class="token punctuation">:</span> block<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Cuando el <code>input</code> dentro de <code>.campo</code> tiene foco, el texto de ayuda se muestra. Sin eventos <code>focus</code>/<code>blur</code> en JS, sin toggle de clases.</p>
<h3 id="resaltar-campos-inv%C3%A1lidos-con-contexto" tabindex="-1">Resaltar campos inválidos con contexto <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#resaltar-campos-inv%C3%A1lidos-con-contexto">#</a></h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.campo:has(input:invalid:not(:placeholder-shown))</span> <span class="token punctuation">{</span>
  <span class="token property">--borde-campo</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-error<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token selector">.campo:has(input:invalid:not(:placeholder-shown)) .mensaje-error</span> <span class="token punctuation">{</span>
  <span class="token property">display</span><span class="token punctuation">:</span> block<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>El truco de <code>:not(:placeholder-shown)</code> evita que el campo se marque como inválido antes de que el usuario escriba algo. <code>:has()</code> permite que el contenedor reaccione, no solo el input.</p>
<h3 id="deshabilitar-visualmente-el-bot%C3%B3n-de-env%C3%ADo" tabindex="-1">Deshabilitar visualmente el botón de envío <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#deshabilitar-visualmente-el-bot%C3%B3n-de-env%C3%ADo">#</a></h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">form:has(:invalid) button[type="submit"]</span> <span class="token punctuation">{</span>
  <span class="token property">opacity</span><span class="token punctuation">:</span> 0.5<span class="token punctuation">;</span>
  <span class="token property">pointer-events</span><span class="token punctuation">:</span> none<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Si el formulario contiene algún campo inválido, el botón se atenúa. El formulario entero es consciente de su estado, sin necesidad de validación JavaScript para el feedback visual.</p>
<h2 id="navegaci%C3%B3n-contextual" tabindex="-1">Navegación contextual <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#navegaci%C3%B3n-contextual">#</a></h2>
<p>Otro patrón útil: que la navegación sepa qué está pasando en la página.</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* Si la página tiene un hero, la nav es transparente */</span>
<span class="token selector">body:has(.hero) .nav-principal</span> <span class="token punctuation">{</span>
  <span class="token property">background</span><span class="token punctuation">:</span> transparent<span class="token punctuation">;</span>
  <span class="token property">position</span><span class="token punctuation">:</span> absolute<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token comment">/* Si un menú desplegable está abierto, oscurecer el fondo */</span>
<span class="token selector">body:has(.dropdown[open]) .overlay</span> <span class="token punctuation">{</span>
  <span class="token property">opacity</span><span class="token punctuation">:</span> 1<span class="token punctuation">;</span>
  <span class="token property">pointer-events</span><span class="token punctuation">:</span> auto<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Antes, el patrón habitual era añadir clases al <code>body</code> con JavaScript (<code>body.has-hero</code>, <code>body.menu-open</code>). Con <code>:has()</code>, el CSS lo resuelve solo.</p>
<h2 id="layouts-que-responden-al-contenido" tabindex="-1">Layouts que responden al contenido <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#layouts-que-responden-al-contenido">#</a></h2>
<p>Esto es lo que más me interesa para este mismo sitio. En vez de crear variantes con clases CSS, el layout se adapta a lo que contiene.</p>
<h3 id="tarjetas-con-o-sin-imagen" tabindex="-1">Tarjetas con o sin imagen <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#tarjetas-con-o-sin-imagen">#</a></h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.tarjeta:has(img)</span> <span class="token punctuation">{</span>
  <span class="token property">display</span><span class="token punctuation">:</span> grid<span class="token punctuation">;</span>
  <span class="token property">grid-template-columns</span><span class="token punctuation">:</span> 200px 1fr<span class="token punctuation">;</span>
  <span class="token property">gap</span><span class="token punctuation">:</span> 1rem<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token selector">.tarjeta:not(:has(img))</span> <span class="token punctuation">{</span>
  <span class="token property">padding</span><span class="token punctuation">:</span> 1.5rem<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<h3 id="grids-que-reaccionan-a-la-cantidad-de-hijos" tabindex="-1">Grids que reaccionan a la cantidad de hijos <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#grids-que-reaccionan-a-la-cantidad-de-hijos">#</a></h3>
<p>Combinando <code>:has()</code> con <code>:nth-child()</code> puedes crear grids que se reorganizan según cuántos elementos contienen:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* Si el grid tiene más de 3 hijos, usar 3 columnas */</span>
<span class="token selector">.grid:has(:nth-child(4))</span> <span class="token punctuation">{</span>
  <span class="token property">grid-template-columns</span><span class="token punctuation">:</span> <span class="token function">repeat</span><span class="token punctuation">(</span>3<span class="token punctuation">,</span> 1fr<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token comment">/* Si tiene 2 o menos, usar 2 columnas */</span>
<span class="token selector">.grid:not(:has(:nth-child(3)))</span> <span class="token punctuation">{</span>
  <span class="token property">grid-template-columns</span><span class="token punctuation">:</span> <span class="token function">repeat</span><span class="token punctuation">(</span>2<span class="token punctuation">,</span> 1fr<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Sin JavaScript contando elementos, sin clases condicionales generadas desde el servidor.</p>
<h2 id="seleccionar-hermanos-previos" tabindex="-1">Seleccionar hermanos previos <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#seleccionar-hermanos-previos">#</a></h2>
<p>Un efecto secundario inesperado de <code>:has()</code> es que habilita la selección de hermanos <strong>anteriores</strong>, algo que nunca fue posible en CSS:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* Resaltar todos los elementos de la lista anteriores al hover */</span>
<span class="token selector">li:has(~ li:hover)</span> <span class="token punctuation">{</span>
  <span class="token property">color</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-acento<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Esto dice: &quot;selecciona un <code>li</code> que tiene un hermano posterior (<code>~</code>) que está en hover&quot;. En otras palabras, selecciona los anteriores al elemento en hover. Perfecto para crear un sistema de valoración con estrellas, por ejemplo.</p>
<h2 id="soporte-en-navegadores" tabindex="-1">Soporte en navegadores <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#soporte-en-navegadores">#</a></h2>
<p><code>:has()</code> está soportado en todos los navegadores modernos desde finales de 2023:</p>
<ul>
<li><strong>Chrome / Edge</strong>: desde la versión 105 (agosto 2022)</li>
<li><strong>Safari</strong>: desde la versión 15.4 (marzo 2022) — fue el primero en implementarlo</li>
<li><strong>Firefox</strong>: desde la versión 121 (diciembre 2023) — el último en llegar</li>
</ul>
<p>A fecha de hoy, el soporte global supera el 96% de los usuarios según Can I Use. La única franja sin cobertura son versiones muy antiguas de navegadores que probablemente tampoco soporten otras características modernas que ya usamos con normalidad como <code>gap</code> en flexbox o <code>aspect-ratio</code>.</p>
<h2 id="progressive-enhancement%3A-%C3%BAsalo-sin-miedo" tabindex="-1">Progressive enhancement: úsalo sin miedo <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#progressive-enhancement%3A-%C3%BAsalo-sin-miedo">#</a></h2>
<p>Hay una característica de <code>:has()</code> que lo convierte en candidato ideal para progressive enhancement: <strong>cuando un navegador no lo entiende, simplemente ignora la regla</strong>. No lanza errores, no rompe el resto del CSS. El selector no aplica y punto.</p>
<p>Esto significa que puedes usarlo como una <strong>mejora visual</strong> sobre un diseño base que funciona sin él:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* Base: funciona en todos los navegadores */</span>
<span class="token selector">.tarjeta</span> <span class="token punctuation">{</span>
  <span class="token property">padding</span><span class="token punctuation">:</span> 1.5rem<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token comment">/* Mejora: solo se aplica si el navegador entiende :has() */</span>
<span class="token selector">.tarjeta:has(img)</span> <span class="token punctuation">{</span>
  <span class="token property">display</span><span class="token punctuation">:</span> grid<span class="token punctuation">;</span>
  <span class="token property">grid-template-columns</span><span class="token punctuation">:</span> 200px 1fr<span class="token punctuation">;</span>
  <span class="token property">gap</span><span class="token punctuation">:</span> 1rem<span class="token punctuation">;</span>
  <span class="token property">padding</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>El diseño base muestra una tarjeta funcional y legible. Si el navegador soporta <code>:has()</code>, obtiene un layout mejorado con la imagen al lado. Si no, la imagen simplemente aparece encima del texto, dentro del flujo normal. Nada se rompe.</p>
<p>La clave es diseñar el caso base primero — el CSS que funciona sin <code>:has()</code> — y luego añadir las mejoras. No construyas layouts que <em>dependan</em> de <code>:has()</code> para ser usables. Úsalo para mejorar la experiencia, no para sostenerla.</p>
<p>En la práctica, con más del 96% de soporte, el porcentaje de usuarios que no se beneficiarán de tus reglas con <code>:has()</code> es mínimo, y esos usuarios seguirán viendo un sitio perfectamente funcional.</p>
<h2 id="cu%C3%A1ndo-no-usarlo" tabindex="-1">Cuándo no usarlo <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#cu%C3%A1ndo-no-usarlo">#</a></h2>
<p><code>:has()</code> no reemplaza JavaScript para lógica compleja. Si necesitas validación asíncrona, peticiones al servidor o transformaciones de datos, sigue usando JS. Lo que elimina son los casos donde JavaScript solo servía para <strong>observar el DOM y aplicar clases CSS</strong> — exactamente el tipo de código que siempre pareció que no debería ser necesario.</p>
<p>Tampoco es ideal para animaciones complejas basadas en el estado. Para eso, las custom properties con un pequeño script siguen siendo más explícitas y depurables.</p>
<h2 id="la-regla-que-me-aplico" tabindex="-1">La regla que me aplico <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/selector-has-css/#la-regla-que-me-aplico">#</a></h2>
<p>Antes de añadir un <code>addEventListener</code> que solo hace <code>classList.toggle()</code>, pienso si <code>:has()</code> lo resuelve. La mayoría de las veces, sí. Y el resultado es CSS que se lee como una frase: <em>&quot;si el formulario tiene un campo inválido, atenúa el botón&quot;</em>. Sin intermediarios.</p>
]]>
      </content:encoded>
      <pubDate>Wed, 14 Jan 2026 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Eleventy y Decap CMS: cuando el cliente quiere editar</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/eleventy-decap-cms/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/eleventy-decap-cms/</guid>
      <description>
        Las ventajas y los problemas reales de usar un CMS headless con un generador estático. Qué pasa cuando un cliente sin perfil técnico gestiona su propio contenido.
      </description>
      <content:encoded>
        <![CDATA[<p>Eleventy es mi herramienta favorita para construir sitios web. Pero tiene un problema: no tiene panel de administración. Para mí eso es una virtud. Para un cliente que quiere actualizar su web sin llamarme cada vez, es un obstáculo.</p>
<p>La solución que más se recomienda en la comunidad Eleventy es <a href="https://decapcms.org/">Decap CMS</a> (antes Netlify CMS): un panel de administración que se conecta a un repositorio Git y genera ficheros Markdown. En teoría, el cliente edita en un formulario bonito, el CMS hace commit a Git, se dispara un build, y la web se actualiza. En la práctica, es más complicado.</p>
<h2 id="lo-bueno" tabindex="-1">Lo bueno <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/eleventy-decap-cms/#lo-bueno">#</a></h2>
<p>Decap CMS resuelve el problema fundamental: el cliente no necesita saber qué es Git, ni Markdown, ni un terminal. Ve un formulario con campos, escribe, le da a &quot;Publicar&quot; y su contenido aparece en la web.</p>
<p>La configuración es un único fichero <code>config.yml</code> donde defines las colecciones (blog, páginas, etc.) y los campos de cada una. La integración con Eleventy es natural porque ambos trabajan con ficheros Markdown y frontmatter YAML.</p>
<pre class="language-yaml" tabindex="0"><code class="language-yaml"><span class="token key atrule">collections</span><span class="token punctuation">:</span>
  <span class="token punctuation">-</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> <span class="token string">"blog"</span>
    <span class="token key atrule">label</span><span class="token punctuation">:</span> <span class="token string">"Blog"</span>
    <span class="token key atrule">folder</span><span class="token punctuation">:</span> <span class="token string">"content/blog/posts"</span>
    <span class="token key atrule">create</span><span class="token punctuation">:</span> <span class="token boolean important">true</span>
    <span class="token key atrule">fields</span><span class="token punctuation">:</span>
      <span class="token punctuation">-</span> <span class="token punctuation">{</span> <span class="token key atrule">label</span><span class="token punctuation">:</span> <span class="token string">"Título"</span><span class="token punctuation">,</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> <span class="token string">"title"</span><span class="token punctuation">,</span> <span class="token key atrule">widget</span><span class="token punctuation">:</span> <span class="token string">"string"</span> <span class="token punctuation">}</span>
      <span class="token punctuation">-</span> <span class="token punctuation">{</span> <span class="token key atrule">label</span><span class="token punctuation">:</span> <span class="token string">"Fecha"</span><span class="token punctuation">,</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> <span class="token string">"date"</span><span class="token punctuation">,</span> <span class="token key atrule">widget</span><span class="token punctuation">:</span> <span class="token string">"datetime"</span> <span class="token punctuation">}</span>
      <span class="token punctuation">-</span> <span class="token punctuation">{</span> <span class="token key atrule">label</span><span class="token punctuation">:</span> <span class="token string">"Contenido"</span><span class="token punctuation">,</span> <span class="token key atrule">name</span><span class="token punctuation">:</span> <span class="token string">"body"</span><span class="token punctuation">,</span> <span class="token key atrule">widget</span><span class="token punctuation">:</span> <span class="token string">"markdown"</span> <span class="token punctuation">}</span>
</code></pre>
<h2 id="lo-malo%3A-los-builds" tabindex="-1">Lo malo: los builds <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/eleventy-decap-cms/#lo-malo%3A-los-builds">#</a></h2>
<p>Aquí es donde empiezan los problemas. Cada vez que el cliente guarda un borrador, Decap CMS hace un commit. Cada commit dispara un build en Netlify, Cloudflare Pages o el servicio que uses. Un cliente que escribe un artículo y guarda tres borradores antes de publicar ha generado cuatro builds.</p>
<p>Si el sitio es pequeño y el build dura segundos, no pasa nada. Pero he visto proyectos donde un cliente entusiasta generaba 30-40 builds al día porque guardaba cada párrafo. Con los límites de minutos de build que tienen los planes gratuitos de la mayoría de plataformas, eso se convierte en un problema real.</p>
<h2 id="lo-peor%3A-el-contenido-roto" tabindex="-1">Lo peor: el contenido roto <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/eleventy-decap-cms/#lo-peor%3A-el-contenido-roto">#</a></h2>
<p>Un CMS headless no valida el resultado visual. El cliente puede:</p>
<ul>
<li>Pegar texto desde Word con formato oculto que rompe el Markdown</li>
<li>Subir imágenes de 5MB sin optimizar</li>
<li>Dejar campos obligatorios vacíos (Decap los valida, pero no siempre bien)</li>
<li>Crear estructuras de encabezados incorrectas (un H4 después de un H2)</li>
<li>Romper el build si introduce caracteres especiales en el título que afectan al slug</li>
</ul>
<p>He tenido clientes que borran contenido pensando que estaban editando, y como Decap hace commit directamente, el contenido desaparece de la web. Sí, está en el historial de Git, pero explicarle a un cliente qué es un <code>git revert</code> no es una conversación productiva.</p>
<h2 id="la-alternativa-honesta" tabindex="-1">La alternativa honesta <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/eleventy-decap-cms/#la-alternativa-honesta">#</a></h2>
<p>Después de varios proyectos con esta arquitectura, he llegado a una conclusión incómoda: <strong>si el cliente necesita editar contenido frecuentemente, un generador estático probablemente no es la herramienta adecuada</strong>.</p>
<p>No digo que no funcione nunca. Para un cliente que actualiza su blog una vez al mes y tiene un perfil mínimamente técnico, Decap CMS con Eleventy es una buena solución. Pero para un cliente que publica a diario, tiene varios editores, necesita previsualización en tiempo real y quiere un flujo de borrador-revisión-publicación, un CMS tradicional como WordPress (sí, he dicho WordPress) sigue siendo más apropiado.</p>
<h2 id="cu%C3%A1ndo-s%C3%AD-funciona" tabindex="-1">Cuándo sí funciona <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/eleventy-decap-cms/#cu%C3%A1ndo-s%C3%AD-funciona">#</a></h2>
<p>La combinación Eleventy + Decap CMS funciona bien cuando:</p>
<ul>
<li>El contenido se actualiza con poca frecuencia (semanal o menos)</li>
<li>Hay un solo editor o un equipo muy pequeño</li>
<li>El editor entiende la estructura básica del contenido (título, cuerpo, fecha)</li>
<li>Hay un desarrollador disponible para solucionar problemas ocasionales</li>
<li>Los builds son rápidos (menos de un minuto)</li>
</ul>
<p>En esos casos, las ventajas del stack estático (rendimiento, seguridad, coste cero de hosting) superan las incomodidades del CMS.</p>
<h2 id="lo-que-hago-ahora" tabindex="-1">Lo que hago ahora <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/eleventy-decap-cms/#lo-que-hago-ahora">#</a></h2>
<p>Para proyectos donde el cliente necesita autonomía total, uso WordPress con un tema a medida. Para proyectos donde el contenido es mayoritariamente estático o lo gestiono yo, uso Eleventy sin CMS. Y para el terreno intermedio, valoro caso a caso y soy honesto con el cliente sobre lo que implica cada opción.</p>
<p>La peor decisión es elegir la herramienta que a ti te gusta en vez de la que el cliente necesita.</p>
]]>
      </content:encoded>
      <pubDate>Mon, 22 Dec 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Animaciones ligadas al scroll con una sola línea de JavaScript</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/</guid>
      <description>
        Cómo usar una variable CSS alimentada por el scroll del navegador para crear animaciones complejas sin librerías. La técnica detrás de la versión anterior de paigar.es.
      </description>
      <content:encoded>
        <![CDATA[<p>La versión anterior de paigar.es tenía animaciones complejas ligadas al scroll: secciones que rotaban, imágenes que aparecían desde los laterales, textos que se revelaban progresivamente. Todo eso sin GSAP, sin ScrollMagic, sin ninguna librería de animación. Solo CSS y una línea de JavaScript.</p>
<p>La técnica se basa en un truco elegante: usar una animación CSS pausada, y controlar en qué fotograma se encuentra usando <code>animation-delay</code> con un valor negativo calculado a partir del scroll.</p>
<h2 id="la-idea-central" tabindex="-1">La idea central <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/#la-idea-central">#</a></h2>
<p>El único JavaScript necesario es actualizar una variable CSS con la posición de scroll:</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript">window<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"scroll"</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
  <span class="token function">requestAnimationFrame</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
    document<span class="token punctuation">.</span>body<span class="token punctuation">.</span>style<span class="token punctuation">.</span><span class="token function">setProperty</span><span class="token punctuation">(</span>
      <span class="token string">"--recorrido"</span><span class="token punctuation">,</span>
      window<span class="token punctuation">.</span>pageYOffset
    <span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span> <span class="token punctuation">{</span> <span class="token literal-property property">passive</span><span class="token operator">:</span> <span class="token boolean">true</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>Eso es todo el JavaScript. Una variable CSS (<code>--recorrido</code>) que contiene los píxeles recorridos. El resto es CSS puro.</p>
<h2 id="la-clase-m%C3%A1gica" tabindex="-1">La clase mágica <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/#la-clase-m%C3%A1gica">#</a></h2>
<p>El núcleo del sistema es una clase <code>.animado</code> que convierte cualquier animación CSS en una animación controlada por scroll:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.animado</span> <span class="token punctuation">{</span>
  <span class="token property">animation</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--nombreanimacion<span class="token punctuation">)</span> 1s linear forwards<span class="token punctuation">;</span>
  <span class="token property">animation-play-state</span><span class="token punctuation">:</span> paused<span class="token punctuation">;</span>
  <span class="token property">animation-delay</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span>
    <span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--recorrido<span class="token punctuation">,</span> 0<span class="token punctuation">)</span> - <span class="token function">var</span><span class="token punctuation">(</span>--posinicio<span class="token punctuation">,</span> 0<span class="token punctuation">)</span><span class="token punctuation">)</span> / <span class="token function">var</span><span class="token punctuation">(</span>--altura<span class="token punctuation">,</span> 1<span class="token punctuation">)</span><span class="token punctuation">)</span> * -1s
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">animation-fill-mode</span><span class="token punctuation">:</span> both<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Vamos a descomponer esta fórmula:</p>
<ul>
<li><code>--recorrido</code> — la posición de scroll actual (viene del JS)</li>
<li><code>--posinicio</code> — el punto de scroll donde empieza la animación de este elemento</li>
<li><code>--altura</code> — la distancia en píxeles durante la que se desarrolla la animación</li>
<li>El resultado se multiplica por <code>-1s</code> para convertirlo en un delay negativo</li>
</ul>
<h2 id="%C2%BFpor-qu%C3%A9-funciona%3F" tabindex="-1">¿Por qué funciona? <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/#%C2%BFpor-qu%C3%A9-funciona%3F">#</a></h2>
<p>El truco está en cómo los navegadores interpretan <code>animation-delay</code> con valores negativos. Cuando una animación tiene un delay de <code>-0.5s</code> y dura <code>1s</code>, el navegador la renderiza como si llevase medio segundo reproduciéndose. Pero como <code>animation-play-state</code> está en <code>paused</code>, no avanza: se queda congelada en ese punto exacto.</p>
<p>Al cambiar el valor de <code>--recorrido</code> con el scroll, el <code>animation-delay</code> se recalcula automáticamente (gracias a las custom properties), y el navegador renderiza el fotograma correspondiente. El efecto es una animación que avanza y retrocede siguiendo el scroll.</p>
<h2 id="configurar-cada-elemento" tabindex="-1">Configurar cada elemento <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/#configurar-cada-elemento">#</a></h2>
<p>Cada elemento animado necesita tres cosas:</p>
<ol>
<li><strong>La animación</strong> — un <code>@keyframes</code> que defina el movimiento</li>
<li><strong>El punto de inicio</strong> — <code>--posinicio</code> establecido en el HTML o CSS</li>
<li><strong>La duración</strong> — <code>--altura</code> que define cuántos píxeles de scroll dura</li>
</ol>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@keyframes</span> rotar</span> <span class="token punctuation">{</span>
  <span class="token selector">from</span> <span class="token punctuation">{</span> <span class="token property">transform</span><span class="token punctuation">:</span> <span class="token function">rotate</span><span class="token punctuation">(</span>0deg<span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token punctuation">}</span>
  <span class="token selector">to</span> <span class="token punctuation">{</span> <span class="token property">transform</span><span class="token punctuation">:</span> <span class="token function">rotate</span><span class="token punctuation">(</span>45deg<span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token punctuation">}</span>

<span class="token selector">#cabecera .intro</span> <span class="token punctuation">{</span>
  <span class="token property">--nombreanimacion</span><span class="token punctuation">:</span> rotar<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Y en el HTML o con JavaScript, se establecen las variables de posición:</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript"><span class="token comment">// Calcular las alturas de cada sección</span>
document<span class="token punctuation">.</span>body<span class="token punctuation">.</span>style<span class="token punctuation">.</span><span class="token function">setProperty</span><span class="token punctuation">(</span>
  <span class="token string">"--alturaCabecera"</span><span class="token punctuation">,</span>
  document<span class="token punctuation">.</span><span class="token function">getElementById</span><span class="token punctuation">(</span><span class="token string">"cabecera"</span><span class="token punctuation">)</span><span class="token punctuation">.</span>offsetHeight
<span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<h2 id="las-ventajas" tabindex="-1">Las ventajas <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/#las-ventajas">#</a></h2>
<p><strong>Rendimiento</strong>: no hay cálculos de transformación en JavaScript. Todo lo hace el motor de renderizado CSS, que está optimizado para eso. El JS solo escribe una variable numérica en cada frame.</p>
<p><strong>Composición</strong>: puedes combinar múltiples animaciones en el mismo elemento. Un elemento puede rotar, cambiar de opacidad y moverse, todo controlado por el mismo scroll.</p>
<p><strong>Reversibilidad</strong>: como el cálculo es bidireccional, hacer scroll hacia arriba revierte la animación naturalmente. No necesitas código adicional.</p>
<p><strong>Mantenibilidad</strong>: las animaciones se definen en CSS, donde pertenecen. Si quieres cambiar un movimiento, editas un <code>@keyframes</code>. El JavaScript no cambia nunca.</p>
<h2 id="las-limitaciones" tabindex="-1">Las limitaciones <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/#las-limitaciones">#</a></h2>
<p>No es perfecto. El cálculo asume un scroll vertical lineal, así que no funciona bien con scroll horizontal o con contenedores con scroll propio. Las variables de posición (<code>--posinicio</code>, <code>--altura</code>) hay que calcularlas una vez al cargar y actualizar en el resize, lo que añade algo de complejidad.</p>
<p>Y si el contenido es dinámico y cambia de altura después de cargar, las posiciones se descuadran. Para una web estática donde tú controlas el contenido, funciona a la perfección.</p>
<h2 id="el-futuro%3A-css-scroll-driven-animations" tabindex="-1">El futuro: CSS Scroll-Driven Animations <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/animaciones-scroll-css/#el-futuro%3A-css-scroll-driven-animations">#</a></h2>
<p>La especificación CSS ahora incluye <code>animation-timeline: scroll()</code>, que hace lo mismo de forma nativa, sin JavaScript:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@keyframes</span> revelar</span> <span class="token punctuation">{</span>
  <span class="token selector">from</span> <span class="token punctuation">{</span> <span class="token property">opacity</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span> <span class="token property">transform</span><span class="token punctuation">:</span> <span class="token function">translateY</span><span class="token punctuation">(</span>20px<span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token punctuation">}</span>
  <span class="token selector">to</span> <span class="token punctuation">{</span> <span class="token property">opacity</span><span class="token punctuation">:</span> 1<span class="token punctuation">;</span> <span class="token property">transform</span><span class="token punctuation">:</span> <span class="token function">translateY</span><span class="token punctuation">(</span>0<span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token punctuation">}</span>

<span class="token selector">.elemento</span> <span class="token punctuation">{</span>
  <span class="token property">animation</span><span class="token punctuation">:</span> revelar linear both<span class="token punctuation">;</span>
  <span class="token property">animation-timeline</span><span class="token punctuation">:</span> <span class="token function">scroll</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">animation-range</span><span class="token punctuation">:</span> entry 0% entry 100%<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Todavía no tiene soporte universal, pero cuando lo tenga, el JavaScript desaparecerá por completo. Hasta entonces, la técnica del <code>animation-delay</code> negativo sigue siendo la más fiable y ligera.</p>
]]>
      </content:encoded>
      <pubDate>Tue, 02 Dec 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Un indicador de scroll con CSS puro</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/indicador-scroll-css/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/indicador-scroll-css/</guid>
      <description>
        Cómo crear una barra de progreso de lectura sin una sola línea de JavaScript, desde el truco clásico del gradiente diagonal hasta scroll-driven animations.
      </description>
      <content:encoded>
        <![CDATA[<p>Hay trucos CSS que cuando los ves por primera vez piensas: &quot;esto no debería funcionar&quot;. El indicador de scroll que ideó <a href="https://codepen.io/MadeByMike/pen/ezLYQL">Mike Riethmuller</a> es uno de ellos. Una barra de progreso de lectura que se llena conforme bajas por la página, sin JavaScript. Cero líneas.</p>
<h2 id="la-t%C3%A9cnica-cl%C3%A1sica%3A-el-gradiente-diagonal" tabindex="-1">La técnica clásica: el gradiente diagonal <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/indicador-scroll-css/#la-t%C3%A9cnica-cl%C3%A1sica%3A-el-gradiente-diagonal">#</a></h2>
<p>La idea original de Mike se basa en tres conceptos combinados:</p>
<ol>
<li><strong>Un gradiente diagonal</strong> aplicado al <code>body</code> con un corte duro al 50%</li>
<li><strong>Un cálculo de tamaño</strong> que compensa la altura del viewport</li>
<li><strong>Una máscara fija</strong> que oculta todo excepto una tira de 3 píxeles</li>
</ol>
<h3 id="la-geometr%C3%ADa" tabindex="-1">La geometría <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/indicador-scroll-css/#la-geometr%C3%ADa">#</a></h3>
<p>Imagina un rectángulo con una línea diagonal:</p>
<pre><code>+------------------+
|            /   B |   B = fondo (transparente)
|         /        |
|      /           |
|   /              |
| A                |   A = color de acento
+------------------+
</code></pre>
<p>Cuando el viewport se desplaza hacia abajo por este rectángulo, la intersección de la diagonal con el borde superior se mueve de izquierda a derecha. Si solo ves una franja fina en la parte de arriba, parece una barra de progreso.</p>
<h3 id="el-css-del-gradiente" tabindex="-1">El CSS del gradiente <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/indicador-scroll-css/#el-css-del-gradiente">#</a></h3>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">body</span> <span class="token punctuation">{</span>
  <span class="token property">background-image</span><span class="token punctuation">:</span> <span class="token function">linear-gradient</span><span class="token punctuation">(</span>
    to right top<span class="token punctuation">,</span>
    #f86624 50%<span class="token punctuation">,</span>
    transparent 50%
  <span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">background-size</span><span class="token punctuation">:</span> 100% <span class="token function">calc</span><span class="token punctuation">(</span>100% - 100vh + 4rem + 3px + 1px<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">background-repeat</span><span class="token punctuation">:</span> no-repeat<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token selector">body::after</span> <span class="token punctuation">{</span>
  <span class="token property">content</span><span class="token punctuation">:</span> <span class="token string">""</span><span class="token punctuation">;</span>
  <span class="token property">position</span><span class="token punctuation">:</span> fixed<span class="token punctuation">;</span>
  <span class="token property">top</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span>4rem + 3px<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">bottom</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
  <span class="token property">left</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
  <span class="token property">right</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
  <span class="token property">background</span><span class="token punctuation">:</span> #fff<span class="token punctuation">;</span>
  <span class="token property">z-index</span><span class="token punctuation">:</span> -1<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>La parte más ingeniosa es el <code>background-size</code>:</p>
<table>
<thead>
<tr>
<th>Parte</th>
<th>Propósito</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>100%</code></td>
<td>Altura total del documento</td>
</tr>
<tr>
<td><code>- 100vh</code></td>
<td>Restar un viewport, porque al llegar al final aún ves una pantalla</td>
</tr>
<tr>
<td><code>+ 4rem</code></td>
<td>Sumar la altura de la cabecera fija</td>
</tr>
<tr>
<td><code>+ 3px</code></td>
<td>Sumar la altura de la barra indicadora</td>
</tr>
<tr>
<td><code>+ 1px</code></td>
<td>Corrección para que llegue al 100% exacto</td>
</tr>
</tbody>
</table>
<p>Sin el <code>- 100vh</code>, el gradiente solo estaría a medio llenar al final de la página. La resta compensa que el viewport ocupa espacio.</p>
<p>El pseudo-elemento <code>::after</code> con <code>z-index: -1</code> actúa como máscara: queda entre el canvas del navegador (donde se pinta el fondo del body) y el contenido normal.</p>
<h3 id="la-limitaci%C3%B3n" tabindex="-1">La limitación <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/indicador-scroll-css/#la-limitaci%C3%B3n">#</a></h3>
<p>Esta técnica tiene un inconveniente importante. El gradiente se pinta en el canvas — la capa más baja del modelo de renderizado del navegador. Cualquier elemento con fondo propio (bloques de código, tarjetas, tablas) se pinta por encima. Cuando estos elementos cruzan la franja del indicador al hacer scroll, la tapan momentáneamente.</p>
<p>En páginas con mucho texto plano funciona bien. En páginas con muchos bloques de código, la barra desaparece durante tramos largos. Es geometría pura, y es brillante. Pero los contextos de apilamiento de CSS le ganan la batalla.</p>
<h2 id="la-versi%C3%B3n-moderna%3A-scroll-driven-animations" tabindex="-1">La versión moderna: scroll-driven animations <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/indicador-scroll-css/#la-versi%C3%B3n-moderna%3A-scroll-driven-animations">#</a></h2>
<p>Desde 2023, CSS tiene una API nativa para vincular animaciones al progreso de scroll: <code>animation-timeline: scroll()</code>. Esta alternativa resuelve el problema del z-index.</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">body::before</span> <span class="token punctuation">{</span>
  <span class="token property">content</span><span class="token punctuation">:</span> <span class="token string">""</span><span class="token punctuation">;</span>
  <span class="token property">position</span><span class="token punctuation">:</span> fixed<span class="token punctuation">;</span>
  <span class="token property">top</span><span class="token punctuation">:</span> 4rem<span class="token punctuation">;</span>
  <span class="token property">left</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
  <span class="token property">width</span><span class="token punctuation">:</span> 100%<span class="token punctuation">;</span>
  <span class="token property">height</span><span class="token punctuation">:</span> 3px<span class="token punctuation">;</span>
  <span class="token property">background</span><span class="token punctuation">:</span> #f86624<span class="token punctuation">;</span>
  <span class="token property">z-index</span><span class="token punctuation">:</span> 99<span class="token punctuation">;</span>
  <span class="token property">transform-origin</span><span class="token punctuation">:</span> left<span class="token punctuation">;</span>
  <span class="token property">scale</span><span class="token punctuation">:</span> 0 1<span class="token punctuation">;</span>
  <span class="token property">animation</span><span class="token punctuation">:</span> scroll-indicator linear forwards<span class="token punctuation">;</span>
  <span class="token property">animation-timeline</span><span class="token punctuation">:</span> <span class="token function">scroll</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token atrule"><span class="token rule">@keyframes</span> scroll-indicator</span> <span class="token punctuation">{</span>
  <span class="token selector">to</span> <span class="token punctuation">{</span> <span class="token property">scale</span><span class="token punctuation">:</span> 1 1<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>El indicador es un pseudo-elemento fijo con <code>z-index: 99</code>, por encima de todo el contenido. La animación <code>scale</code> va de 0 a 1 en el eje X, y <code>animation-timeline: scroll()</code> la vincula al progreso del scroll.</p>
<p>El inconveniente: el soporte de navegadores todavía no es completo. Chrome y Edge lo soportan bien desde 2023, pero Firefox y Safari van con retraso. Para un sitio donde el soporte universal importa, puede no ser suficiente.</p>
<h2 id="css-vs-javascript" tabindex="-1">CSS vs JavaScript <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/indicador-scroll-css/#css-vs-javascript">#</a></h2>
<p>Un listener de scroll en JavaScript ejecuta código en cada frame, necesita <code>requestAnimationFrame</code> para no bloquear el hilo principal, y falla si el usuario deshabilita scripts. Las versiones CSS:</p>
<ul>
<li>No ejecutan código en cada frame</li>
<li>El navegador optimiza la animación internamente</li>
<li>Funcionan sin JavaScript</li>
<li>Son menos de 15 líneas</li>
</ul>
<p>Cada enfoque tiene sus compromisos. El gradiente diagonal es universal pero tiene el problema del z-index. Las scroll-driven animations lo resuelven pero dependen de soporte moderno. Y JavaScript, aunque menos elegante, funciona en todas partes sin limitaciones visuales.</p>
<p>A veces la solución más elegante no es la más práctica. Pero merece la pena conocerla.</p>
<hr>
<p><em>La técnica del gradiente diagonal es de <a href="https://codepen.io/MadeByMike/pen/ezLYQL">Mike Riethmuller</a>. También la explica en detalle <a href="https://css-tricks.com/books/greatest-css-tricks/scroll-indicator/">CSS-Tricks</a>. La API de scroll-driven animations está documentada en <a href="https://developer.mozilla.org/en-US/docs/Web/CSS/animation-timeline/scroll">MDN</a>.</em></p>
]]>
      </content:encoded>
      <pubDate>Wed, 12 Nov 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Rendimiento en webs estáticas: lo que ya haces bien y lo que puedes mejorar</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/rendimiento-webs-estaticas/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/rendimiento-webs-estaticas/</guid>
      <description>
        Una web estática no es automáticamente rápida. Técnicas concretas para que tu sitio generado con Eleventy cargue en milisegundos.
      </description>
      <content:encoded>
        <![CDATA[<p>Hay una idea extendida de que las webs estáticas son rápidas por definición. Es verdad a medias. Una web estática elimina el cuello de botella del servidor (no hay base de datos, no hay rendering server-side), pero el HTML que generas puede ser igual de pesado que el de un CMS si no prestas atención.</p>
<h2 id="lo-que-ya-ganas" tabindex="-1">Lo que ya ganas <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/rendimiento-webs-estaticas/#lo-que-ya-ganas">#</a></h2>
<p>Con Eleventy o cualquier generador estático, partes con ventaja:</p>
<ul>
<li><strong>Sin servidor dinámico</strong> — el HTML está pregenerado, el servidor solo lo sirve</li>
<li><strong>CDN friendly</strong> — los ficheros estáticos se cachean y distribuyen globalmente</li>
<li><strong>Sin base de datos</strong> — cero latencia de queries</li>
<li><strong>Sin framework JS</strong> (si lo evitas) — menos código para parsear y ejecutar</li>
</ul>
<p>Pero estas ventajas se pueden desperdiciar fácilmente.</p>
<h2 id="css%3A-inline-vs-archivo-externo" tabindex="-1">CSS: inline vs archivo externo <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/rendimiento-webs-estaticas/#css%3A-inline-vs-archivo-externo">#</a></h2>
<p>El plugin <code>eleventy-plugin-bundle</code> permite incluir el CSS inline en el HTML:</p>
<p %="" raw="" %=""></p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>style</span><span class="token punctuation">></span></span>
  {% getBundle "css" %}
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>style</span><span class="token punctuation">></span></span>
</code></pre>
<p %="" endraw="" %=""></p>
<p>Para sitios pequeños, esto es una ventaja: eliminas una petición HTTP. El navegador tiene todo lo que necesita en el primer HTML para renderizar la página. No hay FOUC (flash of unstyled content) porque el CSS está disponible antes del primer paint.</p>
<p>El punto de inflexión está alrededor de los 14KB. Ese es el tamaño del primer round-trip TCP (el primer fragmento que el servidor puede enviar). Si tu CSS cabe ahí junto con el HTML, inline es la opción correcta. Si no, un archivo externo con <code>rel=&quot;preload&quot;</code> es mejor porque el navegador lo cachea.</p>
<p>El CSS completo de este sitio, incluyendo todos los componentes y ambos temas, está por debajo de ese límite.</p>
<h2 id="fuentes%3A-la-trampa-silenciosa" tabindex="-1">Fuentes: la trampa silenciosa <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/rendimiento-webs-estaticas/#fuentes%3A-la-trampa-silenciosa">#</a></h2>
<p>Las fuentes web son probablemente el mayor problema de rendimiento en sitios que por lo demás son ligeros. Cada fichero de fuente es una petición HTTP, y el navegador no renderiza el texto hasta que la fuente carga (por defecto).</p>
<p>Opciones, de más rápida a más lenta:</p>
<ol>
<li><strong>System font stack</strong> — cero peticiones, renderizado instantáneo</li>
<li><strong>Fuente local con <code>font-display: swap</code></strong> — el texto aparece inmediatamente con la fuente del sistema y cambia cuando carga la fuente web</li>
<li><strong>Fuente de Google Fonts</strong> — añade latencia de DNS + petición al CDN de Google</li>
<li><strong>Múltiples pesos de fuente</strong> — cada peso es un archivo adicional</li>
</ol>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@font-face</span></span> <span class="token punctuation">{</span>
  <span class="token property">font-family</span><span class="token punctuation">:</span> <span class="token string">"MiFuente"</span><span class="token punctuation">;</span>
  <span class="token property">src</span><span class="token punctuation">:</span> <span class="token url"><span class="token function">url</span><span class="token punctuation">(</span><span class="token string url">"/fonts/mifuente.woff2"</span><span class="token punctuation">)</span></span> <span class="token function">format</span><span class="token punctuation">(</span><span class="token string">"woff2"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">font-display</span><span class="token punctuation">:</span> swap<span class="token punctuation">;</span>
  <span class="token property">font-weight</span><span class="token punctuation">:</span> 400<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>El formato <code>woff2</code> es obligatorio — es el más comprimido y tiene soporte universal. Si tu fuente solo viene en TTF u OTF, conviértela.</p>
<p>Y un consejo: limita los pesos. ¿Realmente necesitas thin, light, regular, medium, semibold, bold, extrabold y black? Probablemente con regular y bold cubras el 95% de los casos.</p>
<h2 id="im%C3%A1genes%3A-el-elefante-en-la-habitaci%C3%B3n" tabindex="-1">Imágenes: el elefante en la habitación <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/rendimiento-webs-estaticas/#im%C3%A1genes%3A-el-elefante-en-la-habitaci%C3%B3n">#</a></h2>
<p>En una web de contenido textual como esta, las imágenes no son un problema porque apenas las hay. Pero en proyectos con imágenes, son el factor dominante del rendimiento.</p>
<p>Lo mínimo que deberías hacer:</p>
<ul>
<li><strong>Formatos modernos</strong>: WebP o AVIF en vez de JPEG/PNG</li>
<li><strong>Dimensiones correctas</strong>: no servir una imagen de 3000px para un espacio de 800px</li>
<li><strong><code>loading=&quot;lazy&quot;</code></strong>: carga diferida para imágenes fuera del viewport</li>
<li><strong><code>srcset</code> y <code>sizes</code></strong>: para que el navegador elija la resolución adecuada</li>
</ul>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>picture</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>source</span> <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>imagen.avif<span class="token punctuation">"</span></span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>image/avif<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>source</span> <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>imagen.webp<span class="token punctuation">"</span></span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>image/webp<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>imagen.jpg<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Descripción<span class="token punctuation">"</span></span> <span class="token attr-name">loading</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>lazy<span class="token punctuation">"</span></span>
       <span class="token attr-name">width</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>800<span class="token punctuation">"</span></span> <span class="token attr-name">height</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>450<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>picture</span><span class="token punctuation">></span></span>
</code></pre>
<h2 id="javascript%3A-menos-es-m%C3%A1s" tabindex="-1">JavaScript: menos es más <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/rendimiento-webs-estaticas/#javascript%3A-menos-es-m%C3%A1s">#</a></h2>
<p>El JavaScript propio de este sitio — toggle de tema, menú hamburguesa, animaciones de entrada y gestión de enlaces externos — va inline en el HTML y pesa menos de lo que pesa una sola imagen de avatar en la mayoría de webs. El cookie consent, que es la dependencia más pesada, se carga de forma diferida: su CSS y JavaScript no bloquean el primer renderizado ni forman parte del bundle principal.</p>
<p>El truco no es minificar mejor ni usar tree-shaking más agresivo. El truco es no cargar lo que no necesitas en el momento crítico. Cada <code>npm install</code> que evitas es rendimiento ganado, y lo que no puedes evitar, lo difiere.</p>
<h2 id="medir%2C-no-adivinar" tabindex="-1">Medir, no adivinar <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/rendimiento-webs-estaticas/#medir%2C-no-adivinar">#</a></h2>
<p>Las herramientas están ahí:</p>
<ul>
<li><strong>Lighthouse</strong> en las DevTools de Chrome — puntuación general</li>
<li><strong>WebPageTest</strong> — waterfall detallado y métricas reales</li>
<li><strong>El panel Network de las DevTools</strong> — para ver exactamente qué se carga y cuánto pesa</li>
</ul>
<p>La métrica más importante para una web de contenido es <strong>Largest Contentful Paint (LCP)</strong>: cuánto tarda en aparecer el contenido principal. Para este sitio, debería estar por debajo de 1 segundo en una conexión decente, porque lo primero que el navegador recibe es un HTML completo con CSS inline y texto. No hay JavaScript que bloquee el renderizado, no hay fuentes que retrasen el paint, no hay imágenes above-the-fold.</p>
<p>Eso no es magia. Es solo tomar decisiones conscientes sobre qué incluir y qué dejar fuera.</p>
]]>
      </content:encoded>
      <pubDate>Sat, 25 Oct 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Imágenes responsive con picture y srcset: la guía práctica</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/imagenes-responsive-picture/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/imagenes-responsive-picture/</guid>
      <description>
        Cómo servir la imagen correcta para cada dispositivo sin cargar bytes innecesarios. picture, srcset, sizes, lazy loading y formatos modernos.
      </description>
      <content:encoded>
        <![CDATA[<p>Las imágenes suelen ser el recurso más pesado de una web. En una página media representan más del 50% del peso total. Y en la mayoría de casos, el navegador está descargando imágenes más grandes de lo que necesita.</p>
<p>Un móvil con pantalla de 375px no necesita una imagen de 2000px de ancho. Un navegador que soporta WebP no debería recibir un JPEG. Pero si no le das opciones al navegador, descargará la única imagen que le ofreces, sea o no la adecuada.</p>
<h2 id="el-atributo-srcset" tabindex="-1">El atributo srcset <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-responsive-picture/#el-atributo-srcset">#</a></h2>
<p>La forma más sencilla de ofrecer alternativas:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span>
  <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-800.jpg<span class="token punctuation">"</span></span>
  <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-400.jpg 400w,
          foto-800.jpg 800w,
          foto-1200.jpg 1200w<span class="token punctuation">"</span></span>
  <span class="token attr-name">sizes</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>(max-width: 600px) 100vw,
         (max-width: 900px) 50vw,
         33vw<span class="token punctuation">"</span></span>
  <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Descripción de la foto<span class="token punctuation">"</span></span>
  <span class="token attr-name">loading</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>lazy<span class="token punctuation">"</span></span>
  <span class="token attr-name">width</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>1200<span class="token punctuation">"</span></span>
  <span class="token attr-name">height</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>800<span class="token punctuation">"</span></span>
<span class="token punctuation">></span></span>
</code></pre>
<p><code>srcset</code> le dice al navegador qué imágenes están disponibles y su ancho real en píxeles. <code>sizes</code> le dice cuánto espacio ocupará la imagen en el layout. Con esa información, el navegador elige la imagen más adecuada considerando el ancho del viewport y la densidad de píxeles de la pantalla.</p>
<p>El <code>src</code> original es el fallback para navegadores que no soportan <code>srcset</code> (prácticamente ninguno en 2025, pero por compatibilidad).</p>
<h2 id="el-elemento-picture" tabindex="-1">El elemento picture <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-responsive-picture/#el-elemento-picture">#</a></h2>
<p>Cuando necesitas cambiar el formato de imagen (no solo el tamaño), <code>&lt;picture&gt;</code> es la herramienta:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>picture</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>source</span> <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto.avif<span class="token punctuation">"</span></span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>image/avif<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>source</span> <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto.webp<span class="token punctuation">"</span></span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>image/webp<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto.jpg<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Descripción<span class="token punctuation">"</span></span> <span class="token attr-name">loading</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>lazy<span class="token punctuation">"</span></span>
       <span class="token attr-name">width</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>800<span class="token punctuation">"</span></span> <span class="token attr-name">height</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>450<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>picture</span><span class="token punctuation">></span></span>
</code></pre>
<p>El navegador recorre las <code>&lt;source&gt;</code> en orden y usa la primera que soporta. Si soporta AVIF (el más comprimido), lo usa. Si no, prueba WebP. Si no soporta ninguno, usa el JPEG del <code>&lt;img&gt;</code>.</p>
<p>También puedes combinar formatos con tamaños:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>picture</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>source</span>
    <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-400.avif 400w, foto-800.avif 800w<span class="token punctuation">"</span></span>
    <span class="token attr-name">sizes</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>(max-width: 600px) 100vw, 50vw<span class="token punctuation">"</span></span>
    <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>image/avif<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>source</span>
    <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-400.webp 400w, foto-800.webp 800w<span class="token punctuation">"</span></span>
    <span class="token attr-name">sizes</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>(max-width: 600px) 100vw, 50vw<span class="token punctuation">"</span></span>
    <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>image/webp<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span>
    <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-800.jpg<span class="token punctuation">"</span></span>
    <span class="token attr-name">srcset</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto-400.jpg 400w, foto-800.jpg 800w<span class="token punctuation">"</span></span>
    <span class="token attr-name">sizes</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>(max-width: 600px) 100vw, 50vw<span class="token punctuation">"</span></span>
    <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Descripción<span class="token punctuation">"</span></span>
    <span class="token attr-name">loading</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>lazy<span class="token punctuation">"</span></span>
    <span class="token attr-name">width</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>800<span class="token punctuation">"</span></span> <span class="token attr-name">height</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>450<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>picture</span><span class="token punctuation">></span></span>
</code></pre>
<p>Es verboso, sí. Pero puede reducir el peso de las imágenes en un 50-70%.</p>
<h2 id="lazy-loading-nativo" tabindex="-1">Lazy loading nativo <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-responsive-picture/#lazy-loading-nativo">#</a></h2>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto.jpg<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>...<span class="token punctuation">"</span></span> <span class="token attr-name">loading</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>lazy<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
</code></pre>
<p>Una línea. Le dice al navegador que no descargue la imagen hasta que esté cerca del viewport. No necesitas IntersectionObserver, no necesitas una librería. Es un atributo HTML.</p>
<p>Úsalo en todas las imágenes que no estén en el viewport inicial (above the fold). Para la imagen hero o el logo, usa <code>loading=&quot;eager&quot;</code> (o simplemente no pongas el atributo, que es el valor por defecto).</p>
<h2 id="width-y-height%3A-evitar-el-layout-shift" tabindex="-1">Width y height: evitar el layout shift <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-responsive-picture/#width-y-height%3A-evitar-el-layout-shift">#</a></h2>
<p>Siempre incluye <code>width</code> y <code>height</code> en tus imágenes:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>foto.jpg<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>...<span class="token punctuation">"</span></span> <span class="token attr-name">width</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>800<span class="token punctuation">"</span></span> <span class="token attr-name">height</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>450<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
</code></pre>
<p>No es para definir el tamaño visual (eso lo hace CSS). Es para que el navegador pueda calcular el aspect ratio <strong>antes</strong> de descargar la imagen y reservar el espacio correcto en el layout. Sin estos atributos, el contenido &quot;salta&quot; cuando la imagen carga — eso es un layout shift, y es una de las métricas que más penaliza en Core Web Vitals.</p>
<h2 id="formatos%3A-avif-%3E-webp-%3E-jpeg" tabindex="-1">Formatos: AVIF &gt; WebP &gt; JPEG <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-responsive-picture/#formatos%3A-avif-%3E-webp-%3E-jpeg">#</a></h2>
<ul>
<li><strong>AVIF</strong>: la mejor compresión disponible. Archivos 50% más pequeños que JPEG a calidad equivalente. Soporte del 92%+ de navegadores.</li>
<li><strong>WebP</strong>: buen compromiso entre compresión y soporte. 25-35% más pequeño que JPEG. Soporte prácticamente universal.</li>
<li><strong>JPEG</strong>: el fallback universal. Sigue siendo necesario como último recurso.</li>
<li><strong>PNG</strong>: solo para imágenes que necesitan transparencia y no pueden ser WebP/AVIF.</li>
</ul>
<h2 id="en-la-pr%C3%A1ctica" tabindex="-1">En la práctica <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/imagenes-responsive-picture/#en-la-pr%C3%A1ctica">#</a></h2>
<p>Para una web con pocas imágenes (como esta), la optimización manual es factible: generas las variantes una vez y las subes. Para una web con muchas imágenes, necesitas automatización — <code>eleventy-img</code> para Eleventy, <code>sharp</code> en Node, o un servicio de transformación como Cloudinary.</p>
<p>Lo importante es no servir imágenes sin optimizar. Una foto de 4000x3000 pixels directo de la cámara en un <code>&lt;img&gt;</code> sin srcset ni lazy loading es el error de rendimiento más común y más fácil de corregir en la web.</p>
]]>
      </content:encoded>
      <pubDate>Sun, 05 Oct 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Nunjucks como motor de plantillas: lo bueno y lo mejorable</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/</guid>
      <description>
        Mi experiencia usando Nunjucks con Eleventy tras varios años y varios proyectos. Qué me gusta, qué me frustra y qué alternativas he considerado.
      </description>
      <content:encoded>
        <![CDATA[<p>{% raw %}
Nunjucks es el motor de plantillas que uso en todos mis proyectos con Eleventy. Lo elegí porque es potente, tiene una sintaxis clara y la herencia de layouts funciona exactamente como esperas. Después de varios años usándolo, tengo una opinión matizada: es muy bueno, pero no es perfecto.</p>
<h2 id="lo-que-me-gusta" tabindex="-1">Lo que me gusta <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#lo-que-me-gusta">#</a></h2>
<h3 id="herencia-de-layouts" tabindex="-1">Herencia de layouts <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#herencia-de-layouts">#</a></h3>
<p>La característica estrella. Defines un layout base y los demás lo extienden:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{# base.njk #}
<span class="token doctype"><span class="token punctuation">&lt;!</span><span class="token doctype-tag">DOCTYPE</span> <span class="token name">html</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>html</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>head</span><span class="token punctuation">></span></span>...<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>head</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>body</span><span class="token punctuation">></span></span>
    {{ content | safe }}
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>body</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>html</span><span class="token punctuation">></span></span>

{# post.njk — extiende base #}
---
layout: layouts/base.njk
---
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>article</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>post<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>h1</span><span class="token punctuation">></span></span>{{ title }}<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>h1</span><span class="token punctuation">></span></span>
  {{ content | safe }}
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>article</span><span class="token punctuation">></span></span>
</code></pre>
<p>Es limpio, predecible y fácil de seguir. Cada layout sabe qué hereda y qué añade.</p>
<h3 id="includes-y-partials" tabindex="-1">Includes y partials <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#includes-y-partials">#</a></h3>
<p>Los parciales de Nunjucks son la forma más natural de componentizar HTML sin JavaScript:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{% include 'partials/cabecera.njk' %}
{% include 'partials/pie.njk' %}
</code></pre>
<p>No hay props, no hay estado, no hay ciclo de vida. Es solo insertar HTML. Para una web estática, es todo lo que necesitas.</p>
<h3 id="filtros" tabindex="-1">Filtros <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#filtros">#</a></h3>
<p>Los filtros son funciones que transforman datos en la plantilla. Eleventy permite definirlos en la configuración y usarlos en Nunjucks:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>time</span><span class="token punctuation">></span></span>{{ date | readableDate }}<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>time</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>p</span><span class="token punctuation">></span></span>{{ content | intro(100) }}<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>p</span><span class="token punctuation">></span></span>
</code></pre>
<p>Son compositivos — puedes encadenarlos — y mantienen la lógica de presentación separada de los datos. Es el patrón correcto.</p>
<h3 id="loops-y-condicionales" tabindex="-1">Loops y condicionales <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#loops-y-condicionales">#</a></h3>
<p>La sintaxis para iterar y condicionar es directa:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{%- for post in collections.bitacora %}
  {% if post.data.destacado %}
    {% include 'partials/postcard.njk' %}
  {% endif %}
{%- endfor %}
</code></pre>
<p>Sin sorpresas, sin edge cases raros. Hace lo que parece que hace.</p>
<h2 id="lo-que-me-frustra" tabindex="-1">Lo que me frustra <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#lo-que-me-frustra">#</a></h2>
<h3 id="errores-cr%C3%ADpticos" tabindex="-1">Errores crípticos <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#errores-cr%C3%ADpticos">#</a></h3>
<p>Cuando algo falla en una plantilla Nunjucks, los mensajes de error son a menudo inútiles. Un paréntesis mal cerrado o una variable inexistente te dan un error genérico que apunta a la línea equivocada. Depurar una plantilla compleja a veces se convierte en un proceso de eliminación: vas comentando bloques hasta que encuentras el que falla.</p>
<h3 id="no-hay-tipado" tabindex="-1">No hay tipado <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#no-hay-tipado">#</a></h3>
<p>Nunjucks no sabe qué propiedades tiene un objeto hasta el runtime. Si escribes <code>{{ post.tittle }}</code> (con doble T), no hay error — simplemente no renderiza nada. Te das cuenta cuando ves un hueco vacío en la web. TypeScript arruinó mi tolerancia a los errores en tiempo de ejecución.</p>
<h3 id="sintaxis-verbosa-para-l%C3%B3gica-compleja" tabindex="-1">Sintaxis verbosa para lógica compleja <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#sintaxis-verbosa-para-l%C3%B3gica-compleja">#</a></h3>
<p>Cuando la lógica se complica, Nunjucks se vuelve difícil de leer:</p>
<pre class="language-html" tabindex="0"><code class="language-html">{%- set postsOrdenados = collections.bitacora | ordenarPorFecha | head(3) %}
{%- for post in postsOrdenados %}
  {%- if post.data.tags | filterTagList | length > 0 %}
    ...
  {%- endif %}
{%- endfor %}
</code></pre>
<p>No es terrible, pero cuando tienes tres niveles de anidación con filtros encadenados, echas de menos poder escribir lógica en un lenguaje de verdad.</p>
<h3 id="whitespace-control" tabindex="-1">Whitespace control <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#whitespace-control">#</a></h3>
<p>Los <code>{%-</code> y <code>-%}</code> para controlar los espacios en blanco son necesarios pero molestos. Si no los usas, tu HTML generado tiene líneas vacías y sangrado irregular. Si los usas en todas partes, la plantilla se vuelve ruidosa.</p>
<h2 id="lo-que-he-considerado" tabindex="-1">Lo que he considerado <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/nunjucks-plantillas-eleventy/#lo-que-he-considerado">#</a></h2>
<p><strong>Liquid</strong>: más limitado que Nunjucks, pero con mejor manejo de errores. Jekyll lo usa y la comunidad es enorme.</p>
<p><strong>WebC</strong>: la propuesta nativa de Eleventy para componentes web. Interesante, pero todavía joven y con una curva de aprendizaje diferente.</p>
<p><strong>JSX/TSX con 11ty</strong>: técnicamente posible, pero va contra la filosofía de mantener las plantillas simples y sin build step.</p>
<p %="" endraw="" %="">De momento me quedo con Nunjucks. Sus defectos son menores comparados con sus ventajas, y la familiaridad acumulada después de varios proyectos tiene un valor que no se puede subestimar. El mejor motor de plantillas es el que ya conoces, siempre que no te limite activamente.</p>
]]>
      </content:encoded>
      <pubDate>Mon, 15 Sep 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Implementar un tema claro/oscuro con CSS custom properties</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/</guid>
      <description>
        Cómo construir un sistema de temas sin frameworks ni JavaScript innecesario. Solo custom properties, un atributo data y un par de líneas de JS.
      </description>
      <content:encoded>
        <![CDATA[<p>El tema oscuro dejó de ser un capricho de programadores. Hoy lo esperan los usuarios, los sistemas operativos lo soportan de forma nativa, y CSS tiene las herramientas para implementarlo sin dolor. Así lo hago en mis proyectos.</p>
<h2 id="la-estructura" tabindex="-1">La estructura <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#la-estructura">#</a></h2>
<p>Todo el sistema se basa en tres piezas:</p>
<ol>
<li><strong>Custom properties</strong> para los colores del tema</li>
<li><strong>Un atributo <code>data-theme</code></strong> en el <code>&lt;html&gt;</code> para cambiar entre temas</li>
<li><strong>Un script mínimo</strong> para persistir la preferencia</li>
</ol>
<h2 id="los-colores-como-variables" tabindex="-1">Los colores como variables <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#los-colores-como-variables">#</a></h2>
<p>En vez de usar colores directos en los componentes, todo pasa por custom properties:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:root</span> <span class="token punctuation">{</span>
  <span class="token property">--color-texto</span><span class="token punctuation">:</span> #1a1b2e<span class="token punctuation">;</span>
  <span class="token property">--color-texto-alt</span><span class="token punctuation">:</span> #5a5b6e<span class="token punctuation">;</span>
  <span class="token property">--color-fondo</span><span class="token punctuation">:</span> #fafaf8<span class="token punctuation">;</span>
  <span class="token property">--color-fondo-alt</span><span class="token punctuation">:</span> #f0f0ec<span class="token punctuation">;</span>
  <span class="token property">--color-acento</span><span class="token punctuation">:</span> #e05a1b<span class="token punctuation">;</span>
  <span class="token property">--color-borde</span><span class="token punctuation">:</span> #d8d8d0<span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token selector">[data-theme="dark"]</span> <span class="token punctuation">{</span>
  <span class="token property">--color-texto</span><span class="token punctuation">:</span> #dcdcd4<span class="token punctuation">;</span>
  <span class="token property">--color-texto-alt</span><span class="token punctuation">:</span> #8e8e86<span class="token punctuation">;</span>
  <span class="token property">--color-fondo</span><span class="token punctuation">:</span> #111118<span class="token punctuation">;</span>
  <span class="token property">--color-fondo-alt</span><span class="token punctuation">:</span> #1a1a24<span class="token punctuation">;</span>
  <span class="token property">--color-acento</span><span class="token punctuation">:</span> #f86624<span class="token punctuation">;</span>
  <span class="token property">--color-borde</span><span class="token punctuation">:</span> #2a2a3a<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Lo importante: el acento siempre es naranja, pero se ajusta ligeramente entre temas. En tema claro uso un naranja más contenido (#e05a1b) para que no resulte agresivo sobre fondo blanco. En tema oscuro, un naranja más vivo (#f86624) que destaque sobre el fondo oscuro. Es el mismo tono, con diferente intensidad. Lo que cambia de forma más notable son los fondos, los textos y los bordes.</p>
<h2 id="los-componentes-no-saben-de-temas" tabindex="-1">Los componentes no saben de temas <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#los-componentes-no-saben-de-temas">#</a></h2>
<p>Una vez definidas las variables, los componentes las usan sin saber si estamos en tema claro u oscuro:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.tarjeta</span> <span class="token punctuation">{</span>
  <span class="token property">background</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-fondo-alt<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">color</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-texto<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">border</span><span class="token punctuation">:</span> 1px solid <span class="token function">var</span><span class="token punctuation">(</span>--color-borde<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>No hay <code>.tarjeta--dark</code>, no hay <code>@media (prefers-color-scheme: dark)</code> repetido en cada componente, no hay clases condicionales. El cambio de tema se resuelve en un solo lugar: la definición de las variables.</p>
<h2 id="evitar-el-flash-(fouc)" tabindex="-1">Evitar el flash (FOUC) <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#evitar-el-flash-(fouc)">#</a></h2>
<p>El error más común al implementar temas es poner el JavaScript al final del body. El resultado: la página carga en el tema por defecto (claro) y medio segundo después salta al oscuro. Ese parpadeo es molesto y fácil de evitar.</p>
<p>La solución es un script inline en el <code>&lt;head&gt;</code>, antes del CSS:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>script</span><span class="token punctuation">></span></span>
  (function() {
    var theme = localStorage.getItem('theme');
    if (!theme) {
      theme = window.matchMedia('(prefers-color-scheme: dark)').matches
        ? 'dark' : 'light';
    }
    document.documentElement.setAttribute('data-theme', theme);
  })();
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>script</span><span class="token punctuation">></span></span>
</code></pre>
<p>Este script se ejecuta síncronamente antes de que el navegador renderice nada. Lee la preferencia guardada en localStorage; si no hay ninguna, respeta la del sistema operativo. El atributo <code>data-theme</code> se aplica antes del primer paint, así que no hay flash.</p>
<h2 id="el-bot%C3%B3n-de-toggle" tabindex="-1">El botón de toggle <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#el-bot%C3%B3n-de-toggle">#</a></h2>
<p>El botón es simple: cambia el atributo y guarda en localStorage.</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript">document<span class="token punctuation">.</span><span class="token function">querySelectorAll</span><span class="token punctuation">(</span><span class="token string">".theme-toggle"</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">forEach</span><span class="token punctuation">(</span><span class="token parameter">btn</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
  btn<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"click"</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
    <span class="token keyword">const</span> current <span class="token operator">=</span> document<span class="token punctuation">.</span>documentElement<span class="token punctuation">.</span><span class="token function">getAttribute</span><span class="token punctuation">(</span><span class="token string">"data-theme"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token keyword">const</span> next <span class="token operator">=</span> current <span class="token operator">===</span> <span class="token string">"dark"</span> <span class="token operator">?</span> <span class="token string">"light"</span> <span class="token operator">:</span> <span class="token string">"dark"</span><span class="token punctuation">;</span>
    document<span class="token punctuation">.</span>documentElement<span class="token punctuation">.</span><span class="token function">setAttribute</span><span class="token punctuation">(</span><span class="token string">"data-theme"</span><span class="token punctuation">,</span> next<span class="token punctuation">)</span><span class="token punctuation">;</span>
    localStorage<span class="token punctuation">.</span><span class="token function">setItem</span><span class="token punctuation">(</span><span class="token string">"theme"</span><span class="token punctuation">,</span> next<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token comment">// Actualizar icono</span>
    document<span class="token punctuation">.</span><span class="token function">querySelectorAll</span><span class="token punctuation">(</span><span class="token string">".theme-toggle"</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">forEach</span><span class="token punctuation">(</span><span class="token parameter">b</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
      b<span class="token punctuation">.</span>textContent <span class="token operator">=</span> next <span class="token operator">===</span> <span class="token string">"dark"</span> <span class="token operator">?</span> <span class="token string">"☀"</span> <span class="token operator">:</span> <span class="token string">"☾"</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<h2 id="detalles-que-importan" tabindex="-1">Detalles que importan <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#detalles-que-importan">#</a></h2>
<h3 id="im%C3%A1genes-y-contraste" tabindex="-1">Imágenes y contraste <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#im%C3%A1genes-y-contraste">#</a></h3>
<p>En tema oscuro, las imágenes con fondo blanco quedan como focos en una habitación a oscuras. Puedo reducir su brillo con CSS:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">[data-theme="dark"] img</span> <span class="token punctuation">{</span>
  <span class="token property">filter</span><span class="token punctuation">:</span> <span class="token function">brightness</span><span class="token punctuation">(</span>0.9<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<h3 id="sombras" tabindex="-1">Sombras <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#sombras">#</a></h3>
<p>Las sombras que funcionan en tema claro son invisibles en tema oscuro. En vez de sombras negras, uso sombras basadas en el color de fondo con transparencia.</p>
<h3 id="transiciones" tabindex="-1">Transiciones <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#transiciones">#</a></h3>
<p>Un <code>transition</code> en el body suaviza el cambio:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">body</span> <span class="token punctuation">{</span>
  <span class="token property">transition</span><span class="token punctuation">:</span> background-color 0.3s ease<span class="token punctuation">,</span> color 0.3s ease<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Pero ojo: esto puede causar transiciones no deseadas al cargar la página. Una opción es añadir la transición solo después del primer paint con JavaScript, o usar una clase temporal.</p>
<h2 id="lo-que-no-hago" tabindex="-1">Lo que no hago <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tema-claro-oscuro-css/#lo-que-no-hago">#</a></h2>
<p>No uso <code>prefers-color-scheme</code> como selector principal. Lo uso solo como fallback cuando no hay preferencia guardada. La razón: si el usuario ha elegido explícitamente un tema en mi web, esa decisión debe prevalecer sobre la del sistema operativo.</p>
<p>Tampoco intento hacer una transición automática entre temas según la hora del día. Si el usuario quiere claro a las 3 de la mañana, es su decisión. El toggle está ahí para eso.</p>
]]>
      </content:encoded>
      <pubDate>Thu, 28 Aug 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Desplegar webs estáticas: opciones reales para 2025</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/</guid>
      <description>
        Comparación práctica de las plataformas de hosting para sitios estáticos. De Netlify a Bunny CDN, pasando por StaticHost.eu — mi recorrido y dónde he terminado.
      </description>
      <content:encoded>
        <![CDATA[<p>Una de las grandes ventajas de las webs estáticas es la simplicidad del despliegue. No necesitas configurar servidores, bases de datos ni entornos de ejecución. Son ficheros HTML, CSS y JavaScript que cualquier servidor web puede servir. Pero &quot;cualquier servidor&quot; no significa que todas las opciones sean iguales.</p>
<p>Después de probar varias plataformas para mis proyectos con Eleventy, estas son mis conclusiones. No es una lista teórica — es un recorrido por las que he usado realmente y por qué he acabado donde he acabado.</p>
<h2 id="netlify" tabindex="-1">Netlify <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/#netlify">#</a></h2>
<p>Fue pionera en popularizar el hosting estático con builds automáticos. Conectas un repositorio Git, configuras el comando de build (<code>npx eleventy</code>), y cada push despliega automáticamente. Durante años fue la referencia del sector, y con razón.</p>
<p><strong>Lo bueno</strong>: Configuración trivial, previews de PR, redirects con <code>_redirects</code>, formularios nativos, functions serverless. El plan gratuito es generoso (100GB de ancho de banda, 300 minutos de build).</p>
<p><strong>Lo malo</strong>: Los builds pueden ser lentos (arrancar el entorno lleva más que el build en sí). El CDN no es tan rápido como el de Cloudflare. Y las funciones serverless añaden complejidad que contradice la filosofía del sitio estático. Con el tiempo, la plataforma ha ido creciendo en funcionalidades hasta convertirse en algo que ya no se siente tan simple.</p>
<p>Yo usé Netlify durante una buena temporada. Cumple, pero llegó un momento en que busqué alternativas que se ajustaran mejor a mi forma de trabajar.</p>
<p><strong>Ideal para</strong>: proyectos con formularios, funciones serverless o equipos que necesitan previews de PR.</p>
<h2 id="cloudflare-pages" tabindex="-1">Cloudflare Pages <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/#cloudflare-pages">#</a></h2>
<p>Es más reciente que Netlify pero la infraestructura de CDN de Cloudflare es difícil de superar. Para quien ya está en el ecosistema Cloudflare (DNS, protección DDoS), la integración es natural.</p>
<p><strong>Lo bueno</strong>: CDN global rapidísimo, builds razonablemente rápidos, plan gratuito sin límites prácticos de ancho de banda (ilimitado), integración con el ecosistema Cloudflare (DNS, protección DDoS, analytics server-side).</p>
<p><strong>Lo malo</strong>: Menos funcionalidades &quot;de conveniencia&quot; que Netlify. No tiene formularios nativos ni redirects con fichero de texto (usa <code>_headers</code> y <code>_redirects</code>, pero con limitaciones). La documentación es menos madura.</p>
<p>La he probado para algunas pruebas puntuales, poco más. Funciona bien, pero no me ha dado motivos para adoptarla como opción principal.</p>
<p><strong>Ideal para</strong>: proyectos donde el rendimiento de entrega es prioritario y ya estás en el ecosistema Cloudflare.</p>
<h2 id="statichost.eu" tabindex="-1">StaticHost.eu <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/#statichost.eu">#</a></h2>
<p>Una alternativa europea que merece atención. <a href="https://statichost.eu/">StaticHost</a> es un servicio de hosting pensado específicamente para webs estáticas, con una filosofía que me atrae: simplicidad, transparencia y servidores en Europa.</p>
<p><strong>Lo bueno</strong>: Filosofía alineada con la web estática (sin funciones serverless, sin complejidad innecesaria). Precios claros. Servidores europeos. Soporte cercano — cuando hay un problema, hablas con personas, no con un chatbot. El proyecto avanza con paso firme y cada actualización demuestra que hay un criterio detrás.</p>
<p><strong>Lo malo</strong>: Al ser un servicio más joven, todavía tiene algunas limitaciones técnicas. Nada que impida alojar un sitio estático, pero las opciones de configuración avanzada no están al nivel de los grandes. Es un servicio en crecimiento, y eso implica que no todo está pulido aún.</p>
<p>Me gusta lo que están construyendo. Es el tipo de servicio que quieres que funcione, porque representa una alternativa real a los gigantes estadounidenses.</p>
<p><strong>Ideal para</strong>: proyectos estáticos que valoren privacidad, servidores europeos y un servicio con filosofía clara.</p>
<h2 id="bunny-cdn" tabindex="-1">Bunny CDN <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/#bunny-cdn">#</a></h2>
<p>Y aquí es donde he acabado. <a href="https://bunny.net/">Bunny CDN</a> es, técnicamente, una CDN — no una plataforma de hosting estático al uso. Pero su servicio de Storage + Pull Zone funciona perfectamente como hosting para webs estáticas, y con una flexibilidad que las plataformas especializadas no ofrecen.</p>
<p><strong>Lo bueno</strong>: Red global de servidores rápida y fiable. Puedo subir ficheros por FTP, usar su API, o automatizar el despliegue desde GitHub con Actions — cada proyecto puede usar el flujo que más le convenga. El panel de control es claro y sin artificios. Los precios son de pago por uso real, sin sorpresas. Y lo que más me convence: puedo agrupar todos mis proyectos en un mismo proveedor con un mismo flujo de trabajo.</p>
<p><strong>Lo malo</strong>: No es &quot;conectar repositorio y olvidarse&quot; como Netlify o Cloudflare Pages. Hay que configurar el storage, la pull zone, los headers, el certificado SSL. No es difícil, pero es trabajo que en otras plataformas viene hecho. Tampoco tiene builds automáticos — tú haces el build y subes el resultado.</p>
<p>Para mí, esa &quot;desventaja&quot; es parte del atractivo. Quiero controlar el proceso de despliegue, no delegarlo en una caja negra.</p>
<p><strong>Ideal para</strong>: desarrolladores que quieren flexibilidad real, múltiples vías de despliegue y una CDN de verdad detrás del hosting.</p>
<h2 id="github-pages" tabindex="-1">GitHub Pages <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/#github-pages">#</a></h2>
<p>La opción gratuita más simple. Sin build automático (necesitas GitHub Actions o hacer el build local), pero para proyectos personales es difícil de superar en simplicidad.</p>
<p><strong>Lo bueno</strong>: Gratis, integrado con GitHub, sin configuración si usas Jekyll. Con un GitHub Action de unas pocas líneas puedes desplegar Eleventy sin problemas.</p>
<p><strong>Lo malo</strong>: Sin CDN propio (aunque puedes poner Cloudflare delante), dominio personalizado con HTTPS requiere configuración manual, sin headers personalizables.</p>
<p><strong>Ideal para</strong>: proyectos personales, documentación de repositorios, webs de organizaciones de GitHub.</p>
<h2 id="un-servidor-propio-(vps)" tabindex="-1">Un servidor propio (VPS) <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/#un-servidor-propio-(vps)">#</a></h2>
<p>Para quien quiere control total. Un VPS barato (5-10€/mes) con Nginx sirviendo ficheros estáticos es la opción más controlable.</p>
<p><strong>Lo bueno</strong>: Control absoluto sobre headers, redirects, configuración del servidor, logs. Sin límites artificiales de ningún plan. Puedes alojar múltiples sitios.</p>
<p><strong>Lo malo</strong>: Tú te encargas de todo: actualizaciones de seguridad, certificados SSL (Let's Encrypt lo automatiza, pero hay que configurarlo), backups, monitorización. Si el servidor cae, es tu problema.</p>
<p><strong>Ideal para</strong>: desarrolladores que quieren control total y no les importa la administración de sistemas.</p>
<h2 id="ftp-a-un-hosting-compartido" tabindex="-1">FTP a un hosting compartido <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/#ftp-a-un-hosting-compartido">#</a></h2>
<p>Sí, sigue existiendo. Y para un sitio estático, funciona perfectamente. Generas el build local, subes por FTP, listo.</p>
<p><strong>Lo bueno</strong>: Funciona con cualquier hosting. No necesitas Git, ni CI/CD, ni cuenta en ningún servicio. Si ya tienes hosting para otra cosa, no cuesta nada adicional.</p>
<p><strong>Lo malo</strong>: Despliegue manual (o script que automatices tú). Sin previews, sin rollbacks fáciles, sin builds automáticos.</p>
<p><strong>Ideal para</strong>: proyectos donde ya tienes un hosting contratado y no quieres añadir otra plataforma.</p>
<h2 id="mi-recorrido-y-d%C3%B3nde-he-acabado" tabindex="-1">Mi recorrido y dónde he acabado <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/desplegar-webs-estaticas/#mi-recorrido-y-d%C3%B3nde-he-acabado">#</a></h2>
<p>Mi camino ha sido: Netlify → StaticHost → Bunny CDN. Cada salto tenía un motivo.</p>
<p>De Netlify me fui porque quería algo más simple y más alineado con mi forma de trabajar. StaticHost me atrajo por su filosofía — un servicio europeo, pensado para webs estáticas, sin artificios. Me gusta mucho lo que están haciendo y sigo su evolución con interés.</p>
<p>Pero al final me decanté por Bunny CDN por una razón práctica: flexibilidad. Puedo subir ficheros por FTP cuando estoy haciendo pruebas rápidas, usar su API para scripts de despliegue, o configurar GitHub Actions para proyectos donde quiero automatización completa. Cada proyecto elige su flujo. Y al final, por comodidad, estoy agrupando todos los proyectos en un mismo proveedor. Este sitio y Bilbonauta están en Bunny.</p>
<p>No es la solución para todo el mundo. Si lo que buscas es conectar un repositorio y olvidarte, Netlify o Cloudflare Pages siguen siendo opciones excelentes. Pero si prefieres entender y controlar cada paso del despliegue, Bunny merece que le eches un vistazo.</p>
<p>Lo importante no es qué plataforma elijas, sino entender que para un sitio estático, el hosting es un problema resuelto. No dejes que la decisión de dónde alojar te retrase. Pon los ficheros en cualquier servidor y ocúpate de lo que importa: el contenido.</p>
]]>
      </content:encoded>
      <pubDate>Sun, 10 Aug 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>JavaScript vanilla es suficiente para tu web</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/javascript-vanilla-suficiente/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/javascript-vanilla-suficiente/</guid>
      <description>
        Por qué no necesitas React, Vue ni ningún framework JavaScript para la mayoría de sitios web. Con ejemplos de lo que puedes hacer con el lenguaje nativo del navegador.
      </description>
      <content:encoded>
        <![CDATA[<p>Cada vez que alguien me pregunta qué framework JavaScript uso, la respuesta les sorprende: ninguno. Para los proyectos que hago — webs corporativas, blogs, portfolios, sitios de contenido — JavaScript vanilla es más que suficiente. Y no lo digo por nostalgia, sino por pragmatismo.</p>
<h2 id="lo-que-realmente-necesitas" tabindex="-1">Lo que realmente necesitas <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/javascript-vanilla-suficiente/#lo-que-realmente-necesitas">#</a></h2>
<p>Pensemos en las interacciones típicas de un sitio web:</p>
<ul>
<li>Menú hamburguesa que se abre y cierra</li>
<li>Toggle de tema claro/oscuro</li>
<li>Elementos que aparecen al hacer scroll</li>
<li>Enlaces externos que se abren en pestaña nueva</li>
<li>Un banner de cookies</li>
</ul>
<p>¿Cuánto JavaScript necesitas para eso? Veamos el menú hamburguesa completo:</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript">document<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"DOMContentLoaded"</span><span class="token punctuation">,</span> <span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
  <span class="token keyword">const</span> btn <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">querySelector</span><span class="token punctuation">(</span><span class="token string">".nav-hamburger"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">const</span> panel <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">getElementById</span><span class="token punctuation">(</span><span class="token string">"nav-panel"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">const</span> overlay <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">getElementById</span><span class="token punctuation">(</span><span class="token string">"nav-overlay"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token keyword">const</span> close <span class="token operator">=</span> document<span class="token punctuation">.</span><span class="token function">querySelector</span><span class="token punctuation">(</span><span class="token string">".nav-panel__close"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

  <span class="token keyword">function</span> <span class="token function">open</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    panel<span class="token punctuation">.</span>classList<span class="token punctuation">.</span><span class="token function">add</span><span class="token punctuation">(</span><span class="token string">"is-open"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    overlay<span class="token punctuation">.</span>classList<span class="token punctuation">.</span><span class="token function">add</span><span class="token punctuation">(</span><span class="token string">"is-open"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    document<span class="token punctuation">.</span>body<span class="token punctuation">.</span>style<span class="token punctuation">.</span>overflow <span class="token operator">=</span> <span class="token string">"hidden"</span><span class="token punctuation">;</span>
    btn<span class="token punctuation">.</span><span class="token function">setAttribute</span><span class="token punctuation">(</span><span class="token string">"aria-expanded"</span><span class="token punctuation">,</span> <span class="token string">"true"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>

  <span class="token keyword">function</span> <span class="token function">cerrar</span><span class="token punctuation">(</span><span class="token punctuation">)</span> <span class="token punctuation">{</span>
    panel<span class="token punctuation">.</span>classList<span class="token punctuation">.</span><span class="token function">remove</span><span class="token punctuation">(</span><span class="token string">"is-open"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    overlay<span class="token punctuation">.</span>classList<span class="token punctuation">.</span><span class="token function">remove</span><span class="token punctuation">(</span><span class="token string">"is-open"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
    document<span class="token punctuation">.</span>body<span class="token punctuation">.</span>style<span class="token punctuation">.</span>overflow <span class="token operator">=</span> <span class="token string">""</span><span class="token punctuation">;</span>
    btn<span class="token punctuation">.</span><span class="token function">setAttribute</span><span class="token punctuation">(</span><span class="token string">"aria-expanded"</span><span class="token punctuation">,</span> <span class="token string">"false"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>

  btn<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"click"</span><span class="token punctuation">,</span> open<span class="token punctuation">)</span><span class="token punctuation">;</span>
  close<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"click"</span><span class="token punctuation">,</span> cerrar<span class="token punctuation">)</span><span class="token punctuation">;</span>
  overlay<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"click"</span><span class="token punctuation">,</span> cerrar<span class="token punctuation">)</span><span class="token punctuation">;</span>
  document<span class="token punctuation">.</span><span class="token function">addEventListener</span><span class="token punctuation">(</span><span class="token string">"keydown"</span><span class="token punctuation">,</span> <span class="token parameter">e</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span>e<span class="token punctuation">.</span>key <span class="token operator">===</span> <span class="token string">"Escape"</span><span class="token punctuation">)</span> <span class="token function">cerrar</span><span class="token punctuation">(</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>Son 25 líneas. Funciona en todos los navegadores, no tiene dependencias, no necesita build step, y hace exactamente lo que tiene que hacer. ¿Merece la pena cargar React (40KB+ gzipped) más React DOM para esto?</p>
<h2 id="lo-que-ha-cambiado" tabindex="-1">Lo que ha cambiado <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/javascript-vanilla-suficiente/#lo-que-ha-cambiado">#</a></h2>
<p>La razón por la que jQuery era imprescindible en 2010 era que JavaScript y el DOM eran un campo de minas. <code>addEventListener</code> no existía en IE. Seleccionar elementos era verboso. Las diferencias entre navegadores eran enormes.</p>
<p>Hoy tenemos:</p>
<ul>
<li><code>document.querySelector</code> y <code>querySelectorAll</code> — selectores CSS nativos</li>
<li><code>classList</code> — manipulación de clases sin regex</li>
<li><code>addEventListener</code> — universal y consistente</li>
<li><code>fetch</code> — peticiones HTTP sin XMLHttpRequest</li>
<li><code>IntersectionObserver</code> — detección de visibilidad sin cálculos de scroll</li>
<li>Template literals, destructuring, arrow functions, async/await</li>
</ul>
<p>El lenguaje que justificó la existencia de jQuery ha mejorado tanto que la librería ya no tiene razón de ser.</p>
<h2 id="intersectionobserver%3A-el-ejemplo-perfecto" tabindex="-1">IntersectionObserver: el ejemplo perfecto <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/javascript-vanilla-suficiente/#intersectionobserver%3A-el-ejemplo-perfecto">#</a></h2>
<p>Antes, para saber si un elemento era visible en pantalla necesitabas escuchar el evento scroll, calcular la posición del elemento con <code>getBoundingClientRect()</code>, y comparar con el viewport. Era costoso y propenso a errores.</p>
<p>Ahora:</p>
<pre class="language-javascript" tabindex="0"><code class="language-javascript"><span class="token keyword">const</span> observer <span class="token operator">=</span> <span class="token keyword">new</span> <span class="token class-name">IntersectionObserver</span><span class="token punctuation">(</span><span class="token punctuation">(</span><span class="token parameter">entries</span><span class="token punctuation">)</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
  entries<span class="token punctuation">.</span><span class="token function">forEach</span><span class="token punctuation">(</span><span class="token parameter">entry</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
    <span class="token keyword">if</span> <span class="token punctuation">(</span>entry<span class="token punctuation">.</span>isIntersecting<span class="token punctuation">)</span> <span class="token punctuation">{</span>
      entry<span class="token punctuation">.</span>target<span class="token punctuation">.</span>classList<span class="token punctuation">.</span><span class="token function">add</span><span class="token punctuation">(</span><span class="token string">"visible"</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
      observer<span class="token punctuation">.</span><span class="token function">unobserve</span><span class="token punctuation">(</span>entry<span class="token punctuation">.</span>target<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token punctuation">}</span>
  <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">,</span> <span class="token punctuation">{</span> <span class="token literal-property property">threshold</span><span class="token operator">:</span> <span class="token number">0.1</span> <span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>

document<span class="token punctuation">.</span><span class="token function">querySelectorAll</span><span class="token punctuation">(</span><span class="token string">".reveal"</span><span class="token punctuation">)</span><span class="token punctuation">.</span><span class="token function">forEach</span><span class="token punctuation">(</span><span class="token parameter">el</span> <span class="token operator">=></span> <span class="token punctuation">{</span>
  observer<span class="token punctuation">.</span><span class="token function">observe</span><span class="token punctuation">(</span>el<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
</code></pre>
<p>Esto es lo que uso en este sitio para las animaciones de entrada. No necesité instalar <code>aos.js</code> (24KB), ni <code>scroll-reveal</code> (22KB), ni mucho menos una librería de animación completa. Diez líneas de JavaScript vanilla y las transiciones se definen en CSS, donde pertenecen.</p>
<h2 id="cu%C3%A1ndo-s%C3%AD-necesitas-un-framework" tabindex="-1">Cuándo sí necesitas un framework <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/javascript-vanilla-suficiente/#cu%C3%A1ndo-s%C3%AD-necesitas-un-framework">#</a></h2>
<p>No soy un fundamentalista. Los frameworks tienen su lugar:</p>
<ul>
<li><strong>Aplicaciones con estado complejo</strong> — un dashboard con datos en tiempo real, un editor visual, una herramienta de gestión. Aquí la reactividad de React o Vue aporta valor real.</li>
<li><strong>SPAs con mucha interactividad</strong> — donde la navegación client-side y la gestión de estado justifican la complejidad.</li>
<li><strong>Equipos grandes</strong> — donde la estructura que impone un framework ayuda a la coordinación.</li>
</ul>
<p>Pero un blog, una web corporativa, un portfolio, una landing page... no son aplicaciones. Son documentos. Y los documentos se resuelven con HTML, CSS y una pizca de JavaScript.</p>
<h2 id="el-coste-oculto" tabindex="-1">El coste oculto <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/javascript-vanilla-suficiente/#el-coste-oculto">#</a></h2>
<p>Cada framework que añades tiene un coste que va más allá del tamaño del bundle:</p>
<ul>
<li>Dependencia de un ecosistema que puede cambiar de versión mayor cada año</li>
<li>Build step obligatorio — ya no puedes abrir un HTML y trabajar</li>
<li>Curva de aprendizaje para ti y para quien mantenga el proyecto después</li>
<li>Actualizaciones de seguridad que tienes que seguir</li>
<li>La tentación de &quot;componentizar&quot; todo, incluso lo que no lo necesita</li>
</ul>
<p>JavaScript vanilla no tiene ninguno de estos costes. Funciona hoy y funcionará dentro de diez años. No se deprecia, no cambia de API, no necesita migración.</p>
]]>
      </content:encoded>
      <pubDate>Sun, 20 Jul 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Accesibilidad web: el mínimo que todo desarrollador debería cumplir</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/</guid>
      <description>
        No hace falta ser experto en WCAG para hacer webs accesibles. Estas son las prácticas básicas que marcan la diferencia y que no tienen excusa para no implementarse.
      </description>
      <content:encoded>
        <![CDATA[<p>La accesibilidad web tiene fama de ser complicada. Y a cierto nivel, lo es — las pautas WCAG son extensas y los edge cases son infinitos. Pero el 80% del impacto viene del 20% del esfuerzo. Hay un mínimo viable de accesibilidad que cualquier desarrollador puede implementar sin ser experto en la materia.</p>
<h2 id="html-sem%C3%A1ntico%3A-la-base-de-todo" tabindex="-1">HTML semántico: la base de todo <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/#html-sem%C3%A1ntico%3A-la-base-de-todo">#</a></h2>
<p>La forma más efectiva de hacer una web accesible es usar las etiquetas HTML correctas. No es un consejo sofisticado, pero es el que más se ignora:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token comment">&lt;!-- Mal --></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>div</span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>boton<span class="token punctuation">"</span></span> <span class="token attr-name">onclick</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>enviar()<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>Enviar<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>div</span><span class="token punctuation">></span></span>

<span class="token comment">&lt;!-- Bien --></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>button</span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>submit<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>Enviar<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>button</span><span class="token punctuation">></span></span>
</code></pre>
<p>El <code>&lt;button&gt;</code> es focusable por teclado, activable con Enter y Space, anunciado como &quot;botón&quot; por los lectores de pantalla, y gestiona correctamente los estados <code>:focus</code> y <code>:active</code>. El <code>&lt;div&gt;</code> no hace nada de eso — tendrías que reimplementarlo todo con JavaScript y ARIA.</p>
<p>Lo mismo aplica para:</p>
<ul>
<li><code>&lt;nav&gt;</code> en vez de <code>&lt;div class=&quot;nav&quot;&gt;</code></li>
<li><code>&lt;main&gt;</code> en vez de <code>&lt;div class=&quot;contenido&quot;&gt;</code></li>
<li><code>&lt;header&gt;</code> y <code>&lt;footer&gt;</code> en vez de divs genéricos</li>
<li><code>&lt;h1&gt;</code>-<code>&lt;h6&gt;</code> en orden jerárquico, sin saltar niveles</li>
<li><code>&lt;a&gt;</code> para navegación, <code>&lt;button&gt;</code> para acciones</li>
</ul>
<h2 id="contraste-de-color" tabindex="-1">Contraste de color <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/#contraste-de-color">#</a></h2>
<p>Las WCAG piden un ratio de contraste mínimo de <strong>4.5:1 para texto normal</strong> y <strong>3:1 para texto grande</strong> (18px+ o 14px+ en negrita). Es una regla simple con herramientas simples para verificarla.</p>
<p>En las DevTools de Chrome y Firefox, al inspeccionar un color de texto, te muestra el ratio de contraste directamente. Si está por debajo del mínimo, cámbialo.</p>
<p>Los errores más comunes:</p>
<ul>
<li>Texto gris claro sobre fondo blanco (esos grises &quot;elegantes&quot; de <code>#999</code> o <code>#aaa</code> que no pasan el mínimo)</li>
<li>Texto sobre imágenes sin overlay oscuro</li>
<li>Placeholders de formulario demasiado claros (los placeholders también necesitan contraste)</li>
</ul>
<h2 id="textos-alternativos-en-im%C3%A1genes" tabindex="-1">Textos alternativos en imágenes <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/#textos-alternativos-en-im%C3%A1genes">#</a></h2>
<p>Toda imagen con contenido informativo necesita un <code>alt</code> descriptivo:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token comment">&lt;!-- Decorativa: alt vacío --></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>ornamento.svg<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span><span class="token punctuation">"</span></span><span class="token punctuation">></span></span>

<span class="token comment">&lt;!-- Informativa: descripción del contenido --></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>img</span> <span class="token attr-name">src</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>grafico-ventas.png<span class="token punctuation">"</span></span> <span class="token attr-name">alt</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>Gráfico de ventas trimestrales mostrando un crecimiento del 15%<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
</code></pre>
<p>Un <code>alt</code> vacío (<code>alt=&quot;&quot;</code>) le dice al lector de pantalla &quot;ignora esta imagen&quot;. Omitir el atributo <code>alt</code> completamente hace que el lector de pantalla lea el nombre del fichero, que suele ser algo como &quot;IMG_20240315_142356.jpg&quot;. Eso no ayuda a nadie.</p>
<h2 id="navegaci%C3%B3n-por-teclado" tabindex="-1">Navegación por teclado <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/#navegaci%C3%B3n-por-teclado">#</a></h2>
<p>Toda funcionalidad de la web debe ser accesible con teclado. Esto significa:</p>
<ul>
<li><strong>Tab</strong> navega entre elementos interactivos</li>
<li><strong>Enter/Space</strong> activa botones y enlaces</li>
<li><strong>Escape</strong> cierra modales y menús</li>
<li>El <strong>foco</strong> es visible en todo momento</li>
</ul>
<p>El error más dañino es eliminar el outline del foco:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* NUNCA hagas esto sin proporcionar una alternativa */</span>
<span class="token selector">*:focus</span> <span class="token punctuation">{</span> <span class="token property">outline</span><span class="token punctuation">:</span> none<span class="token punctuation">;</span> <span class="token punctuation">}</span>
</code></pre>
<p>Si el outline por defecto te parece feo, reemplázalo con uno mejor, pero no lo elimines:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:focus-visible</span> <span class="token punctuation">{</span>
  <span class="token property">outline</span><span class="token punctuation">:</span> 2px solid <span class="token function">var</span><span class="token punctuation">(</span>--color-acento<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">outline-offset</span><span class="token punctuation">:</span> 2px<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p><code>:focus-visible</code> es mejor que <code>:focus</code> porque solo se activa con navegación por teclado, no con clics de ratón. Así mantienes la indicación visual para quien la necesita sin molestar a quien usa ratón.</p>
<h2 id="skip-to-content" tabindex="-1">Skip to content <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/#skip-to-content">#</a></h2>
<p>Un enlace oculto al principio del documento que permite a los usuarios de teclado saltar directamente al contenido principal, sin tener que tabular por toda la navegación:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>body</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>a</span> <span class="token attr-name">href</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>#contenido<span class="token punctuation">"</span></span> <span class="token attr-name">class</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>skip-link<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>Saltar al contenido principal<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>a</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>header</span><span class="token punctuation">></span></span>...<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>header</span><span class="token punctuation">></span></span>
  <span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>main</span> <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>contenido<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>...<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>main</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>body</span><span class="token punctuation">></span></span>
</code></pre>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.skip-link</span> <span class="token punctuation">{</span>
  <span class="token property">position</span><span class="token punctuation">:</span> absolute<span class="token punctuation">;</span>
  <span class="token property">left</span><span class="token punctuation">:</span> -9999px<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
<span class="token selector">.skip-link:focus</span> <span class="token punctuation">{</span>
  <span class="token property">position</span><span class="token punctuation">:</span> fixed<span class="token punctuation">;</span>
  <span class="token property">top</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
  <span class="token property">left</span><span class="token punctuation">:</span> 0<span class="token punctuation">;</span>
  <span class="token property">z-index</span><span class="token punctuation">:</span> 999<span class="token punctuation">;</span>
  <span class="token comment">/* estilos visibles */</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Es una de las técnicas más simples de implementar y de las más apreciadas por usuarios de teclado y lectores de pantalla.</p>
<h2 id="formularios-con-labels" tabindex="-1">Formularios con labels <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/#formularios-con-labels">#</a></h2>
<p>Cada campo de formulario necesita un <code>&lt;label&gt;</code> asociado:</p>
<pre class="language-html" tabindex="0"><code class="language-html"><span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>label</span> <span class="token attr-name">for</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>email<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>Tu email<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;/</span>label</span><span class="token punctuation">></span></span>
<span class="token tag"><span class="token tag"><span class="token punctuation">&lt;</span>input</span> <span class="token attr-name">type</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>email<span class="token punctuation">"</span></span> <span class="token attr-name">id</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>email<span class="token punctuation">"</span></span> <span class="token attr-name">name</span><span class="token attr-value"><span class="token punctuation attr-equals">=</span><span class="token punctuation">"</span>email<span class="token punctuation">"</span></span><span class="token punctuation">></span></span>
</code></pre>
<p>No un placeholder como sustituto del label. No un div con texto al lado. Un <code>&lt;label&gt;</code> con <code>for</code> apuntando al <code>id</code> del input. Es la forma en que los lectores de pantalla saben qué pedir al usuario.</p>
<h2 id="tama%C3%B1o-de-%C3%A1reas-t%C3%A1ctiles" tabindex="-1">Tamaño de áreas táctiles <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/#tama%C3%B1o-de-%C3%A1reas-t%C3%A1ctiles">#</a></h2>
<p>En móvil, los elementos interactivos deben tener al menos <strong>44x44 píxeles</strong> de área de toque. Un enlace de texto pequeño en una lista puede ser difícil de pulsar para personas con motricidad reducida — o para cualquiera en un autobús en movimiento.</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.nav__link</span> <span class="token punctuation">{</span>
  <span class="token property">padding</span><span class="token punctuation">:</span> 0.75em 1em<span class="token punctuation">;</span>
  <span class="token property">min-height</span><span class="token punctuation">:</span> 44px<span class="token punctuation">;</span>
  <span class="token property">display</span><span class="token punctuation">:</span> flex<span class="token punctuation">;</span>
  <span class="token property">align-items</span><span class="token punctuation">:</span> center<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<h2 id="no-es-dif%C3%ADcil%2C-es-h%C3%A1bito" tabindex="-1">No es difícil, es hábito <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/accesibilidad-minimo-viable/#no-es-dif%C3%ADcil%2C-es-h%C3%A1bito">#</a></h2>
<p>Ninguna de estas prácticas es técnicamente compleja. No requieren librerías, plugins ni conocimientos especializados. Son decisiones de implementación que cualquier desarrollador puede tomar si es consciente de ellas.</p>
<p>La accesibilidad no es un feature que se añade al final del proyecto. Es una forma de escribir código desde el principio. Y como toda buena práctica, se convierte en automática con el tiempo.</p>
]]>
      </content:encoded>
      <pubDate>Sat, 28 Jun 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>Tipografía fluida con clamp(): adiós a las media queries para tamaños</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/tipografia-fluida-clamp/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/tipografia-fluida-clamp/</guid>
      <description>
        Cómo usar clamp() para crear una escala tipográfica que se adapta al viewport sin necesidad de breakpoints. Con ejemplos prácticos y la lógica detrás de los valores.
      </description>
      <content:encoded>
        <![CDATA[<p>Durante años, el enfoque estándar para adaptar el tamaño de texto al dispositivo eran las media queries: un tamaño para móvil, otro para tablet, otro para escritorio. Tres o cuatro breakpoints con valores discretos que saltaban de uno a otro.</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* El enfoque antiguo */</span>
<span class="token selector">h1</span> <span class="token punctuation">{</span> <span class="token property">font-size</span><span class="token punctuation">:</span> 1.5rem<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token atrule"><span class="token rule">@media</span> <span class="token punctuation">(</span><span class="token property">min-width</span><span class="token punctuation">:</span> 768px<span class="token punctuation">)</span></span> <span class="token punctuation">{</span> <span class="token selector">h1</span> <span class="token punctuation">{</span> <span class="token property">font-size</span><span class="token punctuation">:</span> 2rem<span class="token punctuation">;</span> <span class="token punctuation">}</span> <span class="token punctuation">}</span>
<span class="token atrule"><span class="token rule">@media</span> <span class="token punctuation">(</span><span class="token property">min-width</span><span class="token punctuation">:</span> 1200px<span class="token punctuation">)</span></span> <span class="token punctuation">{</span> <span class="token selector">h1</span> <span class="token punctuation">{</span> <span class="token property">font-size</span><span class="token punctuation">:</span> 2.5rem<span class="token punctuation">;</span> <span class="token punctuation">}</span> <span class="token punctuation">}</span>
</code></pre>
<p>Funciona, pero tiene un problema: los saltos son bruscos. En 767px tienes un tamaño, en 768px otro. Y tienes que decidir cuántos breakpoints usar, qué valores poner en cada uno, y mantener toda esa cascada cuando cambias la escala.</p>
<h2 id="la-funci%C3%B3n-clamp()" tabindex="-1">La función clamp() <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tipografia-fluida-clamp/#la-funci%C3%B3n-clamp()">#</a></h2>
<p><code>clamp()</code> acepta tres valores: un mínimo, un valor preferido (fluido), y un máximo.</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">h1</span> <span class="token punctuation">{</span>
  <span class="token property">font-size</span><span class="token punctuation">:</span> <span class="token function">clamp</span><span class="token punctuation">(</span>1.5rem<span class="token punctuation">,</span> 1.2rem + 1.5vw<span class="token punctuation">,</span> 2.5rem<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Esto significa: el tamaño será <code>1.2rem + 1.5vw</code>, pero nunca menor que <code>1.5rem</code> ni mayor que <code>2.5rem</code>. El texto crece suavemente con el viewport, sin saltos, sin breakpoints.</p>
<h2 id="c%C3%B3mo-calcular-los-valores" tabindex="-1">Cómo calcular los valores <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tipografia-fluida-clamp/#c%C3%B3mo-calcular-los-valores">#</a></h2>
<p>El valor fluido (<code>1.2rem + 1.5vw</code>) no es arbitrario. Se calcula a partir de los dos extremos que quieres:</p>
<ul>
<li><strong>Viewport mínimo</strong>: 320px → tamaño mínimo: 1.5rem (24px)</li>
<li><strong>Viewport máximo</strong>: 1200px → tamaño máximo: 2.5rem (40px)</li>
</ul>
<p>La fórmula es:</p>
<pre><code>valor fluido = mínimo + (máximo - mínimo) * ((100vw - viewport_min) / (viewport_max - viewport_min))
</code></pre>
<p>Simplificado:</p>
<pre><code>pendiente = (40 - 24) / (1200 - 320) = 16 / 880 ≈ 0.01818
intercepto = 24 - (0.01818 * 320) = 24 - 5.818 ≈ 18.18px ≈ 1.136rem
</code></pre>
<p>Redondeando: <code>clamp(1.5rem, 1.14rem + 1.82vw, 2.5rem)</code>.</p>
<p>No hace falta hacer esto a mano — hay calculadoras online. Pero entender la lógica te permite ajustar los valores con criterio en vez de copiar y pegar sin saber por qué.</p>
<h2 id="mi-escala-tipogr%C3%A1fica" tabindex="-1">Mi escala tipográfica <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tipografia-fluida-clamp/#mi-escala-tipogr%C3%A1fica">#</a></h2>
<p>En este sitio uso un enfoque ligeramente diferente al de aplicar <code>clamp()</code> a cada nivel por separado. Defino una sola variable base fluida y construyo el resto de la escala con multiplicadores:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:root</span> <span class="token punctuation">{</span>
  <span class="token property">--fs-base</span><span class="token punctuation">:</span> <span class="token function">clamp</span><span class="token punctuation">(</span>1rem<span class="token punctuation">,</span> 1.5vw<span class="token punctuation">,</span> 1.15rem<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs-2</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 0.75<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs-1</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 0.85<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs0</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs1</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 1.2<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs2</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 1.45<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs3</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 1.75<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs4</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 2.1<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs5</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 2.5<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--fs6</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 3.2<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>La ventaja de este enfoque es que toda la escala se mueve junta. Si cambio el <code>clamp()</code> de la base, todos los tamaños se reajustan proporcionalmente. No tengo que recalcular siete valores diferentes.</p>
<p>Además, para móvil uso una media query que reduce los multiplicadores de los niveles grandes, porque en pantallas pequeñas el contraste entre tamaños tiene que ser más sutil:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token atrule"><span class="token rule">@media</span> screen <span class="token keyword">and</span> <span class="token punctuation">(</span><span class="token property">max-width</span><span class="token punctuation">:</span> 45rem<span class="token punctuation">)</span></span> <span class="token punctuation">{</span>
  <span class="token selector">:root</span> <span class="token punctuation">{</span>
    <span class="token property">--fs1</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 1.15<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token property">--fs2</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 1.3<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token property">--fs3</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 1.5<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token property">--fs4</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 1.7<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token property">--fs5</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 2<span class="token punctuation">)</span><span class="token punctuation">;</span>
    <span class="token property">--fs6</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--fs-base<span class="token punctuation">)</span> * 2.5<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Sí, estoy usando una media query — justo lo que este artículo pretende evitar. Pero el <code>clamp()</code> de la base se encarga de la fluidez continua, y la media query solo ajusta las proporciones de la escala en viewports estrechos. Es un híbrido pragmático: la transición es suave gracias a la base fluida, y los ratios se ajustan cuando realmente hace falta.</p>
<h2 id="tipograf%C3%ADa-fluida-m%C3%A1s-all%C3%A1-del-font-size" tabindex="-1">Tipografía fluida más allá del font-size <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tipografia-fluida-clamp/#tipograf%C3%ADa-fluida-m%C3%A1s-all%C3%A1-del-font-size">#</a></h2>
<p><code>clamp()</code> no solo sirve para tamaños de texto. Lo uso para:</p>
<p><strong>Espaciado vertical entre secciones:</strong></p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">:root</span> <span class="token punctuation">{</span>
  <span class="token property">--space-l</span><span class="token punctuation">:</span> <span class="token function">clamp</span><span class="token punctuation">(</span>1.5rem<span class="token punctuation">,</span> 1.2rem + 1.5vw<span class="token punctuation">,</span> 2.5rem<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">--space-xl</span><span class="token punctuation">:</span> <span class="token function">clamp</span><span class="token punctuation">(</span>2rem<span class="token punctuation">,</span> 1.5rem + 2.5vw<span class="token punctuation">,</span> 4rem<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p><strong>Interlineado que se ajusta al tamaño:</strong></p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">body</span> <span class="token punctuation">{</span>
  <span class="token property">line-height</span><span class="token punctuation">:</span> <span class="token function">clamp</span><span class="token punctuation">(</span>1.5<span class="token punctuation">,</span> 1.4 + 0.2vw<span class="token punctuation">,</span> 1.7<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p><strong>Márgenes laterales:</strong></p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.contenido</span> <span class="token punctuation">{</span>
  <span class="token property">padding-inline</span><span class="token punctuation">:</span> <span class="token function">clamp</span><span class="token punctuation">(</span>1rem<span class="token punctuation">,</span> 0.5rem + 2vw<span class="token punctuation">,</span> 3rem<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>El resultado es un diseño que fluye de forma natural entre cualquier tamaño de pantalla, sin los saltos artificiales de las media queries.</p>
<h2 id="accesibilidad" tabindex="-1">Accesibilidad <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/tipografia-fluida-clamp/#accesibilidad">#</a></h2>
<p>Un detalle importante: al usar <code>rem</code> como base y unidad mínima/máxima, la tipografía fluida respeta la configuración de tamaño de texto del usuario. Si alguien ha aumentado el tamaño base en su navegador, todos los <code>rem</code> escalan con él. Si usases solo <code>px</code> o solo <code>vw</code>, romperías esa preferencia.</p>
<p>La combinación de <code>rem</code> (accesible, respeta preferencias) con <code>vw</code> (fluido, se adapta al viewport) es lo que hace que <code>clamp()</code> sea la herramienta correcta para tipografía responsive.</p>
]]>
      </content:encoded>
      <pubDate>Sun, 08 Jun 2025 00:00:00 GMT</pubDate>
    </item>
    <item>
      <title>CSS custom properties vs variables Sass: por qué ya no necesito un preprocesador</title>
      <link>https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/</link>
      <guid isPermaLink="false">https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/</guid>
      <description>
        Las custom properties de CSS hacen innecesario Sass para la mayoría de proyectos. Comparación práctica con ejemplos de lo que puedes hacer con CSS nativo que antes requería un preprocesador.
      </description>
      <content:encoded>
        <![CDATA[<p>Usé Sass durante años. Era imprescindible: variables, nesting, mixins, funciones. CSS por sí solo era limitado y repetitivo. Sass lo hacía manejable.</p>
<p>Pero CSS ha cambiado. Y la pregunta que me hago ahora no es &quot;¿qué preprocesador uso?&quot; sino &quot;¿necesito un preprocesador?&quot;. La respuesta, para la mayoría de mis proyectos, es no.</p>
<h2 id="lo-que-sass-resolv%C3%ADa" tabindex="-1">Lo que Sass resolvía <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/#lo-que-sass-resolv%C3%ADa">#</a></h2>
<p>Las razones principales por las que usábamos Sass eran:</p>
<ol>
<li><strong>Variables</strong> — para no repetir valores de colores, tamaños, fuentes</li>
<li><strong>Nesting</strong> — para evitar selectores largos</li>
<li><strong>Mixins</strong> — para reutilizar bloques de declaraciones</li>
<li><strong>Funciones</strong> — para cálculos y transformaciones de color</li>
<li><strong>Partials e imports</strong> — para organizar el código en ficheros</li>
</ol>
<p>Veamos qué ha pasado con cada una.</p>
<h2 id="variables%3A-custom-properties-ganan" tabindex="-1">Variables: custom properties ganan <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/#variables%3A-custom-properties-ganan">#</a></h2>
<pre class="language-scss" tabindex="0"><code class="language-scss">// Sass
$color-acento: #f86624;
.boton { background: $color-acento; }
</code></pre>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* CSS */</span>
<span class="token selector">:root</span> <span class="token punctuation">{</span> <span class="token property">--color-acento</span><span class="token punctuation">:</span> #f86624<span class="token punctuation">;</span> <span class="token punctuation">}</span>
<span class="token selector">.boton</span> <span class="token punctuation">{</span> <span class="token property">background</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-acento<span class="token punctuation">)</span><span class="token punctuation">;</span> <span class="token punctuation">}</span>
</code></pre>
<p>Hasta aquí parecen equivalentes. Pero las custom properties hacen algo que Sass no puede: <strong>cambiar en tiempo de ejecución</strong>. Un cambio de tema, un hover, una media query — las custom properties se recalculan en el navegador. Las variables Sass se resuelven en el build y desaparecen del CSS final.</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* Esto es imposible con Sass */</span>
<span class="token selector">[data-theme="dark"]</span> <span class="token punctuation">{</span>
  <span class="token property">--color-fondo</span><span class="token punctuation">:</span> #111118<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Con Sass tendrías que duplicar todos los selectores que usan ese color. Con custom properties, cambias la variable y todo se actualiza automáticamente.</p>
<h2 id="nesting%3A-css-lo-tiene-(casi)" tabindex="-1">Nesting: CSS lo tiene (casi) <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/#nesting%3A-css-lo-tiene-(casi)">#</a></h2>
<p>CSS Nesting ya está disponible en los navegadores modernos:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.tarjeta</span> <span class="token punctuation">{</span>
  <span class="token property">padding</span><span class="token punctuation">:</span> 1rem<span class="token punctuation">;</span>

  <span class="token selector">&amp; .titulo</span> <span class="token punctuation">{</span>
    <span class="token property">font-weight</span><span class="token punctuation">:</span> 700<span class="token punctuation">;</span>
  <span class="token punctuation">}</span>

  <span class="token selector">&amp;:hover</span> <span class="token punctuation">{</span>
    <span class="token property">border-color</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-acento<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token punctuation">}</span>
<span class="token punctuation">}</span>
</code></pre>
<p>La sintaxis es ligeramente diferente (necesitas el <code>&amp;</code> en más casos), pero funciona. Para proyectos que no necesitan soportar navegadores antiguos, el nesting nativo es suficiente.</p>
<p>Dicho esto, yo uso poco el nesting — prefiero selectores planos por claridad. Pero si era tu razón principal para usar Sass, ya no lo es.</p>
<h2 id="mixins%3A-no-los-echo-de-menos" tabindex="-1">Mixins: no los echo de menos <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/#mixins%3A-no-los-echo-de-menos">#</a></h2>
<p>Los mixins más comunes eran para vendor prefixes y patrones repetitivos. Los vendor prefixes ya no son necesarios para la mayoría de propiedades. Y los patrones repetitivos se resuelven mejor con custom properties:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token comment">/* En vez de un mixin de Sass para botones... */</span>
<span class="token selector">.boton</span> <span class="token punctuation">{</span>
  <span class="token property">padding</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--boton-padding<span class="token punctuation">,</span> 0.5em 1em<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">border-radius</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--boton-radius<span class="token punctuation">,</span> 0.25em<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">background</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--boton-bg<span class="token punctuation">,</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-acento<span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">color</span><span class="token punctuation">:</span> <span class="token function">var</span><span class="token punctuation">(</span>--boton-color<span class="token punctuation">,</span> white<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>

<span class="token comment">/* Variantes cambiando las variables */</span>
<span class="token selector">.boton--grande</span> <span class="token punctuation">{</span>
  <span class="token property">--boton-padding</span><span class="token punctuation">:</span> 0.75em 1.5em<span class="token punctuation">;</span>
  <span class="token property">--boton-radius</span><span class="token punctuation">:</span> 0.5em<span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Más flexible que un mixin porque las variantes se pueden definir en cualquier contexto — incluso desde el HTML con <code>style</code>.</p>
<h2 id="funciones%3A-css-tiene-calc()%2C-clamp()-y-color-mix()" tabindex="-1">Funciones: CSS tiene calc(), clamp() y color-mix() <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/#funciones%3A-css-tiene-calc()%2C-clamp()-y-color-mix()">#</a></h2>
<p>Sass ofrecía <code>darken()</code>, <code>lighten()</code>, <code>mix()</code>. CSS ahora tiene <code>color-mix()</code>:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.overlay</span> <span class="token punctuation">{</span>
  <span class="token property">background</span><span class="token punctuation">:</span> <span class="token function">color-mix</span><span class="token punctuation">(</span>in srgb<span class="token punctuation">,</span> <span class="token function">var</span><span class="token punctuation">(</span>--color-acento<span class="token punctuation">)</span> 15%<span class="token punctuation">,</span> transparent<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<p>Para cálculos numéricos, <code>calc()</code> y <code>clamp()</code> cubren todo lo que necesitas:</p>
<pre class="language-css" tabindex="0"><code class="language-css"><span class="token selector">.grid</span> <span class="token punctuation">{</span>
  <span class="token property">gap</span><span class="token punctuation">:</span> <span class="token function">calc</span><span class="token punctuation">(</span><span class="token function">var</span><span class="token punctuation">(</span>--space-base<span class="token punctuation">)</span> * 2<span class="token punctuation">)</span><span class="token punctuation">;</span>
  <span class="token property">font-size</span><span class="token punctuation">:</span> <span class="token function">clamp</span><span class="token punctuation">(</span>1rem<span class="token punctuation">,</span> 0.95rem + 0.25vw<span class="token punctuation">,</span> 1.125rem<span class="token punctuation">)</span><span class="token punctuation">;</span>
<span class="token punctuation">}</span>
</code></pre>
<h2 id="organizaci%C3%B3n%3A-la-%C3%BAnica-ventaja-real-de-sass" tabindex="-1">Organización: la única ventaja real de Sass <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/#organizaci%C3%B3n%3A-la-%C3%BAnica-ventaja-real-de-sass">#</a></h2>
<p>El único punto donde Sass sigue aportando algo es la organización en parciales con <code>@use</code> y <code>@forward</code>. CSS tiene <code>@import</code>, pero carga cada fichero como una petición HTTP separada, lo que es malo para el rendimiento.</p>
<p>Mi solución es diferente: uso <code>eleventy-plugin-bundle</code> para concatenar los ficheros CSS en build. Cada fichero CSS se incluye en orden en el layout base, y el plugin los une en un único bloque inline. La organización es por ficheros, la salida es un único bloque — lo mejor de ambos mundos sin Sass.</p>
<h2 id="el-build-step-que-desaparece" tabindex="-1">El build step que desaparece <a class="header-anchor" href="https://media.paigar.eu/archivo/v3/bitacora/custom-properties-vs-sass/#el-build-step-que-desaparece">#</a></h2>
<p>Esta es la ventaja más subestimada de abandonar Sass: <strong>no necesitas compilar tu CSS</strong>. No hay <code>sass --watch</code>, no hay plugin de Webpack, no hay dependencia de <code>node-sass</code> o <code>dart-sass</code> que se rompe al actualizar Node.</p>
<p>Abres un fichero CSS, lo editas, el navegador lo interpreta. Así de simple. Así debería ser.</p>
]]>
      </content:encoded>
      <pubDate>Mon, 12 May 2025 00:00:00 GMT</pubDate>
    </item>
  </channel>
</rss>