Edición 298 · ef8b21f · 22 de octubre de 2026

Portada

Todo el sistema de Harol en un vistazo

Qué piezas viven en este repositorio, cómo se conectan, dónde corre cada una y en qué estado están.

5 min de lectura README.md

Todos los artículos

Este repositorio privado (DevCat-HGS/portfolio-hgs en GitHub) reúne la presencia profesional de Harol Guerrero Salgado: el portafolio web, el editor de contenido, la hoja de vida en PDF, el README del perfil de GitHub y el material de LinkedIn. Todo sale de los mismos datos y se publica con un commit en main.

Las piezas

PiezaCarpetaDónde se veQué es
Portafolioportfolio/https://hgsportafolio.site (sitio de Netlify portafolio-hgs)Página bilingüe en React 19 + Vite, con los casos de estudio de MIGOZZ (/migozz/), ARIA (/aria/), DevPanel (/devpanel/) y del propio portafolio (/portafolio/).
Adminadmin/admin.hgsportafolio.site (sitio de Netlify admin-hgs)Editor propio en React que lee y publica el contenido del portafolio y del CV en este repositorio. También recibe el formulario de contacto, envía sus correos, guarda los mensajes y manda un resumen semanal por correo.
Hoja de vidacurriculum/portfolio/public/cv/ (botón de descarga del sitio)Generador en Python que produce el CV en PDF en español e inglés desde cv_data.json.
Perfil de GitHubREADME.md, README.es.md, profile/, privacity/, scripts/Repositorio público DevCat-HGS/DevCat-HGSREADME del perfil, con gráficas de lenguajes generadas y copiado al repo público por un workflow.
LinkedInlinkedin/Perfil de LinkedIn (manual)Guía, banners y publicación con la misma identidad visual del sitio.
DocumentaciónDoc/https://docs.hgsportafolio.site (repo público DevCat-HGS.github.io)Estos artículos y su página index.html, publicados por el workflow Publish docs en cada push a main que toque Doc/.

