refactor: Extract all hardcoded content to JSON files

- Move all bilingual text from templates to UI JSON (labels, buttons, modals)
- Move skills summary paragraph to CV JSON with HTML support
- Add new UI sections: navigation, viewControls, sections, footer, portfolio,
  pdfModal, shortcutsModal, infoModal, widgets
- Update Go structs to match expanded JSON structure
- Add template.HTML type for CV.SkillsSummary field
- Add JSON content validation test (70-json-content-validation.test.mjs)

Templates now contain only structural logic (CSS classes, HTML attributes)
while all user-visible text loads from JSON files for proper i18n support.
This commit is contained in:
juanatsap
2025-11-30 10:13:37 +00:00
parent c834919a3c
commit 9636b3659f
36 changed files with 806 additions and 168 deletions
+18 -1
View File
@@ -2,6 +2,12 @@
"personal": {
"name": "Juan Andrés Moreno Rubio",
"title": "Lead Technical Consultant, FullStack Developer",
"titleBadges": [
"Technical Consultant",
"Full-Stack Engineer",
"Authentication Specialist",
"Solution Architect"
],
"location": "Arrecife, Las Palmas de Gran Canaria, Spain",
"email": "txeo.msx@gmail.com",
"phone": "+34 676875420",
@@ -12,9 +18,20 @@
"github": "https://github.com/juanatsap",
"domestika": "https://www.domestika.org/es/txeo/portfolio",
"website": "https://juan.andres.morenorub.io",
"photo": "/static/images/profile.jpg"
"photo": "/static/images/profile.jpg",
"firstName": "Juan Andrés",
"lastName": "Moreno Rubio",
"username": "txeo"
},
"seo": {
"pageTitle": "Curriculum Vitae",
"metaTitle": "Professional CV",
"metaDescription": "18 years of experience in web development, SAP CDC, React, Node.js, Go, HTMX and AI-assisted development",
"ogDescription": "Senior Technical Consultant with 18 years of experience",
"keywords": "CV, Resume, FullStack Developer, SAP CDC, React, Node.js, Go, HTMX, AI, Web Development, Technical Consultant"
},
"summary": "Full-stack developer specialized in high-availability systems. I've worked on Olympic Games platforms, airport authentication systems with millions of users, and built around 20 websites for diverse sectors (e-commerce, enterprise, institutional). Certified SAP Customer Data Cloud consultant, advising 35-40 international clients on digital identity solutions.",
"skillsSummary": "<strong>Full-stack</strong> developer with experience in <strong>Go</strong>, <strong>Node.js</strong>, <strong>React</strong>, and <strong>HTMX</strong> for <strong>modern applications</strong>, plus Java and PHP knowledge for legacy projects. I've worked on <strong>around 20 websites</strong> and provided <strong>consulting for 35-40 international clients</strong>, from e-commerce and enterprise platforms to <strong>authentication systems</strong> managing <strong>millions of users</strong>. Familiar with <strong>AI-assisted development</strong> workflows and infrastructure management (<strong>Linux</strong>, <strong>Docker</strong>, <strong>CI/CD</strong>). I adapt well to both independent work and collaborative teams across different countries.",
"experience": [
{
"position": "Senior SAP Technical Consultant",
+18 -1
View File
@@ -2,6 +2,12 @@
"personal": {
"name": "Juan Andrés Moreno Rubio",
"title": "Consultor Técnico Senior, Desarrollador FullStack",
"titleBadges": [
"Consultor Técnico",
"Ingeniero Full-Stack",
"Especialista en Autenticación",
"Arquitecto de Soluciones"
],
"location": "Arrecife, Las Palmas de Gran Canaria, España",
"email": "txeo.msx@gmail.com",
"phone": "+34 676875420",
@@ -12,9 +18,20 @@
"github": "https://github.com/juanatsap",
"domestika": "https://www.domestika.org/es/txeo/portfolio",
"website": "https://juan.andres.morenorub.io",
"photo": "/static/images/profile.jpg"
"photo": "/static/images/profile.jpg",
"firstName": "Juan Andrés",
"lastName": "Moreno Rubio",
"username": "txeo"
},
"seo": {
"pageTitle": "Curriculum Vitae",
"metaTitle": "CV Profesional",
"metaDescription": "18 años de experiencia en desarrollo web, SAP CDC, React, Node.js, Go, HTMX y desarrollo asistido por IA",
"ogDescription": "Consultor Técnico Senior con 18 años de experiencia",
"keywords": "CV, Curriculum Vitae, Desarrollador FullStack, SAP CDC, React, Node.js, Go, HTMX, IA, Desarrollo Web, Consultor Técnico"
},
"summary": "Desarrollador full-stack especializado en sistemas de alta disponibilidad. He participado en plataformas de Juegos Olímpicos, sistemas de autenticación aeroportuaria con millones de usuarios, y desarrollado unos 20 sitios web para diversos sectores (e-commerce, empresariales, institucionales). Consultor certificado de SAP Customer Data Cloud, asesorando a 35-40 clientes internacionales en soluciones de identidad digital.",
"skillsSummary": "Desarrollador <strong>full-stack</strong> con experiencia en <strong>Go</strong>, <strong>Node.js</strong>, <strong>React</strong> y <strong>HTMX</strong> para <strong>aplicaciones modernas</strong>, además de conocimientos en Java y PHP para proyectos legacy. He trabajado en <strong>unos 20 sitios web</strong> y realizado <strong>consultoría para 35-40 clientes internacionales</strong>, desde e-commerce y plataformas empresariales hasta <strong>sistemas de autenticación</strong> que gestionan <strong>millones de usuarios</strong>. Familiarizado con flujos de trabajo asistidos por <strong>IA</strong> y gestión de infraestructura (<strong>Linux</strong>, <strong>Docker</strong>, <strong>CI/CD</strong>). Me adapto bien tanto al trabajo independiente como colaborativo en equipos internacionales.",
"experience": [
{
"position": "Consultor Técnico Senior SAP",
+123 -10
View File
@@ -1,19 +1,78 @@
{
"infoModal": {
"title": "About this CV",
"description": "This interactive CV was built by myself with <strong>Go + HTMX</strong>, showcasing modern hypermedia architecture without heavy JavaScript frameworks.",
"techStack": {
"goHono": "Go + Hono",
"htmx": "HTMX",
"html5": "Semantic HTML5",
"css3": "Pure CSS3"
"navigation": {
"cvSections": "CV Sections",
"training": "Training",
"skills": "Skills",
"experience": "Experience",
"awards": "Awards",
"projects": "Personal / Freelance Projects",
"courses": "Courses",
"languages": "Languages",
"references": "References",
"other": "Other",
"quickActions": "Quick Actions",
"collapseAll": "Collapse All",
"expandAll": "Expand All",
"zoom": "Zoom",
"viewControls": "View Controls",
"actions": "Actions"
},
"viewControls": {
"length": "Length",
"icons": "Icons",
"view": "View"
},
"sections": {
"technicalSkills": "Technical Skills",
"moreSkills": "More Skills",
"yearsOfExperience": "years of experience",
"drivingLicense": "Driving License type",
"obtainedFrom": "obtained from the",
"currentBadge": "CURRENT",
"expiredBadge": "EXPIRED",
"present": "now",
"technologies": "Technologies:",
"maintainedBy": "MAINTAINED BY"
},
"footer": {
"viewOnGithub": "View this project on GitHub",
"lastUpdated": "Last updated"
},
"portfolio": {
"seeAllProjects": "See all projects on my",
"domestikaPortfolio": "Domestika portfolio"
},
"pdfModal": {
"title": "Download PDF",
"subtitle": "Choose your preferred format",
"preparingPdf": "Preparing PDF...",
"pleaseWait": "Please wait while we generate your CV",
"close": "Close",
"downloadButton": "Download PDF",
"shortCv": {
"title": "Short CV (4 pages)",
"pages": "4 Pages",
"description": "Essential info",
"ariaLabel": "Short CV - 4 pages, essential information"
},
"viewSource": "View Project in Github",
"viewSourceSubtext": "Want to know how it's built?"
"defaultCv": {
"title": "Default CV (5 pages)",
"pages": "5 Pages",
"description": "Short with skills - Recommended",
"ariaLabel": "Default CV - 5 pages with skills (Recommended)"
},
"extendedCv": {
"title": "Extended CV (9 pages)",
"pages": "9 Pages",
"description": "All details",
"ariaLabel": "Extended CV - 9 pages, full version"
}
},
"shortcutsModal": {
"title": "Keyboard Shortcuts",
"subtitle": "Learn the Shortcuts",
"description": "Use these keyboard shortcuts to navigate and control the CV more efficiently.",
"close": "Close",
"sections": {
"zoom": {
"title": "Zoom Control",
@@ -87,5 +146,59 @@
}
}
}
},
"infoModal": {
"title": "About this CV",
"description": "This interactive CV was built by myself with <strong>Go + HTMX</strong>, showcasing modern hypermedia architecture without heavy JavaScript frameworks.",
"techStack": {
"goHono": "Go + Hono",
"htmx": "HTMX",
"html5": "Semantic HTML5",
"css3": "Pure CSS3"
},
"viewSource": "View Project in Github",
"viewSourceSubtext": "Want to know how it's built?"
},
"widgets": {
"backToTop": {
"ariaLabel": "Back to top",
"tooltip": "Back to top"
},
"info": {
"ariaLabel": "Information",
"tooltip": "Information"
},
"download": {
"ariaLabel": "Download as PDF",
"tooltip": "Download as PDF"
},
"print": {
"ariaLabel": "Print Friendly",
"tooltip": "Print Friendly"
},
"shortcuts": {
"ariaLabel": "Keyboard shortcuts",
"tooltip": "Keyboard shortcuts (?)"
},
"zoomToggle": {
"ariaLabel": "Toggle zoom control",
"tooltip": "Zoom control"
},
"zoomControl": {
"groupLabel": "Zoom control",
"closeLabel": "Close zoom control",
"closeTitle": "Close",
"sliderLabel": "Adjust CV zoom level",
"resetLabel": "Reset zoom to 100%",
"resetTitle": "Reset"
},
"pdfToast": {
"title": "Preparing PDF",
"closeLabel": "Close notification"
},
"actionButtons": {
"downloadPdf": "Download as PDF",
"printFriendly": "Print Friendly"
}
}
}
+123 -10
View File
@@ -1,19 +1,78 @@
{
"infoModal": {
"title": "Acerca de este CV",
"description": "Este CV interactivo fue construido por mí mismo con <strong>Go + HTMX</strong>, demostrando arquitectura moderna de hipermedia sin frameworks pesados de JavaScript.",
"techStack": {
"goHono": "Go + Hono",
"htmx": "HTMX",
"html5": "HTML5 Semántico",
"css3": "CSS3 Puro"
"navigation": {
"cvSections": "Secciones CV",
"training": "Formación",
"skills": "Competencias",
"experience": "Experiencia",
"awards": "Premios y Reconocimientos",
"projects": "Proyectos Personales / Freelance",
"courses": "Cursos Realizados",
"languages": "Idiomas",
"references": "Referencias",
"other": "Otros",
"quickActions": "Acciones Rápidas",
"collapseAll": "Colapsar Todo",
"expandAll": "Expandir Todo",
"zoom": "Zoom",
"viewControls": "Controles de Vista",
"actions": "Acciones"
},
"viewControls": {
"length": "Longitud",
"icons": "Iconos",
"view": "Vista"
},
"sections": {
"technicalSkills": "Competencias Técnicas",
"moreSkills": "Más Competencias",
"yearsOfExperience": "años de experiencia",
"drivingLicense": "Carnet de conducir tipo",
"obtainedFrom": "obtenido de",
"currentBadge": "ACTUAL",
"expiredBadge": "EXPIRADO",
"present": "presente",
"technologies": "Tecnologías:",
"maintainedBy": "MANTENIDO POR"
},
"footer": {
"viewOnGithub": "Ver este proyecto en GitHub",
"lastUpdated": "Última actualización"
},
"portfolio": {
"seeAllProjects": "Ver todos los proyectos en mi",
"domestikaPortfolio": "portfolio de Domestika"
},
"pdfModal": {
"title": "Descargar PDF",
"subtitle": "Elige tu formato preferido",
"preparingPdf": "Preparando PDF...",
"pleaseWait": "Por favor espera mientras generamos tu CV",
"close": "Cerrar",
"downloadButton": "Descargar PDF",
"shortCv": {
"title": "CV Corto (4 páginas)",
"pages": "4 Páginas",
"description": "Información esencial",
"ariaLabel": "CV Corto - 4 páginas, información esencial"
},
"viewSource": "Ver proyecto en Github",
"viewSourceSubtext": "¿Quieres saber cómo está hecho?"
"defaultCv": {
"title": "CV Por Defecto (5 páginas)",
"pages": "5 Páginas",
"description": "Corto con habilidades - Recomendado",
"ariaLabel": "CV Por Defecto - 5 páginas con habilidades (Recomendado)"
},
"extendedCv": {
"title": "CV Extendido (9 páginas)",
"pages": "9 Páginas",
"description": "Todos los detalles",
"ariaLabel": "CV Extendido - 9 páginas, versión completa"
}
},
"shortcutsModal": {
"title": "Atajos de Teclado",
"subtitle": "Aprende los Atajos",
"description": "Usa estos atajos de teclado para navegar y controlar el CV de forma más eficiente.",
"close": "Cerrar",
"sections": {
"zoom": {
"title": "Control de Zoom",
@@ -87,5 +146,59 @@
}
}
}
},
"infoModal": {
"title": "Acerca de este CV",
"description": "Este CV interactivo fue construido por mí mismo con <strong>Go + HTMX</strong>, demostrando arquitectura moderna de hipermedia sin frameworks pesados de JavaScript.",
"techStack": {
"goHono": "Go + Hono",
"htmx": "HTMX",
"html5": "HTML5 Semántico",
"css3": "CSS3 Puro"
},
"viewSource": "Ver proyecto en Github",
"viewSourceSubtext": "¿Quieres saber cómo está hecho?"
},
"widgets": {
"backToTop": {
"ariaLabel": "Volver arriba",
"tooltip": "Volver arriba"
},
"info": {
"ariaLabel": "Información",
"tooltip": "Información"
},
"download": {
"ariaLabel": "Descargar PDF",
"tooltip": "Descargar PDF"
},
"print": {
"ariaLabel": "Imprimir CV",
"tooltip": "Imprimir CV"
},
"shortcuts": {
"ariaLabel": "Atajos de teclado",
"tooltip": "Atajos de teclado (?)"
},
"zoomToggle": {
"ariaLabel": "Alternar control de zoom",
"tooltip": "Control de zoom"
},
"zoomControl": {
"groupLabel": "Control de zoom",
"closeLabel": "Cerrar control de zoom",
"closeTitle": "Cerrar",
"sliderLabel": "Ajustar nivel de zoom del CV",
"resetLabel": "Restablecer zoom al 100%",
"resetTitle": "Restablecer"
},
"pdfToast": {
"title": "Preparando PDF",
"closeLabel": "Cerrar notificación"
},
"actionButtons": {
"downloadPdf": "Descargar como PDF",
"printFriendly": "Imprimir amigable"
}
}
}
+29 -13
View File
@@ -1,9 +1,13 @@
package cv
import "html/template"
// CV represents the complete curriculum vitae structure
type CV struct {
Personal Personal `json:"personal"`
SEO SEO `json:"seo"`
Summary string `json:"summary"`
SkillsSummary template.HTML `json:"skillsSummary"`
Experience []Experience `json:"experience"`
Education []Education `json:"education"`
Skills Skills `json:"skills"`
@@ -18,19 +22,31 @@ type CV struct {
}
type Personal struct {
Name string `json:"name"`
Title string `json:"title"`
Location string `json:"location"`
Email string `json:"email"`
Phone string `json:"phone"`
DateOfBirth string `json:"dateOfBirth"`
PlaceOfBirth string `json:"placeOfBirth"`
Citizenship string `json:"citizenship"`
LinkedIn string `json:"linkedin"`
GitHub string `json:"github"`
Domestika string `json:"domestika"`
Website string `json:"website"`
Photo string `json:"photo"`
Name string `json:"name"`
Title string `json:"title"`
TitleBadges []string `json:"titleBadges"`
Location string `json:"location"`
Email string `json:"email"`
Phone string `json:"phone"`
DateOfBirth string `json:"dateOfBirth"`
PlaceOfBirth string `json:"placeOfBirth"`
Citizenship string `json:"citizenship"`
LinkedIn string `json:"linkedin"`
GitHub string `json:"github"`
Domestika string `json:"domestika"`
Website string `json:"website"`
Photo string `json:"photo"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Username string `json:"username"`
}
type SEO struct {
PageTitle string `json:"pageTitle"`
MetaTitle string `json:"metaTitle"`
MetaDescription string `json:"metaDescription"`
OgDescription string `json:"ogDescription"`
Keywords string `json:"keywords"`
}
type Experience struct {
+131 -12
View File
@@ -4,28 +4,95 @@ import "html/template"
// UI represents user interface translations and configuration
type UI struct {
InfoModal InfoModal `json:"infoModal"`
Navigation Navigation `json:"navigation"`
ViewControls ViewControls `json:"viewControls"`
Sections Sections `json:"sections"`
Footer Footer `json:"footer"`
Portfolio Portfolio `json:"portfolio"`
PdfModal PdfModal `json:"pdfModal"`
ShortcutsModal ShortcutsModal `json:"shortcutsModal"`
InfoModal InfoModal `json:"infoModal"`
Widgets Widgets `json:"widgets"`
}
type InfoModal struct {
Title string `json:"title"`
Description template.HTML `json:"description"`
TechStack TechStack `json:"techStack"`
ViewSource string `json:"viewSource"`
ViewSourceSubtext string `json:"viewSourceSubtext"`
// Navigation labels for hamburger menu
type Navigation struct {
CvSections string `json:"cvSections"`
Training string `json:"training"`
Skills string `json:"skills"`
Experience string `json:"experience"`
Awards string `json:"awards"`
Projects string `json:"projects"`
Courses string `json:"courses"`
Languages string `json:"languages"`
References string `json:"references"`
Other string `json:"other"`
QuickActions string `json:"quickActions"`
CollapseAll string `json:"collapseAll"`
ExpandAll string `json:"expandAll"`
Zoom string `json:"zoom"`
ViewControls string `json:"viewControls"`
Actions string `json:"actions"`
}
type TechStack struct {
GoHono string `json:"goHono"`
HTMX string `json:"htmx"`
HTML5 string `json:"html5"`
CSS3 string `json:"css3"`
// ViewControls labels for toggle buttons
type ViewControls struct {
Length string `json:"length"`
Icons string `json:"icons"`
View string `json:"view"`
}
// Sections labels for CV section headers
type Sections struct {
TechnicalSkills string `json:"technicalSkills"`
MoreSkills string `json:"moreSkills"`
YearsOfExperience string `json:"yearsOfExperience"`
DrivingLicense string `json:"drivingLicense"`
ObtainedFrom string `json:"obtainedFrom"`
CurrentBadge string `json:"currentBadge"`
ExpiredBadge string `json:"expiredBadge"`
Present string `json:"present"`
Technologies string `json:"technologies"`
MaintainedBy string `json:"maintainedBy"`
}
// Footer labels
type Footer struct {
ViewOnGithub string `json:"viewOnGithub"`
LastUpdated string `json:"lastUpdated"`
}
// Portfolio labels
type Portfolio struct {
SeeAllProjects string `json:"seeAllProjects"`
DomestikaPortfolio string `json:"domestikaPortfolio"`
}
// PdfModal labels
type PdfModal struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
PreparingPdf string `json:"preparingPdf"`
PleaseWait string `json:"pleaseWait"`
Close string `json:"close"`
DownloadButton string `json:"downloadButton"`
ShortCv PdfCvOption `json:"shortCv"`
DefaultCv PdfCvOption `json:"defaultCv"`
ExtendedCv PdfCvOption `json:"extendedCv"`
}
type PdfCvOption struct {
Title string `json:"title"`
Pages string `json:"pages"`
Description string `json:"description"`
AriaLabel string `json:"ariaLabel"`
}
type ShortcutsModal struct {
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Description string `json:"description"`
Close string `json:"close"`
Sections ShortcutsSections `json:"sections"`
}
@@ -59,3 +126,55 @@ type ShortcutItem struct {
Key string `json:"key"`
Description string `json:"description"`
}
type InfoModal struct {
Title string `json:"title"`
Description template.HTML `json:"description"`
TechStack TechStack `json:"techStack"`
ViewSource string `json:"viewSource"`
ViewSourceSubtext string `json:"viewSourceSubtext"`
}
type TechStack struct {
GoHono string `json:"goHono"`
HTMX string `json:"htmx"`
HTML5 string `json:"html5"`
CSS3 string `json:"css3"`
}
// Widget label types
type Widgets struct {
BackToTop WidgetLabel `json:"backToTop"`
Info WidgetLabel `json:"info"`
Download WidgetLabel `json:"download"`
Print WidgetLabel `json:"print"`
Shortcuts WidgetLabel `json:"shortcuts"`
ZoomToggle WidgetLabel `json:"zoomToggle"`
ZoomControl ZoomControlLabel `json:"zoomControl"`
PdfToast PdfToastLabel `json:"pdfToast"`
ActionButtons ActionButtonsLabel `json:"actionButtons"`
}
type WidgetLabel struct {
AriaLabel string `json:"ariaLabel"`
Tooltip string `json:"tooltip"`
}
type ZoomControlLabel struct {
GroupLabel string `json:"groupLabel"`
CloseLabel string `json:"closeLabel"`
CloseTitle string `json:"closeTitle"`
SliderLabel string `json:"sliderLabel"`
ResetLabel string `json:"resetLabel"`
ResetTitle string `json:"resetTitle"`
}
type PdfToastLabel struct {
Title string `json:"title"`
CloseLabel string `json:"closeLabel"`
}
type ActionButtonsLabel struct {
DownloadPdf string `json:"downloadPdf"`
PrintFriendly string `json:"printFriendly"`
}
+2 -2
View File
@@ -17,7 +17,7 @@
<details class="sidebar-accordion">
<summary class="sidebar-accordion-header">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Competencias Técnicas{{else}}Technical Skills{{end}}</span>
<span>{{.UI.Sections.TechnicalSkills}}</span>
<iconify-icon icon="mdi:chevron-down" width="20" height="20" class="chevron"></iconify-icon>
</summary>
<div class="sidebar-accordion-content">
@@ -96,7 +96,7 @@
<details class="sidebar-accordion">
<summary class="sidebar-accordion-header">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Más Competencias{{else}}More Skills{{end}}</span>
<span>{{.UI.Sections.MoreSkills}}</span>
<iconify-icon icon="mdi:chevron-down" width="20" height="20" class="chevron"></iconify-icon>
</summary>
<div class="sidebar-accordion-content">
+10 -10
View File
@@ -5,10 +5,10 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta Tags -->
<title>{{.CV.Personal.Name}} - {{if eq .Lang "es"}}Curriculum Vitae{{else}}Curriculum Vitae{{end}}</title>
<meta name="title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta name="description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}18 años de experiencia en desarrollo web, SAP CDC, React, Node.js, Go, HTMX y desarrollo asistido por IA{{else}}18 years of experience in web development, SAP CDC, React, Node.js, Go, HTMX and AI-assisted development{{end}}">
<meta name="keywords" content="{{if eq .Lang "es"}}CV, Curriculum Vitae, {{.CV.Personal.Name}}, Desarrollador FullStack, SAP CDC, React, Node.js, Go, HTMX, IA, Desarrollo Web, Consultor Técnico{{else}}CV, Resume, {{.CV.Personal.Name}}, FullStack Developer, SAP CDC, React, Node.js, Go, HTMX, AI, Web Development, Technical Consultant{{end}}">
<title>{{.CV.Personal.Name}} - {{.CV.SEO.PageTitle}}</title>
<meta name="title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}">
<meta name="description" content="{{.CV.Personal.Title}} | {{.CV.SEO.MetaDescription}}">
<meta name="keywords" content="{{.CV.Personal.Name}}, {{.CV.SEO.Keywords}}">
<meta name="author" content="{{.CV.Personal.Name}}">
<meta name="robots" content="index, follow">
<link rel="canonical" href="{{.CanonicalURL}}">
@@ -21,18 +21,18 @@
<!-- Open Graph / Facebook -->
<meta property="og:type" content="profile">
<meta property="og:url" content="{{.CV.Personal.Website}}">
<meta property="og:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta property="og:description" content="{{.CV.Personal.Title}} | {{if eq .Lang "es"}}Consultor Técnico Senior con 18 años de experiencia{{else}}Senior Technical Consultant with 18 years of experience{{end}}">
<meta property="og:title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}">
<meta property="og:description" content="{{.CV.Personal.Title}} | {{.CV.SEO.OgDescription}}">
<meta property="og:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
<meta property="og:locale" content="{{if eq .Lang "es"}}es_ES{{else}}en_US{{end}}">
<meta property="og:site_name" content="{{.CV.Personal.Name}}">
<meta property="profile:first_name" content="Juan Andrés">
<meta property="profile:last_name" content="Moreno Rubio">
<meta property="profile:username" content="txeo">
<meta property="profile:first_name" content="{{.CV.Personal.FirstName}}">
<meta property="profile:last_name" content="{{.CV.Personal.LastName}}">
<meta property="profile:username" content="{{.CV.Personal.Username}}">
<!-- Social Media Card (Generic) -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{{.CV.Personal.Name}} - {{if eq .Lang "es"}}CV Profesional{{else}}Professional CV{{end}}">
<meta name="twitter:title" content="{{.CV.Personal.Name}} - {{.CV.SEO.MetaTitle}}">
<meta name="twitter:description" content="{{.CV.Personal.Title}}">
<meta name="twitter:image" content="{{.CV.Personal.Website}}/static/images/profile.jpg">
+2 -2
View File
@@ -35,7 +35,7 @@
<details class="sidebar-accordion">
<summary class="sidebar-accordion-header">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Competencias Técnicas{{else}}Technical Skills{{end}}</span>
<span>{{.UI.Sections.TechnicalSkills}}</span>
<iconify-icon icon="mdi:chevron-down" width="20" height="20" class="chevron"></iconify-icon>
</summary>
<div class="sidebar-accordion-content">
@@ -89,7 +89,7 @@
<details class="sidebar-accordion">
<summary class="sidebar-accordion-header">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Más Competencias{{else}}More Skills{{end}}</span>
<span>{{.UI.Sections.MoreSkills}}</span>
<iconify-icon icon="mdi:chevron-down" width="20" height="20" class="chevron"></iconify-icon>
</summary>
<div class="sidebar-accordion-content">
+2 -2
View File
@@ -6,10 +6,10 @@
<p style="text-align: center; margin-bottom: 0.5rem;">
<a href="https://github.com/juanatsap/cv-site" target="_blank" rel="noopener noreferrer" class="github-repo-link" style="text-decoration: none; display: inline-flex; align-items: center; gap: 0.5rem;">
<iconify-icon icon="mdi:github" width="20" height="20"></iconify-icon>
{{if eq .Lang "es"}}Ver este proyecto en GitHub{{else}}View this project on GitHub{{end}}
{{.UI.Footer.ViewOnGithub}}
</a>
</p>
<p>© {{.CV.Meta.LastUpdated}} {{.CV.Personal.Name}} |
{{if eq .Lang "es"}}Última actualización{{else}}Last updated{{end}}: {{.CV.Meta.LastUpdated}}</p>
{{.UI.Footer.LastUpdated}}: {{.CV.Meta.LastUpdated}}</p>
</footer>
{{end}}
+1 -7
View File
@@ -1,12 +1,6 @@
{{define "title-badges"}}
<!-- Professional Title Badges - Full Width Top Bar -->
<div class="cv-title-badges-header">
<span class="title-badge">{{if eq .Lang "es"}}CONSULTOR TÉCNICO{{else}}TECHNICAL CONSULTANT{{end}}</span>
<span class="badge-separator">|</span>
<span class="title-badge">{{if eq .Lang "es"}}INGENIERO FULL-STACK{{else}}FULL-STACK ENGINEER{{end}}</span>
<span class="badge-separator">|</span>
<span class="title-badge">{{if eq .Lang "es"}}ESPECIALISTA EN AUTENTICACIÓN{{else}}AUTHENTICATION SPECIALIST{{end}}</span>
<span class="badge-separator">|</span>
<span class="title-badge">{{if eq .Lang "es"}}ARQUITECTO DE SOLUCIONES{{else}}SOLUTION ARCHITECT{{end}}</span>
{{range $i, $badge := .CV.Personal.TitleBadges}}{{if $i}}<span class="badge-separator">|</span>{{end}}<span class="title-badge">{{$badge}}</span>{{end}}
</div>
{{end}}
+1 -1
View File
@@ -3,7 +3,7 @@
<dialog id="info-modal" class="info-modal no-print"
_="on click call closeOnBackdrop(me, event)">
<div class="info-modal-content">
<button class="info-modal-close" onclick="document.getElementById('info-modal').close()" aria-label="Close">
<button class="info-modal-close" onclick="document.getElementById('info-modal').close()" aria-label="{{.UI.PdfModal.Close}}">
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button>
+18 -18
View File
@@ -8,10 +8,10 @@
<div class="pdf-loading-content">
<div class="pdf-loading-spinner"></div>
<h3 class="pdf-loading-title" id="pdf-loading-title">
{{if eq .Lang "es"}}Preparando PDF...{{else}}Preparing PDF...{{end}}
{{.UI.PdfModal.PreparingPdf}}
</h3>
<p class="pdf-loading-message" id="pdf-loading-message">
{{if eq .Lang "es"}}Por favor espera mientras generamos tu CV{{else}}Please wait while we generate your CV{{end}}
{{.UI.PdfModal.PleaseWait}}
</p>
<p class="pdf-loading-estimate" id="pdf-loading-estimate"></p>
</div>
@@ -20,16 +20,16 @@
<!-- Close Button -->
<button class="info-modal-close"
onclick="document.getElementById('pdf-modal').close()"
aria-label="{{if eq .Lang "es"}}Cerrar{{else}}Close{{end}}">
aria-label="{{.UI.PdfModal.Close}}">
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button>
<!-- Header -->
<div class="info-modal-header">
<iconify-icon icon="catppuccin:pdf" width="40" height="40" style="margin-bottom: 0.5rem;"></iconify-icon>
<h2>{{if eq .Lang "es"}}Descargar PDF{{else}}Download PDF{{end}}</h2>
<h2>{{.UI.PdfModal.Title}}</h2>
<p class="pdf-modal-subtitle">
{{if eq .Lang "es"}}Elige tu formato preferido{{else}}Choose your preferred format{{end}}
{{.UI.PdfModal.Subtitle}}
</p>
</div>
@@ -41,7 +41,7 @@
data-cv-format="short"
role="radio"
aria-checked="false"
aria-label="{{if eq .Lang "es"}}CV Corto - 4 páginas, información esencial{{else}}Short CV - 4 pages, essential information{{end}}"
aria-label="{{.UI.PdfModal.ShortCv.AriaLabel}}"
tabindex="0"
_="on click call selectPdfCard(me)
on keydown call handlePdfCardKey(me, event)">
@@ -57,13 +57,13 @@
<!-- Page count badge -->
<div class="thumbnail-badge">
{{if eq .Lang "es"}}4 Páginas{{else}}4 Pages{{end}}
{{.UI.PdfModal.ShortCv.Pages}}
</div>
</div>
<div class="pdf-option-info">
<h3>{{if eq .Lang "es"}}CV Corto (4 páginas){{else}}Short CV (4 pages){{end}}</h3>
<p>{{if eq .Lang "es"}}Información esencial{{else}}Essential info{{end}}</p>
<h3>{{.UI.PdfModal.ShortCv.Title}}</h3>
<p>{{.UI.PdfModal.ShortCv.Description}}</p>
</div>
<div class="pdf-option-badge">
@@ -76,7 +76,7 @@
data-cv-format="default"
role="radio"
aria-checked="true"
aria-label="{{if eq .Lang "es"}}CV Por Defecto - 5 páginas con habilidades (Recomendado){{else}}Default CV - 5 pages with skills (Recommended){{end}}"
aria-label="{{.UI.PdfModal.DefaultCv.AriaLabel}}"
tabindex="0"
_="on click call selectPdfCard(me)
on keydown call handlePdfCardKey(me, event)">
@@ -97,16 +97,16 @@
<!-- Page count badge with star -->
<div class="thumbnail-badge" style="font-weight: 600;">
⭐ {{if eq .Lang "es"}}5 Páginas{{else}}5 Pages{{end}}
⭐ {{.UI.PdfModal.DefaultCv.Pages}}
</div>
</div>
<div class="pdf-option-info">
<h3>
{{if eq .Lang "es"}}CV Por Defecto (5 páginas){{else}}Default CV (5 pages){{end}}
{{.UI.PdfModal.DefaultCv.Title}}
<span style="color: #667eea; font-size: 0.9em;"></span>
</h3>
<p style="font-weight: 500;">{{if eq .Lang "es"}}Corto con habilidades - Recomendado{{else}}Short with skills - Recommended{{end}}</p>
<p style="font-weight: 500;">{{.UI.PdfModal.DefaultCv.Description}}</p>
</div>
<div class="pdf-option-badge">
@@ -119,7 +119,7 @@
data-cv-format="long"
role="radio"
aria-checked="false"
aria-label="{{if eq .Lang "es"}}CV Extendido - 9 páginas, versión completa{{else}}Extended CV - 9 pages, full version{{end}}"
aria-label="{{.UI.PdfModal.ExtendedCv.AriaLabel}}"
tabindex="0"
_="on click call selectPdfCard(me)
on keydown call handlePdfCardKey(me, event)">
@@ -137,13 +137,13 @@
<!-- Page count badge -->
<div class="thumbnail-badge">
{{if eq .Lang "es"}}9 Páginas{{else}}9 Pages{{end}}
{{.UI.PdfModal.ExtendedCv.Pages}}
</div>
</div>
<div class="pdf-option-info">
<h3>{{if eq .Lang "es"}}CV Extendido (9 páginas){{else}}Extended CV (9 pages){{end}}</h3>
<p>{{if eq .Lang "es"}}Todos los detalles{{else}}All details{{end}}</p>
<h3>{{.UI.PdfModal.ExtendedCv.Title}}</h3>
<p>{{.UI.PdfModal.ExtendedCv.Description}}</p>
</div>
<div class="pdf-option-badge">
@@ -158,7 +158,7 @@
id="pdf-download-btn"
onclick="downloadPDF()">
<iconify-icon icon="mdi:download" width="20" height="20"></iconify-icon>
{{if eq .Lang "es"}}Descargar PDF{{else}}Download PDF{{end}}
{{.UI.PdfModal.DownloadButton}}
</button>
</div>
@@ -3,7 +3,7 @@
<dialog id="shortcuts-modal" class="info-modal no-print"
_="on click call closeOnBackdrop(me, event)">
<div class="info-modal-content">
<button class="info-modal-close" onclick="document.getElementById('shortcuts-modal').close()" aria-label="{{if eq .Lang "es"}}Cerrar{{else}}Close{{end}}">
<button class="info-modal-close" onclick="document.getElementById('shortcuts-modal').close()" aria-label="{{.UI.ShortcutsModal.Close}}">
<iconify-icon icon="mdi:close" width="24" height="24"></iconify-icon>
</button>
@@ -13,7 +13,7 @@
<span class="keyboard-icon-wrapper">
<iconify-icon icon="mdi:keyboard-outline" width="32" height="32"></iconify-icon>
</span>
{{if eq .Lang "es"}}Aprende los Atajos{{else}}Learn the Shortcuts{{end}}
{{.UI.ShortcutsModal.Subtitle}}
</div>
</div>
@@ -5,22 +5,22 @@
id="action-bar-pdf-btn"
class="action-btn pdf-btn has-tooltip"
onclick="document.getElementById('pdf-modal').showModal()"
aria-label="{{if eq .Lang "es"}}Descargar como PDF{{else}}Download as PDF{{end}}"
data-tooltip="{{if eq .Lang "es"}}Descargar como PDF{{else}}Download as PDF{{end}}"
aria-label="{{.UI.Widgets.ActionButtons.DownloadPdf}}"
data-tooltip="{{.UI.Widgets.ActionButtons.DownloadPdf}}"
_="on mouseenter call syncPdfHover(true)
on mouseleave call syncPdfHover(false)">
<iconify-icon icon="catppuccin:pdf" width="24" height="24"></iconify-icon>
{{if eq .Lang "es"}}Descargar como PDF{{else}}Download as PDF{{end}}
{{.UI.Widgets.ActionButtons.DownloadPdf}}
</button>
<button
class="action-btn print-btn action-bar-print-btn has-tooltip"
aria-label="{{if eq .Lang "es"}}Imprimir amigable{{else}}Print Friendly{{end}}"
data-tooltip="{{if eq .Lang "es"}}Imprimir amigable{{else}}Print Friendly{{end}}"
aria-label="{{.UI.Widgets.ActionButtons.PrintFriendly}}"
data-tooltip="{{.UI.Widgets.ActionButtons.PrintFriendly}}"
_="on click call printFriendly()
on mouseenter call syncPrintHover(true)
on mouseleave call syncPrintHover(false)">
<iconify-icon icon="mdi:leaf" width="24" height="24"></iconify-icon>
{{if eq .Lang "es"}}Imprimir amigable{{else}}Print Friendly{{end}}
{{.UI.Widgets.ActionButtons.PrintFriendly}}
</button>
</div>
{{end}}
@@ -6,54 +6,54 @@
<div class="menu-item-submenu">
<a href="#" class="menu-item has-submenu">
<iconify-icon icon="mdi:menu" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Secciones CV{{else}}CV Sections{{end}}</span>
<span>{{.UI.Navigation.CvSections}}</span>
<iconify-icon icon="mdi:chevron-right" width="16" height="16" class="submenu-arrow"></iconify-icon>
</a>
<div class="submenu-content">
<a href="#education" class="menu-item"
_="on click call scrollToSection(event, 'education')">
<iconify-icon icon="mdi:school" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Formación{{else}}Training{{end}}</span>
<span>{{.UI.Navigation.Training}}</span>
</a>
<a href="#skills" class="menu-item"
_="on click call scrollToSection(event, 'skills')">
<iconify-icon icon="mdi:brain" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}</span>
<span>{{.UI.Navigation.Skills}}</span>
</a>
<a href="#experience" class="menu-item"
_="on click call scrollToSection(event, 'experience')">
<iconify-icon icon="mdi:office-building" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}</span>
<span>{{.UI.Navigation.Experience}}</span>
</a>
<a href="#awards" class="menu-item"
_="on click call scrollToSection(event, 'awards')">
<iconify-icon icon="mdi:trophy" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Premios y Reconocimientos{{else}}Awards{{end}}</span>
<span>{{.UI.Navigation.Awards}}</span>
</a>
<a href="#projects" class="menu-item"
_="on click call scrollToSection(event, 'projects')">
<iconify-icon icon="mdi:web" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Proyectos Personales / Freelance{{else}}Personal / Freelance Projects{{end}}</span>
<span>{{.UI.Navigation.Projects}}</span>
</a>
<a href="#courses" class="menu-item"
_="on click call scrollToSection(event, 'courses')">
<iconify-icon icon="mdi:school" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Cursos Realizados{{else}}Courses{{end}}</span>
<span>{{.UI.Navigation.Courses}}</span>
</a>
<a href="#languages" class="menu-item"
_="on click call scrollToSection(event, 'languages')">
<iconify-icon icon="mdi:translate" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}</span>
<span>{{.UI.Navigation.Languages}}</span>
</a>
<a href="#references" class="menu-item"
_="on click call scrollToSection(event, 'references')">
<iconify-icon icon="mdi:link-variant" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Referencias{{else}}References{{end}}</span>
<span>{{.UI.Navigation.References}}</span>
</a>
<a href="#other" class="menu-item"
_="on click call scrollToSection(event, 'other')">
<iconify-icon icon="mdi:information" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Otros{{else}}Other{{end}}</span>
<span>{{.UI.Navigation.Other}}</span>
</a>
</div>
</div>
@@ -62,21 +62,21 @@
<div class="menu-section-wrapper">
<div class="menu-item menu-item-header">
<iconify-icon icon="mdi:cog-outline" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Acciones Rápidas{{else}}Quick Actions{{end}}</span>
<span>{{.UI.Navigation.QuickActions}}</span>
</div>
<a href="#" class="menu-item menu-item-action" _="on click call collapseAllSections(event)">
<iconify-icon icon="mdi:arrow-collapse-all" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Colapsar Todo{{else}}Collapse All{{end}}</span>
<span>{{.UI.Navigation.CollapseAll}}</span>
</a>
<a href="#" class="menu-item menu-item-action" _="on click call expandAllSections(event)">
<iconify-icon icon="mdi:arrow-expand-all" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Expandir Todo{{else}}Expand All{{end}}</span>
<span>{{.UI.Navigation.ExpandAll}}</span>
</a>
<a href="#" id="show-zoom-menu-btn" class="menu-item menu-item-action zoom-hidden"
_="on click call showZoomControl()">
<iconify-icon icon="mdi:magnify" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Zoom{{else}}Zoom{{end}}</span>
<span>{{.UI.Navigation.Zoom}}</span>
</a>
</div>
@@ -84,14 +84,14 @@
<div class="menu-controls-section">
<div class="menu-item menu-item-header">
<iconify-icon icon="mdi:tune-variant" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Controles de Vista{{else}}View Controls{{end}}</span>
<span>{{.UI.Navigation.ViewControls}}</span>
</div>
<!-- CV Length toggle -->
<div class="menu-control-item" id="mobile-length-toggle">
<label class="menu-control-label">
<iconify-icon icon="mdi:file-document-outline" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Longitud{{else}}Length{{end}}</span>
<span>{{.UI.ViewControls.Length}}</span>
</label>
<div style="display: flex; align-items: center; gap: 8px;">
<label class="icon-toggle">
@@ -118,7 +118,7 @@
<div class="menu-control-item" id="mobile-icon-toggle">
<label class="menu-control-label">
<iconify-icon icon="mdi:image-multiple-outline" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Iconos{{else}}Icons{{end}}</span>
<span>{{.UI.ViewControls.Icons}}</span>
</label>
<div style="display: flex; align-items: center; gap: 8px;">
<label class="icon-toggle">
@@ -145,7 +145,7 @@
<div class="menu-control-item" id="mobile-theme-toggle">
<label class="menu-control-label">
<iconify-icon icon="mdi:page-layout-sidebar-left" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Vista{{else}}View{{end}}</span>
<span>{{.UI.ViewControls.View}}</span>
</label>
<div style="display: flex; align-items: center; gap: 8px;">
<label class="icon-toggle">
@@ -173,7 +173,7 @@
<div class="menu-actions-section">
<div class="menu-item menu-item-header">
<iconify-icon icon="mdi:lightning-bolt" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Acciones{{else}}Actions{{end}}</span>
<span>{{.UI.Navigation.Actions}}</span>
</div>
<button class="menu-action-btn menu-pdf-btn"
@@ -181,7 +181,7 @@
_="on mouseenter call syncPdfHover(true)
on mouseleave call syncPdfHover(false)">
<iconify-icon icon="catppuccin:pdf" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Descargar como PDF{{else}}Download as PDF{{end}}</span>
<span>{{.UI.Widgets.ActionButtons.DownloadPdf}}</span>
</button>
<button class="menu-action-btn menu-print-btn"
@@ -189,7 +189,7 @@
on mouseenter call syncPrintHover(true)
on mouseleave call syncPrintHover(false)">
<iconify-icon icon="mdi:leaf" width="20" height="20"></iconify-icon>
<span>{{if eq .Lang "es"}}Imprimir amigable{{else}}Print Friendly{{end}}</span>
<span>{{.UI.Widgets.ActionButtons.PrintFriendly}}</span>
</button>
</div>
</div>
@@ -3,7 +3,7 @@
<div class="view-controls-center">
<!-- CV Length toggle -->
<div class="selector-group" id="desktop-length-toggle">
<label class="selector-label">{{if eq .Lang "es"}}Longitud{{else}}Length{{end}}:</label>
<label class="selector-label">{{.UI.ViewControls.Length}}:</label>
<label class="icon-toggle">
<input type="checkbox"
id="lengthToggle"
@@ -25,7 +25,7 @@
<!-- Icon toggle -->
<div class="selector-group" id="desktop-icon-toggle">
<label class="selector-label">{{if eq .Lang "es"}}Iconos{{else}}Icons{{end}}:</label>
<label class="selector-label">{{.UI.ViewControls.Icons}}:</label>
<label class="icon-toggle">
<input type="checkbox"
id="iconToggle"
@@ -47,7 +47,7 @@
<!-- Theme toggle -->
<div class="selector-group" id="desktop-theme-toggle">
<label class="selector-label">{{if eq .Lang "es"}}Vista{{else}}View{{end}}:</label>
<label class="selector-label">{{.UI.ViewControls.View}}:</label>
<label class="icon-toggle">
<input type="checkbox"
id="themeToggle"
+1 -1
View File
@@ -8,7 +8,7 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:trophy" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Premios y Reconocimientos{{else}}Awards{{end}}
{{.UI.Navigation.Awards}}
</h3>
</summary>
{{range .CV.Awards}}
+1 -1
View File
@@ -8,7 +8,7 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:school" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Cursos Realizados{{else}}Courses{{end}}
{{.UI.Navigation.Courses}}
</h3>
</summary>
{{range .CV.Courses}}
+2 -2
View File
@@ -7,12 +7,12 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:school" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Formación{{else}}Training{{end}}
{{.UI.Navigation.Training}}
</h3>
</summary>
{{range .CV.Education}}
<div class="education-item">
<strong>{{.Degree}}</strong> ({{.StartDate}}-{{.EndDate}}) {{if eq $.Lang "es"}}obtenido de{{else}}obtained from the{{end}} <strong>{{.Institution}}</strong> ({{.Location}})
<strong>{{.Degree}}</strong> ({{.StartDate}}-{{.EndDate}}) {{$.UI.Sections.ObtainedFrom}} <strong>{{.Institution}}</strong> ({{.Location}})
</div>
{{end}}
</details>
+4 -4
View File
@@ -7,7 +7,7 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:office-building" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Experiencia{{else}}Experience{{end}}
{{.UI.Navigation.Experience}}
</h3>
</summary>
@@ -22,10 +22,10 @@
</div>
<div class="experience-content">
<strong>{{.Position}}{{if .Company}} - {{if .CompanyURL}}<a href="{{.CompanyURL}}" target="_blank" rel="noopener noreferrer">{{.Company}}</a>{{else}}{{.Company}}{{end}}{{if .Duration}} - <span class="duration-text">{{.Duration}}</span>{{end}}{{end}}</strong>
{{if .Current}}<span class="current-badge">{{if eq $.Lang "es"}}ACTUAL{{else}}CURRENT{{end}}</span>{{end}}
{{if .Expired}}<span class="expired-badge">{{if eq $.Lang "es"}}EXPIRADO{{else}}EXPIRED{{end}}</span>{{end}}
{{if .Current}}<span class="current-badge">{{$.UI.Sections.CurrentBadge}}</span>{{end}}
{{if .Expired}}<span class="expired-badge">{{$.UI.Sections.ExpiredBadge}}</span>{{end}}
<br>
<small>{{.StartDate}} / {{if .Current}}{{if eq $.Lang "es"}}presente{{else}}now{{end}}{{else}}{{.EndDate}}{{end}} - ({{.Location}})</small>
<small>{{.StartDate}} / {{if .Current}}{{$.UI.Sections.Present}}{{else}}{{.EndDate}}{{end}} - ({{.Location}})</small>
{{if .ShortDescription}}
<p class="experience-desc short-desc">{{.ShortDescription | safeHTML}}</p>
+1 -1
View File
@@ -6,7 +6,7 @@
<div class="cv-header-content">
<div class="cv-header-left">
<h1 class="cv-name">Moreno Rubio, Juan Andrés</h1>
<p class="years-experience">{{.YearsOfExperience}} {{if eq .Lang "es"}}años de experiencia{{else}}years of experience{{end}}</p>
<p class="years-experience">{{.YearsOfExperience}} {{.UI.Sections.YearsOfExperience}}</p>
<!-- Photo positioned for mobile (centered between name and intro) -->
<div class="cv-photo">
+1 -1
View File
@@ -7,7 +7,7 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:translate" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Idiomas{{else}}Languages{{end}}
{{.UI.Navigation.Languages}}
</h3>
</summary>
{{range .CV.Languages}}
+2 -2
View File
@@ -8,11 +8,11 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:information" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Otros{{else}}Other{{end}}
{{.UI.Navigation.Other}}
</h3>
</summary>
<div class="other-content">
{{if eq .Lang "es"}}Carnet de conducir tipo <strong>{{.CV.Other.DriverLicense}}</strong>{{else}}Driving License type <strong>{{.CV.Other.DriverLicense}}</strong>{{end}}
{{.UI.Sections.DrivingLicense}} <strong>{{.CV.Other.DriverLicense}}</strong>
</div>
</details>
</div>
+6 -6
View File
@@ -8,7 +8,7 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:web" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Proyectos Personales / Freelance{{else}}Personal / Freelance Projects{{end}}
{{.UI.Navigation.Projects}}
</h3>
</summary>
{{range .CV.Projects}}
@@ -31,9 +31,9 @@
{{end}}
</strong>
{{if .Current}}<span class="live-badge"><iconify-icon icon="mdi:wifi" width="14" height="14"></iconify-icon>LIVE</span>{{end}}
{{if .MaintainedBy}}<span class="maintained-badge">{{if eq $.Lang "es"}}MANTENIDO POR{{else}}MAINTAINED BY{{end}} {{.MaintainedBy}}</span>{{end}}
{{if .MaintainedBy}}<span class="maintained-badge">{{$.UI.Sections.MaintainedBy}} {{.MaintainedBy}}</span>{{end}}
<br>
<small>{{if .StartDate}}{{.StartDate}}{{if .Current}}{{if .DynamicDate}} / {{.DynamicDate}}{{else}} / {{if eq $.Lang "es"}}presente{{else}}ahora{{end}}{{end}}{{end}}{{end}} - ({{.Location}})</small>
<small>{{if .StartDate}}{{.StartDate}}{{if .Current}}{{if .DynamicDate}} / {{.DynamicDate}}{{else}} / {{$.UI.Sections.Present}}{{end}}{{end}}{{end}} - ({{.Location}})</small>
{{if .ShortDescription}}
<p class="project-desc short-desc">{{.ShortDescription | safeHTML}}</p>
@@ -49,7 +49,7 @@
{{if .Technologies}}
<div class="project-technologies long-only">
<strong>{{if eq $.Lang "es"}}Tecnologías:{{else}}Technologies:{{end}}</strong>
<strong>{{$.UI.Sections.Technologies}}</strong>
{{range $index, $tech := .Technologies}}{{if $index}}, {{end}}{{$tech}}{{end}}
</div>
{{end}}
@@ -59,8 +59,8 @@
<!-- Link to full portfolio -->
<div class="projects-footer">
<p>{{if eq .Lang "es"}}Ver todos los proyectos en mi{{else}}See all projects on my{{end}}
<a href="{{.CV.Personal.Domestika}}" target="_blank" rel="noopener noreferrer"><strong>{{if eq .Lang "es"}}portfolio de Domestika{{else}}Domestika portfolio{{end}}</strong></a></p>
<p>{{.UI.Portfolio.SeeAllProjects}}
<a href="{{.CV.Personal.Domestika}}" target="_blank" rel="noopener noreferrer"><strong>{{.UI.Portfolio.DomestikaPortfolio}}</strong></a></p>
</div>
</details>
</div>
+1 -1
View File
@@ -8,7 +8,7 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:link-variant" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Referencias{{else}}References{{end}}
{{.UI.Navigation.References}}
</h3>
</summary>
{{range .CV.References}}
@@ -7,16 +7,10 @@
<summary>
<h3 class="section-title">
<iconify-icon icon="mdi:brain" width="24" height="24" class="section-icon"></iconify-icon>
{{if eq .Lang "es"}}Competencias{{else}}Skills{{end}}
{{.UI.Navigation.Skills}}
</h3>
</summary>
<p class="summary-text">
{{if eq .Lang "es"}}
Desarrollador <strong>full-stack</strong> con experiencia en <strong>Go</strong>, <strong>Node.js</strong>, <strong>React</strong> y <strong>HTMX</strong> para <strong>aplicaciones modernas</strong>, además de conocimientos en Java y PHP para proyectos legacy. He trabajado en <strong>unos 20 sitios web</strong> y realizado <strong>consultoría para 35-40 clientes internacionales</strong>, desde e-commerce y plataformas empresariales hasta <strong>sistemas de autenticación</strong> que gestionan <strong>millones de usuarios</strong>. Familiarizado con flujos de trabajo asistidos por <strong>IA</strong> y gestión de infraestructura (<strong>Linux</strong>, <strong>Docker</strong>, <strong>CI/CD</strong>). Me adapto bien tanto al trabajo independiente como colaborativo en equipos internacionales.
{{else}}
<strong>Full-stack</strong> developer with experience in <strong>Go</strong>, <strong>Node.js</strong>, <strong>React</strong>, and <strong>HTMX</strong> for <strong>modern applications</strong>, plus Java and PHP knowledge for legacy projects. I've worked on <strong>around 20 websites</strong> and provided <strong>consulting for 35-40 international clients</strong>, from e-commerce and enterprise platforms to <strong>authentication systems</strong> managing <strong>millions of users</strong>. Familiar with <strong>AI-assisted development</strong> workflows and infrastructure management (<strong>Linux</strong>, <strong>Docker</strong>, <strong>CI/CD</strong>). I adapt well to both independent work and collaborative teams across different countries.
{{end}}
</p>
<p class="summary-text">{{.CV.SkillsSummary}}</p>
</details>
</div>
+2 -2
View File
@@ -2,8 +2,8 @@
<!-- Back to Top Link - Hyperscript smooth scroll without URL pollution -->
<button id="back-to-top"
class="back-to-top no-print has-tooltip tooltip-left"
aria-label="{{if eq .Lang "es"}}Volver arriba{{else}}Back to top{{end}}"
data-tooltip="{{if eq .Lang "es"}}Volver arriba{{else}}Back to top{{end}}"
aria-label="{{.UI.Widgets.BackToTop.AriaLabel}}"
data-tooltip="{{.UI.Widgets.BackToTop.Tooltip}}"
style="display: none;"
_="on click call scrollToTop(event)">
<iconify-icon icon="mdi:arrow-up" width="24" height="24"></iconify-icon>
@@ -3,8 +3,8 @@
<button
id="download-button"
class="fixed-btn download-btn no-print has-tooltip"
aria-label="{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}"
data-tooltip="{{if eq .Lang "es"}}Descargar PDF{{else}}Download as PDF{{end}}"
aria-label="{{.UI.Widgets.Download.AriaLabel}}"
data-tooltip="{{.UI.Widgets.Download.Tooltip}}"
onclick="openPdfModal()"
_="on mouseenter call syncPdfHover(true)
on mouseleave call syncPdfHover(false)">
+2 -2
View File
@@ -1,8 +1,8 @@
{{define "info-button"}}
<!-- Info Button (Bottom Left) -->
<button id="info-button" class="info-button no-print has-tooltip"
aria-label="{{if eq .Lang "es"}}Información{{else}}Information{{end}}"
data-tooltip="{{if eq .Lang "es"}}Información{{else}}Information{{end}}"
aria-label="{{.UI.Widgets.Info.AriaLabel}}"
data-tooltip="{{.UI.Widgets.Info.Tooltip}}"
onclick="document.getElementById('info-modal').showModal()">
<iconify-icon icon="mdi:information-outline" width="24" height="24"></iconify-icon>
</button>
+2 -2
View File
@@ -3,10 +3,10 @@
<div id="pdf-toast" class="success-toast no-print" role="status" aria-live="polite" aria-atomic="true">
<span class="toast-icon" id="pdf-toast-icon">📥</span>
<div class="toast-content">
<p class="toast-title" id="pdf-toast-title">{{if eq .Lang "es"}}Preparando PDF{{else}}Preparing PDF{{end}}</p>
<p class="toast-title" id="pdf-toast-title">{{.UI.Widgets.PdfToast.Title}}</p>
<p class="toast-message" id="pdf-toast-message"></p>
</div>
<button aria-label="{{if eq .Lang "es"}}Cerrar notificación{{else}}Close notification{{end}}" class="toast-close" onclick="document.getElementById('pdf-toast').classList.remove('show')">×</button>
<button aria-label="{{.UI.Widgets.PdfToast.CloseLabel}}" class="toast-close" onclick="document.getElementById('pdf-toast').classList.remove('show')">×</button>
<div class="toast-progress">
<div class="toast-progress-bar" id="pdf-toast-progress"></div>
</div>
@@ -3,8 +3,8 @@
<button
id="print-friendly-button"
class="fixed-btn print-friendly-btn no-print has-tooltip"
aria-label="{{if eq .Lang "es"}}Imprimir CV{{else}}Print Friendly{{end}}"
data-tooltip="{{if eq .Lang "es"}}Imprimir CV{{else}}Print Friendly{{end}}"
aria-label="{{.UI.Widgets.Print.AriaLabel}}"
data-tooltip="{{.UI.Widgets.Print.Tooltip}}"
onclick="window.print()"
_="on mouseenter call syncPrintHover(true)
on mouseleave call syncPrintHover(false)">
@@ -4,8 +4,8 @@
id="shortcuts-button"
class="fixed-btn shortcuts-btn no-print has-tooltip"
onclick="document.getElementById('shortcuts-modal').showModal()"
aria-label="{{if eq .Lang "es"}}Atajos de teclado{{else}}Keyboard shortcuts{{end}}"
data-tooltip="{{if eq .Lang "es"}}Atajos de teclado (?){{else}}Keyboard shortcuts (?){{end}}">
aria-label="{{.UI.Widgets.Shortcuts.AriaLabel}}"
data-tooltip="{{.UI.Widgets.Shortcuts.Tooltip}}">
<iconify-icon icon="mdi:keyboard-outline" width="28" height="28"></iconify-icon>
</button>
{{end}}
+6 -6
View File
@@ -1,6 +1,6 @@
{{define "zoom-control"}}
<!-- Zoom Control (Fixed Bottom Center, Draggable) - Hyperscript Enhanced -->
<div id="zoom-control" class="zoom-control no-print zoom-hidden" role="group" aria-label="{{if eq .Lang "es"}}Control de zoom{{else}}Zoom control{{end}}"
<div id="zoom-control" class="zoom-control no-print zoom-hidden" role="group" aria-label="{{.UI.Widgets.ZoomControl.GroupLabel}}"
_="on load call initZoomControl(me)
on mousedown(clientX, clientY) if isZoomDragTarget(event.target) call startZoomDrag(me, clientX, clientY) then halt the event end
on mousemove(clientX, clientY) from document if moveZoomDrag(me, clientX, clientY) halt the event end
@@ -9,8 +9,8 @@
<button
id="zoom-close"
class="zoom-close-btn"
aria-label="{{if eq .Lang "es"}}Cerrar control de zoom{{else}}Close zoom control{{end}}"
title="{{if eq .Lang "es"}}Cerrar{{else}}Close{{end}}"
aria-label="{{.UI.Widgets.ZoomControl.CloseLabel}}"
title="{{.UI.Widgets.ZoomControl.CloseTitle}}"
_="on click call hideZoomControl()">
<iconify-icon icon="mdi:close" width="16" height="16" style="pointer-events: none;"></iconify-icon>
</button>
@@ -25,7 +25,7 @@
max="300"
step="1"
value="100"
aria-label="{{if eq .Lang "es"}}Ajustar nivel de zoom del CV{{else}}Adjust CV zoom level{{end}}"
aria-label="{{.UI.Widgets.ZoomControl.SliderLabel}}"
aria-valuemin="25"
aria-valuemax="300"
aria-valuenow="100"
@@ -37,8 +37,8 @@
<button
id="zoom-reset"
class="zoom-reset-btn"
aria-label="{{if eq .Lang "es"}}Restablecer zoom al 100%{{else}}Reset zoom to 100%{{end}}"
title="{{if eq .Lang "es"}}Restablecer{{else}}Reset{{end}}"
aria-label="{{.UI.Widgets.ZoomControl.ResetLabel}}"
title="{{.UI.Widgets.ZoomControl.ResetTitle}}"
aria-live="polite"
_="on click call handleZoomReset()">
<span id="zoom-value-current">100</span>
@@ -3,8 +3,8 @@
<button
id="zoom-toggle-button"
class="fixed-btn zoom-toggle-btn no-print has-tooltip"
aria-label="{{if eq .Lang "es"}}Alternar control de zoom{{else}}Toggle zoom control{{end}}"
data-tooltip="{{if eq .Lang "es"}}Control de zoom{{else}}Zoom control{{end}}"
aria-label="{{.UI.Widgets.ZoomToggle.AriaLabel}}"
data-tooltip="{{.UI.Widgets.ZoomToggle.Tooltip}}"
_="on click call toggleZoomControl()
on mouseenter call highlightZoomControl(true)
on mouseleave call highlightZoomControl(false)">
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env bun
/**
* JSON CONTENT VALIDATION TEST
* ============================
* Tests that CV content is loaded from JSON files, not hardcoded in templates.
* Validates:
* - Title badges rendered from CV JSON
* - SEO meta tags from CV JSON
* - Widget labels from UI JSON
* - Both EN and ES languages
*/
import { chromium } from 'playwright';
import { readFileSync } from 'fs';
import { join } from 'path';
const URL = "http://localhost:1999";
// Load JSON files for comparison
const dataDir = join(process.cwd(), 'data');
const cvEN = JSON.parse(readFileSync(join(dataDir, 'cv-en.json'), 'utf-8'));
const cvES = JSON.parse(readFileSync(join(dataDir, 'cv-es.json'), 'utf-8'));
const uiEN = JSON.parse(readFileSync(join(dataDir, 'ui-en.json'), 'utf-8'));
const uiES = JSON.parse(readFileSync(join(dataDir, 'ui-es.json'), 'utf-8'));
async function testJSONContentValidation() {
console.log('📋 JSON CONTENT VALIDATION TEST\n');
console.log('='.repeat(70));
const browser = await chromium.launch({ headless: true });
const testResults = [];
// ========================================================================
// TEST 1: English - Title Badges from JSON
// ========================================================================
console.log("\n1️⃣ Testing English Title Badges...");
const pageEN = await browser.newPage();
await pageEN.goto(`${URL}/?lang=en`);
await pageEN.waitForTimeout(1000);
const titleBadgesEN = await pageEN.evaluate(() => {
const badges = document.querySelectorAll('.title-badge');
return Array.from(badges).map(b => b.textContent.trim());
});
// Compare with JSON (CSS makes them uppercase, so compare case-insensitively)
const expectedBadgesEN = cvEN.personal.titleBadges.map(b => b.toUpperCase());
const actualBadgesEN = titleBadgesEN.map(b => b.toUpperCase());
const badgesMatchEN = expectedBadgesEN.every((badge, i) => actualBadgesEN[i] === badge);
console.log(` Expected: ${expectedBadgesEN.join(' | ')}`);
console.log(` Actual: ${actualBadgesEN.join(' | ')}`);
console.log(` ${badgesMatchEN ? '✅ PASS' : '❌ FAIL'} - Title badges match JSON`);
testResults.push({ test: 'EN Title Badges', passed: badgesMatchEN });
// ========================================================================
// TEST 2: English - SEO Meta Tags from JSON
// ========================================================================
console.log("\n2️⃣ Testing English SEO Meta Tags...");
const metaEN = await pageEN.evaluate(() => {
return {
title: document.title,
description: document.querySelector('meta[name="description"]')?.content,
keywords: document.querySelector('meta[name="keywords"]')?.content,
ogTitle: document.querySelector('meta[property="og:title"]')?.content,
ogDescription: document.querySelector('meta[property="og:description"]')?.content,
firstName: document.querySelector('meta[property="profile:first_name"]')?.content,
lastName: document.querySelector('meta[property="profile:last_name"]')?.content,
username: document.querySelector('meta[property="profile:username"]')?.content,
};
});
const seoTestsEN = [
{ name: 'Page title contains SEO pageTitle', passed: metaEN.title.includes(cvEN.seo.pageTitle) },
{ name: 'Description contains SEO metaDescription', passed: metaEN.description.includes(cvEN.seo.metaDescription) },
{ name: 'Keywords contain SEO keywords', passed: metaEN.keywords.includes(cvEN.seo.keywords.split(',')[0].trim()) },
{ name: 'OG description contains SEO ogDescription', passed: metaEN.ogDescription.includes(cvEN.seo.ogDescription) },
{ name: 'First name from JSON', passed: metaEN.firstName === cvEN.personal.firstName },
{ name: 'Last name from JSON', passed: metaEN.lastName === cvEN.personal.lastName },
{ name: 'Username from JSON', passed: metaEN.username === cvEN.personal.username },
];
seoTestsEN.forEach(t => {
console.log(` ${t.passed ? '✅' : '❌'} ${t.name}`);
testResults.push({ test: `EN SEO: ${t.name}`, passed: t.passed });
});
// ========================================================================
// TEST 3: English - Widget Labels from UI JSON
// ========================================================================
console.log("\n3️⃣ Testing English Widget Labels...");
const widgetsEN = await pageEN.evaluate(() => {
return {
backToTop: document.querySelector('#back-to-top')?.getAttribute('aria-label'),
infoButton: document.querySelector('#info-button')?.getAttribute('aria-label'),
downloadButton: document.querySelector('#download-button')?.getAttribute('aria-label'),
printButton: document.querySelector('#print-friendly-button')?.getAttribute('aria-label'),
shortcutsButton: document.querySelector('#shortcuts-button')?.getAttribute('aria-label'),
zoomToggle: document.querySelector('#zoom-toggle-button')?.getAttribute('aria-label'),
zoomControl: document.querySelector('#zoom-control')?.getAttribute('aria-label'),
};
});
const widgetTestsEN = [
{ name: 'Back to top label', passed: widgetsEN.backToTop === uiEN.widgets.backToTop.ariaLabel },
{ name: 'Info button label', passed: widgetsEN.infoButton === uiEN.widgets.info.ariaLabel },
{ name: 'Download button label', passed: widgetsEN.downloadButton === uiEN.widgets.download.ariaLabel },
{ name: 'Print button label', passed: widgetsEN.printButton === uiEN.widgets.print.ariaLabel },
{ name: 'Shortcuts button label', passed: widgetsEN.shortcutsButton === uiEN.widgets.shortcuts.ariaLabel },
{ name: 'Zoom toggle label', passed: widgetsEN.zoomToggle === uiEN.widgets.zoomToggle.ariaLabel },
{ name: 'Zoom control label', passed: widgetsEN.zoomControl === uiEN.widgets.zoomControl.groupLabel },
];
widgetTestsEN.forEach(t => {
console.log(` ${t.passed ? '✅' : '❌'} ${t.name}: "${t.passed ? 'matches' : 'MISMATCH'}"`);
testResults.push({ test: `EN Widget: ${t.name}`, passed: t.passed });
});
await pageEN.close();
// ========================================================================
// TEST 4: Spanish - Title Badges from JSON
// ========================================================================
console.log("\n4️⃣ Testing Spanish Title Badges...");
const pageES = await browser.newPage();
await pageES.goto(`${URL}/?lang=es`);
await pageES.waitForTimeout(1000);
const titleBadgesES = await pageES.evaluate(() => {
const badges = document.querySelectorAll('.title-badge');
return Array.from(badges).map(b => b.textContent.trim());
});
const expectedBadgesES = cvES.personal.titleBadges.map(b => b.toUpperCase());
const actualBadgesES = titleBadgesES.map(b => b.toUpperCase());
const badgesMatchES = expectedBadgesES.every((badge, i) => actualBadgesES[i] === badge);
console.log(` Expected: ${expectedBadgesES.join(' | ')}`);
console.log(` Actual: ${actualBadgesES.join(' | ')}`);
console.log(` ${badgesMatchES ? '✅ PASS' : '❌ FAIL'} - Title badges match JSON`);
testResults.push({ test: 'ES Title Badges', passed: badgesMatchES });
// ========================================================================
// TEST 5: Spanish - SEO Meta Tags from JSON
// ========================================================================
console.log("\n5️⃣ Testing Spanish SEO Meta Tags...");
const metaES = await pageES.evaluate(() => {
return {
title: document.title,
description: document.querySelector('meta[name="description"]')?.content,
ogDescription: document.querySelector('meta[property="og:description"]')?.content,
};
});
const seoTestsES = [
{ name: 'Page title contains SEO pageTitle', passed: metaES.title.includes(cvES.seo.pageTitle) },
{ name: 'Description contains SEO metaDescription', passed: metaES.description.includes(cvES.seo.metaDescription) },
{ name: 'OG description contains SEO ogDescription', passed: metaES.ogDescription.includes(cvES.seo.ogDescription) },
];
seoTestsES.forEach(t => {
console.log(` ${t.passed ? '✅' : '❌'} ${t.name}`);
testResults.push({ test: `ES SEO: ${t.name}`, passed: t.passed });
});
// ========================================================================
// TEST 6: Spanish - Widget Labels from UI JSON
// ========================================================================
console.log("\n6️⃣ Testing Spanish Widget Labels...");
const widgetsES = await pageES.evaluate(() => {
return {
backToTop: document.querySelector('#back-to-top')?.getAttribute('aria-label'),
infoButton: document.querySelector('#info-button')?.getAttribute('aria-label'),
downloadButton: document.querySelector('#download-button')?.getAttribute('aria-label'),
printButton: document.querySelector('#print-friendly-button')?.getAttribute('aria-label'),
};
});
const widgetTestsES = [
{ name: 'Back to top label', passed: widgetsES.backToTop === uiES.widgets.backToTop.ariaLabel },
{ name: 'Info button label', passed: widgetsES.infoButton === uiES.widgets.info.ariaLabel },
{ name: 'Download button label', passed: widgetsES.downloadButton === uiES.widgets.download.ariaLabel },
{ name: 'Print button label', passed: widgetsES.printButton === uiES.widgets.print.ariaLabel },
];
widgetTestsES.forEach(t => {
console.log(` ${t.passed ? '✅' : '❌'} ${t.name}: "${t.passed ? 'matches' : 'MISMATCH'}"`);
testResults.push({ test: `ES Widget: ${t.name}`, passed: t.passed });
});
// ========================================================================
// TEST 7: Verify NO hardcoded language conditionals in rendered output
// ========================================================================
console.log("\n7️⃣ Testing for hardcoded content elimination...");
// Check that title badges don't contain "if eq .Lang" template artifacts
const noTemplateArtifacts = await pageES.evaluate(() => {
const html = document.body.innerHTML;
return !html.includes('{{if eq .Lang') && !html.includes('{{else}}');
});
console.log(` ${noTemplateArtifacts ? '✅' : '❌'} No template artifacts in rendered HTML`);
testResults.push({ test: 'No template artifacts', passed: noTemplateArtifacts });
await pageES.close();
await browser.close();
// ========================================================================
// FINAL SUMMARY
// ========================================================================
console.log("\n" + "=".repeat(70));
console.log("📊 TEST SUMMARY\n");
const passedCount = testResults.filter(r => r.passed).length;
const totalCount = testResults.length;
// Group by category
const categories = {
'EN Title Badges': testResults.filter(r => r.test.includes('EN Title')),
'EN SEO': testResults.filter(r => r.test.includes('EN SEO')),
'EN Widgets': testResults.filter(r => r.test.includes('EN Widget')),
'ES Title Badges': testResults.filter(r => r.test.includes('ES Title')),
'ES SEO': testResults.filter(r => r.test.includes('ES SEO')),
'ES Widgets': testResults.filter(r => r.test.includes('ES Widget')),
'Other': testResults.filter(r => !r.test.includes('EN ') && !r.test.includes('ES ')),
};
for (const [category, tests] of Object.entries(categories)) {
if (tests.length === 0) continue;
const catPassed = tests.filter(t => t.passed).length;
const icon = catPassed === tests.length ? '✅' : '❌';
console.log(` ${icon} ${category}: ${catPassed}/${tests.length}`);
}
console.log(`\n Total: ${passedCount}/${totalCount} tests passed`);
console.log("=".repeat(70) + "\n");
if (passedCount === totalCount) {
console.log("🎉 JSON CONTENT VALIDATION PASSED!");
console.log(" All content is correctly loaded from JSON files.");
process.exit(0);
} else {
console.log("⚠️ SOME TESTS FAILED");
console.log(" Check that templates use JSON data instead of hardcoded values.");
process.exit(1);
}
}
await testJSONContentValidation();