<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
  <title>Javier Delgado&#39;s Blog</title>
  <link>https://blog.javierdelgado.com.ve/es/</link>
  <description>Blog de Javier Delgado, ingeniero en computación y desarrollador web full stack: artículos sobre desarrollo web, Vue, AWS, automatización e inteligencia artificial.</description>
  <language>es</language>
  <atom:link href="https://blog.javierdelgado.com.ve/es/rss.xml" rel="self" type="application/rss+xml"/>
  <item>
    <title>VoiceGraph: cómo construí un asistente de voz con LangGraph para demostrar grafos con estado</title>
    <link>https://blog.javierdelgado.com.ve/es/posts/voicegraph-como-construi-un-asistente-de-voz-con-langgraph-para-demostrar-grafos-con-estado/</link>
    <guid isPermaLink="true">https://blog.javierdelgado.com.ve/es/posts/voicegraph-como-construi-un-asistente-de-voz-con-langgraph-para-demostrar-grafos-con-estado/</guid>
    <pubDate>Sun, 20 Sep 2026 12:00:00 GMT</pubDate>
    <dc:creator>Javier Delgado</dc:creator>
    <category>Inteligencia artificial</category>
    <description>Construí VoiceGraph, un asistente de voz con RAG orquestado como grafo de estado en LangGraph, para mostrar en mi portafolio branching, retries y checkpointing reales, no solo un chatbot.</description>
    <content:encoded><![CDATA[<p><strong>VoiceGraph</strong> es un asistente de voz con RAG que construí como proyecto de portafolio, pero el objetivo real no era &quot;hacer un chatbot&quot;: era demostrar dominio de <strong>LangGraph</strong> de forma que un reclutador técnico lo viera funcionando, no que tuviera que creerme la palabra. Este artículo cuenta el diseño del grafo, por qué elegí App Runner en vez de Lambda, cómo construí el backend para que sobreviviera una migración de proyecto standalone a gateway multi-app, y la comparativa lineal vs grafo que terminó siendo la pieza más útil del demo.</p>
<p>Puedes probar el proyecto en vivo en <strong><a href="https://javierdelgado.com.ve/voicegraph/">javierdelgado.com.ve/voicegraph</a></strong>.</p>
<h2 id="el-problema-con-quot-otro-chatbot-con-rag-quot"><a class="anchor" href="#el-problema-con-quot-otro-chatbot-con-rag-quot" aria-hidden="true">#</a>El problema con &quot;otro chatbot con RAG&quot;</h2>
<p>Cualquiera puede envolver un LLM con una búsqueda vectorial y llamarlo &quot;asistente inteligente&quot;. Eso no demuestra nada sobre orquestación: es un <code>if/else</code> con marketing. Lo que quería mostrar era otra cosa: una conversación real tiene ramas (¿el usuario quiere charlar, cambiar de tema, cambiar de nivel, o terminar?), pasos que pueden fallar y necesitan reintentarse, y estado que persiste entre turnos. Eso es exactamente lo que LangGraph modela de forma explícita como grafo, en vez de esconderlo dentro de un script largo.</p>
<p>La decisión de diseño más importante fue esta: <strong>el frontend tiene que visualizar el grafo ejecutándose en vivo</strong> — qué nodo está activo, qué edge se tomó, cómo se ve el estado en cada paso — porque ese panel es lo que convierte &quot;confía en que uso LangGraph&quot; en &quot;mira, esto es un grafo con estado&quot;.</p>
<h2 id="diseno-del-grafo"><a class="anchor" href="#diseno-del-grafo" aria-hidden="true">#</a>Diseño del grafo</h2>
<p>El estado (<code>GraphState</code>, un <code>TypedDict</code>) lleva lo que cualquier conversación necesita, más los campos que hacen visible la orquestación:</p>
<pre><code>conversation_id: str
messages: Annotated[list[BaseMessage], add_messages]
input_audio: bytes | None
input_text: str | None
retrieved_context: list[dict] | None
intent: Literal[&quot;chat&quot;, &quot;change_topic&quot;, &quot;change_level&quot;, &quot;end_conversation&quot;] | None
response_text: str | None
response_audio: bytes | None
student_level: Literal[&quot;beginner&quot;, &quot;intermediate&quot;, &quot;advanced&quot;]
error: str | None
retry_count: dict[str, int]
</code></pre>
<p>El grafo tiene 9 nodos: <code>transcribe_audio → classify_intent → {retrieve_context → generate_response, handle_change_level, handle_change_topic, end_conversation} → synthesize_speech → END</code>, con <code>handle_error</code> como terminal de fallback. Algunas decisiones que dieron trabajo real:</p>
<ul>
<li><strong><code>classify_intent</code> es el nodo que justifica el grafo.</strong> Es el punto donde un pipeline lineal de <code>if/else</code> se vuelve frágil rápido: cada intención nueva es una rama nueva, y con LangGraph eso es un edge condicional más, no un <code>elif</code> más en una función que ya tiene quince.</li>
<li><strong>Retries como self-loop, no como <code>try/except</code> anidado.</strong> Cada nodo con llamada de red incrementa <code>retry_count[nodo]</code>; si supera el máximo, enruta a <code>handle_error</code>. Esto lo hace visible en el panel: cuando un nodo falla y reintenta, se ve como una arista que vuelve sobre sí misma, no como una excepción silenciosa en un log.</li>
<li><strong>Checkpointing con <code>MemorySaver</code></strong>, usando <code>conversation_id</code> como <code>thread_id</code>. Para un demo de tráfico bajo es suficiente y lo documenté como trade-off explícito: si esto escalara a más de una instancia, <code>MemorySaver</code> no persiste entre procesos y habría que mover a un checkpointer con Redis o Postgres. Es, a propósito, un buen punto de conversación en una entrevista.</li>
</ul>
<p>Un detalle que vale la pena anotar porque no es obvio la primera vez: <strong>todo nodo que retorna éxito tiene que limpiar explícitamente <code>error</code> y el sentinel de retry</strong> (<code>&quot;error&quot;: None, &quot;__retry_target__&quot;: None&quot;</code>). Si no lo hace, el edge condicional sigue viendo el error anterior como verdadero y el grafo entra en loop infinito. Es el tipo de bug que solo aparece la segunda vez que un nodo falla y se recupera, no la primera.</p>
<h2 id="el-panel-de-visualizacion-la-pieza-que-realmente-importa"><a class="anchor" href="#el-panel-de-visualizacion-la-pieza-que-realmente-importa" aria-hidden="true">#</a>El panel de visualización: la pieza que realmente importa</h2>
<p>El transporte es <strong>SSE</strong>, no WebSocket. El backend usa el streaming nativo de LangGraph (<code>astream(stream_mode=&quot;debug&quot;)</code>) y traduce cada paso a eventos: <code>node_start</code>, <code>node_end</code>, <code>edge_taken</code>, <code>final</code>. No hace falta instrumentar cada nodo a mano con eventos custom — LangGraph ya expone el estado después de cada paso, solo hay que traducirlo.</p>
<p>En el frontend, <code>GraphVisualizer</code> dibuja el grafo con Mermaid (usando <code>draw_mermaid()</code>, que LangGraph expone directamente sobre el grafo ya compilado) y resalta el nodo activo mientras llegan los eventos. <code>StatePanel</code> muestra el JSON del estado actual y el historial de transiciones. Con esto, un visitante del portafolio ve literalmente los nodos iluminándose en orden, no una respuesta de chat que aparece por arte de magia.</p>
<p>Un matiz gracioso: con los providers en modo mock (útil para desarrollo sin API keys) cada nodo corre en microsegundos, así que todos los eventos SSE de un turno llegan en el mismo tick de red — animar &quot;nodo por nodo&quot; se vuelve un parpadeo imperceptible. La solución fue encolar los eventos en el frontend y drenarlos con un pacing artificial (~350ms por paso), sin tocar el backend ni afectar el modo con providers reales, donde las llamadas de red ya dan tiempo de sobra.</p>
<h2 id="comparar-lineal-vs-grafo-en-vivo"><a class="anchor" href="#comparar-lineal-vs-grafo-en-vivo" aria-hidden="true">#</a>Comparar lineal vs grafo, en vivo</h2>
<p>La pieza que más convence sin necesidad de explicar nada es el endpoint <code>POST /compare/linear</code>: corre el mismo turno de conversación (mismo intent → extract → retrieve → generate) pero como una función lineal, sin edges condicionales, sin retries y sin checkpointing — el mismo trabajo, sin la estructura del grafo alrededor. El frontend lo muestra lado a lado con la versión orquestada por LangGraph.</p>
<p>No es una comparación de &quot;cuál es más rápido&quot; (para una sola llamada, la diferencia es marginal). Es una comparación de <strong>qué pasa cuando algo falla o cuando la conversación se ramifica</strong>: en la versión lineal, cada caso nuevo es una condición más apilada en una función; en la versión con grafo, es una arista más en una estructura que ya sabe reintentar y ya sabe en qué nodo estás.</p>
<h2 id="app-runner-no-lambda"><a class="anchor" href="#app-runner-no-lambda" aria-hidden="true">#</a>App Runner, no Lambda</h2>
<p>El streaming SSE fue la restricción que decidió esto. Ya tengo un backend multi-app (el de <code>levelup</code>) corriendo en <strong>AWS App Runner</strong>, con Dockerfile probado y healthcheck. Ese mismo backend migró antes de WebSocket a SSE precisamente por incompatibilidad de App Runner con conexiones persistentes tipo WebSocket — así que VoiceGraph siguió el mismo patrón desde el inicio en vez de descubrir el problema tarde.</p>
<p>Lambda quedó descartado por tres razones concretas:</p>
<ol>
<li><strong>Cold starts inaceptables</strong> para un demo interactivo que un reclutador prueba una sola vez y no va a esperar.</li>
<li><strong>Requiere un adaptador ASGI</strong> (Mangum) y empaquetado de dependencias pesadas (LangGraph, LangChain) en capas o contenedor — complejidad extra sin beneficio para este caso de uso.</li>
<li><strong>No hay ningún patrón de Lambda para servir APIs</strong> ya probado en mi infraestructura — la única Lambda que tengo corriendo es auxiliar (notificaciones), no un servicio HTTP con estado conversacional.</li>
</ol>
<p>Con App Runner, integrar VoiceGraph al backend existente es, al final, un <code>app.mount(&quot;/portfolio-langgraph&quot;, ...)</code>. Cero infraestructura nueva, cero costo adicional de servicio.</p>
<h2 id="disenar-para-la-migracion-desde-el-primer-commit"><a class="anchor" href="#disenar-para-la-migracion-desde-el-primer-commit" aria-hidden="true">#</a>Diseñar para la migración desde el primer commit</h2>
<p>Aquí está la decisión de arquitectura que más se nota con el tiempo: aunque VoiceGraph se desarrolla como repo standalone (con su propio <code>main.py</code>, <code>Dockerfile</code> y <code>uvicorn</code>), sabía desde el principio que el destino final era vivir como sub-app dentro del gateway FastAPI multi-app que ya tengo en producción. Así que el backend se construyó con dos reglas simples:</p>
<ul>
<li><strong>Los endpoints se definen como <code>APIRouter</code></strong>, nunca atados directamente a la instancia raíz de <code>FastAPI()</code>. El mismo router se puede incluir en la app standalone de desarrollo o en la sub-app montada del gateway sin reescribir una línea.</li>
<li><strong>La configuración usa un prefijo de variables de entorno propio</strong> (<code>VG_*</code>), para no chocar con las demás apps del gateway (<code>levelup</code>, <code>shopify</code>, <code>whatsapp_agent_voice</code>) cuando conviva con ellas bajo el mismo proceso.</li>
</ul>
<p>Esto significa que la migración final (renombrar la carpeta destino, copiar el código, cambiar <code>main.py</code> para que use <code>root_path=&quot;/portfolio-langgraph&quot;</code> en vez de ser la app raíz, y montar con <code>app.mount()</code>) es mecánica, no un rediseño. El código de negocio — grafo, nodos, providers, RAG — no cambia una línea; solo cambia cómo se expone.</p>
<h2 id="providers-reales-detras-de-un-feature-flag"><a class="anchor" href="#providers-reales-detras-de-un-feature-flag" aria-hidden="true">#</a>Providers reales detrás de un feature flag</h2>
<p>Para poder desarrollar y testear sin gastar en llamadas a APIs externas, todo el backend corre con <code>VG_USE_MOCK_PROVIDERS=true</code> por defecto: LLM, STT, TTS y vector store son deterministas y no tocan la red. Los providers reales (OpenAI para chat/Whisper/TTS, Pinecone para el vector store) están detrás del mismo flag, con una interfaz base compartida (<code>BaseChatLLM</code>, etc.) para que cambiar de mock a real — o de un proveedor a otro en el futuro — no toque los nodos del grafo.</p>
<p>La base de conocimiento del RAG (<code>knowledge_base/*.md</code>) es contenido editorial propio: quién soy, mis proyectos, y las decisiones de arquitectura de este mismo proyecto. Es deliberado — así el asistente puede responder preguntas sobre VoiceGraph usando VoiceGraph, que es el tipo de detalle que un reclutador técnico sí nota.</p>
<h2 id="preguntas-frecuentes"><a class="anchor" href="#preguntas-frecuentes" aria-hidden="true">#</a>Preguntas frecuentes</h2>
<h3 id="por-que-usar-langgraph-en-vez-de-encadenar-llamadas-a-un-llm-directamente"><a class="anchor" href="#por-que-usar-langgraph-en-vez-de-encadenar-llamadas-a-un-llm-directamente" aria-hidden="true">#</a>¿Por qué usar LangGraph en vez de encadenar llamadas a un LLM directamente?</h3>
<p>Porque una conversación real tiene ramas, reintentos y estado que persiste entre turnos. Modelar eso como funciones anidadas con <code>if/else</code> funciona al principio, pero cada caso nuevo aumenta la complejidad de forma lineal con el número de ramas. LangGraph hace esa estructura explícita: nodos, edges condicionales y checkpointing son parte del modelo, no un efecto secundario del código.</p>
<h3 id="por-que-elegir-app-runner-en-vez-de-lambda-para-un-backend-con-ia"><a class="anchor" href="#por-que-elegir-app-runner-en-vez-de-lambda-para-un-backend-con-ia" aria-hidden="true">#</a>¿Por qué elegir App Runner en vez de Lambda para un backend con IA?</h3>
<p>Porque el transporte es streaming (SSE) y porque ya existe un backend en App Runner al que integrarse sin costo de infraestructura nuevo. Lambda tiene sentido para trabajo corto y sin estado; para un servicio conversacional con cold starts que importan y dependencias pesadas como LangGraph, un contenedor de larga duración es más simple y más barato de operar.</p>
<h3 id="vale-la-pena-construir-el-panel-de-visualizacion-del-grafo-o-es-solo-estetica"><a class="anchor" href="#vale-la-pena-construir-el-panel-de-visualizacion-del-grafo-o-es-solo-estetica" aria-hidden="true">#</a>¿Vale la pena construir el panel de visualización del grafo, o es solo estética?</h3>
<p>Para un proyecto de portafolio, no es solo estética: es la diferencia entre decir &quot;usé LangGraph&quot; y mostrarlo. El panel convierte una afirmación técnica en algo que se verifica mirando la pantalla — el nodo activo cambia, el edge se resalta, el estado se actualiza. Para un producto real sin ese objetivo de demostración, la prioridad cambiaría (probablemente hacia logs estructurados y trazas en un sistema como LangSmith, que también integré aquí).</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion" aria-hidden="true">#</a>Conclusión</h2>
<p>VoiceGraph no es un chatbot con una capa de RAG encima; es un ejercicio deliberado de mostrar cómo se ve una conversación modelada como grafo de estado — con ramas, reintentos, checkpointing y una comparación directa contra el enfoque lineal para que la diferencia no quede en abstracto. Construirlo como servicio standalone pero con la migración a producción pensada desde el día uno (<code>APIRouter</code>, config con prefijo propio) hizo que integrarlo al backend existente en App Runner fuera un paso mecánico, no una reescritura.</p>
<p>Si quieres verlo en acción, el demo está en <strong><a href="https://javierdelgado.com.ve/voicegraph/">javierdelgado.com.ve/voicegraph</a></strong>.</p>
]]></content:encoded>
  </item>
  <item>
    <title>Cómo migré mi portafolio de Vue 2 a Vue 3 con Vite (sin reescribirlo desde cero)</title>
    <link>https://blog.javierdelgado.com.ve/es/posts/como-migre-mi-portafolio-de-vue-2-a-vue-3-con-vite/</link>
    <guid isPermaLink="true">https://blog.javierdelgado.com.ve/es/posts/como-migre-mi-portafolio-de-vue-2-a-vue-3-con-vite/</guid>
    <pubDate>Sun, 13 Sep 2026 12:00:00 GMT</pubDate>
    <dc:creator>Javier Delgado</dc:creator>
    <category>Desarrollo web</category>
    <description>Migré un portafolio de 2017 (Vue 2, Webpack 3, jQuery) a Vue 3 con Vite por fases, reemplazando cada dependencia muerta sin rehacer el diseño. Aquí está el plan, las decisiones y lo que aprendí.</description>
    <content:encoded><![CDATA[<p>Mi portafolio llevaba casi diez años con el mismo stack: <strong>Vue 2.5, Webpack 3, Babel 6, jQuery 1.12</strong> y una plantilla HTML comprada en 2016. Funcionaba, pero ya no compilaba en ninguna versión moderna de Node y cada dependencia tenía avisos de seguridad. En vez de rehacerlo con un framework nuevo, decidí migrarlo a <strong>Vue 3 con Vite</strong> conservando el diseño. Este artículo resume el método que seguí, que sirve para cualquier proyecto Vue 2 heredado.</p>
<h2 id="empezar-por-la-seguridad-no-por-el-framework"><a class="anchor" href="#empezar-por-la-seguridad-no-por-el-framework" aria-hidden="true">#</a>Empezar por la seguridad, no por el framework</h2>
<p>Lo primero no fue tocar Vue. Fue revisar qué había en el repositorio que pudiera hacer daño hoy mismo:</p>
<ul>
<li>Una <strong>API key de Google Maps</strong> escrita en el <code>index.html</code>, para un mapa que ni siquiera se mostraba. Se eliminó del código, pero como sigue en el historial público de git, la única solución real es rotarla y restringirla por dominio.</li>
<li>Un <strong>formulario de contacto en PHP</strong> sin validación ni protección. El componente Vue ya enviaba los datos a otro backend, así que el PHP era código muerto y peligroso a la vez.</li>
<li>Una integración con la <strong>API v1.1 de Twitter</strong>, cerrada hace años.</li>
<li>La carpeta <code>dist/</code> versionada en git y dos archivos <code>.zip</code> con copias del proyecto.</li>
</ul>
<p>Todo eso se puede limpiar en una tarde y no depende de ninguna migración. Si el proyecto muere a mitad del camino, al menos queda más seguro que antes.</p>
<h2 id="un-roadmap-por-fases-en-una-rama-separada"><a class="anchor" href="#un-roadmap-por-fases-en-una-rama-separada" aria-hidden="true">#</a>Un roadmap por fases, en una rama separada</h2>
<p>Escribí el plan en un archivo Markdown dentro del propio repositorio, con casillas para marcar el avance. Las fases fueron:</p>
<div class="table-wrap"><table><thead><tr><th>Fase</th><th>Objetivo</th><th>Resultado</th></tr></thead><tbody><tr><td>0</td><td>Seguridad inmediata</td><td>API key fuera del código, PHP y Twitter eliminados, artefactos fuera de git</td></tr><tr><td>1</td><td>Limpieza del entorno</td><td><code>browserslist</code> actualizado, plantillas HTML originales borradas, imports sin uso fuera</td></tr><tr><td>2</td><td>Andamiaje Vue 3 + Vite</td><td>Proyecto Vite nuevo, componentes migrados uno por uno</td></tr><tr><td>3</td><td>Reemplazo de dependencias</td><td>Cada plugin de Vue 2 sustituido o eliminado</td></tr><tr><td>5</td><td>Estilos y assets</td><td>Pendiente: SCSS al pipeline de Vite, iconos a SVG</td></tr><tr><td>6</td><td>Despliegue</td><td>GitHub Actions publica <code>dist/</code> en GitHub Pages en cada push a <code>master</code></td></tr></tbody></table></div><p>La clave fue trabajar en la rama <code>migration/vue3</code> mientras <code>master</code> seguía sirviendo el sitio viejo. Nada de &quot;big bang&quot;.</p>
<h2 id="vue-3-sin-reescribir-los-componentes"><a class="anchor" href="#vue-3-sin-reescribir-los-componentes" aria-hidden="true">#</a>Vue 3 sin reescribir los componentes</h2>
<p>Vue 3 sigue soportando la <strong>Options API</strong>, así que casi todos los componentes se movieron con cambios mínimos: <code>new Vue()</code> pasó a <code>createApp()</code>, los filtros desaparecieron y el <code>eventBus</code> (que en Vue 2 era una instancia vacía de Vue) se reemplazó por <a href="https://github.com/developit/mitt" target="_blank" rel="noopener">mitt</a>, que hace lo mismo en 200 bytes.</p>
<p>Lo que sí exigió trabajo fue todo lo que hacía <strong>jQuery desde <code>core.js</code></strong>, el script de la plantilla original: el loader de página, el header que se fija al hacer scroll, el panel lateral móvil y el modal de proyectos. Cada uno se reescribió como comportamiento Vue:</p>
<pre><code class="language-js">// App.vue — header fijo al pasar la altura del top-bar (antes lo hacía core.js con jQuery)
mounted() {
  document.body.classList.add(&#39;loaded&#39;)
  this.setHeader()
  window.addEventListener(&#39;scroll&#39;, this.setHeader, { passive: true })
},
methods: {
  setHeader() {
    const topBar = document.getElementById(&#39;top-bar&#39;)
    const limit = topBar ? topBar.offsetHeight : 0
    document.body.classList.toggle(&#39;sticky-layout&#39;, window.scrollY &gt;= limit)
  }
}
</code></pre>
<p>Con eso, jQuery, Bootstrap JS, Masonry, Owl Carousel e Isotope salieron del proyecto de golpe.</p>
<h2 id="que-reemplazo-a-cada-dependencia-de-vue-2"><a class="anchor" href="#que-reemplazo-a-cada-dependencia-de-vue-2" aria-hidden="true">#</a>Qué reemplazó a cada dependencia de Vue 2</h2>
<p>Esta fue la parte más entretenida. La regla: si un plugin no se actualizó a Vue 3, buscar el reemplazo más pequeño posible, y si lo que hace cabe en veinte líneas, escribirlo.</p>
<div class="table-wrap"><table><thead><tr><th>Vue 2</th><th>Vue 3</th><th>Comentario</th></tr></thead><tbody><tr><td><code>vue-multilanguage</code></td><td><code>vue-i18n</code></td><td>Los diccionarios <code>en</code>/<code>es</code> se movieron a <code>src/i18n.js</code>; <code>v-lang.x.y</code> pasó a <code>v-html=&quot;$t(&#39;x.y&#39;)&quot;</code></td></tr><tr><td><code>vue-carousel</code></td><td><code>@splidejs/vue-splide</code></td><td>Mantiene la misma UX del carrusel</td></tr><tr><td><code>vue-gallery</code></td><td><code>vue-easy-lightbox</code></td><td>Para la galería de certificados</td></tr><tr><td><code>vue-typer</code></td><td>Componente propio <code>Typer.vue</code></td><td>Efecto de &quot;máquina de escribir&quot; en 40 líneas</td></tr><tr><td><code>vueisotope</code> + Masonry</td><td>CSS Grid</td><td>El layout de proyectos ya no necesita JavaScript</td></tr><tr><td><code>vue-scrollto</code></td><td>Directiva propia <code>v-scroll-to</code></td><td><code>scrollIntoView({ behavior: &#39;smooth&#39; })</code></td></tr><tr><td><code>vue-router</code>, <code>vuex</code>, <code>bootstrap-vue</code></td><td>Nada</td><td>Estaban instalados pero no se usaban</td></tr></tbody></table></div><p>La última fila es la más importante: <strong>la mitad de las dependencias de un proyecto viejo no se usan</strong>. Antes de migrar algo, comprueba que realmente se importa en algún componente.</p>
<h2 id="vite-en-lugar-de-webpack-3"><a class="anchor" href="#vite-en-lugar-de-webpack-3" aria-hidden="true">#</a>Vite en lugar de Webpack 3</h2>
<p>El toolchain original (Webpack 3 + Babel 6) rompía en Node 17 o superior por cambios en OpenSSL. Migrar Webpack de la versión 3 a la 5 era casi tanto trabajo como cambiar a Vite, así que fui directo a Vite:</p>
<pre><code class="language-js">// vite.config.js
import { defineConfig } from &#39;vite&#39;
import vue from &#39;@vitejs/plugin-vue&#39;
import { fileURLToPath, URL } from &#39;node:url&#39;

export default defineConfig({
  base: &#39;./&#39;, // rutas relativas para GitHub Pages
  plugins: [vue()],
  resolve: { alias: { &#39;@&#39;: fileURLToPath(new URL(&#39;./src&#39;, import.meta.url)) } }
})
</code></pre>
<p>El resultado: <code>vite build</code> termina en unos segundos, con 240 kB de JavaScript y 306 kB de CSS. El CSS sigue siendo grande porque la plantilla original carga Bootstrap 3 completo; reducirlo es la siguiente fase.</p>
<h2 id="despliegue-automatico"><a class="anchor" href="#despliegue-automatico" aria-hidden="true">#</a>Despliegue automático</h2>
<p>Con Vite, publicar fue lo más sencillo: un workflow de GitHub Actions instala dependencias, ejecuta <code>npm run build</code> y sube <code>dist/</code> a GitHub Pages en cada push a <code>master</code>. La carpeta <code>dist/</code> dejó de estar en git.</p>
<h2 id="lo-que-queda-pendiente"><a class="anchor" href="#lo-que-queda-pendiente" aria-hidden="true">#</a>Lo que queda pendiente</h2>
<ul>
<li>Trasladar el SCSS de la plantilla al pipeline de Vite y eliminar el CSS que no se usa.</li>
<li>Sustituir las fuentes de iconos (FontAwesome, Themify) por SVG inline.</li>
<li>Auditoría con Lighthouse: rendimiento, accesibilidad y SEO.</li>
</ul>
<h2 id="preguntas-frecuentes"><a class="anchor" href="#preguntas-frecuentes" aria-hidden="true">#</a>Preguntas frecuentes</h2>
<h3 id="conviene-migrar-a-vue-3-o-reescribir-con-otro-framework"><a class="anchor" href="#conviene-migrar-a-vue-3-o-reescribir-con-otro-framework" aria-hidden="true">#</a>¿Conviene migrar a Vue 3 o reescribir con otro framework?</h3>
<p>Si el diseño y los componentes siguen valiendo, migrar es más barato: Vue 3 mantiene la Options API y la mayoría del código se mueve sin cambios. Reescribir solo tiene sentido si el diseño también va a cambiar.</p>
<h3 id="cuanto-tiempo-llevo-la-migracion"><a class="anchor" href="#cuanto-tiempo-llevo-la-migracion" aria-hidden="true">#</a>¿Cuánto tiempo llevó la migración?</h3>
<p>Las fases 0 a 3 (seguridad, limpieza, Vue 3 + Vite y reemplazo de dependencias) se completaron en pocas sesiones de trabajo repartidas en unos días, porque el plan estaba escrito de antemano y cada tarea era pequeña.</p>
<h3 id="que-hago-con-una-api-key-que-quedo-en-el-historial-de-git"><a class="anchor" href="#que-hago-con-una-api-key-que-quedo-en-el-historial-de-git" aria-hidden="true">#</a>¿Qué hago con una API key que quedó en el historial de git?</h3>
<p>Rotarla. Borrarla del código actual no sirve de nada si el repositorio es público: cualquiera puede leer los commits antiguos. Genera una nueva clave y restríngela por dominio (HTTP referrer) en la consola del proveedor.</p>
<h3 id="vale-la-pena-mantener-la-options-api-en-vue-3"><a class="anchor" href="#vale-la-pena-mantener-la-options-api-en-vue-3" aria-hidden="true">#</a>¿Vale la pena mantener la Options API en Vue 3?</h3>
<p>Para un proyecto migrado, sí. La Composition API es mejor para lógica compleja y reutilizable, pero cambiar componentes que ya funcionan solo por cambiar de estilo añade riesgo sin beneficio inmediato.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion" aria-hidden="true">#</a>Conclusión</h2>
<p>Migrar un proyecto heredado no es una sola tarea grande sino muchas pequeñas: primero seguridad, luego limpieza, después el andamiaje y por último las dependencias. Escribir el plan en el repositorio y trabajar en una rama separada hizo que el proceso fuera predecible y que el sitio nunca se cayera.</p>
]]></content:encoded>
  </item>
  <item>
    <title>Un blog estático en hosting compartido cPanel: Markdown, Node y deploy por FTP</title>
    <link>https://blog.javierdelgado.com.ve/es/posts/blog-estatico-en-hosting-cpanel-con-markdown-y-ftp/</link>
    <guid isPermaLink="true">https://blog.javierdelgado.com.ve/es/posts/blog-estatico-en-hosting-cpanel-con-markdown-y-ftp/</guid>
    <pubDate>Thu, 10 Sep 2026 12:00:00 GMT</pubDate>
    <dc:creator>Javier Delgado</dc:creator>
    <category>Herramientas</category>
    <description>Cómo funciona este blog: entradas en Markdown, un generador en Node sin frameworks, un .htaccess para Apache y un script que sube por FTP solo los archivos que cambiaron. Todo en un hosting cPanel normal.</description>
    <content:encoded><![CDATA[<p>Este blog vive en un <strong>hosting compartido con cPanel</strong>, el mismo tipo de plan que se contrata por unos pocos dólares al mes y que no ofrece Node en el servidor, ni CI, ni nada parecido a Netlify o Vercel. Aun así, publicar un blog estático ahí es sencillo: el HTML se genera en mi computadora y se sube por FTP. Aquí explico cómo está montado, por si quieres replicarlo.</p>
<h2 id="por-que-estatico-y-por-que-cpanel"><a class="anchor" href="#por-que-estatico-y-por-que-cpanel" aria-hidden="true">#</a>Por qué estático y por qué cPanel</h2>
<p>Un blog personal no necesita base de datos ni panel de administración. Con archivos HTML:</p>
<ul>
<li>No hay nada que actualizar ni parchear en el servidor (adiós a los avisos de &quot;WordPress necesita actualizarse&quot;).</li>
<li>La página carga en milisegundos, porque Apache solo tiene que servir archivos.</li>
<li>El contenido está en git, en texto plano, y se puede editar con cualquier editor.</li>
</ul>
<p>Y cPanel, con todos sus años, tiene lo único que hace falta: un servidor Apache que sirve carpetas, soporta <code>.htaccess</code> y acepta subidas por FTP.</p>
<h2 id="escribir-markdown-con-front-matter"><a class="anchor" href="#escribir-markdown-con-front-matter" aria-hidden="true">#</a>Escribir: Markdown con front matter</h2>
<p>Cada entrada es un archivo en <code>posts/&lt;idioma&gt;/</code> con un encabezado de metadatos (front matter) y el contenido en Markdown:</p>
<pre><code class="language-markdown">---
title: Título de la entrada
description: Resumen de 140-160 caracteres para Google y redes.
date: 2026-09-10
category: Herramientas
tags: [cpanel, ftp]
cover: /assets/img/mi-portada.jpg
keyPoints:
  - Primera idea clave.
  - Segunda idea clave.
---

Contenido en **Markdown**…
</code></pre>
<p>La URL sale del nombre del archivo: <code>posts/es/2026-09-10-mi-titulo.md</code> se publica en <code>/es/posts/mi-titulo/</code>. Si el archivo tiene <code>draft: true</code>, no se publica. Un comando (<code>npm run new -- &quot;Título&quot; --lang es</code>) crea el archivo con la plantilla lista.</p>
<h2 id="generar-un-script-de-node-sin-frameworks"><a class="anchor" href="#generar-un-script-de-node-sin-frameworks" aria-hidden="true">#</a>Generar: un script de Node sin frameworks</h2>
<p>El generador es un único archivo, <code>build.js</code>, que hace tres cosas: lee los <code>.md</code>, los convierte a HTML con <a href="https://marked.js.org/" target="_blank" rel="noopener">marked</a> y los inserta en plantillas HTML con un motor de variables mínimo (<code>{{ titulo }}</code>, <code>{{#if}}</code>, <code>{{#each}}</code>). El resultado va a <code>dist/</code>:</p>
<pre><code class="language-text">dist/
├── index.html                  portada (paginada en /page/2/, /page/3/…)
├── posts/&lt;slug&gt;/index.html     cada entrada
├── posts/&lt;slug&gt;.md             la misma entrada en Markdown limpio
├── categoria/&lt;nombre&gt;/         listados por categoría (category/&lt;name&gt;/ en inglés)
├── assets/                     css, js, imágenes
├── sitemap.xml · rss.xml · robots.txt · llms.txt · 404.html
└── .htaccess                   configuración de Apache
</code></pre>
<p>Sin Jekyll, Hugo ni Astro. No tengo nada en contra de ellos, pero para un blog con un diseño propio (el mismo del portafolio) me resultó más rápido escribir 500 líneas de JavaScript que aprender la forma de hacer plantillas de cada herramienta. Y <code>npm run dev</code> levanta un servidor local que imita a Apache para revisar el resultado antes de subirlo.</p>
<h2 id="configurar-apache-el-archivo-htaccess"><a class="anchor" href="#configurar-apache-el-archivo-htaccess" aria-hidden="true">#</a>Configurar Apache: el archivo .htaccess</h2>
<p>Aquí está la diferencia con un CDN moderno: en cPanel todo se configura con un <code>.htaccess</code> que el generador escribe automáticamente en <code>dist/</code>. Lo esencial:</p>
<pre><code class="language-apache">Options -Indexes
DirectoryIndex index.html
AddType text/markdown .md
ErrorDocument 404 /404.html

&lt;IfModule mod_rewrite.c&gt;
  RewriteEngine On
  RewriteCond %{HTTPS} !=on
  RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301]
&lt;/IfModule&gt;

&lt;IfModule mod_headers.c&gt;
  &lt;FilesMatch &quot;\.(css|js)$&quot;&gt;
    Header set Cache-Control &quot;public, max-age=31536000, immutable&quot;
  &lt;/FilesMatch&gt;
  &lt;FilesMatch &quot;\.(html|md|txt|xml)$&quot;&gt;
    Header set Cache-Control &quot;public, max-age=300, must-revalidate&quot;
  &lt;/FilesMatch&gt;
&lt;/IfModule&gt;

&lt;IfModule mod_deflate.c&gt;
  AddOutputFilterByType DEFLATE text/html text/css text/javascript application/javascript
&lt;/IfModule&gt;
</code></pre>
<p>Las URLs limpias (<code>/posts/mi-titulo/</code>) funcionan solas porque cada entrada es una carpeta con su <code>index.html</code>. El CSS y el JS llevan <code>?v=&lt;id-del-build&gt;</code> en la URL, así que pueden cachearse un año sin miedo a servir versiones viejas.</p>
<h2 id="publicar-ftp-incremental-con-un-manifiesto"><a class="anchor" href="#publicar-ftp-incremental-con-un-manifiesto" aria-hidden="true">#</a>Publicar: FTP incremental con un manifiesto</h2>
<p>Subir todo el <code>dist/</code> en cada cambio es lento por FTP. En su lugar, el script de deploy guarda en el servidor un archivo <code>.deploy-manifest.json</code> con el hash SHA-1 de cada archivo subido. En el siguiente deploy:</p>
<ol>
<li>Descarga el manifiesto y calcula los hashes de <code>dist/</code>.</li>
<li>Sube solo los archivos nuevos o modificados.</li>
<li>Borra del servidor los archivos que estaban en el manifiesto y ya no existen (por ejemplo, una entrada eliminada). Nunca toca archivos que no subió él.</li>
<li>Sube el manifiesto actualizado.</li>
</ol>
<p>Corregir una errata en una entrada supone subir dos archivos (el HTML y el <code>.md</code>) más los feeds, no cientos. El cliente FTP es el paquete <a href="https://github.com/patrickjuchli/basic-ftp" target="_blank" rel="noopener">basic-ftp</a> de Node y las credenciales viven en un <code>.env</code> que no se versiona:</p>
<pre><code class="language-bash">npm run deploy:dry   # conecta y muestra qué subiría, sin tocar nada
npm run deploy       # build + subida incremental
</code></pre>
<h2 id="seo-y-asistentes-de-ia"><a class="anchor" href="#seo-y-asistentes-de-ia" aria-hidden="true">#</a>SEO y asistentes de IA</h2>
<p>Como el HTML se genera completo, es fácil incluir lo que los buscadores esperan: <code>canonical</code>, Open Graph, datos estructurados JSON-LD (<code>BlogPosting</code>, <code>BreadcrumbList</code>, <code>FAQPage</code> a partir de la sección de preguntas frecuentes), <code>sitemap.xml</code> y RSS.</p>
<p>Y para los asistentes de IA (ChatGPT, Claude, Perplexity), que cada vez envían más tráfico, el blog publica:</p>
<ul>
<li><code>/llms.txt</code>, un índice del sitio en el formato de <a href="https://llmstxt.org" target="_blank" rel="noopener">llmstxt.org</a>.</li>
<li><code>/llms-full.txt</code>, con todo el contenido en un solo Markdown.</li>
<li>Cada entrada en <code>/posts/&lt;slug&gt;.md</code>, enlazada desde el HTML con <code>&lt;link rel=&quot;alternate&quot; type=&quot;text/markdown&quot;&gt;</code>.</li>
<li>Un bloque &quot;En resumen&quot; al inicio de cada artículo con las ideas clave.</li>
</ul>
<h2 id="preguntas-frecuentes"><a class="anchor" href="#preguntas-frecuentes" aria-hidden="true">#</a>Preguntas frecuentes</h2>
<h3 id="funciona-en-un-subdominio-o-solo-en-la-raiz"><a class="anchor" href="#funciona-en-un-subdominio-o-solo-en-la-raiz" aria-hidden="true">#</a>¿Funciona en un subdominio o solo en la raíz?</h3>
<p>En ambos. El generador tiene un <code>basePath</code> configurable por idioma: vacío para el idioma por defecto (por ejemplo, inglés en la raíz de <code>blog.midominio.com</code>), o <code>/es</code> para el resto. Todas las rutas internas, el <code>.htaccess</code>, el <code>hreflang</code> y el sitemap se ajustan solos.</p>
<h3 id="que-pasa-si-el-hosting-no-soporta-ftps"><a class="anchor" href="#que-pasa-si-el-hosting-no-soporta-ftps" aria-hidden="true">#</a>¿Qué pasa si el hosting no soporta FTPS?</h3>
<p>El script funciona con FTP plano, FTPS explícito o implícito; se elige con una variable en <code>.env</code>. Si el certificado del hosting no coincide con el nombre del servidor, otra variable permite aceptarlo.</p>
<h3 id="como-agrego-imagenes-a-una-entrada"><a class="anchor" href="#como-agrego-imagenes-a-una-entrada" aria-hidden="true">#</a>¿Cómo agrego imágenes a una entrada?</h3>
<p>Se copian a <code>assets/img/</code> y se referencian como <code>/assets/img/archivo.jpg</code>. Conviene mantener las portadas por debajo de 250 KB. Si reemplazas una imagen, usa un nombre nuevo: las imágenes se cachean un mes.</p>
<h3 id="puedo-usar-este-sistema-con-otro-diseno"><a class="anchor" href="#puedo-usar-este-sistema-con-otro-diseno" aria-hidden="true">#</a>¿Puedo usar este sistema con otro diseño?</h3>
<p>Sí. Las plantillas están en <code>layout/</code> (cabecera, pie, tarjeta de entrada, página de entrada) y los estilos en un único CSS sin frameworks. Cambiar el diseño es editar esos archivos.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion" aria-hidden="true">#</a>Conclusión</h2>
<p>Un hosting compartido no es un límite para tener un blog rápido, bien posicionado y fácil de mantener. Markdown para escribir, un script de Node para generar, <code>.htaccess</code> para configurar Apache y FTP incremental para publicar: cuatro piezas pequeñas que se entienden completas.</p>
]]></content:encoded>
  </item>
  <item>
    <title>5 problemas de seguridad que encontré al revisar un frontend de hace diez años</title>
    <link>https://blog.javierdelgado.com.ve/es/posts/5-problemas-de-seguridad-en-un-frontend-heredado/</link>
    <guid isPermaLink="true">https://blog.javierdelgado.com.ve/es/posts/5-problemas-de-seguridad-en-un-frontend-heredado/</guid>
    <pubDate>Sun, 06 Sep 2026 12:00:00 GMT</pubDate>
    <dc:creator>Javier Delgado</dc:creator>
    <category>Seguridad</category>
    <description>Al retomar un proyecto web de 2016 encontré claves expuestas, un formulario PHP vulnerable y APIs muertas. Esta es la lista de lo que revisar antes de tocar una sola línea de código nuevo.</description>
    <content:encoded><![CDATA[<p>Cuando decidí modernizar mi portafolio, lo primero que hice no fue elegir un framework sino leer el repositorio con ojos de atacante. En un proyecto de 2016 que se fue parcheando durante años, encontré cinco problemas que son muy comunes en cualquier frontend heredado. Los cuento con la solución que apliqué en cada caso.</p>
<h2 id="1-una-clave-de-api-en-el-html"><a class="anchor" href="#1-una-clave-de-api-en-el-html" aria-hidden="true">#</a>1. Una clave de API en el HTML</h2>
<p>En el <code>index.html</code> había un <code>&lt;script&gt;</code> de Google Maps con la clave de API escrita en la URL. El mapa ni siquiera se mostraba: el <code>div</code> que lo contenía estaba comentado. Pero la clave seguía ahí, en un repositorio público, disponible para cualquiera.</p>
<p><strong>Lo que hice</strong>: borrar el script. <strong>Lo que hay que hacer además</strong>: rotar la clave. Eliminarla del código no basta, porque el historial de git conserva cada versión anterior del archivo. La nueva clave debe restringirse por dominio (HTTP referrer) para que solo funcione desde el sitio.</p>
<blockquote>
<p>Regla práctica: si un secreto tocó alguna vez un commit público, está comprometido. Rótalo.</p>
</blockquote>
<h2 id="2-un-formulario-de-contacto-en-php-que-nadie-usaba"><a class="anchor" href="#2-un-formulario-de-contacto-en-php-que-nadie-usaba" aria-hidden="true">#</a>2. Un formulario de contacto en PHP que nadie usaba</h2>
<p>El proyecto incluía <code>contact-form.php</code>, el script que traía la plantilla original para enviar correos. El componente Vue de contacto ya enviaba los datos a otro servicio, así que el PHP no se usaba… pero seguía desplegado y era alcanzable por URL.</p>
<p>Un script así, sin validación ni límite de envíos, es un relay de spam listo para usar. <strong>Lo que hice</strong>: eliminarlo y, ya puestos, eliminar toda la sección de contacto, que era la única razón para cargar jQuery Validate.</p>
<h2 id="3-una-integracion-con-una-api-muerta"><a class="anchor" href="#3-una-integracion-con-una-api-muerta" aria-hidden="true">#</a>3. Una integración con una API muerta</h2>
<p>Había una carpeta <code>api/twitter/</code> con código para la API v1.1 de Twitter, cerrada hace años, con su archivo de configuración para tokens. Código muerto no da errores, así que nadie lo revisa; pero cualquier archivo PHP servible es superficie de ataque y cualquier archivo de configuración es un candidato a filtrar credenciales.</p>
<p><strong>Lo que hice</strong>: borrar la carpeta completa. Si algún día vuelve a hacer falta, está en el historial de git.</p>
<h2 id="4-artefactos-de-build-y-zips-dentro-del-repositorio"><a class="anchor" href="#4-artefactos-de-build-y-zips-dentro-del-repositorio" aria-hidden="true">#</a>4. Artefactos de build y zips dentro del repositorio</h2>
<p>La carpeta <code>dist/</code> (el resultado compilado) estaba versionada, junto con un <code>dist.zip</code> y un <code>src.zip</code>. Tres copias del proyecto, cada una con sus propias versiones de dependencias y potencialmente con secretos ya &quot;borrados&quot; del código fuente.</p>
<p><strong>Lo que hice</strong>: <code>git rm -r --cached dist/</code>, borrar los zips y comprobar que <code>.gitignore</code> los excluye. El despliegue debe generar el build, no leerlo del repositorio.</p>
<h2 id="5-jquery-1-12-y-una-auditoria-que-sorprendio"><a class="anchor" href="#5-jquery-1-12-y-una-auditoria-que-sorprendio" aria-hidden="true">#</a>5. jQuery 1.12 y una auditoría que sorprendió</h2>
<p>jQuery 1.12 tiene vulnerabilidades conocidas y ya no recibe parches. La reacción obvia es &quot;quitarlo&quot;, pero primero conviene saber quién lo usa. La auditoría dio un resultado curioso: de todo el código, <strong>solo el script de la plantilla (<code>core.js</code>) y la validación del formulario</strong> dependían de jQuery. Los componentes Vue no.</p>
<p>Al eliminar la sección de contacto (punto 2), el único uso restante quedó en <code>core.js</code>, que se reemplazó por comportamiento Vue durante la migración. La lección: antes de auditar o reemplazar una dependencia, mide su uso real; muchas veces es más pequeño de lo que parece.</p>
<h2 id="una-lista-para-tu-proximo-proyecto-heredado"><a class="anchor" href="#una-lista-para-tu-proximo-proyecto-heredado" aria-hidden="true">#</a>Una lista para tu próximo proyecto heredado</h2>
<p>Si vas a retomar un frontend viejo, esta revisión cabe en una tarde:</p>
<div class="table-wrap"><table><thead><tr><th>Qué buscar</th><th>Cómo</th><th>Qué hacer</th></tr></thead><tbody><tr><td>Claves y tokens en el código</td><td><code>git log -p</code> y búsqueda de <code>key=</code>, <code>token</code>, <code>secret</code></td><td>Rotar y restringir</td></tr><tr><td>Scripts de servidor (PHP, CGI)</td><td>Listar archivos ejecutables desplegados</td><td>Eliminar los que no se usan</td></tr><tr><td>Integraciones con APIs cerradas</td><td>Revisar carpetas <code>api/</code>, <code>lib/</code>, <code>vendor/</code></td><td>Borrar</td></tr><tr><td>Artefactos versionados</td><td><code>dist/</code>, <code>build/</code>, <code>*.zip</code> en <code>git ls-files</code></td><td>Sacar de git</td></tr><tr><td>Dependencias obsoletas</td><td><code>npm audit</code> y buscar los <code>import</code> reales</td><td>Reemplazar solo las que se usan</td></tr></tbody></table></div><h2 id="preguntas-frecuentes"><a class="anchor" href="#preguntas-frecuentes" aria-hidden="true">#</a>Preguntas frecuentes</h2>
<h3 id="de-verdad-hay-que-rotar-una-clave-que-ya-borre-del-codigo"><a class="anchor" href="#de-verdad-hay-que-rotar-una-clave-que-ya-borre-del-codigo" aria-hidden="true">#</a>¿De verdad hay que rotar una clave que ya borré del código?</h3>
<p>Sí. En un repositorio público cualquiera puede recuperar la versión anterior del archivo con <code>git log -p</code>. Incluso en repositorios privados, los clones y forks conservan el historial.</p>
<h3 id="como-se-si-un-archivo-php-viejo-sigue-siendo-accesible"><a class="anchor" href="#como-se-si-un-archivo-php-viejo-sigue-siendo-accesible" aria-hidden="true">#</a>¿Cómo sé si un archivo PHP viejo sigue siendo accesible?</h3>
<p>Si está dentro de la carpeta pública del hosting (<code>public_html</code> o equivalente), es accesible por URL aunque nada lo enlace. Bórralo o muévelo fuera de la carpeta pública.</p>
<h3 id="sirve-npm-audit-para-un-proyecto-de-2016"><a class="anchor" href="#sirve-npm-audit-para-un-proyecto-de-2016" aria-hidden="true">#</a>¿Sirve <code>npm audit</code> para un proyecto de 2016?</h3>
<p>Sirve como inventario, pero producirá cientos de avisos, muchos de dependencias que no se usan. Prioriza las que realmente se importan en el código y las que se ejecutan en el servidor.</p>
<h2 id="conclusion"><a class="anchor" href="#conclusion" aria-hidden="true">#</a>Conclusión</h2>
<p>Ninguno de estos cinco problemas requería migrar el proyecto para resolverse. Se corrigieron antes de tocar Vue, en una rama aparte y con commits pequeños. Empezar por la seguridad hace que la migración posterior sea más simple, porque hay menos código que mover, y garantiza que el esfuerzo valga la pena aunque el proyecto se quede a medias.</p>
]]></content:encoded>
  </item>
</channel>
</rss>