Cómo se conectan

  1. El contenido visible del portafolio vive en portfolio/src/content/data/*.json y el del CV en curriculum/cv_data.json.
  2. El admin edita esos JSON y publica un solo commit en main a través de una función de Netlify que guarda el token de GitHub.
  3. Ese commit dispara los workflows de GitHub Actions (CV, imágenes, pruebas) y el deploy de Netlify del portafolio.
  4. El formulario de contacto del portafolio envía cada mensaje a una función del admin, que lo guarda y manda los correos.
  5. El README del perfil se regenera aparte (gráficas diarias) y se copia al repositorio público.
  6. Cada lunes, una función programada del admin lee Umami y Search Console y le manda a Harol un resumen de la semana.

El detalle está en arquitectura.

Estado actual

  • El portafolio está en línea en el dominio propio hgsportafolio.site; portafolio-hgs.netlify.app sigue abierto como respaldo (ver dominio y SEO).
  • El admin acaba de pasar a entrada con PIN más código por correo y sesión firmada; el token de GitHub ya no pasa por el navegador (commit bf84696, 2026-10-21).
  • El formulario de contacto ya no usa Netlify Forms: envía al admin, que guarda el mensaje (estación «Mensajes») y, con Resend configurado, envía la copia a Harol y la confirmación al visitante (commit f4be780, 2026-10-21).
  • El caso de estudio de ARIA está en /aria/ y /en/aria/, editable desde el admin; la tarjeta y el CV describen ARIA como trabajo de equipo, con S3 Vectors y un tiempo medido (commits b9d13b1 y 8bbfdbf, 2026-10-21).
  • DevPanel, la app de escritorio de código abierto de Harol, tiene caso de estudio en /devpanel/ y /en/devpanel/, tarjeta destacada y una franja propia en la portada después de los proyectos. ARIA y DevPanel comparten la plantilla CaseStudyPage (commits 06a3e2b y 90ff404, 2026-10-21).
  • El propio portafolio tiene caso de estudio en /portafolio/ y /en/portafolio/, con la misma plantilla CaseStudyPage y una tarjeta destacada después de DevPanel. Como su código es privado, el caso, la tarjeta y el texto de respaldo enlazan a esta documentación en docs.hgsportafolio.site (commit 433174c, 2026-10-22).
  • La documentación se publica en https://docs.hgsportafolio.site y su página es siempre clara, sobre papel crema (commits 83ccd6b y 6a21775, 2026-10-22).
  • SENA Cloud y SensoresAPP tienen repos públicos nuevos y limpios, enlazados desde sus tarjetas. Los repos de clientes se quedan privados (ver perfil de GitHub).
  • La analítica con Umami está lista en el código; se activa con VITE_UMAMI_WEBSITE_ID (solo el UUID) en portafolio-hgs.
  • El resumen semanal por correo (weekly-digest) está desplegado, pero no envía nada hasta que se configuren sus variables en admin-hgs.
  • Vite 8.3.5 en los dos proyectos; sin alertas abiertas de Dependabot tras f3089f8. gitleaks revisa el historial en cada push.
  • El repo sigue privado: hacerlo público pide un repo nuevo con historial limpio, decisión aplazada (ver seguridad).
  • La carpeta img/ ya no está en el repositorio (commit 236c9d3).
  • Falta configuración de Harol: las variables del admin en Netlify (incluidas las del formulario y del resumen semanal) y la verificación del dominio en Resend. Mientras falten, el admin no deja entrar y el formulario no envía correos. Lista completa en configuración y pendientes.
  • Ramas: solo main (producción) y desarrollo.

Índice de artículos

#ArtículoDe qué va
2ArquitecturaCarpetas, tecnologías y el camino de un cambio hasta el sitio.
3PortafolioSecciones, componentes, contenido en JSON, idiomas, casos MIGOZZ, ARIA, DevPanel y Portafolio, imágenes, formulario, analítica y pruebas.
4AdminEstaciones (incluidas «Mensajes», ARIA, DevPanel y Portafolio), edición, imágenes, borradores, publicación y resumen semanal.
5SeguridadEntrada al admin, sesiones, límites, intermediario de GitHub, CSP, cabeceras, dependencias, gitleaks y publicación del repo.
6CorreosFormulario de contacto, códigos de acceso y resumen semanal, con Resend.
7Hoja de vidaFuente única, generación de los PDF y diseño para ATS.
8Perfil de GitHubREADME del perfil, gráficas, publicación y qué código se hace público.
9CI/CDWorkflows, Netlify, Dependabot y ramas.
10Dominio y SEODominio, DNS, Search Console, sitemap y datos estructurados.
11ConfiguraciónVariables de entorno por sitio y lo que falta configurar.
12LinkedInGuía, banners y publicación.
13HistorialCronología del repositorio según git log.
14PendientesLo que falta, lo que depende de Harol y mejoras sugeridas.

Arquitectura

Cómo está armado el repositorio

Mapa de carpetas, tecnologías de cada pieza y el recorrido de un cambio desde el admin o un commit hasta el sitio en línea.

4 min de lectura arquitectura.md

Mapa de carpetas

Carpeta o archivoContenido
portfolio/Sitio web. src/ (React), src/content/data/*.json (contenido), seo/ (cabeceras, head, sitemap), migozz/, aria/, devpanel/ y portafolio/ (HTML de cada caso de estudio), scripts/ (imágenes, Lighthouse, og.png, datos de GitHub), tests/ (Vitest), e2e/ (Playwright), images/ (originales de imágenes), public/ (archivos servidos).
admin/Editor de contenido. src/ (React), netlify/functions/ (auth.mts, github.mts, contact.mts, messages.mts, weekly-digest.mts), netlify/lib/ (auth.ts, contact.ts, digest.ts, google.ts, netlify.ts), netlify.toml, .env.example.
curriculum/cv_data.json (fuente del CV), generate_cv.py, fonts/ (Inter), output/ (PDF versionados).
profile/SVG del README del perfil (banner.svg, languages.svg, languages.es.svg).
privacity/Lista elegida de repos privados para el README del perfil (featured.json) y su script.
scripts/generate_language_charts.py, que dibuja las gráficas de lenguajes del perfil.
linkedin/Guía, plantillas HTML, PNG exportados y exportar.mjs.
.github/Workflows, dependabot.yml y plantilla de pull request.
.claude/agents/Agentes de Claude Code del proyecto (documentador.md, rrhh-estricto.md).
release-please-config.json, .release-please-manifest.jsonConfiguración de Release Please.
Doc/Esta documentación y su generador build.mjs; se publica en DevCat-HGS.github.io, servido en https://docs.hgsportafolio.site.

Tecnologías

PiezaTecnologías (versiones en su package.json o requirements.txt)
PortafolioReact 19, TypeScript, Vite, motion (solo en la página de MIGOZZ), fuente Archivo autoalojada, sharp para imágenes, Vitest, Playwright con axe, Lighthouse, ESLint y Prettier.
AdminReact 19, TypeScript, Vite, @netlify/blobs, @netlify/functions. Sin librerías de interfaz.
FuncionesSolo en el admin: Netlify Functions (formato .mts con Config, una de ellas programada), Netlify Blobs para estado y mensajes, Resend para correos, APIs de Umami Cloud y Search Console para el resumen semanal (firma con Web Crypto, sin SDK de Google). El portafolio no tiene funciones.
Hoja de vidaPython 3 con ReportLab.
PerfilPython sin dependencias externas (urllib), API REST de GitHub.

Dos sitios de Netlify

  • portafolio-hgs construye portfolio/ con portfolio/netlify.toml (npm run images && npm run build, publica dist).
  • admin-hgs construye admin/ con admin/netlify.toml (base directory admin). Su ignore (git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF -- .) hace que solo se reconstruya cuando cambia la carpeta admin/, no con cada cambio de contenido.

Van separados para que el admin tenga su propia política de seguridad y no se republique con cada cambio de contenido (comentario en admin/netlify.toml).

Cómo fluye un cambio

Desde el admin

  1. Harol entra en admin.hgsportafolio.site (PIN y código por correo, ver seguridad).
  2. El admin lee los JSON de main por /api/github.
  3. Al publicar, el navegador manda a /api/github las llamadas de la Git Data API: blobs, árbol, commit y mover refs/heads/main. Resultado: un solo commit con todos los archivos e imágenes (admin/src/github.ts).
  4. El push a main dispara:
  5. el deploy de Netlify del portafolio, cuyo build corre npm run images (convierte originales a WebP) y tsc --noEmit (valida la forma de cada JSON);
  6. Curriculum, que regenera los PDF y hace commit si cambiaron;
  7. Images, si hay originales nuevos en portfolio/images/, que commitea los WebP y lanza Portfolio CI;
  8. Portfolio CI, si cambió algo en portfolio/**.
  9. El admin sigue en su tablero de salida las ejecuciones de Actions de ese commit.

Si un JSON tiene la forma equivocada, el typecheck falla y Netlify no publica esa versión: el sitio se queda con la anterior. Que un check de GitHub Actions bloquee o no el deploy de Netlify depende de la configuración del panel de Netlify, que no está en el repositorio (por confirmar).

Desde un commit

Un cambio de código sigue el mismo camino. En un pull request, además, Netlify publica un deploy preview por sitio y preview-comment.yml deja un comentario con el enlace. Ver CI/CD.

Un mensaje del formulario

El formulario del portafolio envía JSON a https://admin.hgsportafolio.site/api/contact. La función del admin lo filtra, lo guarda en Netlify Blobs (almacén contact-messages) y envía dos correos por Resend. Harol lo lee en su correo y en la estación «Mensajes» del admin. El portafolio sigue siendo estático; por eso su CSP permite conectarse al dominio del admin. Ver correos.

El resumen semanal

Cada lunes a las 8:00 de Bogotá, Netlify corre weekly-digest en el sitio del admin: lee la semana anterior en Umami Cloud y Search Console y envía un correo por Resend. No depende de ningún commit. Ver admin.

El perfil de GitHub

Va por un camino aparte: Metrics y Privacity actualizan README.md, README.es.md y las gráficas en este repo, y Publish profile copia esos archivos al repositorio público DevCat-HGS/DevCat-HGS. Ver perfil de GitHub.

Portafolio

El portafolio por dentro

Secciones del sitio, componentes, contenido en JSON y su validación, idiomas, los casos MIGOZZ, ARIA, DevPanel y Portafolio, la franja de DevPanel, imágenes y pruebas.

13 min de lectura portafolio.md

El portafolio es una página principal bilingüe en React 19 + TypeScript + Vite, sin librerías de interfaz, más cuatro casos de estudio (MIGOZZ, ARIA, DevPanel y el propio portafolio). El diseño sigue una línea del metro de Medellín que recorre la página: una estación por capítulo (portfolio/README.md).

Secciones

portfolio/src/App.tsx solo compone: hooks y un componente por sección, en este orden.

#Capítulo (chapters en site.json)Componente
01InicioHero.tsx (con LineArt.tsx)
02OficioPractice.tsx (retrato, bento y marquesina)
03StartupStartup.tsx (MIGOZZ y botón al caso)
04CargoTenure.tsx (cargo anterior)
05ProyectosProjects.tsx (los marcados featured y el resto tras «ver más»)
—(sin capítulo)SideProject.tsx (franja de DevPanel, ver abajo)
06CredencialesCredentials.tsx y Lightbox.tsx (carga diferida)
07StackStack.tsx (acordeón por grupo)
08CierreClose.tsx con ContactForm.tsx, Socials.tsx y GithubStats.tsx

Nav.tsx es el mapa de la línea con el cambio de idioma. Los ids de los capítulos están en CHAPTER_IDS (src/content/types.ts) y una prueba comprueba que el menú los sigue en orden.

GithubStats.tsx muestra repos públicos, lenguajes y último push desde src/data/github.json, y se oculta mientras haya menos de 3 repos públicos (MIN_REPOS).

Contenido en JSON

Todo el texto visible vive en portfolio/src/content/data/:

ArchivoQué contieneQuién lo lee
site.jsonTextos del sitio en es y en, incluidos credGroups y stackGroupscontent/es.ts, content/en.ts
projects.jsonProyectos (campos comunes y textos por idioma)content/projects.ts
stack.jsonTecnologías e íconos por grupocontent/stack.ts
migozz.jsonPágina del caso MIGOZZcontent/migozz.ts
migozz-public.jsonVersiones de la App Store y comunidad de creadores destacadoscontent/migozzPublic.ts
migozz-users.jsonCifras de usuarios de MIGOZZ (ocultas mientras visible sea false)content/migozzUsers.ts
aria.jsonPágina del caso ARIAcontent/aria.ts
devpanel.jsonPágina del caso DevPanelcontent/devpanel.ts
portafolio.jsonPágina del caso del propio portafoliocontent/portafolio.ts
seo.jsonIdentidad del sitio, enlaces, códigos de Search Console y textos de cada páginaseo/pages.ts

Validación

JSON pierde los tipos literales, así que checked() en src/content/json.ts compara cada archivo con una copia «ensanchada» del tipo real (Widen<T>). Una clave que falta o está mal escrita hace fallar tsc y, por tanto, el build. Así un dato roto nunca llega a publicarse.

projects.ts normaliza las entradas: el admin puede dejar campos opcionales como "" o null, y projectsFrom() los quita antes de llegar a los componentes. hidden: true deja el proyecto en el archivo pero fuera del sitio; featured: true lo pone entre los destacados.

Campos case y code de un proyecto

Dos campos opcionales de projects.json, agregados el 2026-10-21:

CampoQué guardaQué muestra la tarjeta
caseId del caso de estudio en el sitio (hoy "aria", "devpanel" y "portafolio"). projectsFrom() lo convierte en /<id>/ o /en/<id>/ según el idioma.Enlace «Ver el caso completo» / «Read the case study».
codeURL de un repositorio público. Solo código propio, nunca de un cliente (comentario de ProjectEntry y ayuda del campo en el admin). Hoy lo usan SENA Cloud, SensoresAPP y DevPanel.Enlace «Ver código» / «View code», que abre en otra pestaña.

Antes toda tarjeta con sitio en línea era un enlace completo. Un enlace no puede contener otros enlaces, así que una tarjeta con caso o con código pasa a ser un <article> con enlaces separados (sitio, caso, código); las demás siguen siendo un enlace completo (ProjectCard en components/Projects.tsx, commit 94f0f6a).

Las pruebas de tests/content.test.ts añaden: misma estructura en los dos idiomas, ningún texto vacío, ids de proyectos iguales en ambos idiomas, enlaces solo https, y que existan las imágenes, íconos y PDF referenciados.

Idiomas

  • Español en / y inglés en /en/: dos páginas estáticas con su propio título, descripción, canonical, hreflang, Open Graph, datos estructurados y texto de respaldo.
  • La URL decide el idioma (useLocale, public/splash.js y el build usan la misma regla). El botón cambia sin recargar y actualiza la dirección.
  • portfolio/netlify.toml redirige (302) / a /en/, /migozz/ a /en/migozz/, /aria/ a /en/aria/, /devpanel/ a /en/devpanel/ y /portafolio/ a /en/portafolio/ solo si el navegador pide inglés o tiene la cookie nf_lang=en. El botón de idioma fija esa cookie, que Netlify lee antes que el idioma del navegador. Los rastreadores no mandan idioma y reciben la versión en español. La regla de /aria/ llegó en 574e51b y la de /devpanel/ en 90ff404 y la de /portafolio/ en 433174c (ver dominio y SEO).

Casos de estudio

MIGOZZ

/migozz/ y /en/migozz/ son una segunda entrada de Vite (portfolio/migozz/index.html, src/migozz.tsx, src/MigozzPage.tsx). Tiene portada animada, cifras públicas con su fuente, gráficas (components/migozz/UserCharts.tsx), flujo de uso (Flow.tsx), problema, funciones, cronología, capturas y el rol de Harol. Usa motion para las animaciones.

Desde el commit fc4b004 sigue la identidad actual de la marca: fondo blanco, morado como color principal y fucsia de acento, con contraste AA.

  • Las gráficas de usuarios solo aparecen con visible: true en migozz-users.json, para no mostrar cifras inventadas.
  • shots en migozz.json está vacío hoy, así que la sección de capturas no aparece.

Plantilla compartida de los casos

Desde el commit 90ff404, ARIA y DevPanel usan la misma página, y desde 433174c también el caso del portafolio: src/CaseStudyPage.tsx, con los tipos en src/content/caseStudy.ts (CaseCopy) y los estilos en src/styles/case-study.css. Antes eran AriaPage.tsx y aria.css. MIGOZZ sigue con su página propia.

Cada caso tiene:

PiezaARIADevPanelPortafolio
HTML (entrada de Vite)portfolio/aria/index.htmlportfolio/devpanel/index.htmlportfolio/portafolio/index.html
Entrada de Reactsrc/aria.tsxsrc/devpanel.tsxsrc/portafolio.tsx
Textos (es y en)src/content/data/aria.jsonsrc/content/data/devpanel.jsonsrc/content/data/portafolio.json
Carga y validaciónsrc/content/aria.tssrc/content/devpanel.tssrc/content/portafolio.ts
Textos SEO (seo.json)ariaPagesdevpanelPagesportafolioPages
Estación del adminARIADevPanelPortafolio

La entrada solo monta <CaseStudyPage name=… copy=… titles=… />. El título de pestaña es el mismo de los resultados de búsqueda (seo.json → ariaPages, devpanelPages o portafolioPages). No usa motion ni pantalla de carga.

Secciones de la plantilla, en orden: portada, cifras con su nota de contexto, problema y solución, capturas, la arquitectura como dos líneas de metro con su estación de transbordo, decisiones, lo que se rompió, la parte de Harol, lo que falta y el stack. Tres campos de CaseCopy son opcionales:

  • links: botones en la portada (etiqueta, enlace y nombre del evento de Umami).
  • shotsTitle y shots: capturas bajo public/; la plantilla usa también la variante -480.webp que está al lado. Sin capturas, la sección no aparece.
  • teamNote: quién hizo qué, cuando el proyecto fue de equipo. ARIA la tiene; DevPanel y Portafolio no.

Agregar un caso nuevo exige también su entrada en la capa SEO (ver dominio y SEO).

ARIA

/aria/ y /en/aria/ (commit b9d13b1). ARIA es un asistente de soporte SAP hecho en equipo para Arix Group. Sus dos líneas de metro son la consulta y la ingesta diaria. La nota del admin para esta estación pide solo cifras medidas y nada de IDs de AWS, URLs internas ni nombres del equipo.

DevPanel

/devpanel/ y /en/devpanel/ (commit 90ff404). DevPanel es una app de escritorio de código abierto de Harol, para Windows: repos de GitHub, proyectos locales y herramientas en un panel con entrada por rostro. Su página tiene enlaces en la portada al código y a la descarga (eventos devpanel-code y devpanel-download), tres capturas (shots/devpanel.webp, devpanel-lock.webp, devpanel-tools.webp) y dos líneas de metro: instalación y uso diario. No tiene nota de equipo. La nota del admin pide solo cifras que se puedan comprobar en el repositorio de DevPanel.

Las capturas salen de las pruebas e2e del propio DevPanel, con datos de prueba (mensaje del commit 06a3e2b). Están directamente en portfolio/public/shots/, cada una con su variante -480.webp.

Portafolio

/portafolio/ y /en/portafolio/ (commit 433174c). Es el caso de estudio de este mismo sitio, tratado como producto: admin propio, workflows de GitHub Actions, Lighthouse y accesibilidad exigidos en cada cambio. Sus dos líneas de metro son «Cada cambio» y «Solo, sin tocarlo» (en inglés, «Every change» y «On its own»): lo que pasa con un commit y lo que corre programado. No tiene nota de equipo.

Como el código del portafolio es privado, el registro público de cómo está hecho es esta documentación. Por eso la portada del caso tiene un solo botón, «Leer la documentación técnica», hacia https://docs.hgsportafolio.site (evento portafolio-docs), y el pie del caso, la tarjeta y el texto de respaldo apuntan al mismo sitio. La nota del admin pide solo cifras que se puedan comprobar en este repositorio o en su documentación; las cifras del texto (número de workflows, de pruebas) se actualizan a mano en portafolio.json, projects.json y seo.json.

Tiene tres capturas: shots/portafolio.webp (la portada), portafolio-case.webp (un caso de estudio) y portafolio-docs.webp (esta documentación), cada una con su variante -480.webp. Están directamente en portfolio/public/shots/, sin original en images/: son capturas del sitio local y de docs.hgsportafolio.site, y el repositorio no guarda el script que las tomó, así que al cambiar el diseño hay que rehacerlas a mano.

Franja de DevPanel en la portada

src/components/SideProject.tsx (estilos en src/styles/side-project.css) es una franja oscura justo después de Proyectos, con el ancla #devpanel. No es un capítulo de la línea: no está en CHAPTER_IDS ni en el menú. Sus textos están en site.json, clave sideProject (antetítulo, título, frase, tres puntos, etiquetas y la captura), y se editan en la estación «Textos del sitio» del admin, en el grupo «Otros textos» (admin/src/components/Fields.tsx junta ahí las claves que schema.ts no asigna a ningún grupo).

Tiene tres enlaces:

EnlaceDestinoEvento de Umami
Ver el casosideProject.caseHref (/devpanel/ o /en/devpanel/)case-devpanel
CódigoDEVPANEL_LINKS.codedevpanel-code
Descargar para WindowsDEVPANEL_LINKS.download (release installer del repositorio)devpanel-download

DEVPANEL_LINKS vive en src/content/devpanel.ts; las direcciones del código y de la descarga no se editan en el admin desde la franja (los enlaces de la portada del caso sí, en devpanel.json).

Tarjeta de ARIA

La tarjeta de ARIA en projects.json se corrigió el mismo día: dice S3 Vectors en vez de OpenSearch (que el proyecto no usó), cambia el resultado «Escalamiento humano incluido» (que no se construyó) por un tiempo de respuesta medido, describe la parte de Harol en el equipo y enlaza al caso con case: "aria". OpenSearch también salió del stack del sitio (stack.json, el texto de site.json y su ícono) en el commit 8bbfdbf.

Tarjeta del portafolio

Desde 433174c, projects.json tiene una tarjeta destacada portafolio («Este portafolio» / «This portfolio») justo después de la de DevPanel, con la captura shots/portafolio.webp y case: "portafolio". No tiene href ni code, porque el código es privado; su resultado menciona la documentación en docs.hgsportafolio.site.

Tarjeta de DevPanel

Desde 06a3e2b, DevPanel es un proyecto destacado (featured: true) en projects.json, con la captura shots/devpanel.webp, enlace a su código (code) y, desde 90ff404, a su caso (case: "devpanel"). El mensaje del commit explica por qué: es el código público más sólido de Harol.

Imágenes

Los originales van en portfolio/images/<tipo>/ y npm run images (scripts/images.mjs, con sharp) escribe los WebP en public/:

Carpeta de originalesSalida
images/shots/public/shots/<nombre>.webp (960 px) y -480.webp
images/credentials/public/credentials/<nombre>.webp (1100 px), thumb/<nombre>.webp (560) y thumb/<nombre>-360.webp
images/migozz/public/migozz/<nombre>.webp (780 px)
images/portrait/public/portrait/<nombre>.webp (cuadrado, hasta 600 px)

Los certificados (fotos de celular) pasan antes por un filtro de mediana. Los presupuestos de peso están en tests/assets.test.ts. El build de Netlify corre npm run images antes de compilar, así una imagen subida desde el admin se sirve aunque el commit del workflow Images aún no haya llegado.

src/lib/asset.ts arma rutas absolutas desde la raíz, porque la página en inglés vive un nivel más abajo. La tarjeta para compartir (public/og.png) se genera desde scripts/og-template.html.

Formulario de contacto

ContactForm.tsx envía JSON a https://admin.hgsportafolio.site/api/contact (src/lib/endpoints.ts). El portafolio no tiene funciones de Netlify propias: guardar el mensaje y enviar los correos lo hace el admin. Además de nombre, correo y mensaje, manda el idioma de la página, un campo trampa invisible (website) y el tiempo de llenado (elapsed). La CSP permite esa conexión (seo/headers.ts). Detalle en correos.

Analítica

Umami Cloud, sin cookies, por eso no hace falta aviso de consentimiento (comentario de seo/headers.ts). Está apagada mientras VITE_UMAMI_WEBSITE_ID esté vacía. Con un valor, el build agrega el script de cloud.umami.is con data-domains del dominio de producción, así los previews y localhost no cuentan (vite.config.ts), y la CSP abre los orígenes de Umami.

Eventos que ya se registran (atributos data-umami-event y llamadas a umami.track):

EventoDóndeCuándo
cv-downloadSocials.tsxClic en descargar el CV.
social-<red>Socials.tsxClic en una red social (nombre de la red en minúsculas).
project-<id>Projects.tsxClic en un proyecto con enlace.
case-<id>Projects.tsxClic en «Ver el caso completo» de una tarjeta.
case-devpanelSideProject.tsxClic en «Ver el caso» de la franja de DevPanel (mismo nombre que el de su tarjeta).
devpanel-code, devpanel-downloadSideProject.tsx y portada del caso DevPanelClic en el código o en la descarga de DevPanel.
portafolio-docsPortada del caso Portafolio (portafolio.json → links)Clic en «Leer la documentación técnica».
code-<id>Projects.tsxClic en «Ver código» de una tarjeta.
migozz-play, migozz-appstoreStartup.tsxClic en Google Play o App Store de MIGOZZ.
migozz-caseStartup.tsxClic en el botón al caso de MIGOZZ.
contactContactForm.tsxClic en enviar el formulario.
contact-sentContactForm.tsxEl mensaje se envió bien (window.umami.track).

umamiId() (seo/headers.ts) extrae de la variable solo el UUID, así que un fragmento <script> pegado completo no rompe el <head>.

Para activarla: crear el sitio en cloud.umami.is (Add website), copiar su Website ID (solo el UUID), ponerlo como VITE_UMAMI_WEBSITE_ID en el sitio portafolio-hgs de Netlify y volver a desplegar, porque se lee al construir. Ver configuración.

Estilos

src/styles/index.css importa un archivo por sección en orden de cascada; los últimos refinan a los primeros. El desenfoque backdrop-filter solo se usa con puntero fino en pantallas anchas (glass-touch.css lo cambia por fondos opacos en táctiles). Hay modo de alto contraste (contrast.css) y hoja de impresión (print.css). Las animaciones se apagan con prefers-reduced-motion.

Comandos

cd portfolio
npm install
npm run dev              # http://localhost:5173
npm run build            # typecheck + build en dist/
npm run check            # typecheck, lint, formato y pruebas
npm run e2e              # axe y teclado contra el build
npm run lighthouse       # mediana de varias corridas contra los presupuestos
npm run images           # originales → WebP

Requiere Node 20.19 o superior; CI usa Node 22.

Pruebas

ArchivoQué comprueba
tests/content.test.tsIntegridad del contenido (ver arriba).
tests/assets.test.tsPeso de capturas, certificados, íconos, capturas de MIGOZZ y retrato.
tests/seo.test.tsTítulos por idioma, canonical, hreflang, Open Graph, JSON-LD, sitemap, texto de respaldo y páginas de ARIA, DevPanel y Portafolio (en DevPanel, que Harol figure como author y no como contributor; en Portafolio, largos de título y descripción, las dos URL en el sitemap, author y subjectOf hacia la documentación en el JSON-LD, y los enlaces del texto de respaldo).
tests/github-data.test.tsForma de src/data/github.json.
tests/contact-email.test.tsLógica del formulario en el admin (admin/netlify/lib/contact.ts): correos, descarte de bots, límites y orígenes permitidos.
tests/admin-auth.test.tsReglas de entrada del admin y lista de llamadas a GitHub permitidas.
tests/digest.test.tsResumen semanal del admin (admin/netlify/lib/digest.ts): rango de fechas, lectura de Umami y Search Console, y el correo.
tests/security-txt.test.tspublic/.well-known/security.txt tiene contacto y dirección canónica, y falla un mes antes de que venza.
e2e/a11y.spec.tsSin violaciones WCAG A/AA con axe, en ES y EN, móvil y escritorio, en la portada, MIGOZZ, ARIA, DevPanel y Portafolio.
e2e/keyboard.spec.tsEnlace de salto, nombres accesibles, foco del visor de certificados, cambio de idioma.
e2e/features.spec.tsEnvío del formulario (JSON a la función del admin, simulada), modo de alto contraste e impresión.
e2e/seo.spec.tsIdioma por URL, rutas de recursos, head estático y política de seguridad sin violaciones.
e2e/screenshots.spec.tsCapturas por sección para el comentario del PR.

Los presupuestos de Lighthouse están en scripts/lighthouse.mjs: móvil rendimiento 0.90, escritorio 0.95, accesibilidad 1, buenas prácticas y SEO 0.95. Cada corrida fallida se reintenta hasta 3 veces.

Admin

El admin, la cabina de control del contenido

Para qué sirve el editor de admin.hgsportafolio.site, qué se edita en cada estación, cómo se revisa y publica un cambio y el resumen semanal por correo.

8 min de lectura admin.md

El admin es un editor propio en React + Vite (admin/), con la misma identidad de línea de metro del portafolio. Lee y guarda directamente en este repositorio, así que Harol cambia textos, proyectos, credenciales, stack, CV, los casos MIGOZZ, ARIA, DevPanel y Portafolio, y SEO sin tocar código. También recibe los mensajes del formulario de contacto del portafolio y envía sus correos (ver correos) y, cada lunes, le manda a Harol un resumen de la semana. Se despliega como segundo sitio de Netlify (admin-hgs) en admin.hgsportafolio.site.

Reemplazó el 2026-10-21 a un primer editor basado en Sveltia CMS (commits 3a1e1e4 y c1629bf).

Entrar

  1. Escribir el PIN.
  2. Escribir el código de 6 dígitos que llega al correo del dueño (vence en 10 minutos).
  3. Queda abierta una sesión de 12 horas en una cookie que la página no puede leer.

Si la sesión termina mientras se edita, el admin vuelve a la entrada y los cambios sin publicar siguen guardados en el navegador. El detalle y los límites están en seguridad.

Estaciones

Cada estación del mapa de la izquierda es una sección (STATIONS en admin/src/schema.ts):

EstaciónArchivo
MensajesNinguno: lee los mensajes del formulario de contacto guardados en Netlify Blobs (ver abajo).
Textos del sitioportfolio/src/content/data/site.json, sin credGroups ni stackGroups. Agrupado en Portada, Oficio, MIGOZZ en la página principal, Cargo anterior, Encabezados, Contacto, Navegación y pie. Las claves sin grupo en schema.ts caen en «Otros textos» (components/Fields.tsx); ahí están los textos de la franja de DevPanel (sideProject).
Proyectosportfolio/src/content/data/projects.json. El orden de la lista es el del sitio. Incluye los campos case (id del caso de estudio: aria, devpanel o portafolio) y code (repositorio público: solo código propio, nunca de un cliente); ver portafolio.
Credencialessite.json → credGroups.
Stacksite.json → stackGroups (grupos) y stack.json (tecnologías de cada grupo, con el mismo identificador).
Hoja de vidacurriculum/cv_data.json. El PDF se regenera solo al publicar.
MIGOZZmigozz.json (página del caso), migozz-public.json (cifras públicas) y migozz-users.json (usuarios).
ARIAaria.json (página del caso /aria/). Nueva desde el commit b9d13b1. La nota del panel pide solo cifras medidas y con su contexto, y nada de IDs de AWS, URLs internas ni nombres del equipo.
DevPaneldevpanel.json (página del caso /devpanel/). Nueva desde el commit 90ff404. La nota del panel pide solo cifras que se puedan comprobar en el repositorio de DevPanel; el texto de la estación recuerda que la franja de la portada está en «Textos del sitio».
Portafolioportafolio.json (página del caso /portafolio/, el de este mismo sitio). Nueva desde el commit 433174c. El texto de la estación recuerda que la documentación técnica está en docs.hgsportafolio.site, y la nota del panel pide solo cifras que se puedan comprobar en este repositorio o en su documentación.
SEOportfolio/src/content/data/seo.json, incluidos los textos de las páginas de MIGOZZ, de ARIA (ariaPages), de DevPanel (devpanelPages) y del portafolio (portafolioPages, que el editor y la revisión antes de publicar nombran «Página del portafolio», según PATH_LABELS en schema.ts).

Mensajes

La primera estación no edita archivos. admin/src/components/Inbox.tsx pide a GET /api/messages (admin/netlify/functions/messages.mts) los mensajes que dejó /api/contact en el almacén contact-messages, del más nuevo al más viejo (hasta 200).

  • Cada mensaje muestra nombre, correo, fecha y hora de Bogotá, lugar aproximado (geolocalización de Netlify) e idioma de la página.
  • Responder abre el correo con el asunto «Re: tu mensaje» o «Re: your message», según el idioma.
  • Borrar pide confirmación y llama a DELETE /api/messages/:id. No se puede deshacer.
  • Actualizar vuelve a traer la lista. Si la sesión terminó, la estación lo dice y hay que recargar.

Cada mensaje también llega por correo; esta estación es el archivo, y conserva el mensaje aunque el correo falle.

Cómo se edita

  • Los campos salen de los propios datos: si se agrega una clave a un JSON, el admin la muestra sola. schema.ts solo pone nombres en español, ayudas, campos de solo lectura, listas de opciones y editores especiales.
  • Español e inglés van lado a lado. Las listas permiten agregar, subir, bajar y quitar elementos; un elemento nuevo copia las claves que comparten los existentes, vacías.
  • Las estaciones con cambios sin publicar se pintan de amarillo en el mapa.

Imágenes

CampoSe sube aValor que queda en el JSON
Captura de proyecto (projects[].shot)portfolio/images/shots/shots/<nombre>.webp
Certificado (credGroups[].items[].img)portfolio/images/credentials/<nombre>
Captura de MIGOZZ (shots[].src)portfolio/images/migozz/migozz/<nombre>.webp
Ícono del stackportfolio/public/icons/<nombre>.svg (saneado, sin conversión)<nombre>
  • Acepta PNG, JPG o WebP de hasta 8 MB; los íconos, SVG de hasta 25 KB.
  • Antes de guardarlo, cleanSvg() (admin/src/svg.ts) quita del SVG scripts, manejadores on*, enlaces externos o javascript:, documentos incrustados y estilos con @import o URL externas, y avisa cuántos elementos quitó (commit 2d942c4). El ícono se sirve desde el dominio del portafolio, así que un script dentro correría ahí si alguien abriera el archivo directamente.
  • El nombre del archivo se normaliza (slug en admin/src/data.ts): «Mi Captura 2.PNG» queda mi-captura-2.
  • La conversión a WebP la hacen el build de Netlify y el workflow Images (ver portafolio, sección Imágenes).

Borradores

Lo que no se alcanza a publicar se guarda en localStorage (clave hgs-admin-draft:<rama>), junto con la versión del archivo sobre la que se editó. Al volver, si el archivo en GitHub no cambió, el admin recupera el borrador y lo avisa. Si cambió, el borrador se descarta. El navegador avisa antes de cerrar la pestaña con cambios pendientes.

Las imágenes subidas no se guardan como borrador: solo viven hasta recargar.

Revisar y publicar

El tablero de salida (abajo, components/Departure.tsx) cuenta los cambios y ofrece:

  • Revisar: cada cambio con su antes y ahora, y un aviso de los textos que quedaron vacíos. Permite escribir el mensaje del commit; si se deja vacío, usa content: actualizar <estaciones> desde el admin.
  • Publicar: un solo commit en la rama con todos los archivos e imágenes, hecho con la Git Data API (blobs → árbol → commit → mover la rama). Si la rama se movió entretanto (por ejemplo el commit del CV), reintenta hasta 3 veces sobre la cabeza nueva.
  • Conflictos: si alguien cambió en GitHub uno de los archivos editados desde que se abrió, no publica nada y ofrece «Recargar <archivo>», que trae la versión nueva y descarta los cambios de ese archivo.
  • Seguimiento: después de publicar, el tablero consulta cada 10 segundos (hasta 20 minutos) las ejecuciones de GitHub Actions de ese commit y muestra su estado.

Si GitHub rechaza la publicación, el tablero lo explica: ante un 401 la sesión terminó y hay que entrar de nuevo (los cambios siguen guardados como borrador); ante un 403 el token del servidor (ADMIN_GITHUB_TOKEN en Netlify) no tiene permiso «Contents: Read and write».

Netlify pone el sitio en línea en uno o dos minutos. Si un JSON quedó con la forma equivocada, el build falla y el sitio sigue con la versión anterior (ver arquitectura).

Resumen semanal por correo

La función programada admin/netlify/functions/weekly-digest.mts (commit ba00cb5) le envía a Harol un correo con cómo le fue al portafolio la semana anterior. La lógica está en admin/netlify/lib/digest.ts y la firma para Google en admin/netlify/lib/google.ts; las pruebas, en portfolio/tests/digest.test.ts.

Cuándo

Cada lunes a las 8:00 de Bogotá (schedule: "0 13 * * 1", en UTC). Cubre los siete días completos anteriores, de medianoche a medianoche en Bogotá.

Qué manda

  • Asunto: visitantes y descargas del CV de la semana.
  • Visitas (Umami Cloud): visitantes, visitas, páginas vistas y tiempo medio por visita, comparados con la semana anterior; páginas más vistas, de dónde llegaron, países y los eventos del sitio. eventLabel() traduce a palabras los de descarga del CV, formulario, MIGOZZ, redes, proyectos, casos (case-<id>, desde 574e51b, con el nombre de cada caso en CASE_NAMES: ARIA, DevPanel, MIGOZZ y «del portafolio»), código (code-<id>), el código y la descarga de DevPanel (devpanel-code, devpanel-download) y, desde 433174c, el botón de la documentación del caso Portafolio (portafolio-docs, «Abrieron la documentación del portafolio»). Los eventos que no conoce salen con su nombre tal cual.
  • Google (Search Console): apariciones en resultados, clics, posición media y las 5 búsquedas que más mostraron el sitio. Search Console va dos o tres días atrasado, así que su semana termina antes y el correo lo dice.
  • Lo que falló: si una fuente responde con error, el correo lo dice en «No se pudo leer» en vez de mostrar ceros.

Cada fuente es opcional: lo que no está configurado se deja fuera, nunca se inventa. Se envía por Resend con una clave de idempotencia por semana (weekly-digest-<inicio de la semana>): un reintento o una segunda corrida a mano dentro de las 24 horas en que Resend recuerda la clave no envía otro correo.

Variables que necesita

En el sitio admin-hgs de Netlify (ver configuración):

VariablePara qué
UMAMI_API_KEY y UMAMI_WEBSITE_IDLas dos activan la parte de visitas. Es la API de Umami Cloud (api.umami.is), distinta de VITE_UMAMI_WEBSITE_ID del portafolio aunque el id del sitio sea el mismo.
GSC_SERVICE_ACCOUNTOpcional. El JSON de una cuenta de servicio de Google Cloud con lectura de Search Console. El correo de esa cuenta debe agregarse como usuario de la propiedad en Search Console.
GSC_SITEOpcional. Propiedad de Search Console; por defecto la de dominio de hgsportafolio.site.
DIGEST_TOA quién llega. Si falta, usa CONTACT_TO, luego ADMIN_EMAIL y, al final, una dirección fija en el código (desde 574e51b).
CONTACT_FROMRemitente. Si falta, usa el mismo remitente por defecto del formulario.

Además usa RESEND_API_KEY, como todos los correos del admin.

Estado

El código está desplegado, pero el resumen no llega hasta que se configuren esas variables: sin ninguna fuente (Umami o Search Console) la función falla con «No source configured». Destinatario y remitente ya tienen valores por defecto. Es un pendiente de Harol (ver pendientes).

Cómo probarlo

En Netlify: sitio admin-hgs → Functions → weekly-digest → Run now. El registro de la función muestra el error si falta algo. Por la clave de idempotencia, otra corrida en las 24 horas siguientes no envía un segundo correo.

Probar contra otra rama

?branch=nombre en la URL hace que el admin lea y publique en esa rama; la barra superior lo muestra como «Rama de prueba». El comentario del deploy preview del admin lo recuerda.

Desarrollo local

cd admin
npm install
cp .env.example .env   # y llenar los valores
npx netlify dev        # interfaz y funciones /api con el .env

npm run dev levanta solo la interfaz (puerto 5600), sin funciones, así que no deja entrar. npm run build corre antes el typecheck de la app y de las funciones (tsconfig.netlify.json).

Seguridad

Seguridad del admin y del sitio

Entrada con PIN y código por correo, sesiones, límites, intermediario de GitHub, CSP, cabeceras, formulario, dependencias, búsqueda de secretos y qué haría falta para publicar el repositorio.

7 min de lectura seguridad.md

Principio

El token de GitHub nunca llega al navegador. Vive en Netlify (ADMIN_GITHUB_TOKEN) y solo lo usa la función /api/github, para sesiones abiertas y para una lista cerrada de llamadas. Antes del commit bf84696 (2026-10-21) el admin pedía el token en el navegador; ese modelo ya no existe.

Las reglas están en admin/netlify/lib/auth.ts, escritas como lógica pura con dependencias inyectadas (almacén, correo, reloj) para probarlas sin Netlify en portfolio/tests/admin-auth.test.ts.

Entrada en dos pasos

PasoRutaQué pasa
PINPOST /api/auth/pinCompara el PIN con ADMIN_PIN en tiempo constante. Si es correcto, genera un código de 6 dígitos uniforme, guarda solo su HMAC, lo envía a ADMIN_EMAIL y pone la cookie hgs_challenge (10 minutos, solo para /api/auth).
CódigoPOST /api/auth/codeVerifica la cookie de reto y el código. Si es correcto, gasta el reto y abre una sesión de 12 horas en la cookie hgs_session.
SesiónGET /api/auth/sessionDice si el navegador tiene una sesión válida.
SalirPOST /api/auth/logoutBorra la sesión del almacén y la cookie.

Por qué dos pasos: un PIN corto tiene pocos valores posibles (uno de 4 dígitos, 10.000). Aun acertándolo, solo se envía un código al correo del dueño, así que el PIN solo nunca abre el editor (comentario de auth.ts).

El correo con el código dice desde qué lugar e IP se pidió (geolocalización de Netlify) y avisa que, si no fue Harol, alguien conoce el PIN y hay que cambiar ADMIN_PIN. La pantalla de entrada muestra la dirección de destino enmascarada (maskEmail), suficiente para reconocerla pero no para leerla.

Cómo elegir el PIN

Los límites frenan a quien prueba PIN al azar, no a quien lo adivina en pocos intentos. Por eso ADMIN_PIN no debe poder deducirse de datos públicos del dueño: ni los números de nombres de usuario actuales o anteriores, ni fechas, teléfonos o años que aparezcan en el sitio, el CV o las redes. Conviene un PIN sin relación con nada publicado y de más de 4 dígitos (el mínimo que pide admin/.env.example). Se cambia en las variables de entorno del sitio admin-hgs en Netlify.

Límites

Definidos en LIMITS (admin/netlify/lib/auth.ts):

LímiteValor
PIN fallidos por dirección IP5, luego bloqueo de 15 minutos para esa dirección
PIN fallidos en total (todas las direcciones)20 en una hora, luego bloqueo del paso del PIN para todos durante 1 hora
Vida del código10 minutos
Intentos por código5, luego el código se quema y hay que pedir otro
Espera entre códigos30 segundos por dirección
Duración de la sesión12 horas

Cuando se produce un bloqueo, llega un correo de aviso al dueño una vez por bloqueo. Los contadores, retos y sesiones se guardan en Netlify Blobs (almacén admin-auth) con consistencia fuerte, para que un bloqueo o un código gastado se vean en la petición siguiente (admin/netlify/lib/netlify.ts).

Sesiones

  • Cookie hgs_session con HttpOnly, Secure, SameSite=Strict y Path=/.
  • El valor lleva id, vencimiento y firma HMAC-SHA256 con SESSION_SECRET (mínimo 32 caracteres).
  • La sesión también existe en el almacén: «Salir» la borra, así que una cookie copiada deja de servir.
  • Cambiar SESSION_SECRET cierra todas las sesiones abiertas.
  • Sin ADMIN_PIN o sin un SESSION_SECRET de 32+ caracteres, la función responde 503 y nadie entra.

Intermediario de GitHub

admin/netlify/functions/github.mts atiende /api/github/* y reenvía a api.github.com agregando el token. Antes revisa:

  1. Que la petición traiga X-Requested-With: hgs-admin (fromAdmin). Un formulario de otro sitio no puede poner esa cabecera.
  2. Que haya una sesión válida (si no, 401 y el admin vuelve a la entrada).
  3. Que la llamada esté permitida por allowedGitHubCall:
MétodoRutas permitidas
GET/user, /repos/DevCat-HGS/portfolio-hgs y todo lo que cuelga de ella
POST.../git/blobs, .../git/trees, .../git/commits
PATCH.../git/refs/heads/<rama>

Nada más: ni borrar, ni configuraciones, ni otros repositorios. Rutas con .. o que no empiezan con / se rechazan. Las respuestas salen con Cache-Control: no-store.

El token recomendado en admin/.env.example es fine-grained, solo para el repo portfolio-hgs, con permiso «Contents: Read and write». El admin comprueba al cargar que tenga permiso de escritura.

Cabeceras del admin

En admin/netlify.toml, para todas las rutas:

  • Content-Security-Policy: default-src 'self', script-src 'self', connect-src 'self' (la interfaz solo habla con sus funciones), imágenes de self, data:, blob:, https://hgsportafolio.site y https://avatars.githubusercontent.com, frame-ancestors 'none'.
  • X-Robots-Tag: noindex, nofollow, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, Permissions-Policy restrictiva, Cross-Origin-Opener-Policy: same-origin, Strict-Transport-Security de un año con subdominios.

Cabeceras del portafolio

portfolio/seo/headers.ts es la única fuente: vite preview las sirve para las pruebas y el build las escribe en dist/_headers para Netlify.

  • CSP sin scripts en línea ni unsafe-eval. Por eso los scripts de la pantalla de bienvenida y del 404 son archivos (public/splash.js, public/404.js). style-src admite 'unsafe-inline' por los bloques <style> de index.html y los atributos style de React.
  • connect-src permite 'self' y https://admin.hgsportafolio.site (CONTACT_ORIGIN en src/lib/endpoints.ts), porque el formulario de contacto envía ahí. La misma constante alimenta el formulario y la política, así no se desalinean.
  • Si VITE_UMAMI_WEBSITE_ID trae un UUID válido, la CSP abre solo los orígenes de Umami: https://cloud.umami.is en script-src y, en connect-src, además https://gateway.umami.is (adonde el script envía los eventos desde el commit 7837484) y https://api-gateway.umami.dev (el host anterior). Sin UUID, la analítica está apagada y la CSP no abre nada.
  • nosniff, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy, COOP y HSTS.

e2e/seo.spec.ts comprueba que cada página envía la política y corre sin violaciones.

Formulario de contacto

El formulario del portafolio le escribe al admin (POST https://admin.hgsportafolio.site/api/contact). Junto con la entrada (/api/auth/*), es la única ruta del admin que responde sin sesión, y se protege así:

  • Origen: allowedOrigin en admin/netlify/lib/contact.ts solo acepta el portafolio, su dirección de Netlify y sus deploy previews; las cabeceras CORS solo se envían a esos orígenes. Una prueba comprueba que un dominio que imita el nombre (...netlify.app.evil.example) se rechaza.
  • Bots: campo trampa website y tiempo mínimo de llenado de 2,5 segundos. Los bots reciben la misma respuesta que una persona.
  • Límites: 3 mensajes por IP cada 10 minutos y 40 por día en total.
  • Confirmación sin eco: el correo al visitante nunca repite lo que escribió (ni el mensaje, ni un nombre que no parezca nombre), para que el formulario no sirva para mandar texto a desconocidos.

Los mensajes guardados solo se leen y se borran con sesión abierta: admin/netlify/functions/messages.mts (GET /api/messages, DELETE /api/messages/:id) exige la cabecera X-Requested-With: hgs-admin y una sesión válida, igual que el intermediario de GitHub. El borrado solo acepta ids de letras, números, _ y -.

Detalle en correos.

Íconos SVG subidos desde el admin

cleanSvg() (admin/src/svg.ts) limpia cada SVG antes del commit: quita scripts, manejadores on*, enlaces externos y javascript:, documentos incrustados y estilos con @import o URL externas, y rechaza archivos de más de 25 KB (commit 2d942c4). La CSP del portafolio ya bloquea scripts en línea; esto cierra el caso de alguien que abra el SVG directamente.

security.txt

portfolio/public/.well-known/security.txt (RFC 9116) dice a quién avisar de un problema de seguridad. Tiene fecha de vencimiento: tests/security-txt.test.ts falla un mes antes, como recordatorio para renovarla.

Dependencias

  • Dependabot revisa cada semana portfolio/, admin/ y las acciones de GitHub (ver CI/CD). Según el mensaje del commit 2d942c4, las alertas de vulnerabilidades y los arreglos automáticos de seguridad de Dependabot están activados en portfolio-hgs (configuración de GitHub, fuera del código).
  • Vite 8.3.5 (commit f3089f8, 2026-10-21): el portafolio y el admin subieron a esa versión, que cerró las 12 alertas abiertas de Dependabot (servidor de desarrollo de Vite y launch-editor). Las ramas de Dependabot .../vite-8.3.5 siguen en el remoto, ya sin PR abierto.

Búsqueda de secretos

El escaneo de secretos de GitHub no está disponible en repos privados del plan gratuito, así que .github/workflows/secret-scan.yml corre gitleaks sobre todo el historial en cada push a main o desarrollo y en cada pull request. Si encuentra una clave o un token, el check falla y el valor sale tapado en el registro. Según el commit 2d942c4, el historial actual sale limpio.

El mismo commit registra reglas de GitHub (fuera del código) que impiden borrar o reescribir con force-push la rama main en DevCat-HGS y DevCat-HGS.github.io.

¿Se puede hacer público este repositorio?

El 2026-10-21 se revisó el historial de portfolio-hgs para ver si podía pasar a público. No hay credenciales (coincide con gitleaks), pero el historial guarda archivos que salieron del árbol y no deben publicarse, y GitHub conserva cada PR en referencias refs/pull/* que el dueño no puede borrar, así que reescribir el historial no basta.

Por eso hacerlo público exige crear un repositorio nuevo con un historial limpio, no cambiar la visibilidad de este. Harol aplazó la decisión (ver pendientes).

Regla para el código público

Solo se publica código propio. Los repos de clientes se quedan privados (ver perfil de GitHub). Los repos públicos de proyectos de formación se publican como copias nuevas en un solo commit, sin archivos .env, direcciones del servidor ni contraseñas, nunca cambiando la visibilidad del repo original, cuyo historial puede guardar credenciales.

Lo que nunca llega al navegador

  • ADMIN_GITHUB_TOKEN, ADMIN_PIN, SESSION_SECRET, RESEND_API_KEY, y las del resumen semanal (UMAMI_API_KEY, GSC_SERVICE_ACCOUNT): solo las leen las funciones del admin.
  • Las cookies de sesión y de reto (son HttpOnly).
  • El código de acceso en claro: el servidor solo guarda su HMAC.

Los archivos .env están en .gitignore; solo se versionan las plantillas .env.example, sin valores secretos.

Correos

Los correos que envía el sistema

Cuándo se envía cada correo del formulario de contacto, del admin y del resumen semanal, a quién, con qué proveedor y cómo se evita el abuso.

5 min de lectura correos.md

Todos los correos salen del sitio del admin (admin-hgs) con Resend, por su API (https://api.resend.com/emails), con la clave RESEND_API_KEY y el dominio hgsportafolio.site, que debe estar verificado en Resend. Mientras no lo esté, no sale ningún correo. La función que llama a Resend es resend() en admin/netlify/lib/netlify.ts. Ver configuración.

Desde el commit f4be780 (2026-10-21) el portafolio ya no envía correos ni tiene funciones de Netlify: su formulario le escribe al admin.

Resumen

CorreoCuándoA quiénRemitente
Mensaje nuevoLlega un mensaje válido a /api/contactCONTACT_TO (si falta, ADMIN_EMAIL)CONTACT_FROM
ConfirmaciónEl mismo mensajeEl correo que escribió el visitanteCONTACT_FROM
Código de accesoPIN correcto en el adminADMIN_EMAILADMIN_MAIL_FROM
Intentos fallidosSe bloquea el PINADMIN_EMAILADMIN_MAIL_FROM
Resumen semanalLunes 8:00 de Bogotá (función programada)DIGEST_TO (si falta, el mismo destino del formulario)CONTACT_FROM (con el mismo valor por defecto del formulario)

Si una variable no está definida, el código del formulario y de los códigos usa un valor por defecto: el destino es el correo de contacto de Harol, y los remitentes son contacto@hgsportafolio.site (formulario) y admin@hgsportafolio.site (códigos).

Formulario de contacto

El recorrido

  1. El visitante llena el formulario de la sección Cierre (portfolio/src/components/ContactForm.tsx): nombre (máx. 120), correo (máx. 200) y mensaje (máx. 4000).
  2. El formulario envía JSON por fetch a https://admin.hgsportafolio.site/api/contact (constante CONTACT_API en portfolio/src/lib/endpoints.ts), sin recargar la página. Además de los campos manda lang (idioma de la página), website (campo trampa, invisible) y elapsed (milisegundos desde que se abrió la página hasta el envío). Si la respuesta no es correcta, el formulario pide escribir por correo.
  3. Responde admin/netlify/functions/contact.mts. Solo acepta orígenes del portafolio (allowedOrigin) y contesta las peticiones CORS previas (OPTIONS).
  4. receiveContact() en admin/netlify/lib/contact.ts descarta bots, valida, aplica los límites, guarda el mensaje en Netlify Blobs (almacén contact-messages, clave msg:<id>) y envía los dos correos en paralelo.
  5. El mensaje queda en la estación «Mensajes» del admin aunque falle el envío de los correos (ver admin).

Por qué cambió: el formulario anterior usaba Netlify Forms, que no detectaba el formulario (respondía 404) y redirigía los envíos de la página en inglés, así que no se guardaba ni se enviaba nada (mensaje del commit f4be780).

Los dos correos

  • A Harol: asunto «Nuevo mensaje de <nombre> desde el portafolio», con el nombre, el correo, el mensaje escapado, la fecha en hora de Bogotá y el idioma de la página. «Responder» va directo al visitante (reply_to).
  • Al visitante: «Recibí tu mensaje» o «I got your message», según el idioma de la página. Agradece, dice que la respuesta llega en uno o dos días hábiles y firma Harol. «Responder» va a Harol.

La copia de Harol se envía primero en la lista, pero los dos van con Promise.allSettled: si uno falla, el otro sale igual y el error queda en el registro de la función.

Protecciones contra abuso

ProtecciónCómo funciona
OrigenSolo https://hgsportafolio.site, https://portafolio-hgs.netlify.app y sus deploy previews (<algo>--portafolio-hgs.netlify.app). Otro origen recibe 403.
Campo trampaSi website (o el antiguo bot-field) trae algo, el envío se descarta.
Tiempo mínimoUn envío hecho en menos de 2,5 segundos (minFillMs) se descarta. Si la petición no trae elapsed, esta revisión no se aplica.
Respuesta a botsLos bots descartados reciben el mismo 200 ok que una persona, para que no descubran la trampa. No se guarda nada ni se envía nada.
Por dirección IP3 mensajes cada 10 minutos (perIp, ipWindowMs). Luego responde 429.
Por día40 mensajes en total (perDay), para no gastar la cuota diaria gratuita de Resend (100, según el comentario del código).
Contenido de la confirmaciónNunca repite lo que escribió el visitante: ni el mensaje, y el nombre solo si es una palabra de letras (safeFirstName). Así el formulario no sirve para mandar texto a desconocidos.
DuplicadosCada correo lleva una Idempotency-Key (SHA-256 de tipo, correo, nombre y mensaje); Resend no envía dos veces la misma en 24 horas.

Los límites están en CONTACT_LIMITS (admin/netlify/lib/contact.ts). Los contadores se guardan en el mismo almacén contact-messages (claves limit:ip:<ip> y limit:day:<fecha>).

Pruebas: portfolio/tests/contact-email.test.ts (importa admin/netlify/lib/contact.ts: correos, bots, límites y orígenes) y portfolio/e2e/features.spec.ts (el formulario envía el JSON esperado y avisa si falla; la función se simula).

Correos del admin

Los arma admin/netlify/lib/auth.ts y los envía send() en admin/netlify/lib/netlify.ts.

  • Código de acceso: asunto «<código> es tu código del admin», con el código en grande, la advertencia de que vence en 10 minutos, y el lugar, la IP y la hora (Bogotá) de la petición. El pie dice que, si no fue Harol, alguien conoce el PIN y hay que cambiar ADMIN_PIN.
  • Intentos fallidos: asunto «Intentos fallidos en el admin». Avisa si se bloqueó una dirección 15 minutos tras 5 intentos o si se bloqueó el PIN para todos durante una hora. Se envía una vez por bloqueo.

Si el envío del código falla, el reto se borra y la pantalla muestra «No se pudo enviar el código. Revisa RESEND_API_KEY.». Un fallo al enviar el aviso de bloqueo se ignora para no romper la respuesta.

Límites que frenan el abuso: un código cada 30 segundos por dirección y los bloqueos del PIN descritos en seguridad.

Resumen semanal

admin/netlify/functions/weekly-digest.mts corre cada lunes a las 8:00 de Bogotá y envía un correo con las visitas de la semana anterior (Umami Cloud) y cómo apareció el sitio en Google (Search Console). Asunto: «Tu portafolio esta semana: <n> visitantes, <n> descargas del CV». Lo arma digestEmail() en admin/netlify/lib/digest.ts, con el mismo marco visual que los correos del formulario (layout() de contact.ts), en HTML y en texto plano.

  • Destino y remitente: DIGEST_TO y CONTACT_FROM. Desde el commit 574e51b, si faltan, usa los mismos valores que el formulario (CONTACT_TO, luego ADMIN_EMAIL, luego el valor por defecto del código), así un CONTACT_TO vacío ya no detiene el resumen.
  • Fuentes: UMAMI_API_KEY + UMAMI_WEBSITE_ID para visitas y GSC_SERVICE_ACCOUNT (opcional GSC_SITE) para Google. Sin ninguna fuente, la función falla en vez de mandar un correo vacío. Si una fuente responde con error, el correo lo dice en una sección «No se pudo leer».
  • Lo que se escapa: lo que viene de fuera (sitios de origen, búsquedas) se escapa antes de ir al HTML; hay prueba de eso en portfolio/tests/digest.test.ts.
  • Duplicados: clave de idempotencia weekly-digest-<inicio de la semana>.
  • Privacidad: el pie aclara que Umami no usa cookies ni identifica a nadie: son conteos.

Hoy no se envía: faltan esas variables en admin-hgs. Cómo probarlo y el detalle de cada variable, en admin.

Hoja de vida

La hoja de vida, de un JSON a dos PDF

Fuente única en cv_data.json, generación automática de los PDF en español e inglés y las decisiones para pasar filtros ATS.

2 min de lectura curriculum.md

Fuente única

curriculum/cv_data.json es la única fuente del CV. Tiene los datos de contacto y, por idioma (es y en): title, location, labels, summary, experience, skills, projects, education, certifications y awards. La clave files dice el nombre del PDF de cada idioma.

Se edita desde el admin (estación «Hoja de vida», ver admin) o a mano.

Generación

curriculum/generate_cv.py usa ReportLab y escribe:

SalidaPara qué
curriculum/output/Harol_Guerrero_CV_ES.pdf y _EN.pdfFuente versionada de los PDF.
portfolio/public/cv/ (mismos nombres)Copia que descarga el botón del portafolio.
pip install -r curriculum/requirements.txt
python curriculum/generate_cv.py

Opciones: --data (otro JSON), --out (otra carpeta), --site-dir (otra carpeta para la copia) y --no-site-copy.

El PDF es determinista (invariant=1): si cv_data.json no cambió, el archivo sale idéntico y no hay commit.

Automático

El workflow .github/workflows/curriculum.yml corre en cada push a main o desarrollo (salvo cuando solo cambian los propios PDF) y a mano. Instala ReportLab, genera los PDF y, si cambiaron, hace commit chore: update CV y push a la misma rama.

portfolio/netlify.toml sirve /cv/* con must-revalidate, así el visitante siempre baja la última versión.

Pensado para ATS

  • Una sola columna, sin tablas ni imágenes.
  • Texto real y seleccionable: la fuente Inter (curriculum/fonts/, licencia OFL) va incrustada con su mapa Unicode, así el texto se puede extraer.
  • Encabezados convencionales y fechas con formato uniforme.
  • Metadatos del PDF (título y autor) completos.
  • Colores sobrios, legibles también en blanco y negro.

Regla de contenido: solo lo que se hizo

El CV describe lo construido y medido, no lo planeado. Ejemplo del commit 8bbfdbf (2026-10-21): el proyecto ARIA decía que el asistente buscaba en OpenSearch y escalaba casos a un consultor humano, y ninguna de las dos cosas se construyó. Ahora dice que fue trabajo de un equipo de cinco personas, cuál fue la parte de Harol (interfaz de chat en React con respuestas en vivo y panel de fuentes, una API de pruebas en FastAPI y el pipeline batch), el stack real (Bedrock y RAG sobre S3 Vectors, en AWS serverless) y un tiempo de respuesta medido. OpenSearch salió también de la lista de habilidades. El texto coincide con la tarjeta y el caso de ARIA del portafolio (ver portafolio).

Datos personales

El JSON incluye teléfono y correo porque van impresos en el CV. Esta documentación no los repite.

Perfil de GitHub

El README del perfil de GitHub

Cómo se arman el README del perfil y sus gráficas, cómo se publica en el repositorio público, qué workflows lo mantienen al día y qué código se publica.

3 min de lectura perfil-github.md

Dos repositorios

  • Este repo privado (DevCat-HGS/portfolio-hgs) tiene la fuente: README.md (inglés), README.es.md (español), profile/, privacity/ y scripts/.
  • El repo público DevCat-HGS/DevCat-HGS solo recibe el README y sus imágenes. El código del portafolio, los workflows y el historial no salen de aquí (comentario de publish-profile.yml).

Repos públicos de proyectos

El 2026-10-21 se publicaron dos proyectos de la formación en el SENA como repos públicos nuevos:

RepoProyectoEnlazado desde
DevCat-HGS/sena-cloudSENA CloudTarjeta «SENA Cloud» del portafolio (code en projects.json)
DevCat-HGS/sensores-appSensoresAPPTarjeta «SensoresAPP» del portafolio

Son copias limpias en un solo commit, no los repos originales con la visibilidad cambiada: sin archivos .env (solo plantillas .env.example), sin la IP ni el dominio del servidor, sin contraseñas de prueba y sin documentos de compañeros. Así el historial de los originales no sale a la luz. Las tarjetas los enlazan con «Ver código» (commit 94f0f6a).

Regla: los repos de clientes se quedan privados

Por decisión de Harol, el código de los proyectos de clientes no se publica: Hecho en Sabaneta, ARIA, Arcoiris, CashWin y Farmacia siguen privados y sus tarjetas no llevan enlace de código. El campo code de un proyecto es solo para código propio (lo dicen el comentario de ProjectEntry en portfolio/src/content/projects.ts y la ayuda del campo en el admin). Si un proyecto de cliente debe mostrarse, se hace con un caso de estudio (como /aria/), no con el código.

Cifra de repos públicos en el portafolio

GithubStats.tsx se oculta con menos de 3 repos públicos. portfolio/src/data/github.json lo actualiza el workflow GitHub data cada lunes; mientras no corra después de esta publicación, el archivo sigue con la cifra anterior. Se puede lanzar a mano desde Actions.

Qué tiene el README

Banner (profile/banner.svg), insignias (portafolio, LinkedIn, DevPanel, estado de Netlify, visitas), tecnologías con íconos de skillicons.dev, experiencia (MIGOZZ y Mitsubishi Electric de Colombia), proyectos destacados, la sección de repos privados elegidos, gráficas de lenguajes, racha de contribuciones y contacto.

README.md y README.es.md se editan a mano, salvo el bloque entre <!-- PRIVATE-REPOS:START --> y <!-- PRIVATE-REPOS:END -->.

Gráficas de lenguajes

scripts/generate_language_charts.py consulta la API de GitHub y dibuja profile/languages.svg y profile/languages.es.svg. Mide dos cosas: volumen de código (bytes por lenguaje) y amplitud de uso (en cuántos repos aparece).

Para no deformar los datos descarta:

  • repos copiados o con artefactos (SKIPPED_REPOS: GameDB, UnityCW, GameExp, RespaldoGame);
  • lenguajes generados por motores o sistemas de build, y SVG (IGNORED_LANGUAGES);
  • repos subidos desde otros proyectos donde la mayoría de los commits humanos no son propios (cuenta los de DevCat-HGS y la cuenta anterior Catnova0106).

Repos privados elegidos

privacity/update_private_repos.py escribe la tabla de repos privados en los dos README a partir de privacity/featured.json. Nada se publica por defecto: un repo aparece solo si se agrega ahí, con título y descripción públicos en los dos idiomas. Los privados salen sin enlace. blocked_terms lista palabras prohibidas (por ejemplo nombres de clientes): si aparece una, el script falla. Hoy featured.json no tiene repos, así que la sección no se muestra.

Opciones: --check (no escribe, falla si algo está desactualizado) y --no-verify (sin llamadas a la API).

Workflows

WorkflowCuándoQué hace
metrics.yml (Metrics)Diario 06:00 UTC, a mano, o al cambiar el script o el workflow en mainCorre generate_language_charts.py con METRICS_TOKEN y commitea las gráficas si cambiaron.
privacity.yml (Privacity)Diario 06:30 UTC, a mano, o al cambiar featured.json, el script o el workflow en main o desarrolloActualiza la tabla de repos privados y commitea los README si cambiaron.
publish-profile.yml (Publish profile)Push a main que toque README.md, README.es.md, profile/*.svg o el workflow; al terminar bien Metrics o Privacity; a manoClona DevCat-HGS/DevCat-HGS con METRICS_TOKEN, copia los dos README, banner.svg y las dos gráficas, y hace push si hay cambios.

METRICS_TOKEN es un secreto del repositorio en GitHub (no está en el código). Según privacity/README.md, sirve un token clásico con alcance repo o uno fine-grained con lectura de metadatos; para publicar el perfil además necesita escribir en DevCat-HGS/DevCat-HGS (por confirmar en GitHub).

Archivos sin uso

profile/stats.svg y profile/calendar.svg no los referencia ningún README ni workflow (vienen de commits del 2026-09-21). La carpeta img/ (imágenes del README antiguo) salió del repositorio en el commit 236c9d3 y ahora está en .gitignore: solo existe en el equipo de Harol. Ver pendientes.

Operación

Workflows, despliegues y ramas

Qué hace cada workflow de GitHub Actions, cuándo corre y qué bloquea; los dos sitios de Netlify con sus previews; Dependabot y las ramas main y desarrollo.

5 min de lectura ci-cd.md

Ramas

Solo hay dos: main (producción, la que despliega Netlify y la que edita el admin) y desarrollo. Los workflows Curriculum, Privacity y Secret scan corren en las dos; los demás, en pull requests y en main. El commit bf84696 actualizó los workflows al nombre desarrollo (antes portafolio).

Workflows

Todos en .github/workflows/.

WorkflowCuándo correQué haceQué bloquea
portfolio-ci.yml (Portfolio CI)PR y push a main que toquen portfolio/**, admin/netlify/** o el workflow; a mano (lo lanza Images)Job check: typecheck, lint, formato, npm run images, pruebas y build. Job quality: Lighthouse (5 corridas), axe, teclado y capturas; sube reportes y comenta el PR con las cifras frente a main.El check del PR falla si falla cualquier paso, un presupuesto de Lighthouse o una prueba de navegador.
admin-ci.yml (Admin CI)PR y push a main que toquen admin/** o el workflownpm ci y npm run build del admin (incluye typecheck de la app y de las funciones).Check del PR.
images.yml (Images)PR que toque portfolio/images/** o scripts/images.mjs; push a main que toque portfolio/images/**Regenera todos los WebP, comprueba los presupuestos de peso y commitea public/ en la misma rama; después lanza Portfolio CI sobre el commit nuevo (un push con GITHUB_TOKEN no dispara otros workflows). En forks falla si las imágenes no están al día.Check del PR si un WebP excede su presupuesto.
curriculum.yml (Curriculum)Push a main o desarrollo (ignora cambios solo en los PDF); a manoGenera los PDF del CV y commitea si cambiaron.Nada.
links.yml (Links)Lunes 12:00 UTC, a mano, PR que toque portfolio/src/content/** o curriculum/cv_data.jsonRevisa con lychee las URL de portfolio/src/content/*.ts, portfolio/src/content/data/*.json, curriculum/cv_data.json y los dos README. Fuera de PR abre, actualiza o cierra un único issue broken-links. Excluye LinkedIn, wa.me, shields.io, mailto:, hechoensabaneta.com y páginas /blob/ del repo público.En PR, falla si hay enlaces rotos.
og.yml (Open Graph image)Push a main que toque scripts/og-template.html u og.mjs; a manoRenderiza public/og.png con Playwright y lo commitea.Nada.
github-data.yml (GitHub data)Lunes 06:15 UTC; a manoActualiza portfolio/src/data/github.json y commitea si cambió.Nada.
preview-comment.yml (Preview comment)Evento status de NetlifyUn comentario por sitio (Portafolio, Admin) y por PR con el enlace del deploy preview, editado en cada deploy.Nada.
release.yml (Release)Solo a manoRelease Please: PR de versión con CHANGELOG.md desde los mensajes Conventional Commits.Nada.
secret-scan.yml (Secret scan)Push a main o desarrollo, todo PR; a manogitleaks 8.30.1 (en Docker) revisa todo el historial en busca de claves, tokens y llaves privadas, con los valores tapados en el registro.Check del PR si encuentra un secreto.
publish-docs.yml (Publish docs)Push a main que toque Doc/** o el workflow; a manoCorre node Doc/build.mjs y reemplaza la raíz de DevCat-HGS/DevCat-HGS.github.io con los .md y el index.html de Doc/ (más el CNAME de docs.hgsportafolio.site), usando el secreto DOCS_DEPLOY_KEY.Nada.
metrics.yml, privacity.yml, publish-profile.ymlVer perfil de GitHubGráficas, repos privados y copia al repo público.Nada.

Notas:

  • preview-comment.yml corre siempre desde la rama por defecto: un cambio en él solo surte efecto al llegar a main.
  • La línea base de Lighthouse se guarda como artefacto lighthouse-baseline (90 días) en cada push a main; los PR la descargan para comparar.
  • Release Please parte de la versión 1.0.0 (.release-please-manifest.json) con release-type: simple.

Netlify

SitioCarpetaDominioBuild
portafolio-hgsportfolio/hgsportafolio.site (y portafolio-hgs.netlify.app como respaldo)npm run images && npm run build, publica dist.
admin-hgsadmin/ (base directory)admin.hgsportafolio.sitenpm run build, publica dist. Solo construye si cambió admin/.
  • Deploy previews: Netlify publica un preview por PR en cada sitio y lo reporta como estado netlify/<sitio>/deploy-preview; preview-comment.yml lo convierte en comentario. El preview del admin entra con el mismo PIN y código y puede apuntar a la rama del PR con ?branch=<rama>.
  • Funciones: solo en el admin: auth.mts (entrada), github.mts (intermediario de GitHub), contact.mts (formulario del portafolio), messages.mts (estación «Mensajes») y weekly-digest.mts (función programada, lunes 13:00 UTC = 8:00 de Bogotá; se lanza a mano con Run now), en admin/netlify/functions/. El portafolio ya no tiene funciones (commit f4be780). Como admin-hgs solo se reconstruye cuando cambia admin/, un cambio en la lógica del formulario despliega el admin, no el portafolio.
  • Caché: los archivos con hash (/assets/*) se guardan un año; imágenes, íconos, MIGOZZ y CV se revalidan en cada visita (portfolio/netlify.toml).
  • Si un check de GitHub Actions detiene o no un deploy de producción depende del panel de Netlify (por confirmar). Lo que sí detiene un deploy es que falle el build de Netlify, que incluye el typecheck.

Página de la documentación

Doc/ no se despliega en Netlify sino en GitHub Pages, con el workflow publish-docs.yml: el repositorio público DevCat-HGS/DevCat-HGS.github.io, servido en https://docs.hgsportafolio.site. El workflow escribe el archivo CNAME en cada publicación porque reemplaza toda la raíz del sitio; antes, la siguiente publicación borraba el dominio (commit 83ccd6b). El historial de ese repositorio guarda las ediciones anteriores.

Doc/build.mjs arma Doc/index.html a partir de los .md. Desde 6a21775 la página es siempre clara (color-scheme: light, sin modo oscuro), con tinta oscura sobre papel crema (--paper: #f6efe4), para que se lea igual en todos los sistemas. El caso de estudio /portafolio/ del sitio enlaza a esta página porque el código del portafolio es privado (ver portafolio).

Dependabot

.github/dependabot.yml revisa cada semana:

  • npm en /portfolio (máximo 5 PR abiertos, dependencias de desarrollo agrupadas en dev-tooling);
  • npm en /admin, con las mismas reglas;
  • GitHub Actions en /.

El 2026-10-21 (f3089f8) se subió Vite a 8.3.5 en los dos proyectos a mano, lo que cerró las 12 alertas abiertas (ver seguridad).

Plantilla de pull request

.github/pull_request_template.md pide resumen, cambios, tipo de cambio y verificación: build del portafolio, revisión en móvil y escritorio, en ES y EN, y sin regresiones de accesibilidad.

Operación

Dominio, DNS y posicionamiento

El dominio hgsportafolio.site en Hostinger y Netlify, Search Console, sitemap, canonical, hreflang, datos estructurados y por qué Google aún no muestra el favicon.

6 min de lectura dominio-y-seo.md

Dominio

  • Producción: https://hgsportafolio.site, fijado en portfolio/netlify.toml como VITE_SITE_URL. De ahí salen canonical, hreflang, Open Graph, JSON-LD y sitemap.
  • Respaldo: portafolio-hgs.netlify.app sigue abierto. No compite en Google porque cada página declara como canonical su versión en hgsportafolio.site (commit 8d5a3db, que quitó la redirección 301 añadida en 73be47d).
  • Admin: admin.hgsportafolio.site, con un registro CNAME en Hostinger hacia admin-hgs.netlify.app (admin/README.md).
  • Documentación: docs.hgsportafolio.site, con un registro CNAME en Hostinger hacia DevCat-HGS.github.io. El dominio lo fija el archivo CNAME que publish-docs.yml escribe en cada publicación, porque el workflow reemplaza toda la raíz del sitio.

El registrador y el DNS están en Hostinger. Los registros del dominio raíz hacia Netlify no están escritos en el repositorio (por confirmar en el panel de Hostinger).

Registros para Resend

Para que salgan los correos, hgsportafolio.site debe estar verificado en Resend: Resend entrega unos registros DNS (normalmente SPF, DKIM y, opcional, DMARC) que se agregan en Hostinger. Esos registros no están en el repositorio; es un paso pendiente de Harol. Ver configuración.

Search Console

seo.json → site.verification guarda dos códigos de verificación de Google Search Console, que se emiten como <meta name="google-site-verification"> en cada página. Se editan en el admin, estación SEO. Sus valores no se copian aquí.

Search Console también alimenta el resumen semanal por correo del admin, si se configura una cuenta de servicio (ver admin).

Páginas e idiomas

URLPágina
/Portafolio en español
/en/Portafolio en inglés
/migozz/Caso MIGOZZ en español
/en/migozz/Caso MIGOZZ en inglés
/aria/Caso ARIA en español
/en/aria/Caso ARIA en inglés
/devpanel/Caso DevPanel en español
/en/devpanel/Caso DevPanel en inglés
/portafolio/Caso del propio portafolio en español
/en/portafolio/Caso del propio portafolio en inglés

index.html es la fuente HTML de la portada; cada caso de estudio tiene la suya (migozz/index.html, aria/index.html, devpanel/index.html, portafolio/index.html). vite.config.ts llena sus bloques marcados (<!--seo-head-->, <!--seo-body-->) para cada idioma y página. Cada página trae su head completo en el HTML estático, antes de que corra JavaScript, para que los buscadores y las vistas previas lo lean.

Subpáginas genéricas

Desde el commit b9d13b1 los casos de estudio no se programan uno por uno. portfolio/seo/pages.ts define:

  • SUBPAGE_IDS (hoy migozz, aria, devpanel y portafolio; PAGE_IDS suma home): de esa lista salen el head, el JSON-LD, el sitemap, el texto de respaldo, las rutas del servidor de desarrollo y las entradas del build en vite.config.ts.
  • SUBPAGES: por cada caso, sus textos (migozzPages, ariaPages, devpanelPages y portafolioPages en seo.json), el nombre corto para la miga de pan, el enlace del producto, de su cliente o de su código (siteUrl, que el texto de respaldo muestra como enlace), el nodo schema.org del producto y relation. En el caso portafolio el nombre es «Portafolio HGS» y siteUrl es https://docs.hgsportafolio.site: según el comentario de pages.ts, el código es privado y el registro público de cómo está hecho es la documentación.

relation (desde 90ff404) dice cómo se relaciona Harol con el producto: author si es su proyecto propio (DevPanel y Portafolio) o contributor si trabajó en él con un equipo (MIGOZZ y ARIA). structuredData() en seo/render.ts usa ese valor como nombre de la propiedad que enlaza el producto con la Person.

Agregar un caso nuevo es, según el comentario de pages.ts, un id nuevo en SUBPAGE_IDS, su entrada en SUBPAGES y su propio <id>/index.html. Del lado de la página hacen falta además su src/<id>.tsx, su JSON y su carga en src/content/ (ver portafolio), sus textos en seo.json, su regla de idioma en netlify.toml y su estación en el admin, como hizo 90ff404 con DevPanel y 433174c con Portafolio.

Enfoque de los textos

Los títulos, descripciones y texto de respaldo salen de portfolio/src/content/data/seo.json (se editan en el admin, estación SEO). Desde b9d13b1 el mensaje central es «llevo productos web y móviles a producción, con backend serverless e IA» (en inglés, «takes web and mobile products to production, with serverless backends and AI»), y la descripción de la portada menciona los casos MIGOZZ y ARIA. tests/seo.test.ts comprueba los largos de título y descripción.

Lo que genera el build

Todo en portfolio/seo/render.ts, con pruebas en portfolio/tests/seo.test.ts:

  • Head: título, descripción, canonical, hreflang para es, en y x-default, Open Graph (con og:locale y alternativo) y tarjeta para compartir (og.png, 1200 × 630).
  • Datos estructurados (JSON-LD): Person (con worksFor MIGOZZ, alumniOf SENA y dirección en Medellín) y SoftwareApplication de MIGOZZ en todas las páginas. En la portada se suman WebSite y ProfilePage. En un caso de estudio, WebPage y BreadcrumbList; en ARIA y DevPanel, además, un SoftwareApplication propio, y en Portafolio un WebApplication («Portafolio HGS», en es y en, con url en la portada del sitio y subjectOf hacia un TechArticle «Documentación técnica» en https://docs.hgsportafolio.site). En ARIA, Arix Group es el creador y Harol colaborador (contributor). En DevPanel, Harol es el autor (author), y el nodo lleva categoría DeveloperApplication, sistema Windows, licencia MIT, enlace de descarga y el repositorio en sameAs. En Portafolio Harol también es author. Pruebas de tests/seo.test.ts comprueban que DevPanel tenga author y no contributor, y que el nodo del portafolio tenga author y subjectOf.url hacia la documentación.
  • Texto de respaldo: lo que queda dentro de #root para buscadores y lectores sin JavaScript. En la portada lista todos los proyectos visibles con una línea (enlace al caso de estudio si lo tiene, si no al sitio del proyecto, si no a su código —desde 06a3e2b—, si no solo el nombre) y enlaza a cada caso de estudio. Así un rastreador o una IA que lee el HTML sin ejecutar nada ve el mismo trabajo que un visitante.
  • Pantalla de carga: el HTML estático ya trae «Bienvenido / Cargando…» o «Welcome / Loading…» según el idioma de la página (localizeHtml), porque es lo primero que lee un rastreador sin JavaScript. Antes salía en inglés en todas.
  • sitemap.xml: las páginas de cada idioma, con sus alternativas de idioma y lastmod.
  • robots.txt: permite todo y apunta al sitemap.

Si no hay dirección del sitio en el build (ni VITE_SITE_URL ni la variable URL de Netlify), las etiquetas que necesitan una dirección se omiten en vez de quedar vacías.

Fecha del sitemap

contentDate() en portfolio/vite.config.ts toma la fecha del último commit que tocó contenido visible (src, public, seo, index.html y las carpetas de cada caso). Antes era la fecha del build; según el comentario del código, una fecha que cambia en cada despliegue enseña a Google a ignorarla. Si el build no tiene historial de git, lastmod se omite.

Etiqueta de Umami

umamiId() en portfolio/seo/headers.ts toma de VITE_UMAMI_WEBSITE_ID solo el UUID del sitio. Así, si alguien pega en la variable el <script> completo que da Umami, la etiqueta sigue siendo válida en vez de romper el <head> de todas las páginas (commit b7461ec). Lo correcto sigue siendo dejar solo el UUID (ver pendientes).

Redirección por idioma

En portfolio/netlify.toml, / redirige con un 302 a /en/ solo cuando el navegador pide inglés o tiene la cookie nf_lang=en (condición Language = ["en"], que Netlify evalúa leyendo primero esa cookie). El botón de idioma fija la cookie, así la elección manual se mantiene. Los rastreadores no mandan idioma y reciben /, que es la versión en español.

Lo mismo pasa con /migozz/ hacia /en/migozz/, con /aria/ hacia /en/aria/ (commit 574e51b) con /devpanel/ hacia /en/devpanel/ (commit 90ff404) y con /portafolio/ hacia /en/portafolio/ (commit 433174c). Cada caso nuevo necesita su propia regla.

Por qué Google muestra un globo y no el favicon

En los resultados de Google el sitio aparece con un globo genérico. El favicon está bien: public/favicon.ico (48 × 48), favicon-192.png, favicon.svg y apple-touch-icon.png existen y se declaran en el <head> de cada página (commit 4f652c0). Google guarda y actualiza los favicones con un rastreador aparte y a su propio ritmo, así que el globo puede tardar días o semanas en cambiar después de que el ícono es correcto. No hace falta tocar el código; pedir la indexación de la portada en Search Console puede acelerarlo.

Otros detalles

  • public/404.html es la página de error que sirve Netlify.
  • public/.well-known/security.txt publica el contacto de seguridad (ver seguridad).
  • El admin no se indexa: X-Robots-Tag: noindex, nofollow y admin/public/robots.txt.

Operación

Variables de entorno y lo que falta configurar

Cada variable de cada sitio, qué hace y dónde se pone, sin valores, y la lista de pasos pendientes para que el admin, los correos, la analítica y el resumen semanal funcionen.

5 min de lectura configuracion.md

Las variables de producción van en Netlify: sitio → Project configuration → Environment variables. Para probar en local se copia el .env.example de cada carpeta a .env (ignorado por git) y se usa npx netlify dev. Nunca se sube un .env con valores reales.

Sitio del admin (admin-hgs)

Plantilla: admin/.env.example.

VariableObligatoriaQué hace
ADMIN_GITHUB_TOKENSíToken de GitHub que usa /api/github para leer y publicar. Fine-grained, solo el repo portfolio-hgs, permiso «Contents: Read and write». El navegador nunca lo ve.
ADMIN_PINSíPIN de entrada, 4 o más dígitos. Sin él nadie entra (respuesta 503).
SESSION_SECRETSíFirma de sesiones y retos. 32 caracteres aleatorios o más; cambiarlo cierra todas las sesiones. .env.example trae el comando de Node para generarlo.
ADMIN_EMAILNoCorreo que recibe los códigos y los avisos de bloqueo. Tiene un valor por defecto en el código.
RESEND_API_KEYSíClave de Resend para todos los correos: los códigos de acceso y los dos del formulario de contacto. Sin ella el PIN correcto devuelve error de correo y el formulario guarda el mensaje pero no envía correos.
ADMIN_MAIL_FROMNoRemitente de los códigos, en el dominio verificado. Por defecto Admin HGS <admin@hgsportafolio.site>.
CONTACT_TONoDónde llegan los mensajes del formulario. Si falta, usa ADMIN_EMAIL (y si también falta, un valor por defecto del código).
CONTACT_FROMNoRemitente de la copia a Harol, de la confirmación al visitante y del resumen semanal. Por defecto Harol Guerrero Salgado <contacto@hgsportafolio.site>.

Resumen semanal (weekly-digest)

Están en admin/.env.example desde el commit 574e51b; la lógica, en admin/netlify/functions/weekly-digest.mts. Detalle en admin.

VariableObligatoriaQué hace
UMAMI_API_KEYPara visitasClave de la API de Umami Cloud (api.umami.is), creada en la cuenta de Umami. Junto con la siguiente activa la parte de visitas.
UMAMI_WEBSITE_IDPara visitasId del sitio en Umami. Es el mismo UUID que VITE_UMAMI_WEBSITE_ID del portafolio, pero va en el otro sitio de Netlify.
GSC_SERVICE_ACCOUNTNoJSON completo de la llave de una cuenta de servicio de Google Cloud con lectura de Search Console. El correo de la cuenta se agrega como usuario de la propiedad.
GSC_SITENoPropiedad de Search Console. Por defecto, la propiedad de dominio de hgsportafolio.site.
DIGEST_TONoDestino del resumen. Si falta, usa el mismo destino del formulario: CONTACT_TO, luego ADMIN_EMAIL y al final un valor por defecto del código.

Hace falta al menos una fuente (Umami o Search Console); sin ninguna, la función falla.

El estado de la entrada (contadores, retos, sesiones) va en Netlify Blobs, almacén admin-auth; los mensajes del formulario y sus límites, en el almacén contact-messages. Ninguno necesita configuración.

Sitio del portafolio (portafolio-hgs)

Plantilla: portfolio/.env.example. Desde el commit f4be780 el portafolio ya no envía correos: RESEND_API_KEY, CONTACT_TO y CONTACT_FROM van en admin-hgs, no aquí. Si quedaron puestas en portafolio-hgs, ya no las usa nada y se pueden borrar.

VariableObligatoriaQué hace
VITE_SITE_URLYa fijadaDominio de producción. Está en portfolio/netlify.toml, no hace falta ponerla en el panel.
VITE_UMAMI_WEBSITE_IDNoWebsite ID de Umami Cloud (cloud.umami.is → Add website): solo el UUID. El build ignora todo lo demás (umamiId() en seo/headers.ts). Activa la analítica sin cookies y abre sus orígenes en la CSP. Se lee al construir: tras ponerla hay que volver a desplegar. Vacía = apagada. Eventos en portafolio.

Netlify define además URL, que el build usa solo si falta VITE_SITE_URL.

Variables de pruebas locales

VariableDóndeQué hace
PW_CHANNELPlaywrightchrome para usar el Chrome instalado.
CHROME_PATHLighthouseRuta del navegador.
LH_URL, LH_RUNSscripts/lighthouse.mjsDirección y número de corridas (3 por defecto; CI usa 5).
SCREENSHOTSPlaywrightEn CI, 1 para guardar capturas.

Secretos de GitHub Actions

SecretoLo usanPara qué
METRICS_TOKENmetrics.yml, privacity.yml, publish-profile.ymlLeer repos (incluidos privados) para gráficas y tabla, y hacer push al repo público del perfil.
DOCS_DEPLOY_KEYpublish-docs.ymlLlave de despliegue SSH que solo puede escribir en DevCat-HGS/DevCat-HGS.github.io, donde se publica esta documentación.

Los demás workflows usan el GITHUB_TOKEN automático.

Checklist de lo que falta

Pendiente de Harol a la fecha de esta edición (según admin/README.md, los .env.example y los commits bf84696 y f4be780). No se puede comprobar desde el repositorio qué variables ya están puestas en Netlify:

  • [ ] En admin-hgs: ADMIN_GITHUB_TOKEN, ADMIN_PIN, SESSION_SECRET, ADMIN_EMAIL, RESEND_API_KEY, ADMIN_MAIL_FROM, CONTACT_TO, CONTACT_FROM.
  • [ ] En admin-hgs, para el resumen semanal: UMAMI_API_KEY, UMAMI_WEBSITE_ID y, opcional, GSC_SERVICE_ACCOUNT; y, si se quiere otro destino que el del formulario, DIGEST_TO. Probar con Functions → weekly-digest → Run now.
  • [ ] En portafolio-hgs: VITE_UMAMI_WEBSITE_ID (solo el UUID), si se quiere analítica. Quitar RESEND_API_KEY, CONTACT_TO y CONTACT_FROM si siguen ahí.
  • [ ] Enviar un mensaje de prueba desde el formulario y confirmar que llegan los dos correos (la copia y la confirmación) y que aparece en la estación «Mensajes».
  • [ ] En Resend: agregar y verificar el dominio hgsportafolio.site, copiando sus registros DNS en Hostinger.
  • [ ] Confirmar que admin.hgsportafolio.site tiene su CNAME hacia admin-hgs.netlify.app y que admin-hgs tiene base directory admin.
  • [ ] Dejar activos los deploy previews de PR en los dos sitios.

Mientras falten: el admin no deja entrar (sin PIN ni secreto responde 503; sin Resend no llega el código) y el formulario no envía correos, aunque sí guarda los mensajes en el almacén contact-messages para leerlos en «Mensajes» cuando se pueda entrar.

Después de cambiar variables en Netlify hay que volver a desplegar el sitio para que las funciones las tomen (comportamiento de Netlify, por confirmar en el panel si se aplica solo).

Marca personal

Marca personal en LinkedIn

La guía para el perfil de LinkedIn, los banners y la publicación del portafolio, y cómo se regeneran sus imágenes.

2 min de lectura linkedin.md

La carpeta linkedin/ busca que el perfil de LinkedIn, el portafolio y el GitHub se vean como una sola marca: la misma tipografía (Archivo condensada), los mismos colores (papel, tinta, amarillo de señal y verde del metro) y el mismo mensaje (linkedin/GUIA.md).

Qué hay

ArchivoPara qué
GUIA.mdGuía paso a paso: banner, foto, titular, Acerca de, Destacados, Experiencia, Proyectos y certificaciones, Aptitudes, Recomendaciones, configuración, actividad y lista de comprobación.
PUBLICACION.mdTexto de la publicación del portafolio en español e inglés, cómo publicarla e ideas para las siguientes.
banner-es.png, banner-en.pngBanner del perfil, 1584 × 396 px.
publicacion-es.png, publicacion-en.pngImagen de la publicación, 1080 × 1350 px.
banner.html, publicacion.htmlPlantillas; el texto se cambia aquí.
capturas/Capturas del portafolio en vivo que usa la publicación: portada en escritorio y móvil, y el caso /portafolio/ (caso.png).
fuente/Archivo, la fuente del portafolio, para que las plantillas se vean igual.
exportar.mjsRegenera los PNG.

Regenerar las imágenes

node linkedin/exportar.mjs             # banners y publicación
node linkedin/exportar.mjs --capturas  # además, nuevas capturas de hgsportafolio.site

Usa el Playwright instalado en portfolio/ (no instala nada nuevo) con el canal msedge, así que necesita Microsoft Edge. Las capturas se toman de https://hgsportafolio.site a 1440 × 900 y 390 × 844.

Publicación

Mensaje actual

Desde el 2026-10-22 el banner y la publicación siguen el mensaje central del sitio: «Llevo productos a producción» (en inglés, «I take products to production»).

  • Banner: debajo del mensaje nombra los casos de estudio (MIGOZZ, ARIA, DevPanel y el propio portafolio).
  • Publicación: trata del caso /portafolio/ («Mi portafolio se publica solo»), con tres cifras de ese caso: 14 workflows, más de 100 pruebas y accesibilidad 100 exigida.
  • Primer comentario: enlaza el caso y docs.hgsportafolio.site.
  • Guía: suma DevPanel y el portafolio al Acerca de, a Destacados y a Proyectos. ARIA aparece como trabajo en equipo y sin cifras hasta que Arix Group las confirme (ver pendientes).

Esas cifras están escritas a mano en publicacion.html y en PUBLICACION.md: si cambian en el caso, hay que cambiarlas aquí también y volver a exportar.

LinkedIn no tiene API en este flujo: banner, textos y publicación se suben a mano siguiendo GUIA.md y PUBLICACION.md.

Los commits dd6e9b6 y e55f47e (2026-10-13) quitaron detalles confidenciales de trabajo del texto de la publicación y del perfil; cualquier cambio futuro debe mantener ese criterio. El agente .claude/agents/rrhh-estricto.md sirve para auditar CV, LinkedIn, README o portafolio como lo haría un filtro de reclutamiento, incluida una revisión de confidencialidad.

Historial

Cronología del repositorio

Los hitos del proyecto por fecha, tomados de git log, desde el README del perfil en 2024 hasta los casos de estudio de ARIA, DevPanel y el propio portafolio, y el resumen semanal, en octubre de 2026.

7 min de lectura historial.md

Fuente: git log --format="%h %ad %s" --date=short. Se omiten los commits automáticos repetidos (gráficas de lenguajes, CV, datos de GitHub) y los merges de Dependabot, salvo cuando marcan algo.

2024-12 a 2025-10: el README del perfil

  • 2024-12-14 82abbd8 Commit inicial. Ese día y los siguientes, decenas de ediciones de README.md desde la web de GitHub.
  • 2025-01 a 2025-07 Ediciones sueltas del README y subida de imágenes a img/ (fondos, logos, código QR). En total hay 93 commits «Update README.md».
  • 2025-10-21 3d30eed Última edición de esa etapa.

2026-09: renovación del perfil

  • 2026-09-21 README reescrito con nueva marca personal, generación de gráficas de lenguajes y limpieza de imágenes sin uso (fcbf852, e39c105, 818d23c, 9d5f4e3).
  • 2026-09-29 Experiencia en Mitsubishi Electric y correo de contacto en el README (01db10f).

2026-10-01: nace el portafolio

  • 54b15c8 Primera versión del portafolio con la sección de MIGOZZ, tarjetas y capturas.
  • dbc3ded Generador del CV, su workflow y el botón de descarga.
  • b64b7b7 Sección de credenciales.
  • 7a38214 CV rediseñado con Inter incrustada.
  • 4759508 Imágenes WebP, fuentes propias y pantalla de bienvenida.
  • d7352cb Stack en acordeón, SEO y datos estructurados.
  • 7df5731 Sección opcional de repos privados en el perfil (privacity/).
  • 213eca1, b199ff1 Verificación de Google Search Console.

2026-10-07: línea de metro, calidad y CI

  • 857354f Rediseño alrededor de una línea del metro de Medellín.
  • 6dac778 App dividida en componentes y hooks, contenido tipado, CSS por sección.
  • ec543d6 ESLint, Prettier, Vitest, CI, Dependabot y README del portafolio.
  • 42692f4 Copia del README del perfil al repositorio público.
  • 84dc422 Comentario de deploy preview desde los estados de Netlify.
  • f765c77 Presupuestos de Lighthouse, axe, pruebas de teclado, capturas en PR, generador de og.png, Release Please, revisión de enlaces y datos de GitHub.
  • 6f98ab2 Release Please solo a mano.
  • ee130d5 Página /en/ con hreflang, más datos estructurados, CSP y cabeceras de seguridad.
  • Merge del PR #1 (testing) y primeros PR de Dependabot.

2026-10-10: MIGOZZ, formulario y automatización de imágenes

  • 0390adc Página del caso MIGOZZ en /migozz/ y /en/migozz/.
  • b6073f7 Formulario de contacto con Netlify Forms.
  • 8fdee63 Analítica opcional con Umami, apagada hasta configurarla.
  • 7f5f8ab Modo de alto contraste e impresión.
  • 1de4fe7 Workflow de imágenes y presupuestos de peso.
  • 0b87eb7 Comparación de Lighthouse contra main en el comentario del PR.
  • 2a45a15 Un solo issue de enlaces rotos.
  • 39b7332 Revalidación de imágenes, íconos y CV en cada visita.
  • c10c5d4 Rediseño de MIGOZZ con Motion, gráficas públicas y pantalla de carga.
  • PR #13 a #15 y #27 a #29.

2026-10-13 a 2026-10-15: retrato, LinkedIn y revisión de RR. HH.

  • d686b89, 8f4afa5 Retrato en la sección Oficio.
  • 88659a3 Nueva URL de LinkedIn en portafolio, SEO y CV.
  • b182da1 Carpeta linkedin/: banner, publicación y guía.
  • dd6e9b6, e55f47e, ffae6f7 Se quitan detalles confidenciales y cifras; título real y un solo nombre en todas partes (PR #31).
  • b1ba0c0 Dos proyectos destacados y el resto tras «ver más» (PR #32).
  • 2026-10-14 Merges de Dependabot que suben las acciones a checkout@v7, setup-node@v7, github-script@v9, upload-artifact@v7 y release-please-action@v5.
  • 2026-10-15 4f652c0 favicon.ico real y nombre del sitio consistente para Google.

2026-10-19 a 2026-10-21: dominio propio y admin

  • 2026-10-19 5806c65 Contenido afinado, stack reorganizado en Web, Backend, Mobile y Cloud, nuevo correo de contacto.
  • 2026-10-21 73be47d El sitio pasa a hgsportafolio.site.
  • 2026-10-21 8d5a3db netlify.app queda como respaldo y se emiten los dos códigos de Search Console.
  • 2026-10-21 3a1e1e4 Primer admin con Sveltia CMS; los proyectos pasan a projects.json.
  • 2026-10-21 c1629bf Admin propio en React para todo el portafolio; todo el texto visible pasa a src/content/data/*.json validado con checked().
  • 2026-10-21 fc4b004 Correos del formulario con Resend y caso MIGOZZ en blanco con los colores actuales de la marca.
  • 2026-10-21 4d8f465 Formato del README del portafolio y reintentos de Lighthouse.
  • 2026-10-21 bf84696 Entrada al admin con PIN y código por correo, token de GitHub solo en el servidor, bloqueos en Netlify Blobs, .env.example en los dos sitios, un comentario de preview por sitio y workflows con la rama desarrollo.
  • 2026-10-21 fa72716, 4ceff81 Primeros commits hechos desde el admin («content: actualizar textos del sitio desde el admin»): un cambio de prueba en site.json y su corrección.
  • 2026-10-21 5ba4d50 Carpeta Doc/ con el agente documentador y la vista de periódico; Dependabot también revisa admin/.
  • 2026-10-21 f4be780 El formulario de contacto deja Netlify Forms, que no lo detectaba, y envía al admin (/api/contact), que guarda el mensaje y manda los dos correos. Nueva estación «Mensajes». El portafolio se queda sin funciones y registra el evento contact-sent en Umami.
  • 2026-10-21 236c9d3 La carpeta img/ (imágenes personales) sale del repositorio y queda en .gitignore.

2026-10-21 (tarde): documentación, seguridad, casos ARIA y DevPanel, resumen semanal

  • aeb5d33 Doc/ al día con el nuevo flujo del formulario.
  • 4f2c664, fd823c4 La documentación se publica en DevCat-HGS.github.io con el workflow Publish docs, y pasa de vista de periódico a sitio de documentación.
  • 2d942c4 Búsqueda de secretos con gitleaks en cada push y PR, saneado de los SVG subidos desde el admin y security.txt.
  • 7837484 La CSP deja pasar los eventos de Umami hacia gateway.umami.is (antes se perdían); la página de documentación gana favicon y descripción.
  • e4aae5a Se ignora la configuración local de WakaTime.
  • b7461ec La etiqueta de Umami toma solo el UUID de la variable, el lastmod del sitemap sale del último commit con contenido visible, el respaldo de la portada enlaza al caso MIGOZZ y la pantalla de carga sale en el idioma de cada página.
  • f3089f8 Vite 8.3.5 en el portafolio y el admin: cierra 12 alertas de Dependabot.
  • ba00cb5 Resumen semanal por correo desde el admin (visitas de Umami, descargas del CV y resultados de Search Console), los lunes a las 8:00 de Bogotá.
  • b9d13b1 Caso de estudio de ARIA en /aria/ y /en/aria/, editable desde el admin; tarjeta de ARIA corregida (S3 Vectors y un resultado medido); subpáginas genéricas en la capa SEO; el respaldo de la portada lista todos los proyectos; nuevo enfoque de los textos: productos web y móviles a producción, con backend serverless e IA.
  • 94f0f6a Las tarjetas de SENA Cloud y SensoresAPP enlazan a sus repos públicos nuevos (sena-cloud, sensores-app).
  • 8bbfdbf El CV describe ARIA como trabajo de equipo con la parte de Harol y sin OpenSearch, que también sale del stack del sitio.
  • ac531e1 Doc/ al día con el caso ARIA, el resumen semanal y los repos públicos.
  • 574e51b El resumen semanal usa el destino y el remitente del formulario si faltan los suyos; los eventos case-<id> y code-<id> salen con nombre legible en el correo; los navegadores en inglés llegan a /en/aria/; admin/.env.example lista las variables del resumen.
  • 06a3e2b DevPanel entra a los proyectos destacados con una captura tomada de sus propias pruebas e2e y enlace a su código; el texto de respaldo enlaza al código cuando un proyecto no tiene caso ni sitio.
  • 90ff404 La página de ARIA pasa a ser la plantilla compartida CaseStudyPage, con enlaces en la portada, capturas y nota de equipo opcional. Nuevo caso de DevPanel en /devpanel/ y /en/devpanel/, con Harol como autor en el JSON-LD, su redirección por idioma y su estación en el admin. Franja de DevPanel en la portada, después de los proyectos.
  • 244496c Doc/ al día con el caso DevPanel, la plantilla compartida y la franja de la portada; los eventos de DevPanel salen con nombre legible en el resumen semanal.

Ese mismo día, fuera del repositorio: revisión del historial para decidir si portfolio-hgs podía ser público (ver seguridad).

2026-10-22: documentación en su dominio y caso del portafolio

  • 83ccd6b La documentación se sirve en docs.hgsportafolio.site: publish-docs.yml escribe el archivo CNAME en cada publicación, porque reemplaza toda la raíz del sitio y la siguiente publicación borraba el dominio.
  • 6a21775 La página de la documentación queda siempre en tema claro, sobre papel crema (#f6efe4); se quita el modo oscuro para que se lea igual en todos los sistemas.
  • 433174c Caso de estudio del propio portafolio en /portafolio/ y /en/portafolio/, con la plantilla CaseStudyPage, Harol como autor en el JSON-LD y enlaces a la documentación (el código es privado). Tarjeta destacada después de DevPanel, tres capturas, redirección por idioma, estación «Portafolio» en el admin y evento portafolio-docs en el resumen semanal.

Commits automáticos

A la fecha (296 commits en main, contados con git rev-list --count main): 19 de gráficas de lenguajes, 2 del CV, 1 de datos de GitHub y 10 actualizaciones de Dependabot (Bump), cada una con su merge.

Pendientes

Lo que falta y lo que conviene mejorar

Pasos que dependen de Harol y mejoras sugeridas, cada una con su porqué.

4 min de lectura pendientes.md

Depende de Harol

PendientePor qué importa
Configurar en admin-hgs las variables ADMIN_GITHUB_TOKEN, ADMIN_PIN, SESSION_SECRET, ADMIN_EMAIL, RESEND_API_KEY y ADMIN_MAIL_FROM, más CONTACT_TO y CONTACT_FROM para el formulario.Sin ellas el admin no deja entrar (sin PIN o secreto responde 503 y sin Resend no llega el código) y el formulario no envía correos.
Elegir un ADMIN_PIN que no se pueda deducir de datos públicos (ni números de nombres de usuario actuales o anteriores, ni fechas o teléfonos publicados) y de más de 4 dígitos.Los límites solo frenan los intentos al azar; un PIN deducible se acierta en pocos intentos y deja como única barrera el correo. Ver seguridad.
Quitar de portafolio-hgs RESEND_API_KEY, CONTACT_TO y CONTACT_FROM, si se llegaron a poner.Desde f4be780 el portafolio no las usa; dejarlas solo deja una copia más de la clave de Resend.
Enviar un mensaje de prueba desde hgsportafolio.site (en español y en inglés) y confirmar que llegan los dos correos: la copia a Harol y la confirmación al visitante.Es la prueba real del flujo nuevo; las pruebas automáticas simulan Resend y la función. Si no llegan, el mensaje debe verse igual en la estación «Mensajes», y el error queda en el registro de la función contact en Netlify.
Verificar hgsportafolio.site en Resend y copiar sus registros DNS en Hostinger.Resend solo envía desde dominios verificados; sin eso no salen ni los códigos del admin ni los correos del formulario.
Confirmar el CNAME de admin.hgsportafolio.site y que admin-hgs use base directory admin.Es lo que hace que el admin exista en su dominio (admin/README.md).
Confirmar que METRICS_TOKEN puede escribir en DevCat-HGS/DevCat-HGS.publish-profile.yml lo usa para hacer push al repo público.
Llenar migozz-users.json con cifras reales y su fuente, y activar visible.Las gráficas de usuarios del caso MIGOZZ están ocultas hasta entonces, a propósito.
Subir capturas de la app a MIGOZZ (shots).La sección de capturas del caso no aparece mientras la lista esté vacía.
Activar Umami: crear el sitio en cloud.umami.is (Add website) y poner en VITE_UMAMI_WEBSITE_ID del sitio portafolio-hgs solo el UUID del sitio (no el <script> completo); luego volver a desplegar.Los eventos ya están en el código. Desde b7461ec el build extrae el UUID aunque se pegue el fragmento entero, pero dejar solo el UUID evita sorpresas si ese código cambia. Ver portafolio.
Activar el resumen semanal: en el sitio admin-hgs, configurar UMAMI_API_KEY y UMAMI_WEBSITE_ID (y, opcional, GSC_SERVICE_ACCOUNT, con el correo de esa cuenta agregado como usuario en Search Console), y, si se quiere otro destino que el del formulario, DIGEST_TO. Probar con Run now.Sin al menos una fuente la función weekly-digest falla cada lunes y no llega ningún correo. Ver admin.
Pedir en Search Console la indexación de /, de /aria/, de /devpanel/ y de /portafolio/ (y sus versiones /en/).La portada cambió de textos, de respaldo y ganó la franja de DevPanel y la tarjeta del portafolio, y /aria/, /devpanel/ y /portafolio/ son nuevas; pedirlo acelera que Google las lea. También puede adelantar el cambio del globo por el favicon (ver dominio y SEO).
Confirmar con Arix Group las cifras publicadas en /aria/ y en la tarjeta (tiempos de respuesta, número de SAP Notes, stacks).Es un proyecto de cliente: las cifras deben estar aprobadas por ellos antes de que circulen. Se corrigen en la estación ARIA del admin.
Decidir si el portafolio tendrá un repositorio público (aplazado por Harol).Hacerlo exige un repo nuevo con historial limpio, no cambiar la visibilidad de este. Ver seguridad.
Dar los datos para las cifras de los proyectos: un resultado medible por proyecto (usuarios, tiempos, volumen, ahorro) con su fuente y fecha.Salvo ARIA, que ya tiene un tiempo medido, el campo result de projects.json es cualitativo (por ejemplo «Entregado al cliente»). Una cifra con fuente pesa más ante un reclutador, y el sitio no muestra cifras inventadas.

Detalle de variables en configuración.

Mejoras sugeridas

  • Archivos sin uso. profile/stats.svg y profile/calendar.svg no los referencia nada. Borrarlos aligera el repo y evita confusiones.
  • Limpieza del almacén contact-messages. Los contadores diarios (limit:day:<fecha>) y por IP nunca se borran, y la estación «Mensajes» solo muestra los 200 más nuevos. No es urgente, pero una limpieza periódica evita que crezca sin fin y que haya mensajes viejos que no se ven.
  • Cifras escritas a mano en el caso Portafolio. «14 workflows» y «más de 100 pruebas» están en portafolio.json, projects.json y seo.json como texto fijo. Hoy coinciden con .github/workflows/ (14 archivos) y con las pruebas de portfolio/tests/, pero envejecen con cada workflow o prueba nueva. Una prueba que compare la cifra con el número de archivos en .github/workflows/ avisaría del desfase.
  • Cifras de Lighthouse en el README del portafolio. Están escritas a mano y envejecen; el comentario del PR y el artefacto lighthouse-baseline ya tienen las actuales.
  • Borradores de imágenes. Las imágenes subidas en el admin se pierden al recargar (solo los JSON se guardan como borrador). Avisarlo en la interfaz evita sorpresas.
  • Release Please. Corre solo a mano y aún no hay CHANGELOG.md. Si se quieren versiones, hay que lanzarlo o agregar el disparador push a main que ya está comentado en release.yml.
  • Ramas y PR de Dependabot. Las ramas dependabot/.../vite-8.3.5 siguen en el remoto aunque sus PR ya no están abiertos (Vite subió en f3089f8); se pueden borrar. Quedan PR abiertos de Dependabot (React 19.3.1 y @netlify/blobs en el admin, herramientas de desarrollo en los dos); revisarlos con gh pr list y fusionarlos si pasan CI.